From 2af3c9d8bf744a8ae7b75b628b61ceb47c34dd50 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 6 Aug 2026 10:59:51 -0700 Subject: [PATCH 1/4] Harden the swap plugin template against recurring review findings The template is what docs/CREATING_AN_EXCHANGE_PLUGIN.md tells every new provider integration to copy, so each construct it omits gets rediscovered in review on every new plugin. Model the constructs that recur. Add the max-quote probe path. getMaxSwappable rewrites a max request into a from quote for the full pre-fee balance, which is the densest defect site in the review record: probes that create a live order, probes rejected with SpendToSelfError for lacking skipChecks, and probes that throw above-limit on a raw balance instead of clamping. Split quote fetching from order creation, add fetchProbeOrder, and gate the above-limit throw on enforceMax. Call checkInvalidTokenIds, which carries both the repo-wide blocked list and the same-asset guard. Round every provider amount to whole atomic units, up for a floor and down for a ceiling, so no rounding widens the accepted range. Bound the provider-returned source amount by the requested amount before it becomes a signed spend. Clean the deposit memo with asOptionalBlank(asNumberString), so a numeric destination tag or an empty string cannot silently become an untagged deposit. Derive isEstimate from the quote instead of hardcoding false, enforce limits against the requested amount rather than the echoed one, and type the catch binding. --- src/swap/central/template.ts | 449 ++++++++++++++++++++++++++++------- 1 file changed, 365 insertions(+), 84 deletions(-) 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) } } From ba38a2ba58517c35454da24d16f7fe3e7799f82e Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 6 Aug 2026 11:07:51 -0700 Subject: [PATCH 2/4] Add a test suite for the plugin template The template is never registered, so nothing exercised it. Each construct in it is a claim about what a correct plugin does, and these tests are what make those claims checkable. Every case corresponds to a defect that reached review on a shipped provider PR: the max probe spending to self, the probe creating an order, the probe throwing above-limit on a raw balance, limits compared against the echoed amount, a floor limit rounding down, a provider amount exceeding the request, a numeric or blank deposit memo, a same-asset swap reaching the network, and a floating rate reported as guaranteed. The suite discriminates: 13 of its 16 cases fail against the previous template. The three that pass cover behavior it already had right. It also gives a new integration a starting test suite to copy alongside the template, since the fake wallet models the parts that matter here, the SpendToSelfError guard and the fee-trimming getMaxSpendable. --- test/template.test.ts | 763 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 test/template.test.ts 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') +} From b5c343d2481f5601f28e03f64f8d3806a4c27990 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 6 Aug 2026 11:01:29 -0700 Subject: [PATCH 3/4] Add AGENTS.md and Bugbot review rules AGENTS.md indexes the existing docs and carries the two facts an agent cannot cheaply discover: swap plugins execute inside edge-core-js's plugin WebView where Metro's debugger cannot reach them, and Edge amounts are integer atomic units while provider APIs speak decimals. .cursor/BUGBOT.md records the conventions that recent provider reviews settled, so each one is cited rather than rediscovered. It also records the two areas where automated review is least reliable here: chain identity claims, which need live provider metadata rather than a numeric id, and the error-body logging the template prescribes. --- .cursor/BUGBOT.md | 207 ++++++++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 64 ++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .cursor/BUGBOT.md create mode 100644 AGENTS.md 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 From 60f9df5a9b1d3cdde3e477d6a8781a2cdf82b91d Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 6 Aug 2026 11:03:28 -0700 Subject: [PATCH 4/4] Rewrite the plugin guide around what review actually catches Add the max-quote section the guide never had. getMaxSwappable runs the quote function twice and hands it the raw pre-fee balance, which is not discoverable from the interface and is where new plugins break most often. Expand amount conversion to cover integer rounding and its direction, and error handling to cover error ranking, enforcing limits against the requested amount, and the two directions a pair failure can be misclassified. Document the fields in EdgeSpendInfo that are decisions rather than boilerplate, and add the guard bounding provider amounts before they become a signed spend. Explain why memo cleaning is a funds-safety choice on memo-based chains. Replace the generic testing and pitfalls lists with the swap paths that actually break and a pre-PR checklist, each item traceable to a finding on a shipped integration. --- CHANGELOG.md | 5 + docs/CREATING_AN_EXCHANGE_PLUGIN.md | 294 +++++++++++++++++++++++++--- 2 files changed, 273 insertions(+), 26 deletions(-) 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