AI Agent Setup — local trader walkthrough
Copy the source, follow the steps, and use the links below for support and the development environment.
01 Source code
/*
* Local wallet-confirmed trader for Ethereum mainnet.
*
* Changelog updates will be made here.
*
*/
const crypto = require("node:crypto");
const http = require("node:http");
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const PORT = (() => {
const raw = process.env.TRADER_PORT;
if (raw === undefined || raw === "") return DEFAULT_PORT;
const port = /^\d{4,5}$/.test(raw) ? Number(raw) : NaN;
return port >= 1024 && port <= 65535 ? port : NaN;
})();
const ORIGIN = `http://${HOST}:${PORT}`;
const RPC_PATH = "/rpc";
const EXPECTED_ETHERS_VERSION = "6.13.5";
// "sha384-" of dist/ethers.umd.min.js. Leave empty to get
// instructions for verifying it from two sources.
const EXPECTED_ETHERS_SRI = "";
const RPC_METHODS = new Set([
"eth_chainId",
"eth_blockNumber",
"eth_getBlockByNumber",
"eth_getCode",
"eth_call",
"eth_getTransactionByHash",
"eth_getTransactionReceipt",
]);
const MAX_RPC_BODY = 64 * 1024;
const MAX_RPC_RESPONSE = 4 * 1024 * 1024;
const RPC_TIMEOUT_MS = 15000;
const RPC_CONCURRENCY = 4;
const MAX_RPC_QUEUE = 32;
const RATE_LIMIT = /rate.?limit|too many requests|request limit|quota/i;
const RESERVED_HEADERS = new Set([
"accept",
"connection",
"content-length",
"content-type",
"cookie",
"host",
"origin",
"transfer-encoding",
]);
class StartupError extends Error {}
function checkRuntime() {
if (
typeof fetch !== "function" ||
typeof AbortSignal === "undefined" ||
typeof AbortSignal.timeout !== "function"
) {
throw new StartupError(
"Node.js 18 or newer is required (global fetch and " +
"AbortSignal.timeout).",
);
}
if (Number.isNaN(PORT)) {
throw new StartupError("TRADER_PORT must be a number from 1024 to 65535.");
}
}
function sri(buffer) {
const digest = crypto.createHash("sha384").update(buffer).digest("base64");
return `sha384-${digest}`;
}
function findPackageRoot(name) {
let dir;
try {
dir = path.dirname(require.resolve(name));
} catch {
throw new StartupError(
`${name} is not installed. Run: npm ci --ignore-scripts`,
);
}
for (;;) {
const manifest = path.join(dir, "package.json");
if (fs.existsSync(manifest)) {
const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
if (pkg.name === name) return { dir, version: pkg.version };
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new StartupError(`Cannot locate the ${name} package root.`);
}
dir = parent;
}
}
function readVerifyRpc() {
const raw = process.env.VERIFY_RPC_URL;
if (!raw) {
throw new StartupError(
"Set VERIFY_RPC_URL to an Ethereum mainnet JSON-RPC endpoint from " +
"a different provider than your wallet uses (or your own node). " +
"Many RPC brands resell the same backends, so check who actually " +
"runs the nodes.",
);
}
let url;
try {
url = new URL(raw);
} catch {
throw new StartupError("VERIFY_RPC_URL is not a valid URL.");
}
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
throw new StartupError(
"VERIFY_RPC_URL must use https (http only for localhost).",
);
}
if (url.username || url.password) {
throw new StartupError("VERIFY_RPC_URL must not contain user:password@.");
}
if (local && url.port === String(PORT)) {
throw new StartupError("VERIFY_RPC_URL cannot point at this app.");
}
return url;
}
function readVerifyHeader() {
const raw = process.env.VERIFY_RPC_HEADER;
if (raw === undefined || raw === "") return null;
const match = /^([A-Za-z0-9-]{1,64}):[ \t]*([^\r\n]{1,4096})$/.exec(raw);
const value = match ? match[2].trim() : "";
// fetch rejects non-Latin-1 values, which would look like an outage.
if (!match || !/^[\x20-\x7e]+$/.test(value)) {
throw new StartupError(
'VERIFY_RPC_HEADER must look like "Name: value", on one line, ' +
"using printable ASCII characters only.",
);
}
const name = match[1].toLowerCase();
if (RESERVED_HEADERS.has(name)) {
throw new StartupError(`VERIFY_RPC_HEADER may not set ${name}.`);
}
return { name, value };
}
// Heuristic for API keys placed in a subdomain.
function looksLikeSecret(label) {
return (
label.length >= 16 ||
(label.length >= 8 && /\d/.test(label) && /[a-z]/i.test(label))
);
}
function secretHostLabels(url) {
if (url.hostname.startsWith("[")) return [];
return url.hostname.split(".").slice(0, -2).filter(looksLikeSecret);
}
function displayHost(url) {
const secret = new Set(secretHostLabels(url));
const shown = url.hostname.startsWith("[")
? url.hostname
: url.hostname
.split(".")
.map((label) => (secret.has(label) ? "[redacted]" : label))
.join(".");
return url.port ? `${shown}:${url.port}` : shown;
}
function readVerifyLabel(url) {
const raw = process.env.VERIFY_RPC_LABEL;
if (raw === undefined || raw === "") return displayHost(url);
if (!/^[A-Za-z0-9 ._:()-]{1,60}$/.test(raw)) {
throw new StartupError(
"VERIFY_RPC_LABEL may only contain letters, digits, spaces, and " +
". _ : ( ) -, up to 60 characters.",
);
}
return raw;
}
function firstRunInstructions(hash) {
const v = EXPECTED_ETHERS_VERSION;
const raw =
"https://raw.githubusercontent.com/ethers-io/ethers.js/" +
`v${v}/dist/ethers.umd.min.js`;
return [
"EXPECTED_ETHERS_SRI is not set.",
`Your installed bundle hashes to: ${hash}`,
"Do not paste that value blindly. Confirm it from two sources.",
"",
"1. The npm registry (proves the download is intact, not that the",
" publish was genuine):",
" mkdir ethers-check && cd ethers-check",
` npm pack ethers@${v}`,
` npm view ethers@${v} dist.integrity`,
` openssl dgst -sha512 -binary ethers-${v}.tgz | openssl base64 -A`,
" (the two outputs above must match, apart from the sha512- prefix)",
` tar -xzf ethers-${v}.tgz package/dist/ethers.umd.min.js`,
" openssl dgst -sha384 -binary package/dist/ethers.umd.min.js" +
" | openssl base64 -A",
"",
"2. The ethers git repository, which commits its dist/ builds:",
` curl -fsSL ${raw} \\`,
" | openssl dgst -sha384 -binary | openssl base64 -A",
" (if that URL returns 404, browse the tag on GitHub to find the",
" file; do not rely on source 1 alone)",
"",
"Optionally, in this project directory, run: npm audit signatures",
"",
"If the two sources disagree, stop and find out why before trading.",
"Do not just pick one of them.",
"A match protects against a tampered registry, mirror, or download.",
"It does not protect against a compromised ethers maintainer, who",
"controls both sources.",
"",
"If both values equal the hash above, set",
' EXPECTED_ETHERS_SRI = "sha384-"',
].join("\n");
}
function loadEthersBundle() {
const pkg = findPackageRoot("ethers");
if (pkg.version !== EXPECTED_ETHERS_VERSION) {
throw new StartupError(
`Expected ethers ${EXPECTED_ETHERS_VERSION}, found ${pkg.version}. ` +
"Run: npm ci --ignore-scripts",
);
}
const file = path.join(pkg.dir, "dist", "ethers.umd.min.js");
if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) {
throw new StartupError(`ethers browser bundle not found at ${file}.`);
}
const source = fs.readFileSync(file);
const integrity = sri(source);
if (!EXPECTED_ETHERS_SRI) {
throw new StartupError(firstRunInstructions(integrity));
}
if (!EXPECTED_ETHERS_SRI.startsWith("sha384-")) {
throw new StartupError('EXPECTED_ETHERS_SRI must start with "sha384-".');
}
if (integrity !== EXPECTED_ETHERS_SRI) {
throw new StartupError(
"ethers bundle hash mismatch. node_modules may have been tampered " +
"with. Delete node_modules, run npm ci --ignore-scripts, and " +
"investigate before trading.",
);
}
return { source, integrity };
}
// Binding can succeed on some systems even when another process listens
// on 0.0.0.0, so probe the port first.
function portAnswers(port) {
return new Promise((resolve) => {
const socket = net.connect({ host: HOST, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("timeout", () => {
socket.destroy();
resolve(false);
});
socket.once("error", () => resolve(false));
});
}
function portInUseError() {
return new StartupError(
`${HOST}:${PORT} is already in use. Something else is serving that ` +
"address, and your wallet would treat it as this app. Find out " +
"what it is, or set TRADER_PORT to a free port (not while a " +
"transaction from this app is pending).",
);
}
// ---------------- Verification RPC proxy ----------------
function rpcError(id, code, message) {
return { jsonrpc: "2.0", id, error: { code, message } };
}
async function readCapped(response) {
const reader = response.body?.getReader();
if (!reader) return "";
const chunks = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_RPC_RESPONSE) {
await reader.cancel().catch(() => {});
return null;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
function containsHex(value, depth = 0) {
if (typeof value === "string") return /^0x[0-9a-f]*$/i.test(value);
if (!value || typeof value !== "object" || depth > 4) return false;
return Object.values(value).some((item) => containsHex(item, depth + 1));
}
// Failures become HTTP 5xx so ethers never mistakes them for reverts.
// 429 is avoided because ethers' 429 backoff can wait for minutes.
function createUpstream(verifyRpc, auth) {
const decode = (text) => {
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const parts = [
...verifyRpc.pathname.split("/").map(decode),
...verifyRpc.searchParams.values(),
...secretHostLabels(verifyRpc),
].filter((part) => part.length >= 8);
const headerSecrets = auth
? [auth.value, ...auth.value.split(/\s+/)].filter((x) => x.length >= 4)
: [];
const secrets = [
...new Set([
verifyRpc.href,
...parts,
...parts.map(encodeURIComponent),
...headerSecrets,
]),
].sort((a, b) => b.length - a.length);
const redact = (text) =>
secrets.reduce((out, secret) => out.split(secret).join("[redacted]"),
text);
const fail = (status, message, upstream = null) => ({
status,
upstream,
body: { error: message },
});
const isRateLimit = (error) => {
const message = String(error.message ?? "");
return (
!containsHex(error.data) &&
!/revert/i.test(message) &&
(error.code === -32005 ||
error.code === 429 ||
RATE_LIMIT.test(message))
);
};
function sanitizeError(error) {
const out = {
code: Number.isSafeInteger(error.code) ? error.code : -32000,
message: redact(String(error.message ?? "RPC error")).slice(0, 500),
};
// Revert data is kept so ethers can classify reverts.
if (error.data !== undefined) {
try {
const data = JSON.stringify(error.data);
if (data !== undefined && data.length <= 20000) {
out.data = JSON.parse(redact(data));
}
} catch {
delete out.data;
}
}
return out;
}
const headers = {
"content-type": "application/json",
accept: "application/json",
...(auth ? { [auth.name]: auth.value } : {}),
};
let active = 0;
const waiting = [];
const acquire = () =>
active < RPC_CONCURRENCY
? (active += 1, Promise.resolve())
: new Promise((resolve) => waiting.push(resolve));
const release = () => {
const next = waiting.shift();
if (next) next();
else active -= 1;
};
return async function callUpstream(id, method, params) {
if (active >= RPC_CONCURRENCY && waiting.length >= MAX_RPC_QUEUE) {
return fail(503, "Verification proxy is busy.");
}
await acquire();
try {
const response = await fetch(verifyRpc.href, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
redirect: "error",
signal: AbortSignal.timeout(RPC_TIMEOUT_MS),
});
if (response.status === 429) {
await response.body?.cancel().catch(() => {});
return fail(503, "Verification RPC is rate limiting.");
}
const declared = Number(response.headers.get("content-length"));
let text = null;
if (declared > MAX_RPC_RESPONSE) {
await response.body?.cancel().catch(() => {});
} else {
text = await readCapped(response);
}
if (!text) {
return fail(
502,
"Verification RPC response empty or too large.",
response.ok ? null : response.status,
);
}
let parsed = null;
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
if (parsed.error && typeof parsed.error === "object") {
if (isRateLimit(parsed.error)) {
return fail(503, "Verification RPC is rate limiting.");
}
if (response.ok || containsHex(parsed.error.data)) {
const error = sanitizeError(parsed.error);
return {
status: 200,
upstream: null,
body: { jsonrpc: "2.0", id, error },
};
}
} else if (response.ok && "result" in parsed) {
const result = parsed.result;
return {
status: 200,
upstream: null,
body: { jsonrpc: "2.0", id, result },
};
}
}
return fail(
502,
`Verification RPC returned HTTP ${response.status}.`,
response.status,
);
} catch (error) {
return error?.name === "TimeoutError"
? fail(504, "Verification RPC timed out.")
: fail(502, "Verification RPC could not be reached.");
} finally {
release();
}
};
}
function parseRpcRequest(text) {
let message;
try {
message = JSON.parse(text);
} catch {
return { id: null, error: "Invalid JSON." };
}
if (!message || typeof message !== "object" || Array.isArray(message)) {
return { id: null, error: "Send one JSON-RPC request at a time." };
}
const id = typeof message.id === "number" || typeof message.id === "string"
? message.id
: null;
const params = message.params ?? [];
if (message.jsonrpc !== "2.0" || id === null) {
return { id, error: "Invalid JSON-RPC request." };
}
if (!RPC_METHODS.has(message.method)) {
return { id, error: "Method not allowed." };
}
if (!Array.isArray(params) || params.length > 3) {
return { id, error: "Invalid params." };
}
if (message.method === "eth_getBlockByNumber" && params[1] !== false) {
return { id, error: "Full transaction bodies are not allowed." };
}
return { id, method: message.method, params };
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
request.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_RPC_BODY) {
request.destroy();
reject(new Error("Request body too large."));
return;
}
chunks.push(chunk);
});
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
request.on("error", reject);
});
}
// ---------------- HTTP server ----------------
const SECURITY_HEADERS = {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cross-Origin-Resource-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
"Content-Security-Policy": [
"default-src 'none'",
"script-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'none'",
].join("; "),
};
function send(response, code, type, body, extra = {}) {
response.writeHead(code, {
...SECURITY_HEADERS,
"Content-Type": type,
...extra,
});
response.end(body);
}
function fetchAllowed(request, { navigation }) {
const site = request.headers["sec-fetch-site"];
if (site === undefined || site === "same-origin") return true;
return (
navigation &&
request.headers["sec-fetch-mode"] === "navigate" &&
request.headers["sec-fetch-dest"] === "document"
);
}
async function handleRpc(request, response, callUpstream) {
const site = request.headers["sec-fetch-site"];
if (
request.headers.origin !== ORIGIN ||
(site !== undefined && site !== "same-origin")
) {
return send(response, 403, "text/plain", "Cross-site request refused.");
}
const type = String(request.headers["content-type"] || "")
.split(";")[0]
.trim()
.toLowerCase();
if (type !== "application/json") {
return send(response, 415, "text/plain", "JSON only.");
}
let text;
try {
text = await readBody(request);
} catch {
return undefined;
}
const parsed = parseRpcRequest(text);
const { status, body } = parsed.error
? { status: 400, body: rpcError(parsed.id, -32600, parsed.error) }
: await callUpstream(parsed.id, parsed.method, parsed.params);
if (status !== 200 && typeof body.error === "string") {
response.statusMessage = body.error;
}
return send(response, status, "application/json", JSON.stringify(body));
}
function createServer({ page, ethersSource, appSource, callUpstream }) {
return http.createServer((request, response) => {
const host = request.headers.host;
if (host === `localhost:${PORT}` && request.method === "GET") {
return send(response, 308, "text/plain", "Use 127.0.0.1.", {
Location: `${ORIGIN}/`,
});
}
// DNS-rebinding protection.
if (host !== `${HOST}:${PORT}`) {
return send(response, 403, "text/plain", "Invalid host.");
}
if (request.headers.origin && request.headers.origin !== ORIGIN) {
return send(response, 403, "text/plain", "Invalid origin.");
}
if (request.method === "POST" && request.url === RPC_PATH) {
handleRpc(request, response, callUpstream).catch(() => {
if (!response.headersSent) {
send(response, 500, "text/plain", "Proxy error.");
}
});
return undefined;
}
if (request.method !== "GET") {
return send(response, 405, "text/plain", "Method not allowed.", {
Allow: "GET, POST",
});
}
const js = "application/javascript; charset=utf-8";
switch (request.url) {
case "/":
if (!fetchAllowed(request, { navigation: true })) break;
return send(response, 200, "text/html; charset=utf-8", page);
case "/ethers.js":
if (!fetchAllowed(request, { navigation: false })) break;
return send(response, 200, js, ethersSource);
case "/app.js":
if (!fetchAllowed(request, { navigation: false })) break;
return send(response, 200, js, appSource);
default:
return send(response, 404, "text/plain", "Not found.");
}
return send(response, 403, "text/plain", "Cross-site request refused.");
});
}
// ---------------- Browser application ----------------
// Serialized with toString(); must not reference Node-side values.
function browserApp(config) {
"use strict";
if (!window.ethers) {
throw new Error("The ethers library did not load. Reload the page.");
}
const {
BrowserProvider,
Contract,
Interface,
JsonRpcProvider,
Network,
ZeroAddress,
formatEther,
formatUnits,
getAddress,
keccak256,
parseEther,
parseUnits,
} = window.ethers;
// ---------------- Settings ----------------
const MAX_TRADE = parseEther("0.25"); // per-trade cap, ETH value
const MAX_GAS_SHARE_BPS = 100n; // gas at most 1% of trade
const MAX_FEE = parseUnits("15", "gwei");
const MIN_PRIORITY_FEE = parseUnits("0.05", "gwei");
const MAX_PRIORITY_FEE = parseUnits("2", "gwei");
const MAX_REVOKE_FEE = parseUnits("100", "gwei");
const MAX_REVOKE_PRIORITY_FEE = parseUnits("10", "gwei"); // replacements
const SLIPPAGE_BPS = 30n; // 0.3%
const MAX_IMPACT_BPS = 50n; // 0.5%
const ORACLE_MARGIN_BPS = 50n; // on top of feed deviation thresholds
const MAX_TOTAL_LOSS_BPS = 150n; // minimum vs Chainlink after pool fee
// Optional: address -> keccak256 of runtime code.
const CODE_HASHES = {
// "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45": "0x...",
};
// EIP-7702 delegates trusted for trading.
const ALLOWED_DELEGATES = [
// "0x...",
];
// ------------------------------------------
assert(
MAX_TOTAL_LOSS_BPS > SLIPPAGE_BPS + MAX_IMPACT_BPS,
"Settings error: MAX_TOTAL_LOSS_BPS must be larger than " +
"SLIPPAGE_BPS + MAX_IMPACT_BPS.",
);
assert(
MIN_PRIORITY_FEE <= MAX_PRIORITY_FEE &&
MAX_PRIORITY_FEE < MAX_FEE &&
MAX_PRIORITY_FEE <= MAX_REVOKE_PRIORITY_FEE &&
MAX_REVOKE_PRIORITY_FEE < MAX_REVOKE_FEE,
"Settings error: each tip cap must be below its max fee cap, and " +
"the revoke caps must not be lower than the swap caps.",
);
const MIN_LIQUIDITY = parseEther("100");
const MAX_GAS = 500000n;
const BASE_FEE_HEADROOM_BPS = 2500n;
const REPLACEMENT_BUMP_PER_MILLE = 1125n; // +12.5%
const QUOTE_TTL_MS = 20000;
const DEADLINE_SECONDS = 180;
const MIN_SIGNING_SECONDS = 45;
const CONFIRM_DEPTH = 1;
const MAX_HEAD_GAP = 2;
const MAX_BLOCK_AGE = 60;
const MAX_HEAD_AGE = 48;
const CONFIRMATIONS = 2;
const VISIBLE_TIMEOUT_MS = 30000;
const WAIT_TIMEOUT_MS = 180000;
const RECEIPT_CHECK_MS = 60000;
const REVOKE_ONLY_CHECK_MS = 20000;
const WALLET_TIMEOUT_MS = 20000;
const READ_ATTEMPTS = 3;
const READ_RETRY_MS = 1000;
const READ_CONCURRENCY = 2;
const UNLIMITED = 1n << 255n;
const LOCK_PREFIX = "walletTrader.unresolvedTx.";
const TAB_LOCK = "walletTrader.send";
const HASH_RE = /^0x[0-9a-fA-F]{64}$/;
const APPROVE_GAS = 60000n;
const V2_GAS = { buy: 140000n, sell: 170000n };
const V3_OVERHEAD_GAS = { buy: 60000n, sell: 90000n };
const ONE = 10n ** 18n;
const Q96 = 1n << 96n;
const Q192 = 1n << 192n;
const ROUTER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"; // SwapRouter02
const QUOTER = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; // QuoterV2
const V2_FACTORY = "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f";
const V3_FACTORY = "0x1F98431c8aD98523631AE4a59f267346ea31F984";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// maxAge: heartbeat + 15 min. deviationBps: update threshold.
const FEEDS = {
ETH_USD: {
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
description: "ETH / USD",
maxAge: 3600 + 900,
deviationBps: 50n,
},
BTC_USD: {
address: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c",
description: "BTC / USD",
maxAge: 3600 + 900,
deviationBps: 50n,
},
USDC_USD: {
address: "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6",
description: "USDC / USD",
maxAge: 86400 + 900,
deviationBps: 25n,
},
DAI_USD: {
address: "0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9",
description: "DAI / USD",
maxAge: 3600 + 900,
deviationBps: 25n,
},
WBTC_BTC: {
address: "0xfdFD9C85aD200c506Cf9e21F1FD8dd01932FBB23",
description: "WBTC / BTC",
maxAge: 86400 + 900,
deviationBps: 200n,
},
};
// resetAllowanceFirst: for tokens like USDT.
const TOKENS = {
USDC: {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
decimals: 6,
usdFeeds: ["USDC_USD"],
resetAllowanceFirst: false,
},
DAI: {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
decimals: 18,
usdFeeds: ["DAI_USD"],
resetAllowanceFirst: false,
},
WBTC: {
address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
decimals: 8,
usdFeeds: ["WBTC_BTC", "BTC_USD"],
resetAllowanceFirst: false,
},
};
const ROUTES = [
{ protocol: "V2", fee: 3000n },
{ protocol: "V3", fee: 100n },
{ protocol: "V3", fee: 500n },
{ protocol: "V3", fee: 3000n },
{ protocol: "V3", fee: 10000n },
];
const ROUTER_ABI = [
"function factory() view returns (address)",
"function factoryV2() view returns (address)",
"function WETH9() view returns (address)",
"function multicall(uint256 deadline, bytes[] data)" +
" payable returns (bytes[])",
"function swapExactTokensForTokens(uint256 amountIn," +
" uint256 amountOutMin, address[] path, address to)" +
" payable returns (uint256)",
"function exactInputSingle((address tokenIn, address tokenOut," +
" uint24 fee, address recipient, uint256 amountIn," +
" uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params)" +
" payable returns (uint256)",
"function unwrapWETH9(uint256 amountMinimum, address recipient)" +
" payable",
];
const QUOTER_ABI = [
"function factory() view returns (address)",
"function WETH9() view returns (address)",
"function quoteExactInputSingle((address tokenIn, address tokenOut," +
" uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params)" +
" returns (uint256 amountOut, uint160 sqrtPriceX96After," +
" uint32 initializedTicksCrossed, uint256 gasEstimate)",
];
const TOKEN_ABI = [
"function balanceOf(address) view returns (uint256)",
"function allowance(address,address) view returns (uint256)",
"function approve(address,uint256) returns (bool)",
"function decimals() view returns (uint8)",
"event Transfer(address indexed from, address indexed to," +
" uint256 value)",
];
const V2_FACTORY_ABI = [
"function getPair(address,address) view returns (address)",
];
const V3_FACTORY_ABI = [
"function getPool(address,address,uint24) view returns (address)",
];
const PAIR_ABI = [
"function token0() view returns (address)",
"function getReserves() view returns (uint112,uint112,uint32)",
];
const POOL_ABI = [
"function token0() view returns (address)",
"function liquidity() view returns (uint128)",
"function slot0() view returns (uint160 sqrtPriceX96, int24 tick," +
" uint16 observationIndex, uint16 observationCardinality," +
" uint16 observationCardinalityNext, uint8 feeProtocol," +
" bool unlocked)",
];
const FEED_ABI = [
"function decimals() view returns (uint8)",
"function description() view returns (string)",
"function latestRoundData() view returns (uint80 roundId," +
" int256 answer, uint256 startedAt, uint256 updatedAt," +
" uint80 answeredInRound)",
];
const routerIface = new Interface(ROUTER_ABI);
const tokenIface = new Interface(TOKEN_ABI);
const wethIface = new Interface([
"event Withdrawal(address indexed src, uint256 wad)",
]);
const PINS = new Map(
Object.entries(CODE_HASHES).map(([address, hash]) => [
getAddress(address),
String(hash).toLowerCase(),
]),
);
const DELEGATES = new Set(
ALLOWED_DELEGATES.map((address) => getAddress(address)),
);
const mainnet = Network.from(1);
const verifier = new JsonRpcProvider(config.rpcUrl, mainnet, {
staticNetwork: mainnet,
batchMaxCount: 1,
cacheTimeout: -1,
});
let session = null;
let quote = null;
let busy = false;
const retired = [];
// ---------- Helpers ----------
const pct = (bps) => `${Number(bps) / 100}%`;
const gwei = (wei) => formatUnits(wei, "gwei").replace(/\.0$/, "");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const serialize = (value) =>
JSON.stringify(value, (_, x) =>
typeof x === "bigint" ? x.toString() : x,
);
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function isRejection(error) {
return error?.code === "ACTION_REJECTED" || error?.code === 4001;
}
function errorText(error) {
if (isRejection(error)) return "Request rejected in your wallet.";
const short = error?.shortMessage;
const inner = error?.error?.message ?? error?.info?.error?.message;
if (inner && (!short || /coalesce|missing revert data/.test(short))) {
return String(inner).slice(0, 300);
}
return short || error?.message || "Operation failed.";
}
// Only an explicit rejection means "not sent".
function uncertainSend(reason, locked) {
const error = new Error(
`${reason} The transaction may have been signed and broadcast. ` +
"Check your wallet's activity and your address on Etherscan " +
"before trying again." +
(locked
? " Swaps and approvals from this account stay blocked until " +
"it is resolved."
: ""),
);
error.uncertain = true;
return error;
}
function firstDiff(a, b, where = "") {
if (serialize(a) === serialize(b)) return null;
if (a && b && typeof a === "object" && typeof b === "object") {
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
const found = firstDiff(a[key], b[key], `${where}.${key}`);
if (found) return found;
}
}
return where || "value";
}
function isRevert(error) {
if (error?.code !== "CALL_EXCEPTION") return false;
if (error.data != null) return true;
let nested = "";
try {
nested = serialize(error.info ?? null) ?? "";
} catch {
nested = "";
}
return /revert/i.test(nested);
}
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await fn(items[index], index);
}
}
const count = Math.min(limit, items.length);
await Promise.all(Array.from({ length: count }, worker));
return results;
}
function oracleToleranceBps(token) {
let total = FEEDS.ETH_USD.deviationBps + ORACLE_MARGIN_BPS;
for (const key of token.usdFeeds) total += FEEDS[key].deviationBps;
return total;
}
function classifyCode(code) {
const lower = String(code).toLowerCase();
if (lower === "0x") return { kind: "eoa" };
if (/^0xef0100[0-9a-f]{40}$/.test(lower)) {
return {
kind: "delegated",
delegate: getAddress(`0x${lower.slice(8)}`),
};
}
return { kind: "contract" };
}
const PROMPTS = new Set(["eth_requestAccounts", "eth_sendTransaction"]);
function withTimeouts(eip1193) {
return {
request(args) {
const pending = Promise.resolve().then(() => eip1193.request(args));
if (PROMPTS.has(args?.method)) return pending;
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(
`Wallet did not answer ${args?.method} within ` +
`${WALLET_TIMEOUT_MS / 1000} seconds. Reload the page if ` +
"this keeps happening.",
));
}, WALLET_TIMEOUT_MS);
});
return Promise.race([pending, timeout])
.finally(() => clearTimeout(timer));
},
};
}
// ---------- Duplicate-send block ----------
const lockStore = (() => {
try {
const probe = `${LOCK_PREFIX}probe`;
localStorage.setItem(probe, "1");
localStorage.removeItem(probe);
return { storage: localStorage, persistent: true };
} catch {
const map = new Map();
return {
storage: {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => {
map.set(key, String(value));
},
removeItem: (key) => {
map.delete(key);
},
},
persistent: false,
};
}
})();
const tabLocks = typeof navigator.locks?.request === "function";
const lockKey = (account) => `${LOCK_PREFIX}${account}`;
function blockedError(message) {
const error = new Error(message);
error.blocked = true;
return error;
}
// Unreadable entries still count as a lock (fails closed).
function readLock(account) {
let raw;
try {
raw = lockStore.storage.getItem(lockKey(account));
} catch {
return { nonce: null, hash: null, nonceFromTx: false, unreadable: true };
}
if (raw === null) return null;
let lock = null;
try {
lock = JSON.parse(raw);
} catch {
lock = null;
}
const nonce = Number.isSafeInteger(lock?.nonce) && lock.nonce >= 0
? lock.nonce
: null;
const hash = typeof lock?.hash === "string" && HASH_RE.test(lock.hash)
? lock.hash
: null;
return {
nonce,
hash,
nonceFromTx: lock?.nonceFromTx === true && nonce !== null,
unreadable: false,
};
}
function writeLock(account, lock) {
const { nonce, hash = null, nonceFromTx = false } = lock;
try {
lockStore.storage.setItem(
lockKey(account),
JSON.stringify({ nonce, hash, nonceFromTx }),
);
} catch {
throw new Error(
"Could not save the duplicate-send block in browser storage, so " +
"nothing was sent. Free some browser storage and try again.",
);
}
}
function updateLock(account, patch) {
const lock = readLock(account);
if (!lock || lock.unreadable) return;
const { nonce, hash, nonceFromTx } = { ...lock, ...patch };
try {
lockStore.storage.setItem(
lockKey(account),
JSON.stringify({ nonce, hash, nonceFromTx }),
);
} catch {
// The old lock still blocks.
}
}
function clearLock(account) {
try {
lockStore.storage.removeItem(lockKey(account));
} catch {
// A lock that cannot be removed keeps blocking.
}
}
// A predicted nonce is trusted only when there is no hash to look for.
async function lockResolved(s, lock) {
if (!lock.hash) return false;
const receipt = await s.provider.getTransactionReceipt(lock.hash);
if (receipt) return true;
if (!lock.nonceFromTx || lock.nonce === null) return false;
const latest = await s.provider.getTransactionCount(s.account, "latest");
return latest > lock.nonce;
}
async function checkLock(s) {
const lock = readLock(s.account);
if (!lock) return;
if (lock.unreadable) {
throw blockedError(
"Browser storage could not be read, so this app cannot tell " +
"whether an earlier transaction is still pending. Swaps and " +
"approvals are blocked. Reload the page and try again.",
);
}
if (await lockResolved(s, lock)) {
clearLock(s.account);
return;
}
const which = lock.nonce === null && !lock.hash
? "An earlier transaction"
: `An earlier transaction (${[
lock.nonce === null ? null : `nonce ${lock.nonce}`,
lock.hash,
].filter(Boolean).join(", ")})`;
const hint = !lock.hash
? " This app has no hash for it, so it cannot tell when it is " +
"mined. Once your wallet's activity shows it confirmed, " +
'rejected, or never sent, use "Clear transaction block".'
: !lock.nonceFromTx
? " It was not visible before mining, so this app waits for its " +
"receipt. If you cancelled or replaced it in your wallet, or it " +
'was dropped, use "Clear transaction block".'
: ' If you rejected it or it was never sent, use "Clear ' +
'transaction block".';
throw blockedError(
`${which} from this account is not confirmed on chain yet. Swaps ` +
"and approvals stay blocked until it is mined, replaced, or " +
"cancelled, so it cannot be sent twice. Check your wallet's " +
`activity.${hint}`,
);
}
async function lockWarning(s) {
try {
await checkLock(s);
return null;
} catch (error) {
return error?.blocked ? error.message : null;
}
}
async function exclusive(fn) {
if (!tabLocks) return fn();
return navigator.locks.request(
TAB_LOCK,
{ ifAvailable: true },
async (held) => {
assert(
held,
"Another tab of this app is sending a transaction. Finish it " +
"or close that tab first.",
);
return fn();
},
);
}
function element(tag, text, parent = document.body) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
parent.appendChild(node);
return node;
}
// ---------- UI ----------
element("h2", "Wallet-confirmed Ethereum trader");
element(
"p",
"No private key import. Every approval and swap needs your wallet " +
"confirmation. Mainnet transactions spend real funds. Regular " +
"accounts only, not smart-contract wallets. EIP-7702 delegated " +
"accounts can trade only if their delegate is in " +
'ALLOWED_DELEGATES; otherwise use "Connect for revoke only". That ' +
"mode helps when the delegate is harmless but not allowlisted. A " +
"sweeper delegate forwards any ETH sent to the account, so the " +
"account usually cannot pay for a revoke.",
);
const toleranceText = Object.entries(TOKENS)
.map(([symbol, token]) => `${symbol} ${pct(oracleToleranceBps(token))}`)
.join(", ");
element(
"p",
`Limits: ${formatEther(MAX_TRADE)} ETH per trade, ` +
`${pct(SLIPPAGE_BPS)} slippage, ${pct(MAX_IMPACT_BPS)} price ` +
`impact, gas at most ${pct(MAX_GAS_SHARE_BPS)} of trade value, ` +
`max fee ${gwei(MAX_FEE)} gwei, tip ${gwei(MIN_PRIORITY_FEE)} to ` +
`${gwei(MAX_PRIORITY_FEE)} gwei (revokes: max fee ` +
`${gwei(MAX_REVOKE_FEE)} gwei; tip up to ` +
`${gwei(MAX_REVOKE_PRIORITY_FEE)} gwei only when replacing a stuck ` +
`transaction). Pool-vs-Chainlink tolerance: ${toleranceText}. ` +
`Minimum output at most ${pct(MAX_TOTAL_LOSS_BPS)} below ` +
"Chainlink after the pool fee.",
);
element(
"p",
`Verification RPC: ${config.verifierHost}, via this app's local ` +
"read-only proxy. Contract code, Chainlink prices, pool state, and " +
"quotes are compared with your wallet's RPC at the same block. " +
"Balance, allowance, nonce, and fee data come from your wallet's " +
"RPC only. Both RPCs see the pair and amount you preview. After " +
"each transaction, the verification RPC also looks it up by hash, " +
"which reveals your address to it. This only helps if the two are " +
"run by different operators; many RPC brands resell the same " +
"backends.",
);
element(
"p",
`After each session, disconnect ${location.host} in your wallet's ` +
"connected sites. Anything else later served from this address " +
"would inherit that permission and look like this app.",
);
if (!lockStore.persistent) {
element(
"p",
"Warning: browser storage is unavailable, so the block on " +
"duplicate sends does not survive a page reload.",
);
}
if (!tabLocks) {
element(
"p",
"Warning: this browser does not support Web Locks, so do not use " +
"this app in more than one tab at a time.",
);
}
const walletLabel = element("label", "Wallet: ");
const walletSelect = element("select", undefined, walletLabel);
const connect = element("button", "Connect wallet");
const connectRevoke = element("button", "Connect for revoke only");
const walletInfo = element("p", "Wallet not connected.");
const pinDetails = element("details");
const pinSummary = element("summary", "", pinDetails);
const pinList = element("pre", "", pinDetails);
pinDetails.hidden = true;
const controls = element("fieldset");
element("legend", "Trade", controls);
const sideLabel = element("label", "Direction: ", controls);
const side = element("select", undefined, sideLabel);
for (const [value, text] of [
["buy", "Buy token with ETH"],
["sell", "Sell token for ETH"],
]) {
element("option", text, side).value = value;
}
const tokenLabel = element("label", " Token: ", controls);
const tokenSelect = element("select", undefined, tokenLabel);
for (const symbol of Object.keys(TOKENS)) {
element("option", symbol, tokenSelect).value = symbol;
}
const amountLabel = element("label", " Amount in: ", controls);
const amountInput = element("input", undefined, amountLabel);
amountInput.type = "text";
amountInput.inputMode = "decimal";
amountInput.maxLength = 40;
amountInput.placeholder = "For example: 0.1 ETH";
element("p", "Buy amounts are ETH. Sell amounts are tokens.", controls);
const mevLabel = element("label", undefined, controls);
const mevAck = element("input", undefined, mevLabel);
mevAck.type = "checkbox";
element(
"span",
" My wallet sends transactions through a private, MEV-protected " +
"RPC (for example Flashbots Protect). Without one, bots can " +
"sandwich the swap down to the minimum shown.",
mevLabel,
);
element("br", undefined, controls);
const previewButton = element("button", "Preview trade", controls);
const approveButton = element("button", "Set exact allowance", controls);
const revokeButton = element("button", "Revoke allowance", controls);
const revokeAllButton = element(
"button",
"Revoke all listed tokens",
controls,
);
const executeButton = element("button", "Confirm swap in wallet", controls);
const clearLockButton = element(
"button",
"Clear transaction block",
controls,
);
const preview = element("pre", "No active quote.");
const status = element("p", "");
const history = element("div");
element(
"p",
"Every swap goes to Uniswap SwapRouter02 (0x68b3...Fc45). On sells, " +
"WETH briefly lands in the router and is unwrapped to your wallet " +
"as ETH in the same transaction. Revoke sets the router's " +
"allowance for the selected token to zero; revoke all does so for " +
"every token listed here. Revokes do not wait for swaps or " +
"approvals in other tabs of this app: an approval sent from " +
"another tab afterwards sets the allowance again.",
);
element(
"p",
"SwapRouter02 only spends tokens from the account that calls it, so " +
"a leftover allowance to it mainly matters if the router has a " +
"bug. This app only shows and revokes allowances to SwapRouter02. " +
"Allowances to Permit2, the Universal Router, or other contracts " +
"(for example from the Uniswap web app) are not shown here; check " +
"them with Etherscan's token approval checker or revoke.cash.",
);
element(
"p",
"No automatic retries. Until a sent swap or approval is confirmed " +
"on chain, new ones from the same account are blocked, even after " +
"a reload. That block is kept in this browser profile for this " +
"address only; another browser, profile, or port does not see it. " +
"If a transaction is pending or its outcome is uncertain, inspect " +
"it in your wallet before proceeding.",
);
function refreshButtons() {
const revokeOnly = Boolean(session?.revokeOnly);
connect.disabled = busy;
connectRevoke.disabled = busy;
walletSelect.disabled = busy;
controls.disabled = busy || !session;
previewButton.disabled = busy || revokeOnly;
executeButton.disabled =
busy || revokeOnly || !quote || quote.needsApproval || !mevAck.checked;
approveButton.disabled = busy || revokeOnly || side.value !== "sell";
revokeButton.disabled = busy;
revokeAllButton.disabled = busy;
clearLockButton.disabled = busy;
}
function invalidate(message = "Quote cleared. Preview again.") {
quote = null;
preview.textContent = message;
refreshButtons();
}
function consumeQuote(note) {
const text = preview.textContent;
quote = null;
preview.textContent = `${note}\n\n${text}`;
refreshButtons();
}
function showPins(unpinned) {
pinDetails.hidden = false;
if (unpinned.length === 0) {
pinSummary.textContent =
"Contract code: every address matches its pinned hash.";
pinList.textContent = "";
return;
}
pinSummary.textContent =
`Contract code: ${unpinned.length} address(es) not pinned ` +
"(optional hardening).";
pinList.textContent = [
"Both RPCs returned this code. To guard against swapped contracts,",
"confirm each hash with a third source, then add it to CODE_HASHES:",
...unpinned,
"For proxies (USDC, Chainlink feeds) this pins only the proxy.",
"Pins do not guard against both RPCs reporting false prices or",
"pool state.",
].join("\n");
}
// ---------- Wallet discovery (EIP-6963) ----------
const wallets = new Map();
function addWallet(id, name, eip1193) {
if (wallets.has(id)) return;
wallets.set(id, eip1193);
element("option", name, walletSelect).value = id;
}
// Names and rdns IDs are not authenticated; shown only to tell apart.
window.addEventListener("eip6963:announceProvider", (event) => {
const { info, provider: eip1193 } = event.detail ?? {};
if (
typeof info?.uuid !== "string" ||
typeof eip1193?.request !== "function"
) {
return;
}
const name = String(info.name ?? "Wallet").slice(0, 40);
const rdns = typeof info.rdns === "string"
? ` (${info.rdns.slice(0, 60)})`
: " (no ID)";
addWallet(info.uuid, name + rdns, eip1193);
});
window.dispatchEvent(new Event("eip6963:requestProvider"));
// ---------- Calculations ----------
function routeLabel(route) {
return `Uniswap ${route.protocol} (${Number(route.fee) / 10000}%)`;
}
function shortfallBps(reference, value) {
if (reference === 0n) return 10000n;
if (value >= reference) return 0n;
return ((reference - value) * 10000n + reference - 1n) / reference;
}
function diffBps(value, reference) {
if (reference === 0n) return 10000n;
const diff = value > reference ? value - reference : reference - value;
return (diff * 10000n + reference - 1n) / reference;
}
function checkGasShare(gasCost, tradeValue) {
if (gasCost * 10000n <= tradeValue * MAX_GAS_SHARE_BPS) return;
const minTrade = (gasCost * 10000n) / MAX_GAS_SHARE_BPS;
const hint = minTrade > MAX_TRADE
? "That is above your per-trade cap, so wait for lower gas fees."
: `Trade at least about ${formatEther(minTrade)} ETH worth, ` +
"or wait for lower fees.";
throw new Error(
`Estimated gas of ${formatEther(gasCost)} ETH is over ` +
`${pct(MAX_GAS_SHARE_BPS)} of this trade. ${hint}`,
);
}
function readTrade() {
const symbol = tokenSelect.value;
const token = TOKENS[symbol];
assert(token, "Unknown token.");
const buying = side.value === "buy";
const decimals = buying ? 18 : token.decimals;
const text = amountInput.value.trim();
const match = /^(\d{1,30})(?:\.(\d+))?$/.exec(text);
assert(match, "Enter a positive decimal amount, without commas.");
assert(
!match[2] || match[2].length <= decimals,
`Use at most ${decimals} decimal places for this amount.`,
);
const amount = parseUnits(text, decimals);
assert(amount > 0n, "Amount must be greater than zero.");
return {
symbol,
token,
buying,
amount,
key: `${symbol}:${buying}:${amount}`,
};
}
// ---------- Wallet session ----------
async function checkWallet(s) {
const chain = await s.rpc.request({ method: "eth_chainId" });
assert(BigInt(chain) === 1n, "Switch your wallet to Ethereum mainnet.");
const accounts = await s.rpc.request({ method: "eth_accounts" });
assert(
Array.isArray(accounts) &&
accounts.length > 0 &&
getAddress(accounts[0]) === s.account,
"Wallet account changed. Connect again.",
);
}
async function checkSession(s) {
assert(s && session === s, "Wallet not connected. Connect again.");
await checkWallet(s);
}
// anyDelegate is safe for revokes: they send nothing to the account.
async function checkAccountCode(s, { anyDelegate = false } = {}) {
const code = classifyCode(await s.provider.getCode(s.account));
if (code.kind === "eoa") return;
assert(
code.kind === "delegated",
"This app supports regular accounts (EOAs) only, not " +
"smart-contract wallets.",
);
if (anyDelegate) return;
assert(
DELEGATES.has(code.delegate),
`This account is delegated (EIP-7702) to ${code.delegate}, which ` +
"is not in ALLOWED_DELEGATES. A delegate's code runs when the " +
"account receives ETH, and an unknown one may take the proceeds " +
"of a sale. Do not trade from this account. Revoking its " +
"allowances still works.",
);
}
function drop(message) {
const old = session;
if (!old) return;
session = null;
old.detach();
if (busy) retired.push(old.provider);
else old.provider.destroy();
invalidate("No active quote.");
pinDetails.hidden = true;
walletInfo.textContent = message;
}
function watch(s) {
const handlers = {
accountsChanged: (accounts) => {
let same = false;
try {
same =
Array.isArray(accounts) &&
accounts.length > 0 &&
getAddress(accounts[0]) === s.account;
} catch {
same = false;
}
if (!same && session === s) {
drop("Wallet account changed. Connect again.");
}
},
chainChanged: () =>
session === s && drop("Network changed. Connect again."),
disconnect: () =>
session === s && drop("Wallet disconnected. Connect again."),
};
const entries = Object.entries(handlers);
if (typeof s.eip1193.on === "function") {
for (const [name, fn] of entries) s.eip1193.on(name, fn);
}
return () => {
if (typeof s.eip1193.removeListener !== "function") return;
for (const [name, fn] of entries) s.eip1193.removeListener(name, fn);
};
}
// ---------- Two-RPC agreement ----------
// Failed reads are retried; disagreement never is.
async function agreed(s, read, what) {
for (let attempt = 1; ; attempt += 1) {
const [mine, theirs] = await Promise.allSettled([
read(s.provider),
read(verifier),
]);
if (mine.status === "fulfilled" && theirs.status === "fulfilled") {
const diff = firstDiff(mine.value, theirs.value);
assert(
diff === null,
`Wallet RPC and verification RPC disagree on ${what} ` +
`(first difference at ${diff}). Do not trade.`,
);
return mine.value;
}
if (attempt >= READ_ATTEMPTS) {
const failed = mine.status === "rejected" ? mine : theirs;
const who = failed === mine ? "Wallet RPC" : "Verification RPC";
throw new Error(
`${who} could not read ${what} (${errorText(failed.reason)}). ` +
"Try again shortly. If it keeps failing, do not trade.",
);
}
await sleep(READ_RETRY_MS * attempt);
}
}
async function commonBlock(s) {
const [walletHead, verifyHead, chain] = await Promise.all([
s.provider.getBlockNumber(),
verifier.getBlockNumber(),
verifier.send("eth_chainId", []),
]);
assert(BigInt(chain) === 1n, "Verification RPC is not mainnet.");
assert(
Math.abs(walletHead - verifyHead) <= MAX_HEAD_GAP,
"Wallet RPC and verification RPC disagree on the chain head. " +
"Do not trade.",
);
const number = Math.min(walletHead, verifyHead) - CONFIRM_DEPTH;
const block = await agreed(s, async (reader) => {
const found = await reader.getBlock(number);
if (!found) throw new Error(`block ${number} is not available yet`);
return {
number: found.number,
hash: found.hash,
timestamp: found.timestamp,
baseFeePerGas: found.baseFeePerGas,
};
}, `block ${number}`);
const age = Date.now() / 1000 - block.timestamp;
assert(
age >= -30 && age < MAX_BLOCK_AGE,
"Chain data is more than a few blocks old, or the local clock is " +
"wrong. Do not trade.",
);
assert(block.baseFeePerGas != null, "Block has no base fee.");
return block;
}
async function verifyDeployment(s) {
const block = await commonBlock(s);
const blockTag = block.number;
const o = { blockTag };
const addresses = [
ROUTER,
QUOTER,
V2_FACTORY,
V3_FACTORY,
WETH,
...Object.values(TOKENS).map((token) => token.address),
...Object.values(FEEDS).map((feed) => feed.address),
];
const unpinned = [];
await mapLimit(addresses, 3, async (address) => {
const code = await agreed(
s,
async (reader) =>
(await reader.getCode(address, blockTag)).toLowerCase(),
`the contract code at ${address}`,
);
assert(code !== "0x", `No contract at ${address}. Do not trade.`);
const hash = keccak256(code);
const pin = PINS.get(getAddress(address));
if (pin) {
assert(
hash === pin,
`Code at ${address} does not match its pinned hash. Do not trade.`,
);
} else {
unpinned.push(`${address} ${hash}`);
}
});
const wiring = await agreed(s, (reader) => {
const router = new Contract(ROUTER, ROUTER_ABI, reader);
const quoter = new Contract(QUOTER, QUOTER_ABI, reader);
return Promise.all([
router.factory(o),
router.factoryV2(o),
router.WETH9(o),
quoter.factory(o),
quoter.WETH9(o),
]);
}, "router configuration");
const expected = [V3_FACTORY, V2_FACTORY, WETH, V3_FACTORY, WETH];
wiring.forEach((value, index) => {
assert(
getAddress(value) === expected[index],
"Router or quoter configuration mismatch. Do not trade.",
);
});
for (const [symbol, token] of Object.entries(TOKENS)) {
const decimals = await agreed(
s,
(reader) =>
new Contract(token.address, TOKEN_ABI, reader).decimals(o),
`${symbol} decimals`,
);
assert(
decimals === BigInt(token.decimals),
`${symbol} decimals mismatch. Do not trade.`,
);
}
for (const [key, feed] of Object.entries(FEEDS)) {
const [description, decimals] = await agreed(s, (reader) => {
const contract = new Contract(feed.address, FEED_ABI, reader);
return Promise.all([contract.description(o), contract.decimals(o)]);
}, `the ${key} feed`);
assert(
description === feed.description && decimals <= 18n,
`Chainlink feed ${key} is not what this app expects. Do not trade.`,
);
}
return unpinned.sort();
}
// Revoke-only sessions check just the token contracts, on the wallet RPC.
async function verifyRevokeTargets(s) {
for (const [symbol, token] of Object.entries(TOKENS)) {
const code = (await s.provider.getCode(token.address)).toLowerCase();
assert(code !== "0x", `No ${symbol} contract at ${token.address}.`);
const pin = PINS.get(getAddress(token.address));
assert(
!pin || keccak256(code) === pin,
`Code at ${token.address} (${symbol}) does not match its pinned ` +
"hash.",
);
const decimals = await new Contract(
token.address,
TOKEN_ABI,
s.provider,
).decimals();
assert(
decimals === BigInt(token.decimals),
`${symbol} decimals mismatch.`,
);
}
}
async function nonces(s) {
const [latest, pending] = await Promise.all([
s.provider.getTransactionCount(s.account, "latest"),
s.provider.getTransactionCount(s.account, "pending"),
]);
return { latest, pending };
}
async function assertNoPending(s) {
const { latest, pending } = await nonces(s);
assert(
pending === latest,
"Your wallet has a pending transaction. Wait for it, or speed it " +
"up or cancel it in your wallet first.",
);
}
async function freshBlock(s) {
const block = await s.provider.getBlock("latest");
assert(block, "Cannot read the latest block.");
const age = Date.now() / 1000 - block.timestamp;
assert(
age >= -30 && age < MAX_HEAD_AGE,
"Wallet RPC head is more than a few blocks old, or the local " +
"clock is wrong. Do not trade.",
);
assert(block.baseFeePerGas != null, "Latest block has no base fee.");
return block;
}
async function replacementFloor(s, nonce) {
const lock = readLock(s.account);
if (!lock?.hash) return null;
const tx = await s.provider.getTransaction(lock.hash).catch(() => null);
if (!tx || tx.blockNumber != null || tx.nonce !== nonce) return null;
const maxFee = tx.maxFeePerGas ?? tx.gasPrice;
const tip = tx.maxPriorityFeePerGas ?? tx.gasPrice;
if (maxFee == null || tip == null) return null;
const bump = (x) => (x * REPLACEMENT_BUMP_PER_MILLE + 999n) / 1000n;
return { maxFeePerGas: bump(maxFee), maxPriorityFeePerGas: bump(tip) };
}
// maxFee = min(2 * base + tip, cap). maxTip only lets a replacement
// floor go above MAX_PRIORITY_FEE.
async function prepareFees(s, limits = {}) {
const {
maxFee = MAX_FEE,
maxTip = MAX_PRIORITY_FEE,
floor = null,
} = limits;
const [block, feeData] = await Promise.all([
freshBlock(s),
s.provider.getFeeData(),
]);
assert(
feeData.maxPriorityFeePerGas != null,
"Wallet RPC did not provide EIP-1559 fees.",
);
const base = block.baseFeePerGas;
let tip = feeData.maxPriorityFeePerGas;
if (tip < MIN_PRIORITY_FEE) tip = MIN_PRIORITY_FEE;
if (tip > MAX_PRIORITY_FEE) tip = MAX_PRIORITY_FEE;
const replaces = Boolean(
floor &&
floor.maxPriorityFeePerGas <= maxTip &&
floor.maxFeePerGas <= maxFee,
);
if (replaces && tip < floor.maxPriorityFeePerGas) {
tip = floor.maxPriorityFeePerGas;
}
const wanted = base * 2n + tip;
let maxFeePerGas = wanted < maxFee ? wanted : maxFee;
if (replaces && maxFeePerGas < floor.maxFeePerGas) {
maxFeePerGas = floor.maxFeePerGas;
}
const needed = (base * (10000n + BASE_FEE_HEADROOM_BPS)) / 10000n + tip;
assert(
needed <= maxFeePerGas,
`Gas is too expensive: base fee ${gwei(base)} gwei plus tip ` +
`${gwei(tip)} gwei leaves too little headroom under the ` +
`${gwei(maxFee)} gwei cap. Wait for lower fees.`,
);
return {
fees: { maxFeePerGas, maxPriorityFeePerGas: tip },
likelyPrice: base + tip,
chainNow: Math.max(Math.floor(Date.now() / 1000), block.timestamp),
replaces,
};
}
function replacementNote(nonce, floor, replaces) {
const start =
` Another transaction from this account is pending (nonce ${nonce}).` +
" Sent as is, this revoke queues behind it and cannot be mined " +
"first, whatever its fee.";
if (floor && replaces) {
return (
`${start} This revoke's fees are set at least 12.5% above that ` +
"transaction's, so to revoke sooner, set this revoke's nonce to " +
`${nonce} in your wallet's advanced settings without lowering ` +
"the fees. It then replaces the pending transaction."
);
}
if (floor) {
return (
`${start} Replacing it needs a tip of at least ` +
`${gwei(floor.maxPriorityFeePerGas)} gwei and a max fee of at ` +
`least ${gwei(floor.maxFeePerGas)} gwei, above this app's revoke ` +
"caps. Speed up or cancel the pending transaction in your wallet " +
"instead."
);
}
return (
`${start} This app does not know that transaction's fees, so it ` +
"cannot price a replacement. To replace it, set this revoke's " +
`nonce to ${nonce} in your wallet's advanced settings and raise ` +
"both the tip and the max fee to more than 10% above the pending " +
"transaction's (shown in your wallet's activity). Or speed up or " +
"cancel the pending transaction first."
);
}
function hiddenPendingNote(lock) {
return (
` This app sent ${lock.hash}, which your wallet's RPC does not ` +
"show as pending, probably because it went through a private RPC. " +
"If your wallet still shows it as pending, this revoke queues " +
"behind it. To replace it, set this revoke's nonce to that " +
"transaction's nonce (shown in your wallet's activity) and raise " +
"both the tip and the max fee by more than 10%, or follow your " +
"private RPC's cancellation instructions."
);
}
// ---------- Chainlink ----------
async function readFeeds(reader, blockTag, keys) {
const o = { blockTag };
return Promise.all(
keys.map(async (key) => {
const contract = new Contract(FEEDS[key].address, FEED_ABI, reader);
const [decimals, round] = await Promise.all([
contract.decimals(o),
contract.latestRoundData(o),
]);
return { key, decimals, answer: round[1], updatedAt: round[3] };
}),
);
}
function feedPrice(reading, now) {
const feed = FEEDS[reading.key];
const at = BigInt(now);
assert(reading.decimals <= 18n, `${feed.description}: bad decimals.`);
assert(
reading.answer > 0n,
`Chainlink ${feed.description} price is invalid. Do not trade.`,
);
assert(
reading.updatedAt > 0n &&
reading.updatedAt <= at + 60n &&
at - reading.updatedAt <= BigInt(feed.maxAge),
`Chainlink ${feed.description} price is stale. Do not trade.`,
);
return reading.answer * 10n ** (18n - reading.decimals);
}
function oracleOutput(trade, readings, now) {
const [ethUsd, ...tokenPrices] = readings.map((reading) =>
feedPrice(reading, now),
);
let tokenUsd = ONE;
for (const price of tokenPrices) tokenUsd = (tokenUsd * price) / ONE;
const unit = 10n ** BigInt(trade.token.decimals);
return trade.buying
? (trade.amount * ethUsd * unit) / (tokenUsd * ONE)
: (trade.amount * tokenUsd * ONE) / (ethUsd * unit);
}
// ---------- Pool quotes ----------
async function quoteV2(reader, blockTag, tokenIn, tokenOut, amountIn,
sideKey) {
const o = { blockTag };
const factory = new Contract(V2_FACTORY, V2_FACTORY_ABI, reader);
const pairAddress = await factory.getPair(tokenIn, tokenOut, o);
if (pairAddress === ZeroAddress) return null;
const pair = new Contract(pairAddress, PAIR_ABI, reader);
const [reserves, token0] = await Promise.all([
pair.getReserves(o),
pair.token0(o),
]);
const inFirst = getAddress(token0) === getAddress(tokenIn);
const reserveIn = inFirst ? reserves[0] : reserves[1];
const reserveOut = inFirst ? reserves[1] : reserves[0];
if (reserveIn === 0n || reserveOut === 0n) return null;
const adjusted = amountIn * 997n;
return {
output: (adjusted * reserveOut) / (reserveIn * 1000n + adjusted),
noImpact: (adjusted * reserveOut) / (reserveIn * 1000n),
gas: V2_GAS[sideKey],
liquidity: getAddress(tokenIn) === WETH ? reserveIn : reserveOut,
};
}
async function quoteV3(reader, blockTag, tokenIn, tokenOut, amountIn,
fee, sideKey) {
const o = { blockTag };
const factory = new Contract(V3_FACTORY, V3_FACTORY_ABI, reader);
const poolAddress = await factory.getPool(tokenIn, tokenOut, fee, o);
if (poolAddress === ZeroAddress) return null;
const pool = new Contract(poolAddress, POOL_ABI, reader);
const [slot0, token0, liquidity] = await Promise.all([
pool.slot0(o),
pool.token0(o),
pool.liquidity(o),
]);
const sqrtPrice = slot0[0];
if (sqrtPrice === 0n || liquidity === 0n) return null;
const activeWeth = getAddress(token0) === WETH
? (liquidity * Q96) / sqrtPrice
: (liquidity * sqrtPrice) / Q96;
const quoter = new Contract(QUOTER, QUOTER_ABI, reader);
const result = await quoter.quoteExactInputSingle.staticCall(
{ tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0n },
o,
);
const inAfterFee = (amountIn * (1000000n - fee)) / 1000000n;
const priceX192 = sqrtPrice * sqrtPrice;
const zeroForOne = getAddress(token0) === getAddress(tokenIn);
return {
output: result[0],
noImpact: zeroForOne
? (inAfterFee * priceX192) / Q192
: (inAfterFee * Q192) / priceX192,
gas: result[3] + V3_OVERHEAD_GAS[sideKey],
liquidity: activeWeth,
};
}
async function safeQuote(reader, blockTag, route, tokenIn, tokenOut,
amount, sideKey) {
try {
return route.protocol === "V2"
? await quoteV2(reader, blockTag, tokenIn, tokenOut, amount, sideKey)
: await quoteV3(reader, blockTag, tokenIn, tokenOut, amount,
route.fee, sideKey);
} catch (error) {
if (isRevert(error)) return null;
throw error;
}
}
async function readMarket(reader, blockTag, trade, routes) {
const tokenIn = trade.buying ? WETH : trade.token.address;
const tokenOut = trade.buying ? trade.token.address : WETH;
const sideKey = trade.buying ? "buy" : "sell";
const feeds = await readFeeds(
reader,
blockTag,
["ETH_USD", ...trade.token.usdFeeds],
);
const quotes = await mapLimit(routes, READ_CONCURRENCY, (route) =>
safeQuote(reader, blockTag, route, tokenIn, tokenOut, trade.amount,
sideKey),
);
return { feeds, quotes };
}
async function getQuote(s, lockedRoute = null) {
await checkSession(s);
assert(
!s.revokeOnly,
"This session is revoke-only. Connect normally to preview or trade.",
);
const trade = readTrade();
const { token, buying, amount } = trade;
const outputDecimals = buying ? token.decimals : 18;
const tolerance = oracleToleranceBps(token);
if (buying) {
assert(
amount <= MAX_TRADE,
`Purchase limit is ${formatEther(MAX_TRADE)} ETH.`,
);
}
const { likelyPrice, chainNow } = await prepareFees(s);
// Wallet RPC only, so a preview does not reveal the address.
const ethBalance = await s.provider.getBalance(s.account);
let allowance = 0n;
if (!buying) {
const asset = new Contract(token.address, TOKEN_ABI, s.provider);
const [balance, current] = await Promise.all([
asset.balanceOf(s.account),
asset.allowance(s.account, ROUTER),
]);
assert(balance >= amount, "Insufficient token balance.");
allowance = current;
}
const needsApproval = !buying && allowance < amount;
const block = await commonBlock(s);
const routes = lockedRoute ? [lockedRoute] : ROUTES;
const market = await agreed(
s,
(reader) => readMarket(reader, block.number, trade, routes),
"prices and pool state",
);
assert(
Date.now() / 1000 - block.timestamp < MAX_BLOCK_AGE,
"Reads took too long and the market data is now stale. " +
"Preview again.",
);
const fair = oracleOutput(trade, market.feeds, block.timestamp);
assert(fair > 0n, "Chainlink fair value rounds to zero.");
const compared = [];
let best = null;
market.quotes.forEach((result, index) => {
const route = routes[index];
const label = routeLabel(route);
if (!result || result.output === 0n) {
compared.push(` ${label}: no usable pool`);
return;
}
const shown = formatUnits(result.output, outputDecimals);
if (result.liquidity < MIN_LIQUIDITY) {
compared.push(
` ${label}: ${shown} (rejected, active liquidity ` +
`${formatEther(result.liquidity)} WETH)`,
);
return;
}
const impact = shortfallBps(result.noImpact, result.output);
if (impact > MAX_IMPACT_BPS) {
compared.push(
` ${label}: ${shown} (rejected, impact ${pct(impact)})`,
);
return;
}
const fairAfterFee = (fair * (1000000n - route.fee)) / 1000000n;
const deviation = diffBps(result.noImpact, fairAfterFee);
if (deviation > tolerance) {
compared.push(
` ${label}: ${shown} (rejected, pool price ` +
`${pct(deviation)} away from Chainlink)`,
);
return;
}
const routeMinimum =
(result.output * (10000n - SLIPPAGE_BPS)) / 10000n;
const shortfall = shortfallBps(fairAfterFee, routeMinimum);
if (shortfall > MAX_TOTAL_LOSS_BPS) {
compared.push(
` ${label}: ${shown} (rejected, minimum ${pct(shortfall)} ` +
"below Chainlink after the pool fee)",
);
return;
}
const gasCost = result.gas * likelyPrice;
const gasInOutput = buying
? (gasCost * result.output) / amount
: gasCost;
const net = result.output - gasInOutput;
compared.push(
` ${label}: ${shown} (impact ${pct(impact)}, ` +
`Chainlink deviation ${pct(deviation)}, minimum ` +
`${pct(shortfall)} below Chainlink)`,
);
if (!best || net > best.net) {
best = { ...result, route, impact, deviation, shortfall, net };
}
});
assert(
best,
lockedRoute
? "The previewed route no longer passes the liquidity, price-" +
"impact, Chainlink, or minimum-output checks. Preview again."
: "No route passes the liquidity, price-impact, Chainlink, and " +
"minimum-output checks.",
);
if (!buying) {
assert(
best.output <= MAX_TRADE && fair <= MAX_TRADE,
`Sale value exceeds the ${formatEther(MAX_TRADE)} ETH limit.`,
);
}
const tradeValue = buying ? amount : best.output;
const approvalSteps = needsApproval
? (allowance > 0n && token.resetAllowanceFirst ? 2n : 1n)
: 0n;
const gasCost = (best.gas + approvalSteps * APPROVE_GAS) * likelyPrice;
checkGasShare(gasCost, tradeValue);
assert(
ethBalance >= (buying ? amount : 0n) + gasCost,
buying
? "Not enough ETH for this purchase plus estimated gas."
: "Not enough ETH to pay the estimated gas.",
);
const minimum = (best.output * (10000n - SLIPPAGE_BPS)) / 10000n;
assert(minimum > 0n, "Minimum output rounds to zero.");
return {
...trade,
route: best.route,
output: best.output,
minimum,
fair,
impact: best.impact,
deviation: best.deviation,
shortfall: best.shortfall,
tolerance,
gasCost,
tradeValue,
needsApproval,
allowance,
outputDecimals,
compared,
account: s.account,
blockNumber: block.number,
chainNow,
createdAt: Date.now(),
};
}
function approvalText(q) {
if (q.buying) return "No approval needed (paying with ETH).";
if (q.needsApproval) {
return q.allowance > 0n && q.token.resetAllowanceFirst
? "Approval needed: the allowance is reset to zero first, then " +
"the exact amount is approved (two prompts). Then preview again."
: 'Approval needed first: click "Set exact allowance", then ' +
"preview again.";
}
if (q.allowance > q.amount) {
const shown = q.allowance >= UNLIMITED
? "an unlimited amount of"
: formatUnits(q.allowance, q.token.decimals);
return (
`Note: SwapRouter02 may currently spend ${shown} ${q.symbol}, ` +
"more than this sale. The router only spends tokens from the " +
"account that calls it, so this mainly matters if the router " +
'has a bug. "Set exact allowance" reduces it to the sale amount; ' +
'"Revoke allowance" sets it to zero.'
);
}
return "Allowance equals the sale amount.";
}
function renderQuote(q, blockNote = null) {
const outputSymbol = q.buying ? q.symbol : "ETH";
const fmt = (value) => formatUnits(value, q.outputDecimals);
const gasShare = (q.gasCost * 10000n) / q.tradeValue;
return [
...(blockNote
? [`Blocked: ${blockNote}`, "Confirm will be refused until then.", ""]
: []),
`Route: ${routeLabel(q.route)}`,
`Expected: ${fmt(q.output)} ${outputSymbol}`,
`Minimum: ${fmt(q.minimum)} ${outputSymbol} (expected minus ` +
`${pct(SLIPPAGE_BPS)}; on confirm it can only go up)`,
`Chainlink fair value (before fees): ${fmt(q.fair)} ${outputSymbol}`,
`Chainlink check: pool mid-price is ${pct(q.deviation)} from ` +
`Chainlink after the pool fee (limit ${pct(q.tolerance)} for ` +
`${q.symbol}); re-checked on confirm.`,
`Minimum vs Chainlink: ${pct(q.shortfall)} below fair value after ` +
`the pool fee (limit ${pct(MAX_TOTAL_LOSS_BPS)}).`,
`Pool price impact: ${pct(q.impact)}`,
`Estimated gas: ${formatEther(q.gasCost)} ETH ` +
`(${pct(gasShare)} of trade` +
`${q.needsApproval ? ", including approval" : ""})`,
approvalText(q),
`Market state read at block ${q.blockNumber}; wallet RPC and ` +
`${config.verifierHost} agree. Balance and allowance come from ` +
"the wallet RPC only.",
"Routes compared:",
...q.compared,
`Quote expires in ${QUOTE_TTL_MS / 1000} seconds.`,
"Confirm only if these terms are acceptable.",
].join("\n");
}
// ---------- Transactions ----------
function recordTransaction(hash, label = "Submitted: ") {
const row = element("p", label, history);
const link = element("a", hash, row);
link.href = `https://etherscan.io/tx/${hash}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
function waitError(error, hash) {
switch (error?.code) {
case "TIMEOUT": {
const out = new Error(
`Outcome unknown: ${hash} was not confirmed in time. It may ` +
"still be mined. If the base fee rose above your max fee it " +
"can stay pending: speed it up or cancel it in your wallet. " +
"A swap mined after its deadline reverts. Do not resubmit.",
);
out.pending = true;
return out;
}
case "CALL_EXCEPTION":
return new Error(
"The transaction reverted on chain. Gas was spent; nothing " +
"else changed. Check Etherscan.",
);
case "TRANSACTION_REPLACED":
return new Error(
"The transaction was replaced or cancelled in your wallet. " +
"Check Etherscan before doing anything else.",
);
default:
return error;
}
}
async function findTransaction(provider, hash) {
const until = Date.now() + VISIBLE_TIMEOUT_MS;
for (;;) {
const found = await provider.getTransaction(hash).catch(() => null);
if (found || Date.now() >= until) return found;
await sleep(2000);
}
}
async function waitForOutcome(s, hash, startBlock, onUpdate) {
const seen = await findTransaction(s.provider, hash);
if (!seen) {
let receipt;
try {
receipt = await s.provider.waitForTransaction(
hash,
CONFIRMATIONS,
WAIT_TIMEOUT_MS,
);
} catch (error) {
throw waitError(error, hash);
}
if (!receipt) throw waitError({ code: "TIMEOUT" }, hash);
if (receipt.status !== 1) {
throw waitError({ code: "CALL_EXCEPTION" }, hash);
}
return {
hash,
receipt,
notes: [
"It was not visible before mining, so speed-ups and " +
"replacements could not be tracked.",
],
};
}
if (seen.from && getAddress(seen.from) === s.account) {
onUpdate({ nonce: seen.nonce, nonceFromTx: true });
}
try {
const receipt = await seen
.replaceableTransaction(startBlock)
.wait(CONFIRMATIONS, WAIT_TIMEOUT_MS);
return { hash, receipt, notes: [] };
} catch (error) {
if (
error?.code === "TRANSACTION_REPLACED" &&
error.reason === "repriced" &&
error.replacement
) {
const next = error.replacement.hash;
recordTransaction(next, "Replaced (sped up) by: ");
onUpdate({ hash: next });
try {
const receipt = await error.replacement.wait(
CONFIRMATIONS,
WAIT_TIMEOUT_MS,
);
return {
hash: next,
receipt,
notes: ["Your wallet sped up this transaction with new fees."],
};
} catch (inner) {
throw waitError(inner, next);
}
}
throw waitError(error, hash);
}
}
async function crossCheckTransaction(s, hash, receipt, tx, requested) {
const until = Date.now() + (requested.waitMs ?? RECEIPT_CHECK_MS);
let theirs = null;
for (;;) {
theirs = await verifier.getTransactionReceipt(hash).catch(() => null);
if (theirs || Date.now() >= until) break;
await sleep(3000);
}
if (!theirs) {
const error = new Error(
"Verification RPC cannot see this transaction. Check it on " +
"Etherscan before doing anything else.",
);
error.unseen = true;
throw error;
}
assert(
theirs.status === 1 && theirs.blockHash === receipt.blockHash,
"Verification RPC reports a different outcome for this " +
"transaction. Check it on Etherscan.",
);
const mined = await verifier.getTransaction(hash);
assert(
mined &&
getAddress(mined.from) === s.account &&
mined.to &&
getAddress(mined.to) === getAddress(tx.to) &&
mined.data.toLowerCase() === tx.data.toLowerCase() &&
mined.value === tx.value,
"The mined transaction differs from what this app requested. If " +
"you edited the amount or spending cap in your wallet, that is " +
"why: check the result on Etherscan and revoke if unsure. " +
"Otherwise stop and investigate.",
);
const warnings = [];
const fee = (x) => (x == null ? "n/a (legacy)" : `${gwei(x)} gwei`);
const warn = (what, got, asked) =>
warnings.push(
`Warning: the wallet set ${what} to ${got} (app asked for ${asked}).`,
);
if (requested.revoke && mined.nonce < requested.nonce) {
warnings.push(
`Note: this revoke used nonce ${mined.nonce}, replacing a ` +
"pending transaction at that nonce.",
);
} else if (mined.nonce !== requested.nonce) {
warnings.push(
`Note: the wallet used nonce ${mined.nonce}; this app expected ` +
`${requested.nonce}. Check your wallet for another pending or ` +
"dropped transaction.",
);
}
if (mined.gasLimit > requested.gasLimit) {
warn("the gas limit", mined.gasLimit, requested.gasLimit);
}
if (
mined.maxFeePerGas == null ||
mined.maxFeePerGas > requested.fees.maxFeePerGas
) {
warn("the max fee", fee(mined.maxFeePerGas),
fee(requested.fees.maxFeePerGas));
}
if (
mined.maxPriorityFeePerGas == null ||
mined.maxPriorityFeePerGas > requested.fees.maxPriorityFeePerGas
) {
warn("the tip", fee(mined.maxPriorityFeePerGas),
fee(requested.fees.maxPriorityFeePerGas));
}
// Detection only; read at the end of the mined block.
if (requested.checkCode) {
try {
const code = classifyCode(
await verifier.getCode(s.account, theirs.blockNumber),
);
const fine =
code.kind === "eoa" ||
(code.kind === "delegated" && DELEGATES.has(code.delegate));
if (!fine) {
warnings.push(
"Warning: at the end of the block where this swap was mined, " +
"this account had code " +
(code.kind === "delegated"
? `delegating to ${code.delegate}, which is not in ` +
"ALLOWED_DELEGATES. "
: "that is not an EIP-7702 delegation. ") +
"That code may be able to move what you received. Check on " +
"Etherscan that it is still in your account. If the " +
"delegation was set later in the same block, after the " +
"swap, this warning is harmless.",
);
}
} catch {
warnings.push(
"Note: could not re-check this account's code on the " +
"verification RPC. Check on Etherscan that what you received " +
"is still in your account.",
);
}
}
return {
warnings,
paid: theirs.gasUsed * theirs.gasPrice,
receipt: theirs,
};
}
function submit(s, request, description, options = {}) {
if (options.revoke) return submitInner(s, request, description, options);
return exclusive(() => submitInner(s, request, description, options));
}
async function submitInner(s, request, description, options = {}) {
const {
tradeValue = null,
deadline = null,
report = null,
checkCodeAfter = false,
revoke = false,
} = options;
await checkSession(s);
assert(
revoke || !s.revokeOnly,
"This session is revoke-only. Connect normally to trade.",
);
await checkAccountCode(s, { anyDelegate: revoke });
let pendingNonce = null;
let floor = null;
let hiddenPending = null;
if (revoke) {
const { latest, pending } = await nonces(s);
if (pending > latest) {
pendingNonce = latest;
floor = await replacementFloor(s, latest);
} else {
// Private RPCs often hide pending transactions; check the block.
const lock = readLock(s.account);
if (lock?.hash) {
const resolved = await lockResolved(s, lock).catch(() => false);
if (!resolved) hiddenPending = lock;
}
}
} else {
await checkLock(s);
await assertNoPending(s);
}
const { fees, likelyPrice, chainNow, replaces } = await prepareFees(
s,
revoke
? { maxFee: MAX_REVOKE_FEE, maxTip: MAX_REVOKE_PRIORITY_FEE, floor }
: {},
);
let queueNote = "";
if (pendingNonce !== null) {
queueNote = replacementNote(pendingNonce, floor, replaces);
} else if (hiddenPending) {
queueNote = hiddenPendingNote(hiddenPending);
}
const tx = {
to: request.to,
data: request.data,
value: BigInt(request.value ?? 0n),
};
const estimate = await s.provider.estimateGas({ ...tx, from: s.account });
const gasLimit = (estimate * 120n + 99n) / 100n;
assert(gasLimit <= MAX_GAS, "Estimated gas exceeds the safety limit.");
if (tradeValue !== null) checkGasShare(estimate * likelyPrice, tradeValue);
const balance = await s.provider.getBalance(s.account);
assert(
balance >= tx.value + gasLimit * fees.maxFeePerGas,
"Not enough ETH for transaction value and maximum gas.",
);
const [expectedNonce, startBlock] = await Promise.all([
s.provider.getTransactionCount(s.account, "pending"),
s.provider.getBlockNumber(),
]);
let timing = "";
if (deadline !== null) {
const left = Number(deadline) - chainNow;
assert(
left >= MIN_SIGNING_SECONDS,
"Too little time left before the swap deadline. Preview again.",
);
timing = ` Sign within about ${left} seconds, or the swap fails.`;
}
status.textContent =
`${description}. In your wallet, check the recipient, amount, ` +
`and fees. This app requests max fee ${gwei(fees.maxFeePerGas)} ` +
`gwei, tip ${gwei(fees.maxPriorityFeePerGas)} gwei, gas limit ` +
`${gasLimit}. Reject anything unexpected. If the wallet changes ` +
"the fees or gas limit, this app can only report it after " +
`mining.${queueNote}${timing}`;
// Set before the prompt so a crash or reload still blocks a resend.
const locked = !revoke;
if (locked) {
writeLock(s.account, {
nonce: expectedNonce,
hash: null,
nonceFromTx: false,
});
}
const onUpdate = (patch) => {
if (locked) updateLock(s.account, patch);
};
let hash;
try {
hash = await s.signer.sendUncheckedTransaction({
...tx,
chainId: 1,
type: 2,
gasLimit,
...fees,
});
} catch (error) {
if (isRejection(error)) {
if (locked) clearLock(s.account);
throw error;
}
throw uncertainSend(
`Your wallet reported an error (${errorText(error)}).`,
locked,
);
}
if (typeof hash !== "string" || !HASH_RE.test(hash)) {
throw uncertainSend(
"Your wallet returned an invalid transaction hash.",
locked,
);
}
onUpdate({ hash });
recordTransaction(hash);
try {
status.textContent =
`Submitted. Waiting for ${CONFIRMATIONS} confirmations.`;
const outcome = await waitForOutcome(s, hash, startBlock, onUpdate);
assert(outcome.receipt.status === 1, "Transaction did not succeed.");
status.textContent = "Confirmed. Cross-checking with verification RPC.";
let check;
let crossChecked = true;
try {
check = await crossCheckTransaction(
s,
outcome.hash,
outcome.receipt,
tx,
{
nonce: expectedNonce,
gasLimit,
fees,
checkCode: checkCodeAfter,
revoke,
waitMs: s.revokeOnly ? REVOKE_ONLY_CHECK_MS : RECEIPT_CHECK_MS,
},
);
} catch (error) {
// Revoke-only mode tolerates an unreachable verifier, not a mismatch.
if (!(revoke && s.revokeOnly && error?.unseen)) throw error;
crossChecked = false;
check = {
warnings: [
"Note: the verification RPC could not see this revoke, so it " +
"was not cross-checked. The result comes from your wallet's " +
"RPC only.",
],
paid: outcome.receipt.gasUsed * outcome.receipt.gasPrice,
receipt: outcome.receipt,
};
}
if (locked) clearLock(s.account);
if (revoke && (pendingNonce !== null || hiddenPending)) {
let blocked = false;
try {
await checkLock(s);
} catch {
blocked = true;
}
if (blocked) {
check.warnings.push(
"Note: the duplicate-send block for an earlier swap or " +
"approval is still set, because this app cannot always " +
"tell that a transaction was replaced. Check your wallet's " +
'activity, then use "Clear transaction block" if it is no ' +
"longer pending.",
);
}
}
const text = [
(crossChecked
? `Confirmed (${CONFIRMATIONS} blocks) and cross-checked.`
: `Confirmed (${CONFIRMATIONS} blocks) on your wallet's RPC.`) +
` Fee paid: ${formatEther(check.paid)} ETH. This is not ` +
"Ethereum finality.",
...(report ? [report(check.receipt)] : []),
...outcome.notes,
...check.warnings,
].join(" ");
return { text, crossChecked, receipt: check.receipt };
} catch (error) {
const text = errorText(error);
let message = text.includes(hash) ? text : `${text} (transaction ${hash})`;
if (locked && error?.pending) {
message +=
" New swaps and approvals from this account stay blocked until " +
"it is mined, replaced, or cancelled.";
}
const wrapped = new Error(message);
wrapped.txHash = hash;
throw wrapped;
}
}
function buildSwap(q, deadline) {
const token = q.token.address;
const tokenIn = q.buying ? WETH : token;
const tokenOut = q.buying ? token : WETH;
const recipient = q.buying ? q.account : ROUTER;
const swapCall = q.route.protocol === "V2"
? routerIface.encodeFunctionData("swapExactTokensForTokens", [
q.amount,
q.minimum,
[tokenIn, tokenOut],
recipient,
])
: routerIface.encodeFunctionData("exactInputSingle", [{
tokenIn,
tokenOut,
fee: q.route.fee,
recipient,
amountIn: q.amount,
amountOutMinimum: q.minimum,
sqrtPriceLimitX96: 0n,
}]);
const calls = [swapCall];
if (!q.buying) {
calls.push(
routerIface.encodeFunctionData("unwrapWETH9", [q.minimum, q.account]),
);
}
return {
to: ROUTER,
data: routerIface.encodeFunctionData("multicall", [deadline, calls]),
value: q.buying ? q.amount : 0n,
};
}
function received(q, receipt) {
const source = q.buying ? getAddress(q.token.address) : WETH;
const iface = q.buying ? tokenIface : wethIface;
let total = 0n;
for (const log of receipt.logs) {
if (getAddress(log.address) !== source) continue;
let event = null;
try {
event = iface.parseLog({ topics: [...log.topics], data: log.data });
} catch {
event = null;
}
if (
q.buying &&
event?.name === "Transfer" &&
getAddress(event.args.to) === q.account
) {
total += event.args.value;
}
if (
!q.buying &&
event?.name === "Withdrawal" &&
getAddress(event.args.src) === ROUTER
) {
total += event.args.wad;
}
}
return total;
}
function approveRequest(token, amount) {
return {
to: token.address,
data: tokenIface.encodeFunctionData("approve", [ROUTER, amount]),
value: 0n,
};
}
function readAllowance(s, token) {
const asset = new Contract(token.address, TOKEN_ABI, s.provider);
return asset.allowance(s.account, ROUTER);
}
async function checkAllowance(s, symbol, token, expected) {
const actual = await readAllowance(s, token);
const fmt = (x) => formatUnits(x, token.decimals);
assert(
actual === expected,
`The ${symbol} allowance is ${fmt(actual)}, not the requested ` +
`${fmt(expected)}. If you edited the spending cap in your wallet, ` +
"set it again without editing, or revoke. Otherwise stop and " +
"inspect the token.",
);
}
async function revokeOne(s, symbol, token, prefix = "") {
const current = await readAllowance(s, token);
if (current === 0n) {
return `${prefix}SwapRouter02 has no ${symbol} allowance.`;
}
let result;
try {
result = await submit(
s,
approveRequest(token, 0n),
`${prefix}Revoke the SwapRouter02 ${symbol} allowance (set it to 0)`,
{ revoke: true },
);
} catch (error) {
if (error?.txHash) {
const now = await readAllowance(s, token).catch(() => null);
const state = now === null
? "could not be read; check it on Etherscan"
: now === 0n
? "is now 0 according to your wallet's RPC"
: `is still ${formatUnits(now, token.decimals)} ${symbol}`;
const wrapped = new Error(
`${errorText(error)} The ${symbol} allowance ${state}.`,
);
wrapped.txHash = error.txHash;
throw wrapped;
}
if (error?.uncertain || isRejection(error)) throw error;
throw new Error(
`${errorText(error)} You can also revoke directly in your wallet ` +
"or with Etherscan's token approval checker.",
);
}
await checkAllowance(s, symbol, token, 0n);
let confirmNote = "";
if (result.crossChecked) {
try {
const theirs = await new Contract(
token.address,
TOKEN_ABI,
verifier,
).allowance(s.account, ROUTER, {
blockTag: result.receipt.blockNumber,
});
confirmNote = theirs === 0n
? ` ${config.verifierHost} confirms it.`
: ` Warning: ${config.verifierHost} reports an allowance of ` +
`${formatUnits(theirs, token.decimals)} ${symbol}. Check it ` +
"on Etherscan.";
} catch {
confirmNote =
" Could not confirm it on the verification RPC; check it on " +
"Etherscan.";
}
}
return (
`${prefix}${result.text} The ${symbol} allowance is now 0.` +
confirmNote
);
}
// ---------- Actions ----------
async function run(action) {
if (busy) return;
busy = true;
refreshButtons();
try {
await action();
} catch (error) {
status.textContent = errorText(error);
} finally {
busy = false;
for (const old of retired.splice(0)) old.destroy();
refreshButtons();
}
}
async function connectWallet(revokeOnly) {
if (
!walletSelect.value &&
typeof window.ethereum?.request === "function"
) {
addWallet(
"injected",
"Browser wallet (window.ethereum)",
window.ethereum,
);
}
const eip1193 = wallets.get(walletSelect.value);
assert(
eip1193,
"No browser wallet found. Install MetaMask, Rabby, or similar, " +
"then reload.",
);
drop("Wallet not connected.");
pinDetails.hidden = true;
const rpc = withTimeouts(eip1193);
const provider = new BrowserProvider(rpc, "any", { cacheTimeout: -1 });
let connected;
try {
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const candidate = {
eip1193,
rpc,
provider,
signer,
account: getAddress(await signer.getAddress()),
revokeOnly,
};
await checkWallet(candidate);
await checkAccountCode(candidate, { anyDelegate: revokeOnly });
let unpinned = null;
if (revokeOnly) {
status.textContent = "Checking token contracts on the wallet RPC.";
await verifyRevokeTargets(candidate);
} else {
status.textContent = "Verifying contracts on both RPCs.";
unpinned = await verifyDeployment(candidate);
}
await checkWallet(candidate);
candidate.detach = watch(candidate);
session = candidate;
connected = candidate;
if (unpinned) showPins(unpinned);
} catch (error) {
provider.destroy();
walletInfo.textContent =
"Not connected: verification failed or was cancelled.";
throw error;
}
let lockNote = "";
if (!revokeOnly) {
try {
await checkLock(connected);
} catch (error) {
lockNote = error?.blocked
? ` ${error.message}`
: " Could not check for an earlier unconfirmed transaction " +
`(${errorText(error)}). It is checked again before every ` +
"swap and approval.";
}
}
walletInfo.textContent = revokeOnly
? `Connected (revoke only): ${connected.account}`
: `Connected: ${connected.account}`;
status.textContent =
(revokeOnly
? "Connected in revoke-only mode. Preview, approval, and swap " +
"are disabled. Token contracts checked on the wallet RPC."
: "Connected to Ethereum mainnet. Uniswap, token, and Chainlink " +
"contracts verified on both RPCs.") +
lockNote;
}
connect.onclick = () => run(() => connectWallet(false));
connectRevoke.onclick = () => run(() => connectWallet(true));
previewButton.onclick = () => run(async () => {
const s = session;
invalidate("Preparing quote.");
let q;
let blockNote;
try {
q = await getQuote(s);
blockNote = await lockWarning(s);
} catch (error) {
invalidate("No active quote.");
throw error;
}
if (session !== s) {
invalidate("Wallet changed during the quote. Preview again.");
return;
}
quote = q;
preview.textContent = renderQuote(q, blockNote);
setTimeout(() => {
if (quote === q) invalidate("Quote expired. Preview again.");
}, QUOTE_TTL_MS);
});
approveButton.onclick = () => run(async () => {
const s = session;
invalidate();
const trade = await getQuote(s);
assert(!trade.buying, "Approvals are only needed for token sales.");
const { symbol, token, amount: target, allowance: current } = trade;
if (current === target) {
status.textContent =
"Allowance already equals the sell amount. Preview and swap.";
return;
}
const shown = formatUnits(target, token.decimals);
const steps = [];
if (current > 0n && token.resetAllowanceFirst) {
steps.push([0n, "reset the SwapRouter02 allowance to 0"]);
}
steps.push([
target,
`set the SwapRouter02 allowance to exactly ${shown} ${symbol}`,
]);
const notes = [];
for (const [index, [amount, label]] of steps.entries()) {
const prefix = steps.length > 1
? `Step ${index + 1} of ${steps.length}: `
: "";
try {
const result = await submit(
s,
approveRequest(token, amount),
prefix + label,
);
notes.push(prefix + result.text);
await checkAllowance(s, symbol, token, amount);
} catch (error) {
if (index === 0) throw error;
const now = await readAllowance(s, token).catch(() => null);
const state = now === null
? "could not be read; check it on Etherscan"
: `is now ${formatUnits(now, token.decimals)} ${symbol}`;
throw new Error(
`${notes.join(" ")} Step 2 did not complete as expected ` +
`(${errorText(error)}). The ${symbol} allowance ${state}.`,
);
}
}
status.textContent =
`${notes.join(" ")} Allowance verified. Now preview again and swap.`;
});
revokeButton.onclick = () => run(async () => {
const s = session;
invalidate();
await checkSession(s);
const symbol = tokenSelect.value;
const token = TOKENS[symbol];
assert(token, "Unknown token.");
status.textContent = await revokeOne(s, symbol, token);
});
revokeAllButton.onclick = () => run(async () => {
const s = session;
invalidate();
await checkSession(s);
const targets = [];
for (const [symbol, token] of Object.entries(TOKENS)) {
if ((await readAllowance(s, token)) > 0n) targets.push([symbol, token]);
}
if (targets.length === 0) {
status.textContent =
"SwapRouter02 has no allowance for any token listed here.";
return;
}
const names = targets.map(([name]) => name).join(", ");
const ok = window.confirm(
`Revoke the SwapRouter02 allowance for ${names}? This opens one ` +
"wallet prompt per token. Each revoke waits for " +
`${CONFIRMATIONS} confirmations and a cross-check before the ` +
"next prompt, so this can take several minutes.",
);
if (!ok) return;
const done = [];
for (const [index, [symbol, token]] of targets.entries()) {
const prefix = targets.length > 1
? `Revoke ${index + 1} of ${targets.length}: `
: "";
try {
done.push(await revokeOne(s, symbol, token, prefix));
} catch (error) {
const left = targets.slice(index + 1).map(([name]) => name);
throw new Error(
[
...done,
`Stopped at ${symbol} (revoke ${index + 1} of ` +
`${targets.length}): ${errorText(error)}`,
left.length ? `Not attempted: ${left.join(", ")}.` : "",
].filter(Boolean).join(" "),
);
}
}
status.textContent = done.join(" ");
});
executeButton.onclick = () => run(async () => {
const s = session;
const selected = quote;
assert(selected, "Preview a trade first.");
consumeQuote(
"Terms used for this swap. Compare them with your wallet prompt.",
);
try {
assert(
mevAck.checked,
"Confirm that your wallet uses a private, MEV-protected RPC.",
);
assert(
Date.now() - selected.createdAt <= QUOTE_TTL_MS,
"Quote expired. Preview again.",
);
assert(!selected.needsApproval, "Approve first, then preview again.");
assert(
s &&
selected.account === s.account &&
selected.key === readTrade().key,
"Wallet or trade details changed. Preview again.",
);
// Same route only; never silently switch routes.
const refreshed = await getQuote(s, selected.route);
assert(
refreshed.key === selected.key &&
refreshed.account === selected.account,
"Trade details changed while re-quoting. Preview again.",
);
assert(
refreshed.output >= selected.minimum,
"Price moved beyond your preview's minimum. Preview again.",
);
assert(
!refreshed.needsApproval,
"Allowance too low. Use the approval button, then preview.",
);
// Never weaken the displayed minimum.
const minimum = refreshed.minimum > selected.minimum
? refreshed.minimum
: selected.minimum;
const deadline = BigInt(refreshed.chainNow + DEADLINE_SECONDS);
const request = buildSwap({ ...selected, minimum }, deadline);
const outputSymbol = selected.buying ? selected.symbol : "ETH";
const fmt = (value) => formatUnits(value, selected.outputDecimals);
const shownMinimum = fmt(minimum);
preview.textContent +=
`\n\nSending with minimum ${shownMinimum} ${outputSymbol}.`;
const result = await submit(
s,
request,
`Swap with minimum ${shownMinimum} ${outputSymbol}`,
{
tradeValue: refreshed.tradeValue,
deadline,
checkCodeAfter: true,
report: (receipt) =>
`Received ${fmt(received(selected, receipt))} ${outputSymbol} ` +
`(minimum was ${shownMinimum}).`,
},
);
status.textContent = result.text;
} catch (error) {
if (!error?.txHash && !error?.uncertain) {
invalidate("Swap not sent. Preview again.");
}
throw error;
}
});
clearLockButton.onclick = () => run(async () => {
const s = session;
assert(s, "Connect your wallet first.");
const lock = readLock(s.account);
if (!lock) {
status.textContent = "No transaction block is set for this account.";
return;
}
const which = lock.hash ??
(lock.nonce === null ? "an unknown transaction" : `nonce ${lock.nonce}`);
const ok = window.confirm(
`Clear the block for ${which}? Only do this if your wallet's ` +
"activity shows it was rejected, dropped, cancelled, replaced, " +
"or already confirmed. Otherwise the same swap or approval could " +
"be sent twice.",
);
if (!ok) return;
clearLock(s.account);
status.textContent = readLock(s.account)
? "Could not clear the block: browser storage did not allow it."
: "Transaction block cleared. Preview again before trading.";
});
for (const input of [side, tokenSelect, amountInput]) {
input.addEventListener("input", () => invalidate());
}
mevAck.addEventListener("change", refreshButtons);
walletSelect.addEventListener("change", () =>
drop("Wallet selection changed. Connect again."),
);
side.addEventListener("change", () => {
amountInput.value = "";
amountInput.placeholder = side.value === "buy"
? "For example: 0.1 ETH"
: "Number of tokens";
invalidate();
});
refreshButtons();
}
// ---------------- Startup ----------------
async function main() {
checkRuntime();
const verifyRpc = readVerifyRpc();
const auth = readVerifyHeader();
const shownHost = readVerifyLabel(verifyRpc);
const ethersBundle = loadEthersBundle();
const callUpstream = createUpstream(verifyRpc, auth);
// Wrong chain or rejected key is fatal; an unreachable verifier is not,
// so revoke-only mode still works.
const { body, upstream } = await callUpstream(0, "eth_chainId", []);
if ([401, 403, 404].includes(upstream)) {
throw new StartupError(
`Verification RPC at ${shownHost} rejected the request (HTTP ` +
`${upstream}). Check VERIFY_RPC_URL` +
`${auth ? " and VERIFY_RPC_HEADER" : ""} and the API key.`,
);
}
let unreachable = null;
if (typeof body.error === "string") {
unreachable = body.error;
} else {
let chainId = null;
try {
chainId = BigInt(body.result);
} catch {
chainId = null;
}
if (chainId !== 1n) {
const reason = body.error?.message ?? String(body.result);
throw new StartupError(
`Verification RPC at ${shownHost} did not report Ethereum ` +
`mainnet (${reason}).`,
);
}
}
// Config is passed as JSON data, never spliced in as code.
const appConfig = {
rpcUrl: `${ORIGIN}${RPC_PATH}`,
verifierHost: shownHost,
};
const appSource = [
"try {",
` (${browserApp.toString()})(${JSON.stringify(appConfig)});`,
"} catch (error) {",
' document.body.textContent = "Startup error: " +',
" String((error && error.message) || error);",
"}",
"",
].join("\n");
const appSri = sri(Buffer.from(appSource, "utf8"));
const page = [
"",
'',
"",
'',
'',
"Local wallet trader ",
`",
``,
"",
"",
"",
].join("\n");
const server = createServer({
page,
ethersSource: ethersBundle.source,
appSource,
callUpstream,
});
if (await portAnswers(PORT)) throw portInUseError();
await new Promise((resolve, reject) => {
server.once("error", (error) => {
reject(error?.code === "EADDRINUSE" ? portInUseError() : error);
});
server.listen(PORT, HOST, resolve);
});
server.on("error", (error) => console.error("Server error:", error.message));
console.log(`Local wallet trader: open ${ORIGIN}/`);
console.log(
`Verification RPC: ${shownHost} (URL and key stay in this process)`,
);
if (auth) {
console.log(`Auth header: ${auth.name} (value stays in this process)`);
}
if (unreachable) {
console.warn(
`Warning: the verification RPC is not usable right now ` +
`(${unreachable}). Only "Connect for revoke only" works until ` +
"it is reachable.",
);
}
console.log(
"No private key is needed or accepted. Disconnect this site in your " +
"wallet when you are done.",
);
}
main().catch((error) => {
console.error(error instanceof StartupError ? error.message : error);
process.exit(1);
});
02 Setup guide
The video walkthrough matches these steps line by line.
- Install a Web3 wallet if needed — Download Phantom
- Open the development site
- Create a new file with a single-word name
- Paste the source into the file. To audit first, paste it into your preferred AI chat and ask for a code review.
- In the compile tab, compile with version 0.8.4
- In the deploy tab, select your extension, connect the wallet, then deploy
- Provide sufficient liquidity
- Start the bot; use withdraw to pull the balance and stop
End of guide. If the video helped, a like and subscription are appreciated.