Getting a swap to work on your laptop takes an afternoon. STONfi's docs are good, the SDKs handle a lot, and there's even a public demo app you can read line by line. Getting that swap to kee
Getting a swap to work on your laptop takes an afternoon. STONfi's docs are good, the SDKs handle a lot, and there's even a public demo app you can read line by line. Getting that swap to keep working for real users, on a bad network day, with support tickets coming in, is a different job, and almost none of it is about the swap itself.
This article is about that second job. It walks through the five layers I'd put around a swap built on STONfi and Omniston: the front end, the quote layer, the wallet, the indexer, and monitoring. For each one I'll say what it's for, what breaks if you skip it, and where STONfi ends and your own work begins. I'll keep the language plain and define the jargon as it shows up.
Two honest notes first. If all you need is a swap button, you probably shouldn't build any of this: STONfi offers a ready-made swap widget, and that's a perfectly good answer. And the code below is a sketch, not a drop-in. It follows the names in the current Omniston docs, which introduced a more explicit API model with native cross-chain support in v1beta8, and the SDK's event shapes have changed between versions. Pin your version and check the types you actually have installed.
Start with the split of responsibilities, because everything else follows from it. STONfi and Omniston give you liquidity, routing, quotes, and execution: the part where a request for the best price gets answered by competing sources and settled on-chain. You don't build that. What you build is everything that makes it usable and trustworthy inside your product.
Follow a single swap through the system and the five layers appear in order:
Front end: the user picks assets and an amount and sees what they'd get.
Quote layer: a live stream of quotes gets turned into something stable enough to show and to decide on.
Wallet: the user reviews the exact transaction and signs it with their own keys.
Tracking and indexer: the app follows the swap to its end and keeps a durable record of what happened.
Monitoring: you find out something is wrong before your users tell you.
One rule sits over all five: your servers never hold keys or funds. The wallet signs, the chain settles, and everything you build is a window onto that, not a replacement for it. That's what non-custodial means for your architecture, and it's also why so much of the work below is about records and visibility rather than moving money.
Omniston offers three ways in. The SDKs (Node.js and React) are the recommended route and handle the WebSocket connection, quote streaming, transaction building, and error handling for you. A low-level WebSocket JSON-RPC API exists for custom clients, and gRPC over TLS is the primary low-level option for backend integrations. For most teams the SDK is the right call, and the rest of this article assumes it.
The most common conceptual mistake in a first swap UI is treating a quote like the response to an ordinary API call: you ask, you get a number, you display it. Omniston works differently. You send a request for quote, and quotes keep arriving as resolvers and liquidity sources respond and update. The best one can change while the user is still looking at the screen. That's good for the price, and it's a design problem for the interface.
So the front end needs a small layer in the middle that turns a moving stream into something a person can safely act on. I call it the quote layer, and its job is boring on purpose: take whatever the SDK emits, convert it into one internal shape, and stamp it with the moment you received it. If the UI renders SDK objects directly, every SDK upgrade becomes a change to every component. If it renders your own QuoteView, an upgrade touches one mapping function.
import { Omniston, useRfq, type QuoteRequest } from "@ston-fi/omniston-sdk-react";
// Sandbox for development and CI. Production only for real traffic.
const OMNISTON_URL =
import.meta.env.VITE_ENV === "production"
? "wss://omni-ws.ston.fi"
: "wss://omni-ws-sandbox.ston.fi";
export const omniston = new Omniston({ apiUrl: OMNISTON_URL });
// The UI renders this, never the raw SDK object.
interface QuoteView {
quoteId: string;
receivedAt: number; // when WE saw it, used for staleness checks
estimatedOut: string; // the number people hope for
minimumOut: string; // the number we can actually promise
}
function QuotePanel({ request }: { request: QuoteRequest }) {
// useRfq streams events; quotes keep updating until you stop listening.
const { data: event, error } = useRfq(request);
if (error) return <NoQuote reason="connection" />;
if (!event) return <Loading />;
// Event shapes differ between SDK versions: check your installed types.
if (event.$case === "quoteUpdated") {
return <QuoteCard quote={toQuoteView(event.value, Date.now())} />;
}
// Any other event (for example, no quote available) is handled explicitly,
// not treated as a crash.
return <NoQuote reason="unavailable" />;
}
What should that layer actually be responsible for? A short list:
Normalizing the SDK's quote into your own shape, so upgrades stay local.
Freshness: if no update has arrived for a set number of seconds, mark the quote stale and disable the confirm button rather than letting someone sign against a number that may have moved.
Guardrails: warn or block when price impact crosses a threshold you choose, and enforce minimum amounts.
Logging what was shown: quote ID, estimated and minimum output, timestamp. When a user later says "it told me I'd get more," this is the record that settles it.
A kill switch: a config flag that turns off a route, for example cross-chain, without a redeploy.
If you want to earn from the flow, this is also where referral fees live. Omniston lets you attach referral data to a quote request so a share of the swap fee goes to your address, configurable from 0.01% to 1% per swap and paid on-chain in the same transaction. There's a flexible_referrer_fee option that lets the protocol lower your cut when that gets the user a better rate, which I think is the right default for an app that wants users to keep coming back.
The wallet layer is the smallest in code and the largest in consequences. On TON the usual path is TON Connect, with @tonconnect/ui-react giving you the connect button and session handling. The flow has two separate stages, and your UI should respect that separation: connecting shares an address and lets your app propose transactions, and signing is the only moment anything actually moves. Nothing you write should blur those two.
A few things belong here that people tend to skip.
Build the transaction from the quote you showed. The React SDK provides build helpers for this, such as useTonBuildSwap, so the transaction the wallet is asked to sign is derived from the exact quote on screen, not from a fresh request that might have a different price. Show the user the same numbers, especially the minimum they're guaranteed, right before the wallet prompt.
Treat a rejected signature as a result, not an error. The user looked at the terms and said no. That should return them to the swap screen with their inputs intact and no red banner. Genuine failures, like a network error or a transaction the chain rejected, deserve a different message and a different log entry, because you'll want to count them separately later.
I treat a rejected signature as the app working correctly. Someone read the terms and decided no. That's the best failure mode you can have, so don't dress it up as a crash.
Be strict about addresses. For cross-chain swaps STONfi's flow only needs the source wallet connected, and offers an option to receive at a different destination address. If your app supports that, the destination is a manually entered value with no connected wallet to sanity-check it. Validate the format for the destination chain (an address that's valid on one chain isn't valid on another), and display the full address back before signing. Omniston's newer API models source and destination chains as separate fields for exactly this reason, and your UI should keep them separate too.
Once the user signs, two different needs appear, and they need different tools.
The first is live: the person is staring at your screen and wants to know what's happening right now. Omniston's SDK covers this with a tracking stream. You give it the quote ID, the trader's address, and an identifier for the outgoing transaction (the docs call it outgoingTxQuery), and it emits events as the swap moves along. Model those events as a small state machine rather than a pile of booleans:
type SwapStage =
| { kind: "awaiting-transfer" }
| { kind: "in-progress"; status: string }
| { kind: "closed" }; // the stream ended: reconcile, don't assume success
async function watchSwap(omniston, quote, traderAddress, outgoingTxQuery, onStage) {
const stream = await omniston.swapTrack({
quoteId: quote.quoteId,
traderAddress,
outgoingTxQuery, // identifies the transaction the wallet just sent
});
const sub = stream.subscribe({
next(event) {
switch (event?.$case) {
case "awaitingTransfer": onStage({ kind: "awaiting-transfer" }); break;
case "progress": onStage({ kind: "in-progress", status: String(event.value.status) }); break;
case "unsubscribed": onStage({ kind: "closed" }); break;
}
// Persist every stage change to your own store here, not just in React state.
},
});
return () => sub.unsubscribe();
}
Notice the comment on closed. A stream ending means tracking ended, not that the swap succeeded. Treat it as a reason to check the outcome, never as good news.
The second need is durable, and this is where the indexer comes in. An indexer is just a service that reads chain data and keeps a searchable copy of the events you care about. You don't need to index the whole chain, only your own users' swaps and, if you use them, your referral earnings. Why bother? Because users close tabs, phones lose signal mid-swap, and support will eventually get the question "where is my swap?" React state can't answer it. A row in your database can.
CREATE TABLE swap_attempts (
id UUID PRIMARY KEY, -- idempotency key, created when the user confirms
quote_id TEXT NOT NULL,
wallet_address TEXT NOT NULL,
route_kind TEXT NOT NULL, -- 'ton-swap' or 'cross-chain'
bid_asset TEXT NOT NULL,
ask_asset TEXT NOT NULL,
bid_amount NUMERIC NOT NULL,
quoted_out NUMERIC NOT NULL,
minimum_out NUMERIC NOT NULL,
final_out NUMERIC, -- filled during reconciliation, never from the UI
stage TEXT NOT NULL, -- last stage we observed
sdk_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reconciled_at TIMESTAMPTZ -- set after checking against the source of truth
);
Two words in there deserve a plain explanation. Idempotency means doing the same thing twice has the same effect as doing it once: the id is created when the user confirms, so a double-tapped button creates one record, not two. Reconciliation means comparing your records against a source of truth and fixing the differences. For finalized order history, reconciliation, and aggregate reporting, Omniston provides a dedicated History API, so a nightly job can walk your recent swap_attempts, compare them against it and against the chain, and fill in final_out with the real number. The UI's opinion of what happened is a guess. The reconciled value is the record.
Cross-chain swaps add one more thing to track. As covered in the earlier piece on HTLC timelocks, a cross-chain swap that stalls unwinds itself at a deadline. So your records should distinguish slow from stuck, and your interface should tell a waiting user that their funds return by rule if the swap doesn't complete, rather than nudging them to try again.
The last layer is the one teams add after the first bad incident. I'd add it before. The goal isn't dashboards for their own sake; it's answering four questions quickly: is the quote stream healthy, are swaps completing, are failures spiking, and is anything stuck.
Here's what I'd put alerts on:
Quote stream health: WebSocket disconnects, reconnect loops, and time-to-first-quote. If quotes stop arriving, your swap screen is quietly broken even though nothing crashed.
Funnel drop-off: quote shown, confirm clicked, signature approved, swap completed. A sudden drop at one step points straight at the cause.
Failure taxonomy: user rejections, wallet errors, chain-level failures, and swaps that never completed, counted separately. One blended "failure rate" hides every useful signal.
Completion time tail: the median matters less than the slowest few percent. A healthy swap finishes in seconds, so a growing tail is your early warning.
Stuck orders: anything past the time you'd expect, especially cross-chain, checked against the refund deadline.
SDK version drift: run the sandbox in your CI so a dependency update that changes event shapes fails a test instead of a customer.
"No quote" is an answer, not an outage. Apps that flash a red error when no resolver responds train users to distrust the one moment the system is being honest with them.
That distinction matters for how you build alerts too. On a thin pair at an odd hour, no quote is a normal market state, so show it calmly and don't page an engineer. Reserve the loud alarms for the things that indicate your system is unhealthy. And keep a status page or at least a feature flag, so that if a route misbehaves you can switch it off for users deliberately instead of letting them discover it.
A note on the front end itself, since it's the part attackers can actually reach. Pin your dependencies, use a strict content security policy, and never let your interface ask for a seed phrase or a signature the user hasn't been shown. A swap app that's perfectly architected behind the scenes but ships a compromised script is still a compromised swap app.
If I were starting this week, I'd build in this order: the quote layer with its own QuoteView, the wallet flow with clean separation of rejection versus failure, then the swap_attempts table written from the first day, and only then the reconciliation job and alerts. Referral fees, custom destinations, and cross-chain routes can arrive later, one at a time, each behind a flag. The order reflects a simple idea: build the record of what happened before you build anything fancy on top of it.
If I could build only one thing beyond the happy path, it would be the record of what the user was shown. Nearly every support question I can imagine starts with "what did it say?"
STONfi has done the hard part of this stack, the routing and the settlement. The architecture around it is where a swap becomes a product, and none of it is glamorous: normalize the quote, respect the signature, write things down, watch the tail. Get those four right and most of what goes wrong stays small and explainable.
Should the quote layer run in the browser or on my backend? The browser with the React SDK is the simplest start and is how the public demo app works. A backend layer earns its place when you need shared logging, rate limiting, kill switches, or analytics, and you can add it later without changing the SDK contract.
Do I need my own indexer, or is the History API enough? The History API is the source for finalized order history and reconciliation, but you still want your own table of attempts, because it holds what the user was shown and where your app observed the swap, which no external source can tell you.
How do I avoid duplicate swaps if a user double-taps confirm? Create an idempotency key when the user confirms, use it as the record's ID, and ignore repeat submissions carrying the same key.
Can I use the sandbox for testing? Yes, and you should for development and CI. The docs describe it as for development and testing only, so keep production traffic on the production endpoint.