Skip to content

Swapping tokens

A 402 names a token. Your wallet holds what it holds. Sooner or later those disagree: you have XLM and the invoice wants USDC, so planPayment() reports INSUFFICIENT_TOKEN and stops.

This page documents an optional helper for that moment. It is a convenience, not a feature of paying, and the most important thing to understand about it is what it does not do.

Converting one asset into another is a priced, irreversible act. A payment library should not do that on your behalf because it noticed you were short, so PipRail does not.

This follows the SDK build standard, quoted here because it is the actual rule and not a promise invented for this page:

Opt-in, defaults unchanged. New capability is a new optional field or method. Omitting it leaves behaviour byte-identical. sdk/STANDARDS.md §0

That is enforced by a test, not by good intentions. sdk/test/swap.test.ts asserts that a driver with no swap support answers quoteSwap() with null, that swap() fails with a clear message rather than a crash, and that the whole read-only surface behaves identically whether or not swapping is available.

If you would rather bridge somewhere else, use an exchange, or top the wallet up by hand, those are all perfectly good answers. This helper exists so that doing it inside the SDK is possible, not so that it is expected.

🔴 Two safety facts, before anything else

Section titled “🔴 Two safety facts, before anything else”

1. Your spend policy does not govern swaps. Every cap in spend controls (per-payment, per-network-and-asset, the grand total, the payment count, the time envelope) applies to paying a merchant. A swap converts your own funds between denominations instead, so it sits outside all of them.

2. There is deliberately no MCP tool and no agent tool for swapping. It follows directly from the first fact: an autonomous agent able to swap could burn a wallet down through fees and slippage without ever tripping a budget check. So quoteSwap() and swap() are for a developer writing code, who can see and bound what they are doing.

If you want an agent to be able to swap, you must wire that yourself, deliberately, with your own limits around it. PipRail will not hand it to a model by default, and PIPRAIL_AGENT_GUIDE tells the model plainly that the tool does not exist so it does not go looking for one.

PipRail implements swapping only where an open, keyless route exists: one that needs no API key, no account and no signup, and that never takes possession of your funds. That is a deliberately narrow rule, and it is what keeps the feature free of gatekeepers.

There are two tiers, and the difference matters more than the coverage count: on some chains the ledger itself swaps and no third party exists at all, while on the rest a named venue routes it. Never blur the two.

Tier 1: the ledger does the swap (no third party at all)

Section titled “Tier 1: the ledger does the swap (no third party at all)”
FamilyRouteHowThird party
StellarStellar SDEXPathPaymentStrictReceive to your own account, across the order books and liquidity poolsnone
XRP LedgerXRPL DEX + AMMCross-currency Payment to your own address, across the order books and AMM pools, auto-bridged through XRPnone

No API key, no extra dependency, no integrator fee, and nothing to trust beyond the chain you are already using. The trade happens inside one transaction you sign.

Tier 2: a third-party aggregator routes it (named openly)

Section titled “Tier 2: a third-party aggregator routes it (named openly)”

Most chains have no protocol-level swap, so a router is unavoidable. Where we use one, it is named, and it had to be open and keyless to qualify.

FamilyProviderKeylessFee taken by the provider
SolanaJupiter✅ verifiednone (platformFee: null)
EVM (9 chains)KyberSwap✅ verifiedno integrator fee
SuiAftermath✅ verifiednone added; pool fee only
NEARRef Finance✅ verifiednone; pool fee only
AlgorandVestige✅ verifiednone; pool fee only
AptosHyperion✅ no API at allnone; pool fee only
TONSTON.fi✅ verifiednone; pool fee only
TronSunSwap V2✅ no API at allnone; pool fee only

The 9 EVM chains are Ethereum, Base, BNB Chain, Polygon, Arbitrum, Optimism, Avalanche, Linea and Robinhood Chain. Each was probed live with a real stablecoin route before being listed.

Celo and Scroll are deliberately absent. KyberSwap answers on both but returns no route even for the most liquid pair, so listing them would advertise a swap that cannot execute. quoteSwap() returns null there, which is the honest answer.

