diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 00000000..da81fdab --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,207 @@ +# Bugbot Review Rules + +Standing conventions for swap plugins in this repo. Most entries exist because +the same finding was raised, and settled, on several separate provider PRs. + +## Amounts + +### Native Amounts Are Whole Integers (`native-amounts-are-integers`) + +`denominationToNative` is a plain multiply, so a provider amount with more +decimals than the asset's denomination yields a fractional native string. Round +to whole atomic units before the value reaches `spendTargets`, swap metadata, or +a limit carried on a `SwapBelowLimitError` / `SwapAboveLimitError`. + +```ts +// Bad: +const nativeMin = denominationToNative(wallet, limit.min, tokenId) + +// Good: +const nativeMin = ceil(denominationToNative(wallet, limit.min, tokenId), 0) +``` + +### Round Limits Toward the Provider (`limit-rounding-direction`) + +A minimum rounds **up**, a maximum or a receive amount rounds **down**. Rounding +the other way widens the range past what the provider accepts, or shows a +receive amount larger than what actually arrives. + +### Never Assume a Documented Unit (`verify-amount-units`) + +Provider docs are wrong about units often enough that a claim needs a live +response behind it. Decimal-vs-base-unit mistakes are silent whenever the pairs +used during development happen to return null limits. + +### Use `biggystring`, Not Floats (`biggystring-not-floats`) + +Applies to comparison and sorting too, not just arithmetic. `String(smallFloat)` +can produce scientific notation, which string comparison misreads. + +```ts +// Bad: +quotes.sort((a, b) => b.amountOut - a.amountOut) + +// Good: +quotes.sort((a, b) => (lt(a.amountOut, b.amountOut) ? 1 : gt(a.amountOut, b.amountOut) ? -1 : 0)) +``` + +## Max Quotes + +### The Max Probe Must Not Create an Order (`max-probe-no-order`) + +`getMaxSwappable` invokes the plugin's quote function as a probe with the raw +balance, then the real quote runs again with the trimmed amount. If order +creation sits in that path, every max swap creates and abandons a live order. +Split quote fetching from order creation and probe with the quote-only path. + +The split is visible in the cleaners: a response carrying `depositAddress` has +committed the provider. If the quote cleaner requires one, the quote call *is* an +order call and no arrangement of the code avoids the extra order. Check the +cleaner shapes, not just the comments and the call count. + +Known accepted state: for a provider whose quote and order live behind a single +endpoint with no cancel, the abandoned order is inherent, not a plugin defect. +Raise it as a framework follow-up, not a per-plugin bug. + +### The Max Probe Needs `skipChecks` (`max-probe-skip-checks`) + +The probe's spend targets the user's own address, which EVM engines reject with +`SpendToSelfError` because the public key is the address. That error escapes +`getMaxSwappable` and fails every max swap from an EVM wallet. + +```ts +// Good (probe spendInfo only): +skipChecks: true +``` + +### The Max Probe Must Not Throw Above-Limit (`max-probe-clamp-dont-throw`) + +The probe deliberately quotes the full pre-fee balance to find the ceiling. An +above-limit balance must clamp through `getMaxSpendable`, not throw +`SwapAboveLimitError` and abort a swap that fits once fees are subtracted. Gate +the throw on a flag the probe passes as false. + +## Errors and Limits + +### Limit Errors Outrank Currency Errors (`limit-errors-outrank-currency`) + +A limit failure whose code or message also names a token, path or route must +surface as `SwapBelowLimitError` / `SwapAboveLimitError`, not +`SwapCurrencyError`. `pickBestError` ranks by type, so a misclassification hides +the real amount from the user. When matching free text, match whole phrases: a +substring test for `LOW` also matches `ALLOWANCE`. + +### Enforce Limits Against the Requested Amount (`limits-vs-requested-amount`) + +Compare `request.nativeAmount`, never the amount echoed back in the quote. A +provider that silently clamps an out-of-range request returns an in-range echo, +so comparing the echo lets the swap proceed for less than the user asked. + +### A Provider Outage Is Not an Unsupported Pair (`outage-is-not-unsupported`) + +A non-OK status from an asset or token lookup must surface as a real error. +Converting it to `SwapCurrencyError` reports "unsupported pair" for a transient +failure and can poison pair-capability caching. + +### Unquotable Pairs Are Currency Errors (`unquotable-pair-is-currency-error`) + +The mirror case. When a mapped pair is one the provider cannot quote, throw +`SwapCurrencyError` so the GUI simply omits this provider, rather than a plain +`Error` that surfaces as a failed provider. Provider network codes go stale, so +mapped-but-unquotable is a steady state, not an edge case. + +## Trust Boundary + +### Bound Provider Amounts Before Signing (`bound-provider-amounts`) + +Any provider-returned amount that becomes a signed spend or a token approval +must be bounded by the locally requested amount. Bound every field the spend +path consumes, not only the most obvious one, and compare each against a value +in its own units: a native fee in wei compared against a token amount in token +base units falsely rejects valid quotes. + +### Never Persist a Partner URL (`no-partner-supplied-uri`) + +Build `savedAction.orderUri` from a plugin constant plus the order id. A +partner-supplied `statusUrl` is rendered as a tappable link, so accepting its +host and scheme lets a compromised upstream steer users anywhere. + +### Error-Body Logging Is the Repo Convention (`error-body-logging-is-conventional`) + +`log.warn(' API error response:', text)` and the +`JSON.stringify(responseJson)` log on a cleaner failure are prescribed by +`src/swap/central/template.ts` and used by every central plugin. Do not flag them +as a per-plugin data-exposure defect; changing the convention is a repo-wide +decision. + +## Cleaners + +### Memos Must Survive Numbers and Blanks (`memo-cleaner-numeric-and-blank`) + +Both failure modes silently send an untagged deposit, which loses funds on +memo-based chains. A numeric destination tag (including the valid tag `0`) fails +a string-only cleaner, and an empty string becomes an empty `EdgeMemo`. + +```ts +// Bad: +payinExtraId: asMaybe(asString) + +// Good: +payinExtraId: asOptionalBlank(asNumberString) +``` + +### Accept Numeric or String Error Codes (`error-codes-numeric-or-string`) + +A provider that returns amounts as either number or string usually does the same +with error codes. `asNumberString` normalizes both, so classification does not +depend on which shape arrived. + +### `asOptional` Already Treats Null as Absent (`asoptional-handles-null`) + +In this repo's `cleaners` version, `asOptional(asString)` maps a JSON `null` to +`undefined` rather than throwing. `asEither(asString, asNull)` is only needed +when `null` and absent must stay distinguishable. + +## Plugin Structure + +### Call `checkInvalidTokenIds` (`call-check-invalid-token-ids`) + +It carries both the repo-wide blocked list and the same-asset guard, so skipping +it opts out of every future entry added there. For a provider whose main flow is +legitimately same-asset, extend the helper rather than dropping the call. + +### `isEstimate` Reports What Is Guaranteed (`isestimate-reflects-rate`) + +Set it from whether the provider actually fixed the rate. Hardcoding `false` +shows a locked receive amount on a floating route. + +### Clamp Expirations With `ensureInFuture` (`expiry-through-ensure-in-future`) + +A provider `validUntil` can already be in the past from clock skew, which fails +the quote at approval time. + +### Respect the Provider's Retry Window (`retry-window-is-a-floor`) + +A local backoff cap must bound only our own doubling. Truncating a reported +`retryAfter` fires retries early and burns the provider's budget. A backoff that +would land past the quote's own expiry should fail as a rate limit immediately +rather than sleep through the window and then report an expired quote. + +## Chain Mappings + +### Chain Identity Claims Need Live Evidence (`chain-identity-needs-evidence`) + +The highest false-positive area in this repo. A provider's chain id space +frequently reuses a number that means something else on EVM, and a provider can +list two networks under one ticker (an EOSIO chain and its EVM sibling). What +settles it is the provider's own chain metadata plus the shape of a real deposit +address, not the numeric id. + +Before reporting a mapping as wrong, state the live evidence. Implementers: +record that evidence as a comment next to any non-obvious entry, so the same +question is not reopened on the next review. + +### Unsupported Chains Map to `null` (`unsupported-chains-are-null`) + +An explicit `null` documents "checked, not supported" and avoids a round trip. +Leaving a chain absent is indistinguishable from having forgotten it. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3c6f21a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Edge Exchange Plugins - Agent Guidelines + +Swap and exchange-rate plugins loaded by `edge-core-js`. Each plugin adapts one +provider's API to the `EdgeSwapPlugin` interface: quote a pair, map the +provider's failures onto Edge's typed swap errors, and hand back a spend the +wallet can sign. Almost all of the difficulty is in that adaptation, not in the +HTTP calls. + +## Commands + +Use **npm** (this repo moved off yarn), and **mocha**, not jest. + +| Command | What it does | +|---|---| +| `npm run verify` | Full check: `prepare`, `lint`, `types` (tsc), `test`. Run before every PR | +| `npm test` | `nyc mocha 'test/**/*.test.ts'` | +| `npm run types` | `tsc --noEmit` | +| `npm run fix` | `eslint --fix` (includes Prettier) | +| `npm run prepare` | Rebuilds `lib/` + the webpack bundle. Required before `edge-react-gui` can pick up a change | +| `npm run mapctl` | Chain-mapping synchronizer CLI, see the mapping doc below | + +Branch off `master`, not `develop`. + +## Where the code runs + +Swap plugins execute inside **edge-core-js's plugin WebView**, not in the React +Native JS context. Metro's debugger and Hermes breakpoints cannot reach them, so +a change is verified by rebuilding the bundle (`npm run prepare`), linking it +into the app (`npm run updot edge-exchange-plugins` in `edge-react-gui`), and +reading plugin logs. Budget for that loop rather than expecting to step through. + +## Amount units + +The most expensive recurring bug class. Edge amounts (`nativeAmount`, +`spendTargets`, every limit carried on a swap error) are **integer native/atomic +units**. Provider APIs almost always speak **decimal denominated** amounts. + +- Convert at the boundary with `nativeToDenomination` / `denominationToNative` + from `src/util/swapHelpers.ts`, and round the result to a whole atomic unit. + `denominationToNative` is a plain multiply, so it can return a fraction. +- Round a minimum **up** and a maximum or receive amount **down**, so no + rounding widens the range the provider actually accepts. +- Do arithmetic with `biggystring`, never JS numbers. Amounts exceed float + precision, and `String(smallFloat)` can produce scientific notation that + string comparisons silently misread. +- Never assume a provider's documented unit. Several providers document base + units and return decimals; check a live response. + +## Starting a new provider + +`src/swap/central/template.ts` is the starting point and is intentionally not +registered in `src/index.ts`, so it compiles but never loads. Its comments carry +the invariants that recur in review (the max-quote probe, the trust boundary on +provider-returned amounts, memo cleaning, limit direction). Copy it and keep the +comments for the constructs you keep. + +`src/swap/central/nym.ts` is the closest reviewed reference implementation. + +## Docs + +- [`docs/CREATING_AN_EXCHANGE_PLUGIN.md`](./docs/CREATING_AN_EXCHANGE_PLUGIN.md) - build a plugin, plus the pre-PR checklist. Read before writing one +- [`docs/API_REQUIREMENTS.md`](./docs/API_REQUIREMENTS.md) - what a provider's API must offer. Read when evaluating a new provider or arguing a gap back to them +- [`docs/CHAIN_MAPPING_SYNCHRONIZERS.md`](./docs/CHAIN_MAPPING_SYNCHRONIZERS.md) - keeping `src/mappings/*` in sync with a provider's chain list +- [`.cursor/BUGBOT.md`](./.cursor/BUGBOT.md) - standing conventions for PR review on this repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b545636..4237f0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- added: `AGENTS.md` and `.cursor/BUGBOT.md`, recording the swap plugin conventions that recent provider reviews settled. +- added: A test suite for the swap plugin template, covering the max-quote probe, limit handling, the trust boundary on provider amounts, and memo cleaning. A new integration can copy it alongside the template. +- changed: The swap plugin template now models the max-quote probe, the trust boundary on provider-returned amounts, integer native-amount rounding, and funds-safe memo cleaning, so a new integration starts from them rather than rediscovering them in review. +- changed: `docs/CREATING_AN_EXCHANGE_PLUGIN.md` covers max quotes and amount rounding in depth, and ends with a pre-PR checklist drawn from findings on shipped integrations. + ## 2.53.0 (2026-08-18) - added: Swapter swap provider diff --git a/docs/CREATING_AN_EXCHANGE_PLUGIN.md b/docs/CREATING_AN_EXCHANGE_PLUGIN.md index dc4bdc5e..5103dc3a 100644 --- a/docs/CREATING_AN_EXCHANGE_PLUGIN.md +++ b/docs/CREATING_AN_EXCHANGE_PLUGIN.md @@ -104,30 +104,120 @@ yourplugin.set('unsupportedchain', null) // null = not supported by provider ### Step 4: Quote Fetching The `fetchSwapQuote` function must: -1. Get addresses using `getAddress()` -2. Support `from`, `to`, and `max` quote directions -3. Call exchange API with proper error handling -4. Map API errors to Edge error types (see Step 6) -5. Create `EdgeSpendInfo` or `MakeTxParams` -6. Return quote using `makeSwapPluginQuote()` +1. Call `checkInvalidTokenIds()` before touching the network, so blocked assets + and same-asset self-swaps fail client-side +2. Get addresses using `getAddress()` +3. Route `max` requests through `getMaxSwappable()` (see Step 4b) +4. Call exchange API with proper error handling +5. Map API errors to Edge error types (see Step 6) +6. Create `EdgeSpendInfo` or `MakeTxParams` +7. Return quote using `makeSwapPluginQuote()` + +### Step 4b: Max Quotes + +A `max` request arrives carrying the wallet's **raw, pre-fee balance**. +`getMaxSwappable()` invokes your quote function as a probe with that balance, +uses the returned `spendInfo` to price network fees via `getMaxSpendable()`, and +rewrites the request as a `from` quote for the spendable remainder. Your quote +function then runs a second time with the trimmed amount. + +Because the function runs twice, split quote fetching from order creation: + +```typescript +// Probe: quote only, no order. Runs on the raw balance. +const fetchProbeOrder = async (request: EdgeSwapRequestPlugin): Promise => { + const { quote, fromAddress } = await fetchQuote(request, false /* enforceMax */) + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [{ nativeAmount: quote.sourceAmount, publicAddress: fromAddress }], + networkFeeOption: 'high', + skipChecks: true, + assetAction: { assetActionType: 'swap' } + } + return { request, spendInfo, swapInfo, fromNativeAmount: quote.sourceAmount, expirationDate } +} + +const newRequest = await getMaxSwappable(fetchProbeOrder, request) +const swapOrder = await fetchSwapQuoteInner(newRequest) // creates the order, once +``` + +Three details are load-bearing, and each has broken a shipped plugin: + +- **The probe must not create an order.** Otherwise every max swap creates and + abandons a live order, which also burns the budget of any provider that + rate-limits order creation. +- **`skipChecks: true` on the probe's `spendInfo`.** The probe targets the + user's own address, which EVM engines reject with `SpendToSelfError` because + the public key *is* the address. That error escapes `getMaxSwappable` and + fails every max swap from an EVM wallet. +- **The probe must clamp, not throw, above-limit.** It deliberately quotes the + full pre-fee balance, so an above-limit balance must fall through to + `getMaxSpendable`. Throwing `SwapAboveLimitError` there aborts a max swap that + would have fit once fees were subtracted. Gate the throw on a flag the probe + passes as `false`. + +If the provider has no separate quote endpoint (order creation *is* the quote), +say so in a comment. The abandoned probe order is then inherent rather than a +plugin defect. ### Step 5: Amount Conversions ```typescript -import { denominationToNative, nativeToDenomination } from '../../util/utils' +import { denominationToNative, nativeToDenomination } from '../../util/swapHelpers' // To API const apiAmount = nativeToDenomination(wallet, nativeAmount, tokenId) -// From API -const nativeAmount = denominationToNative(wallet, apiAmount, tokenId) +// From API — round to whole atomic units +const nativeAmount = floor(denominationToNative(wallet, apiAmount, tokenId), 0) ``` +`denominationToNative` is a plain multiply, so a provider amount carrying more +decimals than the asset's denomination returns a **fraction** +(`mul('0.123456789', '100000000')` is `12345678.9`). Edge native amounts are +integers everywhere, so always round. + +The direction matters: + +| Value | Rounding | Why | +|---|---|---| +| Minimum limit | `ceil` | The enforced minimum must never fall below the provider's real floor | +| Maximum limit | `floor` | The enforced maximum must never exceed the provider's real ceiling | +| Receive amount | `floor` | Never show the user more than what actually arrives | + +Do all comparison and sorting with `biggystring` too, not just arithmetic. +`String(smallFloat)` can produce scientific notation, which string comparison +misreads, and float subtraction misorders large or close values. + +Never assume the provider's documented unit. Docs claiming base units while the +API returns decimals is common, and the mistake is invisible whenever the pairs +used during development return null limits. + ### Step 6: Error Handling **The API must return all applicable errors in an array.** Your plugin prioritizes which error to throw. - +Four rules, each from a shipped defect: + +1. **Limit errors outrank currency errors.** A limit failure whose code or + message also names a token, path or route must still surface as + `SwapBelowLimitError` / `SwapAboveLimitError`. `pickBestError` ranks by type, + so misclassifying hides the real amount from the user. If the provider only + reports free text, match whole phrases: a substring test for `LOW` also + matches `ALLOWANCE`. +2. **Enforce limits against `request.nativeAmount`**, never against the amount + echoed back in the quote. A provider that silently clamps an out-of-range + request returns an in-range echo, so comparing the echo lets the swap proceed + for less than the user asked, with the difference refunded at whatever rate + the provider picks. +3. **A transient failure is not an unsupported pair.** A non-OK status from an + asset or token lookup must surface as a real error. Converting it to + `SwapCurrencyError` reports "unsupported pair" for an outage and can poison + pair-capability caching. +4. **An unquotable mapped pair *is* a currency error.** Throw + `SwapCurrencyError` so the GUI omits this provider, rather than a plain + `Error` that surfaces as a hard failure. Provider network codes go stale, so + mapped-but-unquotable is a steady state, not an edge case. Define cleaners: @@ -222,23 +312,93 @@ const spendInfo: EdgeSpendInfo = { For DeFi exchanges, use `MakeTxParams` (see DeFi plugin examples). +Three fields above are decisions, not boilerplate: + +- **`orderUri` is built from your own constant** plus the order id. Never persist + a partner-supplied `statusUrl`. It renders as a tappable link in the + transaction details, so accepting its host and scheme lets a compromised + upstream steer users anywhere. +- **`isEstimate` reports what the provider actually guarantees.** Hardcoding + `false` shows the user a locked receive amount on a floating route, so a + market-moving leg silently delivers less than the quote promised. +- **`expirationDate` goes through `ensureInFuture()`.** A provider `validUntil` + can already be in the past from clock skew, which fails the quote at approval. + +#### Bound provider amounts before signing + +`fromNativeAmount` comes from the provider's response and is about to become a +signed spend, so bound it by what the user requested: + +```typescript +if (request.quoteFor === 'from' && gt(fromNativeAmount, request.nativeAmount)) { + throw new Error('Provider returned a source amount above the requested amount') +} +``` + +Bound **every** field the spend path consumes, not only the most obvious one: on +a DeFi route that includes the token-approval amount and any native value on the +transaction. Compare each against a value in **its own units** — a native fee in +wei compared against a token amount in token base units falsely rejects valid +quotes. + +Only a `from` quote pins the source amount locally. On a reverse (`to`) quote the +user pinned the receive amount, so there is nothing local to bound against. + ### Step 8: API Response Validation Always use `cleaners` to validate API responses: +Keep the **quote** and **order** responses as separate cleaners. What belongs to +which is not cosmetic: a response carrying a `depositAddress` has committed the +provider, so if `orderId` and `depositAddress` live on the quote cleaner then the +quote call *is* an order call, and the max probe cannot avoid creating one no +matter how the code is arranged. + ```typescript import { asObject, asString, asNumberString, asDate, asOptional } from 'cleaners' +import { asOptionalBlank } from './changenow' +// Quote step: prices the swap, commits to nothing. const asQuoteResponse = asObject({ + quoteId: asString, sourceAmount: asNumberString, destinationAmount: asNumberString, - depositAddress: asString, + expirationIsoDate: asDate +}) + +// Order step: the only call that commits. Made once per swap. +const asOrderResponse = asObject({ orderId: asString, - expirationIsoDate: asDate, - depositExtraId: asOptional(asString) + depositAddress: asString, + depositExtraId: asOptionalBlank(asNumberString), + sourceAmount: asNumberString, + destinationAmount: asNumberString }) ``` +If the provider offers only one endpoint that both prices and commits, say so in +a comment. The abandoned probe order is then inherent to the provider rather than +a defect in the plugin, and reviewers have accepted that where it is true. + +**Memo cleaning is a funds-safety decision.** `asOptional(asString)` (or +`asMaybe(asString)`) has two failure modes that both silently send an *untagged* +deposit, which loses funds on memo-based chains: + +- a **numeric** memo — the common shape for an XRP destination tag, including + the valid tag `0` — fails a string-only cleaner +- an **empty string** becomes an empty `EdgeMemo` rather than no memo at all + +`asOptionalBlank(asNumberString)` covers both. + +Two more cleaner notes: + +- Accept **numeric or string error codes**. A provider that returns amounts as + either shape usually does the same with codes, and `asNumberString` + normalizes them, so classification does not depend on which arrived. +- `asOptional` in this repo's `cleaners` version already maps a JSON `null` to + `undefined`. `asEither(asString, asNull)` is only needed when `null` and + absent must stay distinguishable. + ## Code Conventions Follow Edge conventions: @@ -269,7 +429,25 @@ Follow Edge conventions: Key must be uppercase with `_INIT` suffix (e.g., `GODEX_INIT`). 4. Test: Settings > Exchange Settings (disable others) > Exchange tab -**Checklist**: Builds without errors, appears in settings, quotes work, error handling works, transactions can be created/signed, order status trackable, all chains mapped +Swap plugins execute inside `edge-core-js`'s plugin WebView, not the React +Native JS context, so Metro's debugger and Hermes breakpoints cannot reach them. +Verification means rebuilding the bundle, relinking, and reading plugin logs. +Budget for that loop. + +**Exercise these paths specifically**, since they are where new plugins break +and none of them show up in a happy-path quote: + +- A **max** swap from an EVM wallet (catches a probe missing `skipChecks`) +- A **max** swap from a wallet whose balance exceeds the provider maximum + (catches a probe that throws instead of clamping) +- A **reverse** (`to`) quote, if the provider supports one +- A swap to a **memo-based** chain such as XRP or XLM (catches a memo cleaner + that drops numeric tags) +- An amount **below the minimum** and one **above the maximum**, checking the + figure the GUI actually shows +- A **token** route, not only the native asset +- A **pair the provider does not route**, confirming it fails as + `SwapCurrencyError` rather than a hard error ### Registration @@ -294,7 +472,9 @@ Plugin ID must match your `pluginId` constant. - [`edge-conventions`](https://github.com/EdgeApp/edge-conventions) - Code style, setup, git conventions **Examples**: -- `src/swap/central/template.ts` - Complete template +- `src/swap/central/template.ts` - Complete template. Its comments carry the + reasoning behind each construct; keep them for the parts you keep +- `src/swap/central/nym.ts` - Closest reviewed central reference - `src/swap/central/changenow.ts` - Production central exchange - `src/swap/defi/lifi.ts` - Production DeFi exchange @@ -309,14 +489,76 @@ Plugin ID must match your `pluginId` constant. **PR Requirements**: 1. Rebase on master -2. Submit PRs to `edge-reports-server` (reporting) and `edge-react-gui` (UI/logos) -3. Update docs if new patterns discovered -4. All linting/type checking passes - -**Common Pitfalls**: -- Missing chain mappings in `src/mappings/yourplugin.ts` -- Incorrect amount conversions (use `nativeToDenomination`/`denominationToNative`) -- Missing error handling (all types from API_REQUIREMENTS.md) -- EVM chains: use `evmChainId`, not provider network names -- Memos required for XRP, Stellar -- Quote expiration dates must be in future +2. `npm run verify` passes (prepare, lint, tsc, mocha) +3. Submit PRs to `edge-reports-server` (reporting) and `edge-react-gui` (UI/logos) +4. Update docs if new patterns discovered + +## Pre-PR Checklist + +Every item below has been raised in review on a recent provider integration. +Walking the list before opening the PR is cheaper than discovering them one +round trip at a time. See [`.cursor/BUGBOT.md`](../.cursor/BUGBOT.md) for the +same conventions phrased as review rules. + +**Amounts** + +- [ ] Every `denominationToNative` result is rounded to whole atomic units +- [ ] Minimums round up; maximums and receive amounts round down +- [ ] All comparison, sorting and arithmetic goes through `biggystring`, never + JS numbers or `String(float)` +- [ ] The provider's amount units were confirmed against a **live response**, + not just its docs +- [ ] `Number(...)` is not used to convert a high-precision amount string + +**Max quotes** + +- [ ] `getMaxSwappable` is wired in +- [ ] The probe creates no order, or a comment explains why the provider makes + that impossible +- [ ] The probe's `spendInfo` sets `skipChecks: true` +- [ ] The probe clamps rather than throwing `SwapAboveLimitError` + +**Errors** + +- [ ] Limit errors are ranked above currency errors +- [ ] Limits are enforced against `request.nativeAmount`, not the echoed amount +- [ ] A provider outage surfaces as a real error, not `SwapCurrencyError` +- [ ] An unquotable mapped pair surfaces as `SwapCurrencyError`, not a plain + `Error` +- [ ] The message the user ends up seeing was actually checked, including any + nested `fields.*.message` +- [ ] A retry backoff never truncates the provider's own `retryAfter`, and never + sleeps past the quote's expiry + +**Trust boundary** + +- [ ] Every provider amount that becomes a signed spend or approval is bounded + by the requested amount, each in its own units +- [ ] `orderUri` is built from a plugin constant, never a partner-supplied URL + +**Cleaners** + +- [ ] Memos use `asOptionalBlank(asNumberString)`, so numeric tags and empty + strings are both handled +- [ ] Error codes accept numeric or string shapes +- [ ] Every API response is cleaned, and a cleaner failure logs the payload + +**Structure** + +- [ ] `checkInvalidTokenIds()` is called +- [ ] `isEstimate` reflects whether the rate is actually fixed +- [ ] Expiration dates go through `ensureInFuture()` +- [ ] Catch bindings are typed `(error: unknown)` +- [ ] No unauthenticated endpoint is being sent an API key, and no authenticated + one is missing it +- [ ] Asset or chain lists are fetched lazily inside `fetchSwapQuote`, not at + plugin construction + +**Mappings** + +- [ ] EVM chains use `evmChainId`, not provider network names +- [ ] Chains the provider does not support are explicitly `null`, not absent +- [ ] Any non-obvious entry carries a comment with the live evidence behind it + (chain id reuse across EVM and non-EVM chains, and one ticker covering two + networks, are both common and both look like bugs without that note) +- [ ] Every mapped chain's native asset actually exists in the provider's list diff --git a/src/swap/central/template.ts b/src/swap/central/template.ts index 1aca9949..33e80e12 100644 --- a/src/swap/central/template.ts +++ b/src/swap/central/template.ts @@ -1,5 +1,7 @@ +import { ceil, floor, gt, lt } from 'biggystring' import { asArray, + asBoolean, asDate, asEither, asObject, @@ -16,6 +18,7 @@ import { EdgeSwapPlugin, EdgeSwapQuote, EdgeSwapRequest, + EdgeTokenId, SwapAboveLimitError, SwapBelowLimitError, SwapCurrencyError, @@ -25,17 +28,20 @@ import { import { template as templateMapping } from '../../mappings/template' import { EdgeCurrencyPluginId } from '../../util/edgeCurrencyPluginIds' import { + checkInvalidTokenIds, CurrencyPluginIdSwapChainCodeMap, denominationToNative, ensureInFuture, getContractAddresses, + getMaxSwappable, makeSwapPluginQuote, mapToRecord, nativeToDenomination, SwapOrder } from '../../util/swapHelpers' import { convertRequest, getAddress, memoType } from '../../util/utils' -import { asNumberString } from '../types' +import { asNumberString, EdgeSwapRequestPlugin } from '../types' +import { asOptionalBlank } from './changenow' const pluginId = 'template' @@ -51,6 +57,14 @@ const asInitOptions = asObject({ affiliateId: asOptional(asString) }) +/** + * Build the user-facing order URI from OUR OWN constant plus the order id. + * + * Never persist a partner-supplied URL (a `statusUrl` field or similar) into + * `savedAction.orderUri`. That value is rendered as a tappable link in the + * transaction details, so taking the host and scheme from an API response lets a + * compromised or misbehaving provider steer users anywhere. + */ const orderUri = 'https://example.com/?orderId=' // TODO: Replace with actual API base URL const apiBaseUrl = 'https://api.example.com/api/v1/' @@ -82,18 +96,71 @@ const asTemplateError = asObject({ ) }) +/** + * Response of the QUOTE step, which prices the swap and nothing else. + * + * Note what is NOT here: no `orderId`, no `depositAddress`. Those belong to the + * order step below. Keeping them apart is what lets the max probe run the quote + * without creating anything, so the split is a requirement rather than a + * stylistic preference. A `getQuote` that hands back a deposit address HAS + * created an order, whatever it is named. + */ const asTemplateQuote = asObject({ + quoteId: asString, sourceAmount: asNumberString, destinationAmount: asNumberString, - depositAddress: asString, - depositExtraId: asOptional(asString), - orderId: asString, + /** API should return an ISO 8601 formatted date */ - expirationIsoDate: asDate + expirationIsoDate: asDate, + + /** + * Whether the provider GUARANTEES this rate, rather than quoting a floating + * one. Drives `isEstimate`; see `fetchSwapQuoteInner`. + */ + isFixedRate: asOptional(asBoolean, false), + + /** + * Limits published alongside a SUCCESSFUL quote, in the same denominated units + * as the amounts above. Providers that only report limits as errors leave + * these absent; see the error branch in `fetchQuote`. + * + * A provider may return `null` for "no limit on this side". `asOptional` in + * this version of `cleaners` treats JSON `null` as absent, so it covers both + * a missing key and an explicit null. + */ + sourceAmountMin: asOptional(asNumberString), + sourceAmountMax: asOptional(asNumberString), + destinationAmountMin: asOptional(asNumberString), + destinationAmountMax: asOptional(asNumberString) }) const asTemplateQuoteReply = asEither(asTemplateQuote, asTemplateError) +/** + * Response of the ORDER step: the provider has now committed, and this is the + * only call that does. `fetchSwapQuoteInner` makes it exactly once per swap. + */ +const asTemplateOrder = asObject({ + orderId: asString, + depositAddress: asString, + + /** + * Deposit tag/memo for memo-based chains (XRP destination tags, XLM memos). + * + * `asOptionalBlank(asNumberString)` rather than `asOptional(asString)`. Both of + * the narrower cleaner's failure modes silently drop the memo and send an + * UNTAGGED deposit, which is a lost-funds path on those chains: + * - a NUMERIC memo (the common shape for an XRP destination tag, including the + * valid tag `0`) fails a string-only cleaner + * - an EMPTY STRING becomes an empty `EdgeMemo` rather than no memo at all + */ + depositExtraId: asOptionalBlank(asNumberString), + + /** The amount the provider expects to receive, echoed back. */ + sourceAmount: asNumberString, + destinationAmount: asNumberString +}) + interface TemplateCommonQuoteParams { fromNetwork: string toNetwork: string @@ -105,10 +172,6 @@ interface TemplateCommonQuoteParams { destinationAddress: string } -type TemplateMaxQuoteParams = TemplateCommonQuoteParams & { - maxAmount: boolean -} - type TemplateFromQuoteParams = TemplateCommonQuoteParams & { sourceAmount: string } @@ -117,10 +180,7 @@ type TemplateToQuoteParams = TemplateCommonQuoteParams & { destinationAmount: string } -type TemplateQuoteParams = - | TemplateFromQuoteParams - | TemplateToQuoteParams - | TemplateMaxQuoteParams +type TemplateQuoteParams = TemplateFromQuoteParams | TemplateToQuoteParams const EVM_CHAIN_NETWORK = 'evmChain' @@ -140,6 +200,31 @@ const getNetwork = (wallet: EdgeCurrencyWallet): string | null => { ] } +/** + * Convert a provider's decimal amount into WHOLE native (atomic) units. + * + * `denominationToNative` is a plain multiply, so a provider amount carrying more + * decimals than the asset's denomination yields a FRACTIONAL native string + * (`mul('0.123456789', '100000000')` is `12345678.9`). Edge native amounts are + * integers everywhere, so a fraction reaching `spendTargets` or the swap + * metadata is invalid. + * + * The rounding DIRECTION is not cosmetic: + * - `'up'` for a floor (a minimum limit), so the enforced minimum never rounds + * BELOW the provider's real floor and the deposit gets rejected + * - `'down'` for a ceiling and for a receive amount, so neither is ever larger + * than what the provider will actually honor + */ +const toNativeAmount = ( + wallet: EdgeCurrencyWallet, + denominatedAmount: string, + tokenId: EdgeTokenId, + rounding: 'up' | 'down' +): string => { + const native = denominationToNative(wallet, denominatedAmount, tokenId) + return rounding === 'up' ? ceil(native, 0) : floor(native, 0) +} + export function makeTemplatePlugin( opts: EdgeCorePluginOptions ): EdgeSwapPlugin { @@ -152,9 +237,31 @@ export function makeTemplatePlugin( Accept: 'application/json' } - const fetchSwapQuoteInner = async ( - request: EdgeSwapRequest - ): Promise => { + /** + * Quote step. Resolves the pair, fetches a quote, maps provider errors, and + * enforces limits. + * + * This step creates NO order, which is what makes it safe to run as the + * `getMaxSwappable` probe (see `fetchProbeOrder`). If the provider exposes + * quoting and order creation behind a SINGLE endpoint, keep that call OUT of + * the probe path anyway: a probe that creates an order leaves an abandoned + * live order at the provider on every max-swap request, and providers that + * rate-limit order creation will then reject the real one. + * + * `enforceMax` is false ONLY for the probe. A max-swap probe deliberately + * quotes the full PRE-FEE balance to discover the ceiling, so an above-limit + * balance must clamp through `getMaxSpendable` rather than throw + * `SwapAboveLimitError` and abort a max swap that would have succeeded once + * network fees were subtracted. + */ + const fetchQuote = async ( + request: EdgeSwapRequestPlugin, + enforceMax: boolean + ): Promise<{ + quote: ReturnType + fromAddress: string + toAddress: string + }> => { const { fromWallet, toWallet, quoteFor } = request const fromNetwork = getNetwork(fromWallet) @@ -173,25 +280,25 @@ export function makeTemplatePlugin( getAddress(toWallet) ]) - // Convert the native amount to a denomination: - let amount - if (quoteFor === 'from') { - const quoteAmount = nativeToDenomination( - request.fromWallet, - request.nativeAmount, - request.fromTokenId - ) - amount = { sourceAmount: quoteAmount } - } else if (quoteFor === 'to') { - const quoteAmount = nativeToDenomination( - request.toWallet, - request.nativeAmount, - request.toTokenId - ) - amount = { destinationAmount: quoteAmount } - } else { - amount = { maxAmount: true } - } + // Convert the native amount to a denomination. A 'max' request never arrives + // here as 'max': `getMaxSwappable` has already rewritten it into a 'from' + // quote for the spendable balance, so only the two real directions exist. + const isReverseQuote = quoteFor === 'to' + const amount = isReverseQuote + ? { + destinationAmount: nativeToDenomination( + toWallet, + request.nativeAmount, + request.toTokenId + ) + } + : { + sourceAmount: nativeToDenomination( + fromWallet, + request.nativeAmount, + request.fromTokenId + ) + } const { fromContractAddress, toContractAddress } = getContractAddresses( request @@ -225,7 +332,7 @@ export function makeTemplatePlugin( let quoteReply try { quoteReply = asTemplateQuoteReply(responseJson) - } catch (error) { + } catch (error: unknown) { log.warn( 'Unexpected Template API response:', JSON.stringify(responseJson) @@ -233,11 +340,25 @@ export function makeTemplatePlugin( throw error } + // The side the user pinned is the side every limit is expressed and thrown + // on, so resolve it once. + const limitSide = isReverseQuote ? 'to' : 'from' + const limitWallet = isReverseQuote ? toWallet : fromWallet + const limitTokenId = isReverseQuote + ? request.toTokenId + : request.fromTokenId + if ('errors' in quoteReply) { - // Throw errors in order of highest priority + // Throw errors in order of highest priority: // 1. Region unsupported // 2. Currency unsupported // 3. Below/Above limit + // + // If the provider reports failures as FREE TEXT rather than codes, rank + // the limit keywords BEFORE the currency keywords, so a limit failure that + // also names a token, path or route still surfaces as the limit error + // instead of a weaker `SwapCurrencyError`. Match whole phrases, not bare + // substrings: a substring test for 'LOW' also matches 'ALLOWANCE'. const errors = quoteReply.errors if (errors.find(error => error.code === 'REGION_UNSUPPORTED') != null) { throw new SwapPermissionError(swapInfo, 'geoRestriction') @@ -249,56 +370,198 @@ export function makeTemplatePlugin( error => error.code === 'BELOW_LIMIT' || error.code === 'ABOVE_LIMIT' ) if (limitError != null && 'sourceLimitAmount' in limitError) { - if (quoteFor === 'max') { - throw new Error( - `Max quote cannot return a limit error: ${JSON.stringify( - limitError - )}` - ) - } - let nativeMinMaxAmount: string - if (quoteFor === 'from') { - nativeMinMaxAmount = denominationToNative( - request.fromWallet, - limitError.sourceLimitAmount, - request.fromTokenId - ) - } else { - nativeMinMaxAmount = denominationToNative( - request.toWallet, - limitError.destinationLimitAmount, - request.toTokenId - ) - } - - if (limitError.code === 'BELOW_LIMIT') { - throw new SwapBelowLimitError(swapInfo, nativeMinMaxAmount, quoteFor) - } else { - throw new SwapAboveLimitError(swapInfo, nativeMinMaxAmount, quoteFor) - } + const isBelow = limitError.code === 'BELOW_LIMIT' + // Pick the limit field by which SIDE the user pinned, never by whether + // the error is a floor or a ceiling. `limitWallet` and `limitTokenId` + // are the pinned side's, so pairing them with the other side's amount + // converts through the wrong denomination and reports a limit that is + // wrong by the ratio between the two assets. + const nativeMinMaxAmount = toNativeAmount( + limitWallet, + isReverseQuote + ? limitError.destinationLimitAmount + : limitError.sourceLimitAmount, + limitTokenId, + // A minimum rounds UP and a maximum rounds DOWN, so neither rounding + // widens the range the provider actually accepts. + isBelow ? 'up' : 'down' + ) + // NOTE: a provider that reports limits ONLY as errors cannot have its + // above-limit case clamped by the max probe, because there is no quote + // to size against. Such a provider should be pre-checked against a + // limits endpoint before quoting, if it exposes one. + throw isBelow + ? new SwapBelowLimitError(swapInfo, nativeMinMaxAmount, limitSide) + : new SwapAboveLimitError(swapInfo, nativeMinMaxAmount, limitSide) } throw new Error( `Unknown error type: ${JSON.stringify(quoteReply.errors)}` ) } - const fromNativeAmount = denominationToNative( + // Enforce limits published ALONGSIDE a successful quote against the USER'S + // REQUESTED amount, never against the amount echoed back in the quote. A + // provider that silently CLAMPS an out-of-range request to its own ceiling + // returns a perfectly in-range echo, so comparing the echo lets the swap + // proceed for less than the user asked, with the difference refunded at + // whatever rate the provider picks. + const rawMin = isReverseQuote + ? quoteReply.destinationAmountMin + : quoteReply.sourceAmountMin + const rawMax = isReverseQuote + ? quoteReply.destinationAmountMax + : quoteReply.sourceAmountMax + + if (rawMin != null) { + const nativeMin = toNativeAmount(limitWallet, rawMin, limitTokenId, 'up') + if (lt(request.nativeAmount, nativeMin)) { + throw new SwapBelowLimitError(swapInfo, nativeMin, limitSide) + } + } + if (enforceMax && rawMax != null) { + const nativeMax = toNativeAmount( + limitWallet, + rawMax, + limitTokenId, + 'down' + ) + if (gt(request.nativeAmount, nativeMax)) { + throw new SwapAboveLimitError(swapInfo, nativeMax, limitSide) + } + } + + return { quote: quoteReply, fromAddress, toAddress } + } + + /** + * `getMaxSwappable` probe: build a `SwapOrder` from a quote ALONE, so + * `getMaxSpendable` can price the network fee before any real order — and its + * payin address — exists. The trimmed amount it computes is then run through + * the real `fetchSwapQuoteInner`, which creates exactly one order. + */ + const fetchProbeOrder = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + const { quote, fromAddress } = await fetchQuote(request, false) + const fromNativeAmount = toNativeAmount( + request.fromWallet, + quote.sourceAmount, + request.fromTokenId, + 'down' + ) + const spendInfo: EdgeSpendInfo = { + tokenId: request.fromTokenId, + spendTargets: [ + { + nativeAmount: fromNativeAmount, + // The user's own from-chain address stands in for the payin address + // that does not exist yet. It is on the correct chain, so fee + // estimation sees the same shape the real spend will have. + publicAddress: fromAddress + } + ], + networkFeeOption: 'high', + // This spend is NEVER broadcast. Its target is the user's own address, + // which engines that compare the target against their own public key + // reject with `SpendToSelfError` — every EVM chain, where the public key + // IS the address. Without this flag that error escapes `getMaxSwappable` + // and fails every max swap from an EVM wallet. The real order keeps all + // checks. + skipChecks: true, + assetAction: { + assetActionType: 'swap' + } + } + return { + request, + spendInfo, + swapInfo, + fromNativeAmount, + expirationDate: ensureInFuture(quote.expirationIsoDate) + } + } + + /** + * The ONLY call that creates an order. Runs once per swap, after + * `getMaxSwappable` has already settled on the final amount. + */ + const fetchSwapQuoteInner = async ( + request: EdgeSwapRequestPlugin + ): Promise => { + const { fromWallet, toWallet } = request + const { quote, fromAddress, toAddress } = await fetchQuote(request, true) + + // Create the order from the quote the provider already holds, so the priced + // amount cannot drift between the two calls. + const orderResponse = await io.fetch(apiBaseUrl + 'createOrder', { + headers, + method: 'POST', + body: JSON.stringify({ + quoteId: quote.quoteId, + refundAddress: fromAddress, + destinationAddress: toAddress + }) + }) + if (!orderResponse.ok) { + const text = await orderResponse.text() + log.warn('Template API error response:', text) + throw new Error(`Template returned error code ${orderResponse.status}`) + } + const orderJson = await orderResponse.json() + + let order + try { + order = asTemplateOrder(orderJson) + } catch (error: unknown) { + log.warn('Unexpected Template API response:', JSON.stringify(orderJson)) + throw error + } + + const fromNativeAmount = toNativeAmount( fromWallet, - quoteReply.sourceAmount, - request.fromTokenId + order.sourceAmount, + request.fromTokenId, + 'down' ) - const toNativeAmount = denominationToNative( + // The receive amount rounds DOWN, so the figure shown to the user is never + // larger than what the provider actually sends. + const payoutNativeAmount = toNativeAmount( toWallet, - quoteReply.destinationAmount, - request.toTokenId + order.destinationAmount, + request.toTokenId, + 'down' ) + + // TRUST BOUNDARY. `fromNativeAmount` comes from the provider's response and + // is about to become a SIGNED SPEND, so bound it by what the user actually + // requested. Without this, a compromised or malformed response can move more + // of the source asset than the quote asked for. + // + // Bound every field the spend path CONSUMES, not just the one field that is + // easiest to reach — on a DeFi route that includes any token-approval amount + // and any native value attached to the transaction. Compare each against a + // value in ITS OWN units: a native fee in wei must not be compared against a + // token amount in token base units, or legitimate quotes get rejected. + // + // Only a 'from' quote pins the source amount locally. On a reverse ('to') + // quote the user pinned the RECEIVE amount, so the source side is the + // provider's to determine and there is nothing local to bound it against. + if ( + request.quoteFor === 'from' && + gt(fromNativeAmount, request.nativeAmount) + ) { + throw new Error( + 'Template returned a source amount above the requested amount' + ) + } + const memos: EdgeMemo[] = - quoteReply.depositExtraId == null + order.depositExtraId == null ? [] : [ { - type: memoType(request.fromWallet.currencyInfo.pluginId), - value: quoteReply.depositExtraId + type: memoType(fromWallet.currencyInfo.pluginId), + value: order.depositExtraId } ] @@ -308,7 +571,7 @@ export function makeTemplatePlugin( spendTargets: [ { nativeAmount: fromNativeAmount, - publicAddress: quoteReply.depositAddress + publicAddress: order.depositAddress } ], memos, @@ -319,13 +582,16 @@ export function makeTemplatePlugin( savedAction: { actionType: 'swap', swapInfo, - orderId: quoteReply.orderId, - orderUri: orderUri + quoteReply.orderId, - isEstimate: false, + orderId: order.orderId, + orderUri: orderUri + order.orderId, + // Report what the provider ACTUALLY guarantees. Hardcoding `false` shows + // the user a locked receive amount on a floating route, so a + // market-moving leg silently delivers less than the quote promised. + isEstimate: !quote.isFixedRate, toAsset: { pluginId: toWallet.currencyInfo.pluginId, tokenId: request.toTokenId, - nativeAmount: toNativeAmount + nativeAmount: payoutNativeAmount }, fromAsset: { pluginId: fromWallet.currencyInfo.pluginId, @@ -340,22 +606,37 @@ export function makeTemplatePlugin( log('spendInfo', spendInfo) - const requestPlugin = convertRequest(request) - return { - request: requestPlugin, + request, spendInfo, swapInfo, fromNativeAmount, - expirationDate: ensureInFuture(quoteReply.expirationIsoDate) + expirationDate: ensureInFuture(quote.expirationIsoDate) } } const out: EdgeSwapPlugin = { swapInfo, - async fetchSwapQuote(request: EdgeSwapRequest): Promise { - const swapOrder = await fetchSwapQuoteInner(request) + async fetchSwapQuote(req: EdgeSwapRequest): Promise { + const request = convertRequest(req) + + // Reject blocked assets and same-asset (self) swaps CLIENT-SIDE, before + // any provider endpoint is hit. This shared helper also carries the + // repo-wide `defaultInvalidCodes` list, so skipping it opts out of every + // future entry added there as well. + // + // If a provider's main flow is legitimately same-asset (a mixer, a private + // send), extend the helper rather than dropping the call, so the blocked + // list still applies. + checkInvalidTokenIds({ from: {}, to: {} }, request, swapInfo) + + // A 'max' request arrives carrying the wallet's RAW balance. + // `getMaxSwappable` probes with `fetchProbeOrder` to price network fees, + // rewrites the request as a 'from' quote for the spendable remainder, and + // only then does the real quote below create an order. + const newRequest = await getMaxSwappable(fetchProbeOrder, request) + const swapOrder = await fetchSwapQuoteInner(newRequest) return await makeSwapPluginQuote(swapOrder) } } diff --git a/test/template.test.ts b/test/template.test.ts new file mode 100644 index 00000000..2d2cd747 --- /dev/null +++ b/test/template.test.ts @@ -0,0 +1,763 @@ +import { sub } from 'biggystring' +import { assert } from 'chai' +import { + EdgeCorePluginOptions, + EdgeCurrencyWallet, + EdgeSpendInfo, + EdgeSwapPlugin, + EdgeSwapRequest, + EdgeTransaction +} from 'edge-core-js/types' +import { describe, it } from 'mocha' + +import { makeTemplatePlugin } from '../src/swap/central/template' + +/** + * `template.ts` is never registered in `src/index.ts`, so nothing exercises it + * at runtime. It is still the file every new provider integration is told to + * copy, which makes each construct in it a claim about what a correct plugin + * does. These tests are what make those claims checkable, and they double as + * the starting test suite a new integration can copy alongside the template. + * + * Each case below corresponds to a defect that reached review on a shipped + * provider PR. + */ + +// Checksummed EVM address. Edge's Ethereum engine stores this exact string as +// `walletLocalData.publicKey` AND returns it from `getAddresses`, so a spend +// targeting the user's own address is indistinguishable from a spend to self. +const ETH_ADDRESS = '0x9A5c4A9F9E6f3fC7f8E1B8B0C9d5e6A7B8C9d0E1' +const XRP_ADDRESS = 'rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh' +const DEPOSIT_ADDRESS = '0x1111111111111111111111111111111111111111' +const USDC_TOKEN_ID = 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +/** On the shared `defaultInvalidCodes` list in `swapHelpers.ts`. */ +const REP_TOKEN_ID = '1985365e9f78359a9b6ad760e32412f4a445e862' + +const ETH_MULTIPLIER = '1000000000000000000' +const XRP_MULTIPLIER = '1000000' +const USDC_MULTIPLIER = '1000000' + +const ETH_BALANCE = '1000000000000000000' // 1 ETH +const ETH_FEE = '196600000000000' +const USDC_BALANCE = '14009000' // 14.009 USDC + +/** + * Mirrors the `SpendToSelfError` guard in edge-currency-accountbased's + * `makeSpendCheck`, which runs before every engine spend estimate. The max + * probe targets the user's own address, so without `skipChecks` this fires. + */ +class SpendToSelfError extends Error { + name = 'SpendToSelfError' + constructor() { + super('Spend to self') + } +} + +interface FakeWalletOpts { + pluginId: string + currencyCode: string + address: string + multiplier: string + evmChainId?: number + balanceMap?: Map + /** Records every spend the plugin asks the engine to price. */ + spendLog?: EdgeSpendInfo[] +} + +const makeFakeWallet = (opts: FakeWalletOpts): EdgeCurrencyWallet => { + const { + address, + balanceMap = new Map(), + currencyCode, + evmChainId, + multiplier, + pluginId, + spendLog = [] + } = opts + + const checkSpend = (spendInfo: EdgeSpendInfo): void => { + spendLog.push(spendInfo) + const { skipChecks = false } = spendInfo + for (const spendTarget of spendInfo.spendTargets) { + if (!skipChecks && spendTarget.publicAddress === address) { + throw new SpendToSelfError() + } + } + } + + const currencyInfo = { + pluginId, + currencyCode, + evmChainId, + denominations: [{ name: currencyCode, multiplier }] + } + + return ({ + id: `${pluginId}-wallet`, + balanceMap, + currencyInfo, + currencyConfig: { + // `SwapCurrencyError` reads the pluginId through here. + currencyInfo, + allTokens: { + [USDC_TOKEN_ID]: { + currencyCode: 'USDC', + denominations: [{ name: 'USDC', multiplier: USDC_MULTIPLIER }], + networkLocation: { contractAddress: `0x${USDC_TOKEN_ID}` } + }, + [REP_TOKEN_ID]: { + currencyCode: 'REP', + denominations: [{ name: 'REP', multiplier: ETH_MULTIPLIER }], + networkLocation: { contractAddress: `0x${REP_TOKEN_ID}` } + } + } + }, + async getAddresses() { + return [{ addressType: 'publicAddress', publicAddress: address }] + }, + async getMaxSpendable(spendInfo: EdgeSpendInfo) { + checkSpend(spendInfo) + const balance = balanceMap.get(spendInfo.tokenId) ?? '0' + // Matches the Ethereum engine: the token branch spends the whole token + // balance (the fee comes out of the parent currency), while the native + // branch holds back the network fee. + return spendInfo.tokenId == null ? sub(balance, ETH_FEE) : balance + }, + async makeSpend(spendInfo: EdgeSpendInfo): Promise { + checkSpend(spendInfo) + return ({ + networkFee: '0', + parentNetworkFee: ETH_FEE, + savedAction: spendInfo.savedAction, + assetAction: spendInfo.assetAction, + tokenId: spendInfo.tokenId + } as unknown) as EdgeTransaction + } + } as unknown) as EdgeCurrencyWallet +} + +interface FakeIoOpts { + /** Extra fields merged onto every successful quote body. */ + quoteExtra?: Record + /** Extra fields merged onto every successful order body. */ + orderExtra?: Record + /** When set, the quote endpoint answers with this structured error body. */ + quoteErrors?: unknown[] + /** Records ` ` for every request the plugin sends. */ + requestLog?: string[] +} + +/** + * A two-endpoint provider: `getQuote` prices, `createOrder` commits. The quote + * echoes the requested amount back, as a real provider does, so a max swap's + * second (post-`getMaxSpendable`) quote reports the trimmed amount. + * + * Keeping these separate is what lets a test assert that the max probe created + * nothing, rather than merely counting calls. + */ +const makeFakeIo = (opts: FakeIoOpts = {}): { fetch: Function } => { + const { + orderExtra = {}, + quoteErrors, + quoteExtra = {}, + requestLog = [] + } = opts + const expirationIsoDate = new Date(Date.now() + 10 * 60 * 1000).toISOString() + let lastQuoteAmounts = { sourceAmount: '0.5', destinationAmount: '1200' } + + return { + fetch: async (uri: string, fetchOpts: { body: string }) => { + const endpoint = uri.split('/').pop() ?? '' + const sent = JSON.parse(fetchOpts.body) + requestLog.push(`${endpoint} ${fetchOpts.body}`) + + let body: Record + if (endpoint === 'createOrder') { + body = { + orderId: 'order-1', + depositAddress: DEPOSIT_ADDRESS, + ...lastQuoteAmounts, + ...orderExtra + } + } else if (quoteErrors != null) { + body = { errors: quoteErrors } + } else { + lastQuoteAmounts = { + sourceAmount: sent.sourceAmount ?? '0.5', + destinationAmount: sent.destinationAmount ?? '1200' + } + body = { + quoteId: 'quote-1', + ...lastQuoteAmounts, + expirationIsoDate, + ...quoteExtra + } + } + + return { + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body) + } + } + } +} + +/** Count how many times the plugin hit a given endpoint. */ +const countCalls = (requestLog: string[], endpoint: string): number => + requestLog.filter(entry => entry.startsWith(`${endpoint} `)).length + +const makePlugin = (opts: FakeIoOpts = {}): EdgeSwapPlugin => + makeTemplatePlugin(({ + io: makeFakeIo(opts), + initOptions: { apiKey: 'test-key' }, + log: Object.assign(() => {}, { warn() {} }) + } as unknown) as EdgeCorePluginOptions) + +const makeEthWallet = ( + balanceMap: Map, + spendLog?: EdgeSpendInfo[] +): EdgeCurrencyWallet => + makeFakeWallet({ + pluginId: 'ethereum', + currencyCode: 'ETH', + address: ETH_ADDRESS, + multiplier: ETH_MULTIPLIER, + evmChainId: 1, + balanceMap, + spendLog + }) + +const makeXrpWallet = (spendLog?: EdgeSpendInfo[]): EdgeCurrencyWallet => + makeFakeWallet({ + pluginId: 'ripple', + currencyCode: 'XRP', + address: XRP_ADDRESS, + multiplier: XRP_MULTIPLIER, + spendLog + }) + +describe('template max quotes', function () { + it('probes without spending to self, so EVM max swaps work', async function () { + // Regression shape: a probe whose spendInfo omits `skipChecks` targets the + // user's own address, and every EVM engine rejects that with + // `SpendToSelfError`, failing max swaps that normal swaps handle fine. + const spendLog: EdgeSpendInfo[] = [] + const plugin = makePlugin() + const fromWallet = makeEthWallet(new Map([[null, ETH_BALANCE]]), spendLog) + + const request: EdgeSwapRequest = { + fromWallet, + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: ETH_BALANCE, + quoteFor: 'max' + } + + const quote = await plugin.fetchSwapQuote(request, undefined, { + infoPayload: {} + }) + + const probeSpend = spendLog[0] + assert.equal( + probeSpend.skipChecks, + true, + 'the max probe must set skipChecks' + ) + assert.equal(probeSpend.spendTargets[0].publicAddress, ETH_ADDRESS) + // The quote is sized to the balance minus the network fee, not the raw + // balance the request arrived with. + assert.equal(quote.fromNativeAmount, sub(ETH_BALANCE, ETH_FEE)) + }) + + it('creates exactly one order across a max swap', async function () { + // Regression shape: when order creation sits in the probed path, every max + // swap creates and abandons a live order at the provider. Counting total + // requests would NOT catch that, since the probe legitimately quotes twice. + // Assert on the order endpoint specifically. + const requestLog: string[] = [] + const plugin = makePlugin({ requestLog }) + + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: ETH_BALANCE, + quoteFor: 'max' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(countCalls(requestLog, 'getQuote'), 2, 'probe + real quote') + assert.equal( + countCalls(requestLog, 'createOrder'), + 1, + 'the probe must create nothing; only the real pass orders' + ) + // The order is created from the trimmed amount, after the probe priced fees. + const orderCall = requestLog.find(e => e.startsWith('createOrder ')) + assert.isDefined(orderCall) + }) + + it('creates no order at all when the probe itself fails', async function () { + // If the probe path could order, a pair the provider rejects would still + // leave a live order behind. + const requestLog: string[] = [] + const plugin = makePlugin({ + requestLog, + quoteErrors: [{ code: 'CURRENCY_UNSUPPORTED', message: 'no route' }] + }) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: ETH_BALANCE, + quoteFor: 'max' + }, + undefined, + { infoPayload: {} } + ), + 'SwapCurrencyError' + ) + + assert.equal(countCalls(requestLog, 'createOrder'), 0) + }) + + it('clamps an above-limit balance instead of throwing', async function () { + // Regression shape: the probe quotes the full PRE-FEE balance, so throwing + // SwapAboveLimitError there aborts a max swap that fits once fees come out. + const plugin = makePlugin({ + quoteExtra: { + // Ceiling sits between the raw balance and the fee-trimmed amount. + sourceAmountMax: '0.9999' + } + }) + + const quote = await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: ETH_BALANCE, + quoteFor: 'max' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(quote.fromNativeAmount, sub(ETH_BALANCE, ETH_FEE)) + }) + + it('still throws above-limit on an explicit from request', async function () { + const plugin = makePlugin({ quoteExtra: { sourceAmountMax: '0.25' } }) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', // 0.5 ETH + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapAboveLimitError' + ) + }) +}) + +describe('template limits', function () { + it('enforces limits against the requested amount, not the echo', async function () { + // Regression shape: a provider that silently CLAMPS an out-of-range request + // returns an in-range echo, so comparing the echo lets the swap proceed for + // less than the user asked. + const plugin = makePlugin({ + quoteExtra: { + sourceAmountMin: '0.25', + // The echo is in range even though the request is not. + sourceAmount: '0.25' + } + }) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '10000000000000000', // 0.01 ETH, below the 0.25 floor + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapBelowLimitError' + ) + }) + + it('rounds a minimum limit up to whole native units', async function () { + // Regression shape: `denominationToNative` is a plain multiply, so a limit + // with more decimals than the asset yields a fraction. Rounding a floor + // DOWN would also let an amount under the provider's real minimum through. + const plugin = makePlugin({ + // 6-decimal XRP: 0.0000015 XRP is 1.5 drops. + quoteExtra: { destinationAmountMin: '0.0000015' } + }) + + const error = await captureRejection( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '1', // 1 drop, below the rounded-up 2-drop floor + quoteFor: 'to' + }, + undefined, + { infoPayload: {} } + ) + ) + + assert.equal(error.name, 'SwapBelowLimitError') + assert.equal( + ((error as unknown) as { nativeMin: string }).nativeMin, + '2', + 'a 1.5-drop floor must round UP to 2 drops, never down to 1' + ) + }) + + it('reads a limit error from the pinned side, not the error kind', async function () { + // The wallet and tokenId used for the conversion are the PINNED side's, so + // the amount has to come from that side too. Picking the field by + // below-vs-above instead converts through the wrong denomination and + // reports a limit wrong by the ratio between the two assets. + const plugin = makePlugin({ + quoteErrors: [ + { + code: 'ABOVE_LIMIT', + message: 'amount too high', + sourceLimitAmount: '0.25', // ETH, the pinned side on a `from` quote + destinationLimitAmount: '900' // XRP, the other side + } + ] + }) + + const error = await captureRejection( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + ) + + assert.equal(error.name, 'SwapAboveLimitError') + assert.equal( + ((error as unknown) as { nativeMax: string }).nativeMax, + '250000000000000000', // 0.25 ETH in wei + 'the ceiling must be the SOURCE limit read in ETH, not the XRP one' + ) + }) + + it('ranks multiple errors by priority, not array order', async function () { + // The API returns every applicable error at once, so the plugin picks. The + // documented order is region, then currency, then limit. Here the limit + // error arrives LAST in the array and the currency error must still win — + // ranking that follows array order silently changes with the provider. + const plugin = makePlugin({ + quoteErrors: [ + { code: 'CURRENCY_UNSUPPORTED', message: 'no route for this pair' }, + { + code: 'BELOW_LIMIT', + message: 'amount too low', + sourceLimitAmount: '0.25', + destinationLimitAmount: '100' + } + ] + }) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '10000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapCurrencyError' + ) + }) +}) + +describe('template trust boundary', function () { + it('rejects a source amount above the requested amount', async function () { + // Regression shape: the provider's amount becomes a SIGNED SPEND, so an + // inflated response can move more of the source asset than was quoted. + const plugin = makePlugin({ + orderExtra: { sourceAmount: '0.9' } // request below asks for 0.5 + }) + + const error = await captureRejection( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', // 0.5 ETH + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + ) + + assert.include(error.message, 'above the requested amount') + }) + + it('builds the order URI from the plugin constant', async function () { + // Regression shape: a partner-supplied `statusUrl` persisted into + // `orderUri` renders as a tappable link, so a compromised upstream could + // steer users anywhere. + const spendLog: EdgeSpendInfo[] = [] + const plugin = makePlugin({ + orderExtra: { statusUrl: 'https://evil.example/steal' } + }) + + await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]]), spendLog), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + const { savedAction } = spendLog[0] + if (savedAction?.actionType !== 'swap') throw new Error('expected a swap') + assert.equal(savedAction.orderUri, 'https://example.com/?orderId=order-1') + }) +}) + +describe('template cleaners', function () { + it('keeps a numeric deposit memo', async function () { + // Regression shape: `asOptional(asString)` drops a NUMERIC memo, which is + // the common shape for an XRP destination tag, and the deposit then goes + // out untagged. That loses funds on memo-based chains. + const spendLog: EdgeSpendInfo[] = [] + const plugin = makePlugin({ orderExtra: { depositExtraId: 1234567890 } }) + + await plugin.fetchSwapQuote( + { + fromWallet: makeXrpWallet(spendLog), + fromTokenId: null, + toWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + toTokenId: null, + nativeAmount: '10000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.deepEqual(spendLog[0].memos, [ + { type: 'number', value: '1234567890' } + ]) + }) + + it('keeps the XRP destination tag 0', async function () { + // `0` is a valid destination tag, and is the value most likely to be lost + // to a truthiness check somewhere along the way. + const spendLog: EdgeSpendInfo[] = [] + const plugin = makePlugin({ orderExtra: { depositExtraId: 0 } }) + + await plugin.fetchSwapQuote( + { + fromWallet: makeXrpWallet(spendLog), + fromTokenId: null, + toWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + toTokenId: null, + nativeAmount: '10000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.deepEqual(spendLog[0].memos, [{ type: 'number', value: '0' }]) + }) + + it('treats a blank deposit memo as absent', async function () { + // Regression shape: an empty string becomes an empty `EdgeMemo`, which can + // break fee estimation or broadcast on memo-sensitive chains. + const spendLog: EdgeSpendInfo[] = [] + const plugin = makePlugin({ orderExtra: { depositExtraId: '' } }) + + await plugin.fetchSwapQuote( + { + fromWallet: makeXrpWallet(spendLog), + fromTokenId: null, + toWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + toTokenId: null, + nativeAmount: '10000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.deepEqual(spendLog[0].memos, [], 'a blank memo must become no memo') + }) +}) + +describe('template guards', function () { + it('rejects a same-asset swap before any network call', async function () { + // Regression shape: skipping `checkInvalidTokenIds` lets a self-swap and + // every blocked asset reach the partner API. + const requestLog: string[] = [] + const plugin = makePlugin({ requestLog }) + const wallet = makeEthWallet(new Map([[null, ETH_BALANCE]])) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: wallet, + fromTokenId: null, + toWallet: wallet, + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapCurrencyError' + ) + assert.equal(requestLog.length, 0, 'no request should reach the provider') + }) + + it('rejects the shared blocked-token list', async function () { + const plugin = makePlugin() + const wallet = makeEthWallet( + new Map([ + [null, ETH_BALANCE], + [USDC_TOKEN_ID, USDC_BALANCE] + ]) + ) + + await assertRejects( + async () => + await plugin.fetchSwapQuote( + { + fromWallet: wallet, + fromTokenId: REP_TOKEN_ID, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ), + 'SwapCurrencyError' + ) + }) + + it('reports a floating rate as an estimate', async function () { + // Regression shape: hardcoding `isEstimate: false` shows the user a LOCKED + // receive amount on a floating route. + const plugin = makePlugin() // no isFixedRate: defaults to floating + + const quote = await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(quote.isEstimate, true) + }) + + it('reports a fixed rate as guaranteed', async function () { + const plugin = makePlugin({ quoteExtra: { isFixedRate: true } }) + + const quote = await plugin.fetchSwapQuote( + { + fromWallet: makeEthWallet(new Map([[null, ETH_BALANCE]])), + fromTokenId: null, + toWallet: makeXrpWallet(), + toTokenId: null, + nativeAmount: '500000000000000000', + quoteFor: 'from' + }, + undefined, + { infoPayload: {} } + ) + + assert.equal(quote.isEstimate, false) + }) +}) + +/** Assert that `fn` rejects with an error whose `name` matches. */ +const assertRejects = async ( + fn: () => Promise, + errorName: string +): Promise => { + const error = await captureRejection(fn) + assert.equal(error.name, errorName, `got: ${error.name}: ${error.message}`) +} + +/** Run `fn`, returning the error it rejected with, or failing if it resolved. */ +const captureRejection = async (fn: () => Promise): Promise => { + try { + await fn() + } catch (error: unknown) { + if (error instanceof Error) return error + throw error + } + throw new Error('expected a rejection, but the call resolved') +}