0. Goal
You will build a single-file HTML page that does:
- Connect wallet (MetaMask)
- Get route options from
getV2Quote, getV3Quote, and getSplitV2Quote
- Compute slippage-protected
minOut
- Optional: verify routes via
callStatic swap simulation (discard routes that would revert)
- Approve input token (if needed)
- Swap using the best executable route:
dexV2Swap, dexV3Swap, or dexSplitV2Swap
Happy-path first: This tutorial is still designed to avoid "first-run snags".
The full working file is included at the end so you can copy/paste and succeed immediately,
then come back and understand each step.
1. Requirements
- Wallet: MetaMask (or any wallet that injects
window.ethereum)
- Network: Base mainnet
- Funds: some ETH for gas + some WETH or USDC (depending which direction you swap)
- Serving: run from
http://localhost or https (avoid file://)
2. Constants (Base)
These are the only chain-specific parts in the demo:
| Item |
Value |
Notes |
| Chain ID |
0x2105 |
Base mainnet |
| NeuyRouter |
0x85D1CDBf53E8882314A8aF3499f0eCeE6945975f
|
Base production |
| WETH |
0x4200000000000000000000000000000000000006
|
18 decimals |
| USDC |
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
|
6 decimals |
3. Mental model (production)
The beginner tutorial is: quote > slippage > approve > swap.
The production version adds one more step:
quote (multiple engines)
> choose best candidate
> verify (callStatic swap simulation)
> approve (if needed)
> execute swap (real tx)
Why verification matters: a route can quote successfully and still revert on execution
(fee-on-transfer behavior, pool edge cases, thin liquidity, router assumptions, etc.).
Verification simulates the exact swap call (no gas, no state change) so your UI discards routes that would fail.
4. Quote (V2 + V3 + Split)
4.1 V2 quote: getV2Quote
Returns the best amountOut and a route id. You must use that same route for the swap.
const [amountOutV2, routeV2] = await router.getV2Quote(tkIn, tkOut, amountIn);
4.2 V3 quote: getV3Quote
Same idea: quote returns amountOut and route id, but for V3 fee tiers/routes.
const [amountOutV3, routeV3] = await router.getV3Quote(tkIn, tkOut, amountIn);
4.3 Split quote: getSplitV2Quote
Split returns a single combined output plus route1 and route2 for the two split legs.
const [amountOutSplit, route1, route2] = await router.getSplitV2Quote(tkIn, tkOut, amountIn);
In the full demo, we collect all quote results into a single list of route options,
then sort by best output.
5. Verification (callStatic)
Verification means: "simulate the exact swap call we plan to send".
If the simulation reverts, that route is removed from the UI.
5.1 Verify V2 route
const out = await router.callStatic.dexV2Swap(route, amountIn, minOut, tkIn, tkOut);
5.2 Verify V3 route
const out = await router.callStatic.dexV3Swap(route, amountIn, minOut, tkIn, tkOut);
5.3 Verify Split route
const out = await router.callStatic.dexSplitV2Swap(route1, route2, amountIn, minOut, tkIn, tkOut);
Important: Verification is best run after approval exists.
In the full demo we keep it beginner-friendly:
if you are not approved yet, we still show quotes; once approved, verification becomes extremely accurate.
6. Slippage protection (minOut)
Same approach as the beginner tutorial: use bps and compute a minimum output.
(200 bps = 2%)
// minOut = amountOut * (10000 - bps) / 10000
function bpsMinOut(amountOutBn, bps) {
const keep = ethers.BigNumber.from(10000 - Number(bps));
return amountOutBn.mul(keep).div(10000);
}
7. ERC20 approval
Same rule as always: for ERC20 input tokens, you must approve the router first.
This demo checks allowance then approves only if needed.
async function ensureApproval(tokenAddr, amountIn) {
const token = new ethers.Contract(tokenAddr, erc20Abi, signer);
const allowance = await token.allowance(account, NEUY_ROUTER);
if (allowance.gte(amountIn)) return;
const tx = await token.approve(NEUY_ROUTER, amountIn);
await tx.wait();
}
8. Route UI pattern (like production)
Your production swap UI shows multiple routes so a user can pick an alternate quickly.
We do the same: render a list of routes and let the user click an alternate to select it.
Tip: The best practice is:
best verified route is preselected, but alternates remain available to click.
9. Run locally (recommended)
Run from http://localhost, not file://.
# In the folder with your HTML file:
python3 -m http.server 8080
# Then open:
http://localhost:8080/neuyrouter_advanced_base.html
10. Full copy/paste working file
Save the following as neuyrouter_advanced_base.html, then run it using the steps above.
This file includes V2 + V3 + Split quotes, optional verification, and a route list UI.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>NeuyRouter Advanced (Base) - V2 + V3 + Split + Verification</title>
<style>
body { font-family: Arial, Helvetica, sans-serif; background:#fff; color:#111; max-width:860px; margin:32px auto; padding:16px; }
h1 { font-size:20px; margin:0 0 14px; }
.box { border:1px solid #ddd; border-radius:10px; padding:14px; }
label { display:block; font-weight:700; margin:12px 0 6px; color:#333; }
input, button, select { width:100%; padding:11px; border-radius:8px; border:1px solid #ccc; font-size:16px; box-sizing:border-box; }
button { background:#2563eb; color:#fff; border:none; cursor:pointer; font-weight:700; }
button:disabled { opacity:0.6; cursor:not-allowed; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.muted { color:#666; font-size:13px; margin-top:6px; }
#status { margin-top:12px; padding:12px; border-radius:8px; background:#f3f4f6; white-space:pre-wrap; line-height:1.35; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.routes { margin-top: 10px; }
.routeItem { margin-top: 8px; border: 1px solid #ddd; border-radius: 10px; padding: 10px 12px; display:flex; justify-content:space-between; cursor:pointer; background:#fafafa; }
.routeItem:hover { background:#f3f4f6; }
.routeLeft { font-weight:700; }
</style>
</head>
<body>
<h1>NeuyRouter Advanced (Base) - V2 + V3 + Split + Verification</h1>
<div class="box">
<button id="connectBtn">Connect Wallet (Base)</button>
<div id="app" style="display:none;">
<div class="row">
<div>
<label>Amount In (Human)</label>
<input id="amountInHuman" value="0.01" />
<div class="muted">Uses token decimals (WETH=18, USDC=6).</div>
</div>
<div>
<label>Slippage (bps)</label>
<input id="slippageBps" value="200" />
<div class="muted">200 = 2%</div>
</div>
</div>
<div class="row" style="margin-top:10px;">
<div>
<label>Direction</label>
<select id="dir">
<option value="WETH_USDC">WETH > USDC</option>
<option value="USDC_WETH">USDC > WETH</option>
</select>
</div>
<div>
<label>Verification</label>
<select id="verify">
<option value="on">ON (recommended)</option>
<option value="off">OFF (quotes only)</option>
</select>
</div>
</div>
<div class="row" style="margin-top:10px;">
<button id="quoteBtn">Quote (V2 + V3 + Split)</button>
<button id="approveBtn">Approve Input Token</button>
</div>
<div class="row" style="margin-top:10px;">
<button id="swapBtn" style="grid-column:1 / span 2; background:#16a34a;">Swap Best Route</button>
</div>
<div class="routes">
<div class="muted">Routes (click alternate to select + swap):</div>
<div id="routeList"></div>
</div>
<div id="status" class="mono">Ready.</div>
</div>
</div>
<script src="https://finance.neuy.io/ethers.umd.min.js" type="application/javascript"></script>
<script>
const CHAIN_ID_HEX = "0x2105";
const NEUY_ROUTER = "0x85D1CDBf53E8882314A8aF3499f0eCeE6945975f";
const WETH = "0x4200000000000000000000000000000000000006";
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const routerAbi = [
"function getV2Quote(address tkIn, address tkOut, uint256 amountIn) external view returns (uint256,uint256)",
"function getV3Quote(address tkIn, address tkOut, uint256 amountIn) external returns (uint256,uint256)",
"function getSplitV2Quote(address tkIn, address tkOut, uint256 amountIn) external view returns (uint256,uint256,uint256)",
"function dexV2Swap(uint256 route, uint256 amountIn, uint256 amountOut, address tkIn, address tkOut) external returns (uint256)",
"function dexV3Swap(uint256 route, uint256 amountIn, uint256 amountOut, address tkIn, address tkOut) external returns (uint256)",
"function dexSplitV2Swap(uint256 route1, uint256 route2, uint256 amountIn, uint256 amountOut, address tkIn, address tkOut) external returns (uint256)"
];
const erc20Abi = [
"function approve(address spender, uint256 amount) external returns (bool)",
"function allowance(address owner, address spender) external view returns (uint256)"
];
const connectBtn = document.getElementById("connectBtn");
const app = document.getElementById("app");
const quoteBtn = document.getElementById("quoteBtn");
const approveBtn = document.getElementById("approveBtn");
const swapBtn = document.getElementById("swapBtn");
const statusEl = document.getElementById("status");
const amountInHumanEl = document.getElementById("amountInHuman");
const slippageBpsEl = document.getElementById("slippageBps");
const dirEl = document.getElementById("dir");
const verifyEl = document.getElementById("verify");
const routeList = document.getElementById("routeList");
let provider, signer, account, router, readRouter;
let lastPick = null;
function setStatus(msg, isError=false) {
statusEl.textContent = msg;
statusEl.style.background = isError ? "#fee2e2" : "#f3f4f6";
statusEl.style.color = isError ? "#7f1d1d" : "#111";
}
async function ensureBase() {
const cid = await window.ethereum.request({ method: "eth_chainId" });
if ((cid || "").toLowerCase() === CHAIN_ID_HEX) return;
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: CHAIN_ID_HEX }]
});
}
function bpsMinOut(amountOutBn, bps) {
const keep = ethers.BigNumber.from(10000 - Number(bps));
return amountOutBn.mul(keep).div(10000);
}
function getPair() {
if (dirEl.value === "USDC_WETH") return { tkIn:USDC, tkOut:WETH, decIn:6, decOut:18, symIn:"USDC", symOut:"WETH" };
return { tkIn:WETH, tkOut:USDC, decIn:18, decOut:6, symIn:"WETH", symOut:"USDC" };
}
async function ensureApproval(tokenAddr, amountIn) {
const token = new ethers.Contract(tokenAddr, erc20Abi, signer);
const allowance = await token.allowance(account, NEUY_ROUTER);
if (allowance.gte(amountIn)) return;
setStatus("Approving " + (tokenAddr === WETH ? "WETH" : "USDC") + "...");
const tx = await token.approve(NEUY_ROUTER, amountIn);
await tx.wait();
}
function renderRoutes(routes, pair) {
routeList.innerHTML = "";
if (!routes || routes.length === 0) return;
for (let i=0;i<routes.length;i++) {
const r = routes[i];
const div = document.createElement("div");
div.className = "routeItem";
const left = document.createElement("div");
left.className = "routeLeft";
left.textContent =
r.kind === "v2" ? ("V2 route " + r.route.toString()) :
r.kind === "v3" ? ("V3 route " + r.route.toString()) :
("Split routes " + r.route1.toString() + " + " + r.route2.toString());
const right = document.createElement("div");
right.textContent = ethers.utils.formatUnits(r.amountOut, pair.decOut) + " " + pair.symOut;
div.appendChild(left);
div.appendChild(right);
div.onclick = async () => {
lastPick = r;
setStatus("Selected route ✅\\n" + left.textContent);
await doSwap();
};
routeList.appendChild(div);
}
}
async function connect() {
if (!window.ethereum) return setStatus("No wallet found (window.ethereum missing). Use MetaMask.", true);
if (typeof ethers === "undefined") return setStatus("ethers v5 did not load.", true);
setStatus("Connecting...");
await ensureBase();
await window.ethereum.request({ method: "eth_requestAccounts" });
provider = new ethers.providers.Web3Provider(window.ethereum, "any");
signer = provider.getSigner();
account = await signer.getAddress();
router = new ethers.Contract(NEUY_ROUTER, routerAbi, signer);
readRouter = new ethers.Contract(NEUY_ROUTER, routerAbi, provider);
connectBtn.style.display = "none";
app.style.display = "block";
setStatus(
"Connected ✅\\n" +
"Account: " + account + "\\n" +
"Router: " + NEUY_ROUTER + "\\n" +
"Chain: Base (0x2105)"
);
}
async function getQuotes() {
quoteBtn.disabled = true;
try {
await ensureBase();
const pair = getPair();
const amountHuman = (amountInHumanEl.value || "").trim();
const slippageBps = Number((slippageBpsEl.value || "0").trim());
if (!amountHuman || Number(amountHuman) <= 0) return setStatus("Enter a valid amount.", true);
if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > 5000) return setStatus("Slippage bps must be 0..5000.", true);
const amountIn = ethers.utils.parseUnits(amountHuman, pair.decIn);
setStatus("Quoting V2 + V3 + Split...");
const routes = [];
// V2
try {
const [amt, r] = await readRouter.callStatic.getV2Quote(pair.tkIn, pair.tkOut, amountIn);
if (ethers.BigNumber.from(amt).gt(0)) routes.push({ kind:"v2", amountOut:ethers.BigNumber.from(amt), route:r, amountIn });
} catch {}
// V3
try {
const [amt, r] = await readRouter.callStatic.getV3Quote(pair.tkIn, pair.tkOut, amountIn);
if (ethers.BigNumber.from(amt).gt(0)) routes.push({ kind:"v3", amountOut:ethers.BigNumber.from(amt), route:r, amountIn });
} catch {}
// Split
try {
const [amt, r1, r2] = await readRouter.callStatic.getSplitV2Quote(pair.tkIn, pair.tkOut, amountIn);
if (ethers.BigNumber.from(amt).gt(0)) routes.push({ kind:"split", amountOut:ethers.BigNumber.from(amt), route1:r1, route2:r2, amountIn });
} catch {}
if (routes.length === 0) return setStatus("All quote calls failed or returned 0.", true);
routes.sort((a,b)=> a.amountOut.eq(b.amountOut)?0:(a.amountOut.gt(b.amountOut)?-1:1));
// Optional verification (after approval exists)
if (verifyEl.value === "on") {
try {
const token = new ethers.Contract(pair.tkIn, erc20Abi, signer);
const allowance = await token.allowance(account, NEUY_ROUTER);
if (allowance.gte(amountIn)) {
setStatus("Verifying routes (callStatic swap simulation)...");
const verified = [];
for (const r of routes) {
try {
const minOut = bpsMinOut(r.amountOut, slippageBps);
let out;
if (r.kind === "v2") out = await router.callStatic.dexV2Swap(r.route, amountIn, minOut, pair.tkIn, pair.tkOut);
if (r.kind === "v3") out = await router.callStatic.dexV3Swap(r.route, amountIn, minOut, pair.tkIn, pair.tkOut);
if (r.kind === "split") out = await router.callStatic.dexSplitV2Swap(r.route1, r.route2, amountIn, minOut, pair.tkIn, pair.tkOut);
const outBN = ethers.BigNumber.from(out || 0);
if (outBN.gt(0)) verified.push({ ...r, amountOut: outBN });
} catch {}
}
if (verified.length > 0) {
verified.sort((a,b)=> a.amountOut.eq(b.amountOut)?0:(a.amountOut.gt(b.amountOut)?-1:1));
renderRoutes(verified, pair);
lastPick = verified[0];
setStatus("Verified best route ✅\\nExpected out: " + ethers.utils.formatUnits(lastPick.amountOut, pair.decOut) + " " + pair.symOut);
return;
}
}
} catch {}
}
renderRoutes(routes, pair);
lastPick = routes[0];
setStatus("Quote OK ✅\\nBest: " + lastPick.kind.toUpperCase() + "\\nExpected out: " + ethers.utils.formatUnits(lastPick.amountOut, pair.decOut) + " " + pair.symOut);
} catch (e) {
setStatus("Quote failed âŒ\\n" + (e?.reason || e?.message || String(e)), true);
} finally {
quoteBtn.disabled = false;
}
}
async function doApprove() {
approveBtn.disabled = true;
try {
await ensureBase();
const pair = getPair();
const amountHuman = (amountInHumanEl.value || "").trim();
if (!amountHuman || Number(amountHuman) <= 0) return setStatus("Enter a valid amount.", true);
const amountIn = ethers.utils.parseUnits(amountHuman, pair.decIn);
await ensureApproval(pair.tkIn, amountIn);
setStatus("Approve success ✅");
} catch (e) {
setStatus("Approve failed âŒ\\n" + (e?.reason || e?.message || String(e)), true);
} finally {
approveBtn.disabled = false;
}
}
async function doSwap() {
swapBtn.disabled = true;
try {
await ensureBase();
const pair = getPair();
if (!lastPick) return setStatus("No quote yet. Click Quote first.", true);
const amountHuman = (amountInHumanEl.value || "").trim();
const slippageBps = Number((slippageBpsEl.value || "0").trim());
const amountIn = ethers.utils.parseUnits(amountHuman, pair.decIn);
await ensureApproval(pair.tkIn, amountIn);
const minOut = bpsMinOut(lastPick.amountOut, slippageBps);
setStatus("Sending swap...");
let tx;
if (lastPick.kind === "v2") tx = await router.dexV2Swap(lastPick.route, amountIn, minOut, pair.tkIn, pair.tkOut);
if (lastPick.kind === "v3") tx = await router.dexV3Swap(lastPick.route, amountIn, minOut, pair.tkIn, pair.tkOut);
if (lastPick.kind === "split") tx = await router.dexSplitV2Swap(lastPick.route1, lastPick.route2, amountIn, minOut, pair.tkIn, pair.tkOut);
setStatus("Swap sent ✅\\n" + tx.hash + "\\nWaiting confirmation...");
const receipt = await tx.wait();
if (receipt.status === 1) setStatus("Swap success ✅\\n" + tx.hash);
else setStatus("Swap reverted âŒ\\n" + tx.hash, true);
} catch (e) {
setStatus("Swap failed âŒ\\n" + (e?.reason || e?.message || String(e)), true);
} finally {
swapBtn.disabled = false;
}
}
document.getElementById("connectBtn").addEventListener("click", connect);
quoteBtn.addEventListener("click", getQuotes);
approveBtn.addEventListener("click", doApprove);
swapBtn.addEventListener("click", doSwap);
</script>
</body>
</html>
11. Troubleshooting
- Wallet not detected: don't use
file://. Use http://localhost or https.
- Wrong network: switch MetaMask to Base mainnet.
- Quotes fail: try again; wallet RPC hiccups are normal. Consider adding a dedicated read RPC later.
- Swap fails after quote: turn Verification ON and try after approval exists; increase slippage slightly (200 > 300).
- Verification removes everything: you may not be approved yet; click Approve, then re-quote.
12. Next steps
Once you are comfortable with this pattern, the upgrade path is simple:
- Add native swaps (ETH <> token) using your router's native endpoints
- Add a dedicated read RPC (
JsonRpcProvider) for stable quote behavior
- Add "route bans" for consistently bad pools/routes
- Expand the same route UI into your terminal (exact same data shape)
Reference: NeuyRouter Dev Docs