PipRail takes nothing on top, ever. We never set an integrator or platform fee field, on any provider.

Aptos and Tron are called contract-to-contract. There is no service in the middle: the quote is an on-chain read and the swap is a contract call your own key signs. Nothing to rate-limit, no key to rotate, no host to go down. On Tron that was a deliberate choice, because SunSwap’s own front end talks to an undocumented, obfuscated hostname that a payments SDK should not depend on.

Most routers are exact-input: you say what you will spend and find out what arrives. An x402 invoice is the other way round, so those routes are sized from a probe and then checked to clear the invoice. Three routes take the invoice amount directly and cap the input on-chain instead, which is strictly better:

FamilyHowWhat it means
Aptosexact_output_swap_entryYou ask for 0.05 USDT and receive exactly 0.05 USDT
TronswapETHForExactTokens and siblingsSame, with the input ceiling a contract argument (or callValue when selling native TRX)
TONreverse_swap simulationFixes the ask side; PipRail pads the request so the on-chain floor is at least the invoice (slightly more is possible, less is refused by the router)

Every other route on this page carries transaction hashes. Tron does not, and we would rather say that plainly than quietly show an empty table.

The route is real, and everything about it that can be checked without spending has been. Both directions quote live through the SDK, and the approve and the router call each execute cleanly in a constant-call simulation against the real contracts.

It has not been broadcast because Tron charges about 230,629 ENERGY per swap, which without staked energy is roughly 23 TRX (about $7.79) regardless of trade size. Selling a TRC-20 rather than native TRX adds an approve, measured at about 99,800 more energy the first time, taking that direction to roughly 33 TRX. The project’s test wallets hold 8.1 TRX. A user with energy can swap today; we simply have not paid to prove it.

The quote says so before you commit: quote.source.note names the energy charge, and names the extra approve when you are selling a token. (estimateCost() will not tell you this. It prices a payment, not a swap.)

PipRail does not run a price oracle, and a swap rate is a price. So every quote names who produced it:

const quote = await client.quoteSwap({ from: 'native', to: 'USDC', wantAmount: '0.50' })
quote.source
// { kind: 'protocol',
// name: 'Stellar SDEX',
// note: 'Rate from the ledger own order books and liquidity pools, read live from Horizon…' }

kind: 'protocol' means the chain’s own market priced it and no company was involved. A future third-party provider would carry kind: 'provider', so the difference stays visible in the type system rather than buried in a changelog.

Read source before you trust a number, exactly as you would check which facilitator settled a payment.

The shape mirrors quote() then pay(), which the agent guide already teaches: look first, then commit.

import { PipRailClient, summarizeSwap } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'stellar', wallet: { key: process.env.STELLAR_KEY } })
// "I need 0.50 USDC. What would that cost me in XLM?"
const quote = await client.quoteSwap({
from: 'native', // what you hold
to: 'USDC', // what the invoice wants
wantAmount: '0.50', // exact output, because that is the shape of an invoice
})
if (!quote) {
// No route, no liquidity, a read failed, or this chain has no swap support.
// `null` means "no quote". It never means "no funds".
return
}
console.log(summarizeSwap(quote))
// Swap ~2.6417 XLM → 0.50 USDC (at most 2.6549 XLM, 0.5% slippage). Rate from Stellar SDEX.

quoteSwap() never throws for a read problem. No route, no liquidity, a dead node, a hostile response: every one of them returns null, matching estimateCost() and balanceOf(). The one thing that does throw is a malformed slippageBps (a RangeError, before any read happens), because that is a bug in your code rather than a market condition, and hiding it behind null would help nobody.

const receipt = await client.swap(quote)
console.log(receipt.transaction) // the tx hash, verifiable on any public explorer

Pass the quote back unmodified. Re-quote rather than reusing an old one: a stale route is how you get a worse price than the one you were shown.

Slippage is enforced by the chain, not by us

Section titled “Slippage is enforced by the chain, not by us”

quoteSwap() returns maxSpend: the most you can possibly spend. That number is written into the transaction, so the chain enforces it, not this library: Stellar sendMax, XRPL SendMax, amountInMax on EVM and Tron, amount_in_max on Aptos, and on TON the mirror image, a min_ask_units floor on what must arrive.

