SDK Integration Walkthrough: Building Your First STONfi Swap Feature
Most SDK documentation shows you the happy path in five clean lines and leaves the actual engineering — the part where quotes expire, wallets reject signatures, and networks hiccup mid-transaction — as an exercise for whoever ships it to production. This is the walkthrough I wish I'd had before my first integration.
I've watched a lot of developers get a STONfi swap working in an afternoon, feel great about it, and then spend the following week discovering everything the happy-path demo quietly skipped. That's not a criticism of the SDK — it's just what happens when documentation optimizes for "does this work" instead of "does this survive contact with real users, real network conditions, and real wallets that sometimes reject things." This walkthrough goes through the same ground a typical quickstart covers, but stays a little longer at each step than a quickstart usually does, specifically at the points where I've seen integrations quietly break in production.
My take: The gap between "my demo works" and "my integration is production-ready" isn't more features. It's almost entirely error handling for cases the demo never triggers, because demos are, by construction, always the happy path.
🧩 Setting Up and Requesting Your First Quote
Before building anything, you need a client instance connected to STONfi's infrastructure. The basic setup is intentionally minimal:
import { StonApiClient } from '@ston-fi/api';
const client = new StonApiClient();
async function getQuote(offerToken, askToken, amount) {
const quote = await client.simulateSwap({
offerAddress: offerToken,
askAddress: askToken,
offerUnits: amount,
slippageTolerance: "0.01", // 1% — set explicitly, not left to a default
});
return quote;
}
That slippageTolerance line deserves more attention than it usually gets in a first pass. Leaving it to whatever the SDK defaults to means your app's behavior under volatile market conditions is whatever some library maintainer decided was reasonable in the abstract, for every possible use case, which is rarely what your specific product actually needs. A wallet app aggregating small casual swaps and a trading terminal handling larger, time-sensitive orders should almost certainly not share the same default — set it explicitly and treat the value as a product decision, not a technical formality you can skip past.
The quote object you get back contains more than just an expected output amount. It carries askUnits (the optimistic expected output), minAskUnits (the guaranteed floor, accounting for your slippage tolerance), a route, fee information, and an expiry timestamp. Every one of those fields matters for what comes next — none of them are safe to ignore just because the demo only ever prints askUnits to the console.
🔻 Building the Transaction Without the One Bug That Silently Breaks Slippage Protection
This is the step where I've seen the most integrations quietly ship a real bug that never shows up in testing, because testing rarely includes the specific market conditions that expose it.
async function buildSwapTransaction(quote, userWalletAddress) {
// Reject stale quotes before doing anything else
if (Date.now() > quote.expiresAt) {
throw new Error('Quote expired — request a fresh one before building the transaction');
}
const transaction = await client.buildSwapTransaction({
userWalletAddress,
offerAddress: quote.offerAddress,
askAddress: quote.askAddress,
offerUnits: quote.offerUnits,
minAskUnits: quote.minAskUnits, // NOT quote.askUnits
});
return transaction;
}
The comment on that last line is doing the most important work in this entire snippet. askUnits is the optimistic number — what you'd get if nothing moved between quote and execution. minAskUnits is the actual floor your slippage tolerance is supposed to guarantee. Pass the wrong one into transaction construction and your code compiles, your tests pass, your demo works perfectly in a quiet market — and your slippage protection silently does nothing the first time a real price movement happens, because you've told the transaction to accept the optimistic number as if it were the guarantee. Nobody notices until a user reports getting a worse fill than expected during a volatile hour, and by then it's a support ticket instead of a code review comment.
The expiry check at the top matters just as much, for a less dramatic but equally real reason: submitting a transaction built from a stale quote doesn't fail gracefully in every wallet and every network condition. Checking client-side, before you even attempt to build the transaction, gives you a clean error message to show the user instead of an opaque on-chain rejection they'll have no context for.
⚙️ Handling the Wallet Connection and Signature Flow
Getting a transaction object is only useful if you can actually get it signed. This is the step where TON Connect enters the picture, and where a surprising amount of production bugs live in how errors from this specific step get surfaced to the user.
import { TonConnectUI } from '@tonconnect/ui';
const tonConnectUI = new TonConnectUI({
manifestUrl: 'https://yourapp.com/tonconnect-manifest.json',
});
async function signAndSend(transaction) {
try {
const result = await tonConnectUI.sendTransaction({
validUntil: Math.floor(Date.now() / 1000) + 300, // 5-minute signing window
messages: [
{
address: transaction.to,
amount: transaction.value,
payload: transaction.payload,
},
],
});
return { status: 'submitted', boc: result.boc };
} catch (error) {
if (error.message?.includes('Reject')) {
return { status: 'rejected_by_user' };
}
return { status: 'failed', error };
}
}
That catch block is doing more product work than it looks like at first glance. A user closing the wallet popup without signing and a genuine network or contract-level failure are fundamentally different events — one is a deliberate, informed decision, the other is a technical problem — and they deserve different messages in your UI. Collapsing both into a single generic "Transaction failed" toast is one of the most common ways a perfectly functional integration ends up feeling broken to users, because a user who intentionally declined to sign gets told something went wrong, when nothing actually did.
The validUntil field is worth setting deliberately rather than accepting whatever default the library ships with, for the same underlying reason the quote expiry mattered earlier: a signing window that's too long increases the odds a user comes back to a stale popup after getting distracted, and a window that's too short creates failures for users who take a normal amount of time to review what they're signing.
🔍 Simulating Before You Ever Touch Real Funds
Before shipping any of the above against mainnet, there's a step worth treating as non-negotiable rather than optional: running the full flow against a simulated or testnet environment, deliberately, against every failure case you can construct, not just the happy path.
async function testFailureCases() {
const cases = [
{ name: 'expired quote', setup: async () => {
const quote = await getQuote(TOKEN_A, TOKEN_B, 1000n);
await new Promise(r => setTimeout(r, quote.expiresAt - Date.now() + 1000));
return quote;
}},
{ name: 'insufficient gas balance', setup: async () => {
// wallet funded with the offer token but zero TON for fees
return getQuote(TOKEN_A, TOKEN_B, 1000n);
}},
{ name: 'slippage exceeded mid-execution', setup: async () => {
// quote requested, then a large counter-trade simulated before signing
return getQuote(VOLATILE_TOKEN, TOKEN_B, 1000n);
}},
];
for (const testCase of cases) {
console.log(`Testing: ${testCase.name}`);
const quote = await testCase.setup();
const result = await buildSwapTransaction(quote, TEST_WALLET).catch(e => ({ error: e.message }));
console.log(result);
}
}
This isn't a complete test suite — it's a sketch of the mindset that matters more than any specific test framework. Each of these cases corresponds to a real failure a real user will eventually trigger, not a hypothetical edge case invented for thoroughness. If your integration's behavior for each of them is "generic error toast," that's a signal to go back to the error-handling step above before considering the integration done, not a acceptable place to stop.
🧭 What Changes When You Move This to Production
A few things stop being optional the moment real users and real funds are involved, beyond what a local test run naturally covers. Rate limiting on quote requests matters — a user rapidly adjusting a trade amount shouldn't fire a new quote request on every keystroke, both for your own API usage and for giving the user a stable number to actually read before it changes again. Idempotency on the submission step matters too — a double-tapped confirm button, common on mobile under lag, shouldn't be able to submit the same transaction twice, which is a UI-layer guard your SDK integration doesn't provide for you automatically.
My take: Almost none of the production hardening described here shows up in an SDK's own documentation, and that's not really a gap in the docs — it's inherent to the fact that an SDK can tell you how to call it correctly, but it can't tell you how your specific product should behave when a user does something the happy path never anticipated.
It's also worth logging enough context at each step — which quote was used, what the expiry was, what error category was hit — that when a support ticket comes in about a confusing failure, you're not reconstructing what happened from a raw transaction hash and a user's incomplete memory of what they clicked.
🧭 Conclusion
A working STONfi swap integration and a production-ready one are separated less by code volume than by how many of the cases above got deliberate attention instead of getting left as whatever the SDK happened to do by default. Reading minAskUnits instead of askUnits, checking quote expiry before building a transaction, distinguishing a user's rejection from an actual failure, and testing against constructed failure cases rather than only the happy path — none of this is exotic engineering. It's the unglamorous difference between a demo that works once, in a quiet market, on a good network day, and an integration that keeps behaving correctly on the day none of those three things are true.
❓ Frequently Asked Questions
Why does using askUnits instead of minAskUnits matter if the quote already accounted for slippage?
The quote calculates both values, but only minAskUnits is the enforced floor. Passing askUnits into the transaction means no real floor is being enforced at all, regardless of what slippage tolerance you originally requested.
Should slippage tolerance ever be left at an SDK's default value?
It's better treated as a product decision made deliberately for your specific use case — a casual small-swap app and a professional trading interface have different needs, and a shared default rarely fits both well.
Is checking quote expiry client-side actually necessary if the chain will reject a stale transaction anyway?
Yes — a clean client-side check lets you show the user a clear, immediate message and offer a fresh quote, instead of relying on an on-chain rejection that arrives later with no context for what to do next.
How much failure-case testing is actually enough before shipping?
At minimum, every case a user is likely to trigger in normal use — expired quotes, rejected signatures, insufficient gas, and slippage exceeded mid-execution — should have an explicit, deliberate behavior, not a generic fallback.
📚 Sources and Further Reading
Tags: STONfi, SDK, TON Connect, Developers, TON Blockchain, DeFi, Web3
Disclosure: Official STONfi Ambassador.


Top comments (0)