"Non-custodial" in a swap provider's docs usually gets read as "not my security problem." That's half right. Integrating a non-custodial instant exchange removes the biggest risk class, which is holding user balances. But it hands your users a different, smaller one, and parts of that risk run straight through your frontend.
Here's the threat model, split by who owns each piece.
First, get the custody model right
A non-custodial instant exchange isn't zero-custody. The flow looks like this:
user wallet ──deposit──▶ provider deposit address ──swap──▶ user wallet
(user keys) (PROVIDER KEYS: transit) (user keys)
From deposit confirmation until payout, the provider controls the funds. The window is short, usually minutes, bounded by confirmation time on the input chain. But it exists, and any honest threat model starts from it.
What your app never holds: keys, balances, deposits. That's the real win. You're not a custodian, you don't carry a hot wallet, and your breach doesn't drain anyone.
Threats the provider owns
Transit-window compromise. If the provider's operational wallets are breached, in-flight funds are exposed. This has happened in this category: FixedFloat lost around $26M from its own wallets in February 2024. You can't mitigate it in code. You mitigate it in vendor selection (incident history, how they handled it, whether in-flight users were made whole) and by keeping per-swap exposure small.
AML holds. Deposits get screened, and flagged ones pause. That's the provider's process, but surfacing it is your job. Give it a distinct held_for_review state and don't fold it into pending.
Availability. Rate feeds stall, pairs get disabled, minimums spike with congestion. Treat every quote as perishable and every pair as possibly unavailable.
Threats you own
This is the part integrators underestimate. Your frontend is the only place several of these can be caught.
Address substitution. Clipboard malware swaps a copied address for the attacker's. Your UI can't detect malware, but it can make substitution visible:
// Render the destination with its first and last 6 chars emphasized,
// and restate it on the confirmation step. Visual diffing catches
// most substitutions, which change the whole string.
function renderAddress(addr) {
return {
head: addr.slice(0, 6),
middle: addr.slice(6, -6),
tail: addr.slice(-6),
};
}
Chain ambiguity. EVM addresses carry no chain identity. Never infer the chain from an address. Make it a required selection with no default, and restate it in words at confirmation.
Conditional memos. For XRP, XLM, ATOM, HBAR, EOS and TON, a memo is required when the destination is a shared (exchange) address and meaningless when it's self-custody. Ask which one it is, and make the field blocking only when it applies.
Refund routing. Refunds go to the sender address by default. If the user funded from an exchange withdrawal, that's a pooled hot wallet and the refund is effectively lost. Collect a refund address at order creation:
const order = await provider.createOrder({
pair,
amount,
destination,
refundAddress: connectedWallet ?? userInput.refundAddress, // never null silently
});
Clone-site exposure. If you embed a widget, load it from the provider's canonical origin and pin it in your CSP. A compromised or typo-squatted widget source is a deposit-address swap at scale.
Content-Security-Policy: frame-src https://widget.<provider-domain>;
Quote integrity. If quotes pass through your backend, sign or verify them end-to-end. A deposit address that can be altered anywhere between provider and user is the single highest-value target in your stack.
Threats nobody in the stack owns
Worth stating in your docs so users aren't surprised:
- Key loss: self-custody moves it entirely to the user
- Malicious token approvals signed elsewhere, which live in the user's wallet
- Issuer freezes: Tether can freeze USDT at the contract level on any chain
Shrinking the transit window by design
The one risk the model adds is time in transit. Design to minimise it:
- Prefer fast input chains in your defaults when the user holds the asset on several
- Don't create the order until the user is ready to send. Generate the deposit address at the last step, not the first, so abandoned sessions don't leave stale addresses around.
- Offer split execution above a threshold. Several moderate swaps cap peak exposure below one large one.
Summary table
| Threat | Owner | Mitigation lives in |
|---|---|---|
| Transit-window breach | Provider | Vendor selection, size limits |
| AML hold | Provider | Your state machine |
| Address substitution | You | Confirmation UI |
| Wrong chain | You | Required selection, restatement |
| Missing memo | You | Conditional blocking field |
| Lost refunds | You | Refund address at creation |
| Widget/clone tampering | You | Canonical origin, CSP |
| Key loss, bad approvals, issuer freeze | User | Documentation |
Non-custodial removes the catastrophic risk: holding everyone's money. What's left is smaller, but most of it gets caught or missed in your frontend.
Disclosure: I work on SwapCore (swapcore.exchange), a non-custodial instant exchange. We have a transit window like every provider in this category, and the mitigations above are the ones I'd expect any integrator to ask us about.
Top comments (0)