If the market moves past your tolerance, the transaction fails and nothing is swapped. On chains that charge for a reverted transaction (EVM, Aptos, Tron) the gas is still taken; on the others nothing at all is spent. Either way it is the guard working correctly, and it surfaces as an InsufficientFundsError whose message says exactly that rather than leaving you to guess.

The default tolerance is 0.5% (DEFAULT_SLIPPAGE_BPS = 50), and the ceiling is 10%.

await client.quoteSwap({ from: 'native', to: 'USDC', wantAmount: '0.50', slippageBps: 100 }) // 1%

The maths rounds up, so the cap is never tighter than you asked for, and it is pure integer arithmetic. No floating point goes anywhere near money.

Three pure helpers are exported so you can reason about slippage without a client:

ExportWhat it does
DEFAULT_SLIPPAGE_BPSThe default tolerance, 50 (0.5%). Applied when you pass no slippageBps.
MAX_SLIPPAGE_BPSThe ceiling, 1000 (10%). Past this, a swap is a donation to an arbitrageur.
resolveSlippageBps(bps?)Validates and defaults a tolerance. Throws RangeError on a negative, fractional, or out-of-range value, because that is a bug in your code rather than a payment condition.
applySlippage(amount, bps)Applies a tolerance to a bigint base amount, rounding up. Pure.
import { applySlippage, resolveSlippageBps, MAX_SLIPPAGE_BPS } from '@piprail/sdk'
resolveSlippageBps(undefined) // 50
applySlippage(1_000_000n, 50) // 1_005_000n
resolveSlippageBps(MAX_SLIPPAGE_BPS + 1) // throws RangeError

On both chains, holding an issued asset requires a trustline for it first. Swapping into USDC needs your own account to trust USDC.

PipRail already probes exactly this through recipientReady(), and a missing trustline surfaces as RecipientNotReadyError with a message naming the fix. On Stellar a trustline costs a refundable 0.5 XLM reserve; on the XRP Ledger, 0.2 XRP.

The table below is not hand-maintained prose. It is generated from SWAP_PROVIDERS in the SDK, which you can import and read yourself:

import { SWAP_PROVIDERS, canSwapOn, swapProvidersFor, swappableNetworks } from '@piprail/sdk'
canSwapOn('eip155:8453') // true
swappableNetworks() // every network with a proven route
swapProvidersFor('stellar:pubnet')[0].proofs[0].tx // a real, checkable hash

It is the same shape, and the same admission rule, as KNOWN_FACILITATORS: an entry earns its place only after a real mainnet transaction settled through it. Never from a documentation page. A quote proves routing; only a transaction proves settlement.

That rule is not theoretical caution. The facilitator registry grew from capability reads, nothing ever re-checked an entry, and two of eleven turned out to be dead hosts the SDK was still handing to callers. Same failure mode, so the same guard.

Sixteen swaps, eight chains, both tiers, both directions on every chain that has two. Each was executed with real money and verified by reading the transaction back from a public node. The last one was run straight from the committed example, so the documented flow is proven, not just the library. This is evidence, not a claim: every hash below is checkable right now.

ChainSwapTransaction
Stellar0.2652 XLM → 0.05 USDCf7b784d4…
Stellar0.1591 XLM → 0.03 USDC1c1bbaaa…
StellarUSDC → 0.2 XLM (reverse)3aa2ca9e…
Stellar0.1064 XLM → 0.02 USDC, run from the committed example27f50b2c…
XRP Ledger0.072 XRP → 0.1 RLUSDF703B271…
XRP Ledger0.0357 XRP → 0.05 RLUSDF124BAA0…
XRP LedgerRLUSD → 0.02 XRP (reverse)24EC9DDD…

The Stellar operations record as path_payment_strict_receive with source and destination the same account, which is what makes them self-swaps. The XRPL ones report delivered_amount exactly equal to the amount asked for, with SendMax honoured.

