This guide will walk you through creating a basic liquidity provision app using the STON.fi SDK and API in a React project. We'll integrate wallet connectivity with TonConnect (via @tonconnect/ui-react
) to allow users to connect their TON wallet and provide liquidity to pools. The guide is beginner-friendly and assumes minimal React experience.
Note : In this demo, we will leverage Tailwind CSS for styling instead of using custom CSS. The setup for Tailwind CSS is already included in the instructions below, so you don't need to set it up separately.
Note : You can use any package manager (npm, yarn, pnpm, or bun) to set up your React project. In this tutorial, we'll demonstrate with pnpm .
Table of Contents
1. Introduction
In this quickstart, we will build a minimal React app to:
Connect to a TON wallet (via TonConnect UI ).
Fetch available tokens from STON.fi (via @ston-fi/api
).
Simulate liquidity provision (to see expected LP tokens).
Execute a liquidity provision transaction on-chain (via @ston-fi/sdk
).
We will use:
@ston-fi/sdk
– Helps build the payload for the actual liquidity provision transaction.
@ston-fi/api
– Lets us fetch asset lists and run liquidity provision simulations.
@tonconnect/ui-react
– Provides a React-based TON wallet connect button and utilities.
2. Setting Up the Project
2.1 Create a React App
First, check if you have pnpm installed:
If pnpm is not installed, install it globally:
Create a new React + Vite project:
Copy pnpm create vite --template react-ts
Provide a project name (e.g., stonfi-liquidity-app
) and then:
Copy cd stonfi-liquidity-app
2.2 Installing the Required Packages
In your project directory, install the required packages:
Copy pnpm add @ston-fi/sdk @ston-fi/api @tonconnect/ui-react @ton/ton
Install Tailwind CSS and the Node.js polyfills plugin for Vite:
Copy pnpm add tailwindcss @tailwindcss/vite vite-plugin-node-polyfills
Configure Vite by updating vite.config.js
:
Copy import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
export default defineConfig({
plugins: [react(), tailwindcss(), nodePolyfills()],
});
In src/index.css
, import Tailwind:
Copy @import "tailwindcss";
You can also remove src/App.css
(we don't need it), and remove the import statement import './App.css'
from src/App.tsx
.
After making these changes, you can verify that your app still runs correctly by starting the development server:
Copy pnpm install
pnpm dev
Open http://localhost:5173. If you see a Vite/React starter page, everything is working correctly.
3. Connecting the Wallet
3.1 Add the TonConnect Provider
Open src/main.tsx
and wrap your app in TonConnectUIProvider
:
Copy import React from "react";
import ReactDOM from "react-dom/client";
import { TonConnectUIProvider } from "@tonconnect/ui-react";
import "./index.css";
import App from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<TonConnectUIProvider
// For demo purposes, we're using a static manifest URL
// Replace with your own: manifestUrl={`${window.location.origin}/tonconnect-manifest.json`}
manifestUrl="https://gist.githubusercontent.com/mrruby/243180339f492a052aefc7a666cb14ee/raw/"
>
<App />
</TonConnectUIProvider>
</React.StrictMode>
);
Note : For the purposes of this demo, we're serving the manifest from a static source. In a real application, you should replace this with your own manifest URL that you'll create in the next step.
3.2 Create the TonConnect Manifest
Create a file named tonconnect-manifest.json
in your public
folder:
Copy {
"url": "https://stonfi-liquidity-demo.example.com",
"name": "STON.fi Liquidity Provider",
"iconUrl": "https://stonfi-liquidity-demo.example.com/icon-192x192.png"
}
Update with your own app name, domain, and icon.
In src/App.tsx
, import and add the TonConnectButton
:
Copy import { TonConnectButton } from "@tonconnect/ui-react";
function App() {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6">
<h1 className="text-2xl font-bold mb-4">STON.fi Liquidity Demo</h1>
<TonConnectButton />
</div>
);
}
export default App;
4. Fetching Available Assets
Let's fetch token data from STON.fi using StonApiClient
. We'll filter by liquidity tags to keep the list manageable.
First, add new imports to the top of src/App.tsx
:
Copy import { useEffect, useState } from "react";
import { TonConnectButton } from "@tonconnect/ui-react";
import { StonApiClient, AssetTag, type AssetInfoV2 } from "@ston-fi/api";
Initialize the STON.fi API client:
Copy const stonApiClient = new StonApiClient();
Add state variables for token management:
Copy function App() {
const [tokens, setTokens] = useState<AssetInfoV2[]>([]);
const [tokenA, setTokenA] = useState<AssetInfoV2 | undefined>();
const [tokenB, setTokenB] = useState<AssetInfoV2 | undefined>();
const [amountA, setAmountA] = useState("");
const [amountB, setAmountB] = useState("");
Add the token fetching logic:
Copy // Fetch assets at startup
useEffect(() => {
const fetchTokens = async () => {
try {
const assets = await stonApiClient.queryAssets({
// Query only assets with medium or higher liquidity to ensure tradability
condition: `${AssetTag.LiquidityVeryHigh} | ${AssetTag.LiquidityHigh} | ${AssetTag.LiquidityMedium}`,
});
setTokens(assets);
// Initialize default selections
setTokenA(assets[0]);
setTokenB(assets[1]);
} catch (err) {
console.error("Failed to fetch tokens:", err);
}
};
fetchTokens();
}, []);
Add the token change handler:
Copy // Factory function for creating onChange handlers for token dropdowns
// Uses composition to create reusable handlers for both token selectors
const handleTokenChange =
(setter: typeof setTokenA | typeof setTokenB) =>
(event: { target: { value: string } }) => {
const selected = tokens.find(
(t) => t.contractAddress === event.target.value
);
if (selected) {
setter(selected);
}
};
Replace the return statement with the enhanced UI:
Copy return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-blue-50 to-indigo-100 p-6">
<div className="max-w-md w-full bg-white rounded-lg shadow p-6 space-y-6">
{/* Application header with branding and wallet connection */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-indigo-700">
STON.fi Liquidity
</h1>
<TonConnectButton />
</div>
<hr className="border-gray-200" />
{/* Main application interface (conditional on token data availability) */}
{tokens.length > 0 ? (
<>
{/* Token A selection dropdown */}
<div>
<label className="block mb-1 text-sm font-medium text-gray-600">
Token A
</label>
<select
className="w-full p-2 border rounded"
onChange={handleTokenChange(setTokenA)}
value={tokenA?.contractAddress || ""}
>
{tokens.map((tok) => (
<option key={tok.contractAddress} value={tok.contractAddress}>
{tok.meta?.symbol ?? "Token"}
</option>
))}
</select>
</div>
{/* Token B selection dropdown */}
<div>
<label className="block mb-1 text-sm font-medium text-gray-600">
Token B
</label>
<select
className="w-full p-2 border rounded"
onChange={handleTokenChange(setTokenB)}
value={tokenB?.contractAddress || ""}
>
{tokens.map((token) => (
<option
key={token.contractAddress}
value={token.contractAddress}
>
{token.meta?.symbol ?? "Token"}
</option>
))}
</select>
</div>
{/* Amount input fields (side by side layout) */}
<div className="flex space-x-4">
<div className="flex-1">
<label className="block mb-1 text-sm font-medium text-gray-600">
Amount A
</label>
<input
type="number"
className="w-full p-2 border rounded"
placeholder="0.0"
value={amountA}
onChange={(e) => setAmountA(e.target.value)}
/>
</div>
<div className="flex-1">
<label className="block mb-1 text-sm font-medium text-gray-600">
Amount B
</label>
<input
type="number"
className="w-full p-2 border rounded"
placeholder="0.0"
value={amountB}
onChange={(e) => setAmountB(e.target.value)}
/>
</div>
</div>
</>
) : (
<p>Loading tokens...</p>
)}
</div>
</div>
);
}
5. Simulating Liquidity Provision
We'll call the simulateLiquidityProvision function on the StonApiClient to get simulation results.
Add additional imports to the top of the file:
Copy import { useTonAddress } from "@tonconnect/ui-react";
import { type LiquidityProvisionSimulation } from "@ston-fi/api";
import { fromNano } from "@ton/ton";
Add utility functions for converting token amounts:
Copy // Convert floating point string amount into integer base units string
// Essential for blockchain transactions which use integer arithmetic
function toBaseUnits(amount: string, decimals?: number) {
return Math.floor(parseFloat(amount) * 10 ** (decimals ?? 9)).toString();
}
// Convert integer base units back to a fixed 2-decimal string for display
function fromBaseUnits(baseUnits: string, decimals?: number) {
return (parseInt(baseUnits) / 10 ** (decimals ?? 9)).toFixed(2);
}
Add wallet address and simulation state:
Copy function App() {
const walletAddress = useTonAddress();
// ... existing state variables ...
const [simulation, setSimulation] = useState<
LiquidityProvisionSimulation | Error | undefined
>();
Add useEffect to reset simulation when tokens change:
Copy // Reset simulation when tokens change
useEffect(() => {
setSimulation(undefined);
}, [tokenA, tokenB]);
Add the simulation handler after the existing handleTokenChange function:
Copy const handleSimulationClick = async () => {
if (!tokenA || !tokenB || !amountA || !amountB) {
alert("Please select tokens and enter amounts");
return;
}
try {
// Retrieve available pools for tokens pair
const pools = (
await stonApiClient.getPoolsByAssetPair({
asset0Address: tokenA.contractAddress,
asset1Address: tokenB.contractAddress,
})
).filter((pool) => !pool.deprecated);
const pool = pools[0];
if (!pool) {
throw new Error(
`Pools for ${tokenA.meta?.symbol} ${tokenB.meta?.symbol} tokens not found`
);
}
// Retrieve simulation
const simulation = await stonApiClient.simulateLiquidityProvision({
provisionType: "Balanced",
tokenA: tokenA.contractAddress,
tokenB: tokenB.contractAddress,
tokenAUnits: toBaseUnits(amountA, tokenA?.meta?.decimals),
poolAddress: pool.address,
slippageTolerance: "0.001",
walletAddress,
});
setSimulation(simulation);
setAmountB(fromBaseUnits(simulation.tokenBUnits, tokenB?.meta?.decimals));
} catch (e) {
setSimulation(new Error(e instanceof Error ? e.message : String(e)));
}
};
Add the simulation button after the amount input fields:
Copy <div className="flex space-x-4">
<div className="flex-1">
<label className="block mb-1 text-sm font-medium text-gray-600">
Amount A
</label>
<input
type="number"
className="w-full p-2 border rounded"
placeholder="0.0"
value={amountA}
onChange={(e) => setAmountA(e.target.value)}
/>
</div>
<div className="flex-1">
<label className="block mb-1 text-sm font-medium text-gray-600">
Amount B
</label>
<input
type="number"
className="w-full p-2 border rounded"
placeholder="0.0"
value={amountB}
onChange={(e) => setAmountB(e.target.value)}
/>
</div>
</div>
{/* Simulation trigger button with validation */}
<button
onClick={handleSimulationClick}
className="w-full bg-indigo-500 hover:bg-indigo-600 text-white font-medium py-2 rounded"
>
Simulate
</button>
Add the simulation results display after the simulate button:
Copy {simulation ? (
simulation instanceof Error ? (
<p className="text-red-600 text-sm">{simulation.message}</p>
) : (
<>
<div className="p-4 bg-gray-50 rounded border border-gray-200 text-sm overflow-x-auto break-all max-w-full">
<p className="font-semibold text-gray-800">
Simulation Result
</p>
<ul className="list-disc list-inside mt-2 space-y-1 text-gray-700">
{Object.entries({
"Provision Type": simulation.provisionType,
"Pool Address": simulation.poolAddress,
"Router Address": simulation.routerAddress,
"Token A": simulation.tokenA,
"Token B": simulation.tokenB,
"Token A Units": fromBaseUnits(
simulation.tokenAUnits,
tokenA?.meta?.decimals
),
"Token B Units": fromBaseUnits(
simulation.tokenBUnits,
tokenB?.meta?.decimals
),
"LP Account": simulation.lpAccountAddress,
"Estimated LP": fromNano(simulation.estimatedLpUnits),
"Min LP": fromNano(simulation.minLpUnits),
"Price Impact": simulation.priceImpact,
}).map(([label, value]) => (
<li key={label}>
<span className="font-medium">{label}:</span>{" "}
<span className="break-all">{value}</span>
</li>
))}
</ul>
</div>
</>
)
) : null}
6. Building the Transaction
Then create a .env
file in the root of your project and add your API key there:
Copy VITE_TON_API_KEY=your_api_key_here
Now let's build the transaction using @ston-fi/sdk
:
Add new imports to top of file and initialize TON JSON-RPC client:
Copy import { useTonConnectUI } from "@tonconnect/ui-react";
import { dexFactory } from "@ston-fi/sdk";
import { TonClient, fromNano } from "@ton/ton";
// TON JSON-RPC client for blockchain interactions
const tonApiClient = new TonClient({
endpoint: "https://toncenter.com/api/v2/jsonRPC",
apiKey: import.meta.env.VITE_TON_API_KEY,
});
Add handleProvideLiquidityClick
callback after handleSimulationClick
callback:
Copy const [tonConnectUI] = useTonConnectUI();
const handleProvideLiquidityClick = async () => {
if (!simulation || simulation instanceof Error) {
alert("Simulation is not valid");
return;
}
const tonAsset = tokens.find((token) => token.kind === "Ton");
if (!tonAsset) {
alert("TON asset info not found");
return;
}
try {
// Retrieve router metadata
const routerMetadata = await stonApiClient.getRouter(
simulation.routerAddress
);
// Create contract instances
const { Router, pTON } = dexFactory(routerMetadata);
const router = tonApiClient.open(Router.create(routerMetadata.address));
const pTon = pTON.create(routerMetadata.ptonMasterAddress);
const isTonAsset = (contractAddress: string) =>
contractAddress === tonAsset.contractAddress;
const buildTransaction = async (args: {
sendAmount: string;
sendTokenAddress: string;
otherTokenAddress: string;
}) => {
const params = {
userWalletAddress: walletAddress,
minLpOut: simulation.minLpUnits,
sendAmount: args.sendAmount,
otherTokenAddress: isTonAsset(args.otherTokenAddress)
? pTon.address
: args.otherTokenAddress,
};
// TON requires proxy contract, Jettons use direct transfer
if (isTonAsset(args.sendTokenAddress)) {
return await router.getProvideLiquidityTonTxParams({
...params,
proxyTon: pTon,
});
} else {
return await router.getProvideLiquidityJettonTxParams({
...params,
sendTokenAddress: args.sendTokenAddress,
});
}
};
// Generate transaction parameters for both tokens
// Different methods are used for TON vs Jetton tokens due to blockchain mechanics
const txParams = await Promise.all([
buildTransaction({
sendAmount: simulation.tokenAUnits,
sendTokenAddress: simulation.tokenA,
otherTokenAddress: simulation.tokenB,
}),
buildTransaction({
sendAmount: simulation.tokenBUnits,
sendTokenAddress: simulation.tokenB,
otherTokenAddress: simulation.tokenA,
}),
]);
// Format transaction messages for TonConnect sendTransaction interface
const messages = txParams.map((txParam) => ({
address: txParam.to.toString(),
amount: txParam.value.toString(),
payload: txParam.body?.toBoc().toString("base64"),
}));
// Trigger TonConnect modal for user transaction approval
await tonConnectUI.sendTransaction({
validUntil: Date.now() + 5 * 60 * 1000, // Transaction valid for 5 minutes
messages,
});
} catch (e) {
alert(`Error: ${e}`);
}
};
Add Provide Liquidity button after the simulation results div:
Copy {Object.entries({
"Provision Type": simulation.provisionType,
"Pool Address": simulation.poolAddress,
"Router Address": simulation.routerAddress,
"Token A": simulation.tokenA,
"Token B": simulation.tokenB,
"Token A Units": fromBaseUnits(
simulation.tokenAUnits,
tokenA?.meta?.decimals
),
"Token B Units": fromBaseUnits(
simulation.tokenBUnits,
tokenB?.meta?.decimals
),
"LP Account": simulation.lpAccountAddress,
"Estimated LP": fromNano(simulation.estimatedLpUnits),
"Min LP": fromNano(simulation.minLpUnits),
"Price Impact": simulation.priceImpact,
}).map(([label, value]) => (
<li key={label}>
<span className="font-medium">{label}:</span>{" "}
<span className="break-all">{value}</span>
</li>
))}
</ul>
</div>
<button
onClick={handleProvideLiquidityClick}
className="w-full bg-green-500 hover:bg-green-600 text-white font-medium py-2 rounded mt-4"
>
Provide Liquidity
</button>
</>
)
) : null}
7. Executing the Provision
After clicking "Provide Liquidity", your wallet will prompt you to confirm and sign. The transaction will:
Send token A and token B to the router contract
Add liquidity to the pool
Mint LP tokens to your LP wallet
8. Testing Your Provision
Open http://localhost:5173
Select two tokens and enter amounts
Simulate to see expected LP tokens
Click "Provide Liquidity" to execute the transaction
Check your wallet for the new LP tokens
9. Conclusion
You've built a minimal React app that:
Fetches tokens from STON.fi
Simulates liquidity provision
Handles both new and existing pools
Executes the provision transaction
10. Live Demo
With this Replit demo, you can:
Open the project directly in your browser
Fork the Replit to make your own copy
Run the application to see it in action
Explore and modify the code to learn how it works
Experiment with different features and UI changes
Alternatively, you can run this example locally by cloning the GitHub repository:
Copy git clone https://github.com/mrruby/stonfi-liquidity-app.git
cd omniston-swap-app
pnpm install
pnpm dev
This will start the development server and you can access the app at http://localhost:5173
.
10. Next steps
For more advanced features you can add:
Dynamic slippage controls