0. Goal
You will build a single-file HTML page that does:
- Connect wallet (MetaMask)
- Get a V2 quote:
getV2Quote(WETH, USDC, amountIn)
- Compute a slippage-protected
minOut
- Approve WETH (if needed)
- Swap:
dexV2Swap(route, amountIn, minOut, WETH, USDC)
Happy-path first: this tutorial is designed to avoid "first-run snags".
It includes a full working file at the end so you can copy/paste and succeed immediately,
then come back to learn each step.
1. Requirements
- Wallet: MetaMask (or any wallet that injects
window.ethereum)
- Network: Ethereum mainnet
- Funds: some ETH for gas + some WETH to swap
- Serving: run from
http://localhost or https (avoid file://)
2. Constants (Ethereum)
These are the only chain-specific parts in the demo:
| Item |
Value |
Notes |
| Chain ID |
0x1 |
Ethereum mainnet |
| NeuyRouter |
0xD3E73c2563fFCE65401DfdEcf66b699D4ce41fB9
|
V3 beta 4 (Ethereum) |
| WETH |
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
|
18 decimals |
| USDC |
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
|
6 decimals |
3. The two NeuyRouter calls
3.1 Quote: getV2Quote
This returns two values: the best amountOut and a route id.
You must use that same route id for the swap.
const [amountOut, route] = await router.getV2Quote(WETH, USDC, amountIn);
3.2 Swap: dexV2Swap
This executes the swap using the chosen route and your slippage-protected minimum output.
await router.dexV2Swap(route, amountIn, minOut, WETH, USDC);
4. Slippage protection (minOut)
Never swap without a minimum output. This demo uses basis points (bps):
(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);
}
5. ERC20 approval (WETH)
Because WETH is an ERC20 token, the router cannot move it unless you approve it first.
This demo checks allowance, then approves only if needed.
async function ensureApprovalWeth(amountIn) {
const weth = new ethers.Contract(WETH, erc20Abi, signer);
const allowance = await weth.allowance(account, NEUY_ROUTER);
if (allowance.gte(amountIn)) return;
const tx = await weth.approve(NEUY_ROUTER, amountIn);
await tx.wait();
}
6. Run locally (recommended)
The most reliable way to run browser Web3 demos is using localhost, not file://.
# In the folder with your HTML file:
python3 -m http.server 8080
# Then open:
http://localhost:8080/neuyrouter_ultrabasic_eth.html
Important: This demo swaps WETH > USDC.
Make sure you hold WETH (not only ETH), and have ETH for gas.
7. Full copy/paste working file
Save the following as neuyrouter_ultrabasic_eth.html, then run it using the steps above.
This is the exact working beginner demo.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>NeuyRouter Ultra-Basic (Ethereum) - WETH > USDC (V2 Quote + V2 Swap)</title>
<style>
body { font-family: Arial, Helvetica, sans-serif; background:#fff; color:#111; max-width:760px; 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 { 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; }
</style>
</head>
<body>
<h1>NeuyRouter Ultra-Basic (Ethereum) - WETH > USDC</h1>
<div class="box">
<button id="connectBtn">Connect Wallet (Ethereum)</button>
<div id="app" style="display:none;">
<div class="row">
<div>
<label>Amount In (WETH)</label>
<input id="amountInHuman" value="0.01" />
<div class="muted">Human units (we'll convert using 18 decimals).</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;">
<button id="quoteBtn">Get V2 Quote</button>
<button id="swapBtn">Approve (if needed) + V2 Swap</button>
</div>
<div id="status" class="mono">Ready.</div>
</div>
</div>
<!-- ethers v5 -->
<script src="https://finance.neuy.io/ethers.umd.min.js" type="application/javascript"></script>
<script>
const CHAIN_ID_HEX = "0x1";
const NEUY_ROUTER = "0xD3E73c2563fFCE65401DfdEcf66b699D4ce41fB9";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const routerAbi = [
"function getV2Quote(address tkIn, address tkOut, uint256 amountIn) external view returns (uint256,uint256)",
"function dexV2Swap(uint256 route, 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 swapBtn = document.getElementById("swapBtn");
const statusEl = document.getElementById("status");
const amountInHumanEl = document.getElementById("amountInHuman");
const slippageBpsEl = document.getElementById("slippageBps");
let provider, signer, account, router;
let lastQuote = null;
function setStatus(msg, isError=false) {
statusEl.textContent = msg;
statusEl.style.background = isError ? "#fee2e2" : "#f3f4f6";
statusEl.style.color = isError ? "#7f1d1d" : "#111";
}
async function ensureEthereumMainnet() {
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);
}
async function ensureApprovalWeth(amountIn) {
const weth = new ethers.Contract(WETH, erc20Abi, signer);
const allowance = await weth.allowance(account, NEUY_ROUTER);
if (allowance.gte(amountIn)) return;
setStatus("Approving WETH...");
const tx = await weth.approve(NEUY_ROUTER, amountIn);
await tx.wait();
}
async function connect() {
if (!window.ethereum) {
setStatus("No wallet found (window.ethereum missing). Use MetaMask.", true);
return;
}
if (typeof ethers === "undefined") {
setStatus("ethers v5 did not load.", true);
return;
}
setStatus("Connecting...");
await ensureEthereumMainnet();
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);
connectBtn.style.display = "none";
app.style.display = "block";
setStatus(
"Connected ✅\n" +
`Account: ${account}\n` +
`Router: ${NEUY_ROUTER}\n` +
`Pair: WETH > USDC`
);
}
async function getQuote() {
try {
quoteBtn.disabled = true;
await ensureEthereumMainnet();
const amountInHuman = (amountInHumanEl.value || "").trim();
const slippageBps = Number((slippageBpsEl.value || "0").trim());
if (!amountInHuman || Number(amountInHuman) <= 0) {
setStatus("Enter a valid WETH amount.", true);
return;
}
if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > 5000) {
setStatus("Slippage bps must be between 0 and 5000.", true);
return;
}
const amountIn = ethers.utils.parseUnits(amountInHuman, 18);
setStatus("Quoting via getV2Quote...");
const [amountOut, route] = await router.getV2Quote(WETH, USDC, amountIn);
const minOut = bpsMinOut(amountOut, slippageBps);
lastQuote = { amountIn, amountOut, route, minOut, slippageBps };
setStatus(
"Quote OK ✅\n" +
`amountIn (WETH): ${amountInHuman}\n` +
`amountOut (USDC): ${ethers.utils.formatUnits(amountOut, 6)}\n` +
`route: ${route.toString()}\n` +
`minOut (@${slippageBps} bps): ${ethers.utils.formatUnits(minOut, 6)}`
);
} catch (e) {
setStatus("Quote failed âŒ\n" + (e?.reason || e?.message || String(e)), true);
} finally {
quoteBtn.disabled = false;
}
}
async function doSwap() {
try {
swapBtn.disabled = true;
await ensureEthereumMainnet();
if (!lastQuote) {
setStatus("No quote yet. Click "Get V2 Quote" first.", true);
return;
}
const { amountIn, minOut, route } = lastQuote;
setStatus("Checking/setting WETH approval...");
await ensureApprovalWeth(amountIn);
setStatus("Sending dexV2Swap...");
const tx = await router.dexV2Swap(route, amountIn, minOut, WETH, USDC);
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;
}
}
connectBtn.addEventListener("click", connect);
quoteBtn.addEventListener("click", getQuote);
swapBtn.addEventListener("click", doSwap);
</script>
</body>
</html>
8. Troubleshooting
- Wallet not detected: don't use
file://. Use http://localhost or https.
- Wrong network: switch MetaMask to Ethereum mainnet.
- Swap fails: ensure you have ETH for gas and WETH to sell.
- Approval succeeds but swap fails: re-quote, then swap again; increase slippage bps slightly (e.g. 200 > 300).
9. Next steps (V3 + Split)
Once you're comfortable with this flow, the upgrade path is simple:
- Swap
getV2Quote > getV3Quote
- Swap
dexV2Swap > dexV3Swap
- Try
getSplitV2Quote + dexSplitV2Swap
The core mental model stays the same: quote > slippage guard > approve > swap.
Reference: NeuyRouter Dev Docs