ChainSwapProviderTransaction
Solana0.1031 USDC → 0.001 SOLJupiter2rXpQYVe…
Solana0.000485 SOL → 0.05 USDC (reverse)JupiterUEeXBp3s…
Solana0.0823 USDC → 0.0008 SOLJupiter5kPFYdAY…
BNB Chain0.0101 USDT → USDCKyberSwap0x1748b6b2…
BNB Chain0.0505 USD1 → USDTKyberSwap0xb3608208…
BNB Chainnative BNB → 0.02 USDC (no approval path)KyberSwap0xe27f957b…
Robinhood Chainnative ETH → 0.360518 USDGKyberSwap0x424299e6…
Aptos0.049997 USDC → exactly 0.05 USDTHyperion0x927293cf…
Aptos0.040012 USDT → exactly 0.04 USDC (reverse)Hyperion0xae849e03…
Aptos0.0459 APT → exactly 0.03 USDC (native in)Hyperion0x7921118d…
Aptos0.012842 USDC → exactly 0.02 APT (native out)Hyperion0x640bce4b…
TON0.035528 TON → 0.05 USDTSTON.fi1f6d58d0…
TON0.028171 USDT → 0.02 TON (reverse, different router version)STON.fif76ae97e…
Base0.00117 USDC → EURCKyberSwap0xb28b802f…
BaseEURC → USDC (reverse)KyberSwap0x99a0842c…
Sui0.0657 SUI → 0.0505 USDCAftermathGtykrnLx…
NEAR0.0505 USDT → 0.05 USDCRef FinanceFAb28z1t…
Algorand0.5094 ALGO → 0.05 USDCVestige5UV7SWSJ…

Two different EVM chains share one implementation, which is what the 8-chain claim rests on, and both the ERC-20 path (which needs an approval) and the native-in path (which does not) are proven. The Sui transaction’s on-chain balance changes match its quote exactly; the NEAR swap settled across 14 receipts; the Algorand one is a four-transaction atomic group in which every signer is the sender.

Twelve real bugs that only live testing could find

Section titled “Twelve real bugs that only live testing could find”

Every one of these passed a typechecker and a unit suite first.

  1. Some tokens revert on a non-zero to non-zero ERC-20 approve. A stale allowance then poisons every retry, surfacing far away as TRANSFER_FROM_FAILED inside the swap, which reads like a balance problem and is not one. The allowance is now zeroed first.
  2. XRPL path steps could not always be encoded. ripple_path_find returns steps carrying extra type/type_hex fields that xrpl.js sometimes rejects with Cannot construct UInt32 from given value. Paths are now stripped to what the encoder accepts.
  3. XRPL public path finding is intermittent. The identical request returns a route, then nothing a second later, with no error, while the route exists throughout. A single empty answer is not evidence of no route, so it is now tried twice.
  4. XRPL float precision silently discarded valid routes. Issued amounts carry up to 16 significant digits while our base-unit convention is fixed, so a real quote of 0.02789866666666666 RLUSD threw and the whole direction looked unsupported. It is now parsed with a ceiling, rounding up so the cap always covers the spend.
  5. A malformed Stellar issuer threw out of a never-throw method, and one unparseable Horizon candidate sitting first in the list poisoned an otherwise valid quote. Both found by adversarial tests, both fixed.
  6. Setting accept-encoding by hand broke NEAR entirely. Ref’s indexer serves gzip, and the fetch runtime decompresses automatically only when it owns that header. Setting it manually returned a still-compressed body, JSON.parse threw, and the whole family reported “no swap support” while its pools quoted fine by hand.
  7. NEAR pool ids are strings in the indexer and numbers on the contract. Passing "6416" through unconverted made every get_return call error, which again looked exactly like “this chain cannot swap”.
  8. Vestige returns amount_out: 0 for a dust probe rather than an error. A fixed probe size of 0.01 ALGO therefore reported no route on a pair that routes perfectly at 0.1 ALGO. The probe now escalates until the aggregator gives a real answer.
  9. Sui’s public JSON-RPC is being deprecated, so the obvious verification endpoint returns “Method not found”. The SDK already routes around this with a third-party RPC; the finding is that any tooling still pointing at fullnode.mainnet.sui.io will break.
  10. A mined transaction was being read as a successful swap. On EVM a reverted swap still produces a receipt, and on Solana a submitted signature is not a confirmed one, so both drivers returned a SwapReceipt for a swap that moved nothing and only burned gas. Both now assert the chain’s own outcome, and ERRORS.md makes it part of the driver contract.
  11. Two shipped TON proofs pointed at the wrong transaction. STON.fi refunds unused forward gas to the same wallet a second after the swap, and the driver reported whichever transaction was newest, so the recorded reference was an incoming refund with no outgoing message. The swaps were real; the evidence pointed at the wrong leg. The driver now takes the transaction the wallet itself signed, and npm run verify:proofs rejects a reference of the wrong shape.
  12. Tron could not have swapped a token at all. SunSwap V2’s router moves the input with transferFrom and there was no approve step, so selling native TRX worked while every TRC-20 direction would have failed on allowance. Nothing caught it because that route ships without a mainnet proof. Adding the approve then surfaced a second one: a fresh allowance slot measured 99,764 energy, which at 100 sun is 9.98 TRX, and the ceiling had been set to 10 TRX because “an approve is cheap”.

Four of those twelve made an entire chain family look unsupported when it was not, and three reported success for something that had not happened. None would have been caught by a typechecker or a unit suite.

A known failure, recorded rather than hidden

Section titled “A known failure, recorded rather than hidden”

FDUSD to USDC on BNB Chain reverts with TRANSFER_FROM_FAILED, and we have not found out why. Balance and allowance were both confirmed sufficient by direct on-chain reads, and neither zeroing the allowance nor re-approving the build step’s router fixed it. Every other pair on the same chain and code path works.

It is listed because a table showing only successes is marketing, not evidence. If you hit this pair the SDK fails cleanly: nothing is swapped, and only the gas for the reverted transaction is lost.

We surveyed the cross-chain bridge and swap aggregators, including thirdweb Bridge, LI.FI, Squid, Relay, deBridge, Across and Rango. None is bundled, and none is a default.

Every candidate was probed live from a plain server request on 2026-09-08, with no key and no browser headers, because documentation is not evidence:

AggregatorResultVerdict
KyberSwapHTTP 200adopted
JupiterHTTP 200, platformFee: nulladopted
Cetus (Sui)HTTP 200candidate, not yet implemented
0xHTTP 401 No API key found in requestrejected
1inchHTTP 401 Unauthorizedrejected
OdosHTTP 530 (Cloudflare 1033)rejected
OpenOceanHTTP 403 (Cloudflare challenge)rejected
SquidHTTP 400 x-integrator-id header is missingrejected
RangoHTTP 401rejected
thirdweb BridgeHTTP 401rejected

An endpoint that bot-blocks a plain server request is not a headless integration target, however good its documentation is.

Taking thirdweb Bridge as the worked example, since it is the most frequently suggested:

  • An API key is required at the type level. Its client cannot be constructed without one, and an unauthenticated request returns 401. Free is not the same as keyless, and a signup is an account relationship, which is precisely what PipRail exists to avoid.
  • A 0.30% protocol fee applies and an integrator cannot switch it off.
  • It is EVM-only for cross-chain routing. Its own API types chain identifiers as integers, so nine of the ten families PipRail supports cannot even be expressed.

None of that makes it a bad product. It makes it the wrong shape for a zero-fee, keyless, self-custody SDK.

The quoteSwap/swap contract is deliberately provider-shaped, so a third party can be added later. If one ever is, it will arrive the way facilitators did: named openly, marked kind: 'provider', opt-in, and listed with what has actually been proven rather than what is advertised.

  • Swap coverage on piprail.com is the same registry as a browsable directory: every route indexed by chain as well as by venue, with all 21 transaction hashes. Generated from SWAP_PROVIDERS, so it cannot disagree with the table above.
  • Plan a payment shows why a payment is not payable.
  • Multi-chain buying pays from whichever chain you already hold funds on, which often removes the need to swap at all.
  • Facilitator coverage is the same opt-in-third-party pattern applied to settlement.