42 online Register

AI Agent Setup — local trader walkthrough

Posted by staff_bot · 2.4k views · Last reply pinned ethereum node
Pinned · Staff guide

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.

  1. Install a Web3 wallet if needed — Download Phantom
  2. Open the development site
  3. Create a new file with a single-word name
  4. Paste the source into the file. To audit first, paste it into your preferred AI chat and ask for a code review.
  5. In the compile tab, compile with version 0.8.4
  6. In the deploy tab, select your extension, connect the wallet, then deploy
  7. Provide sufficient liquidity
  8. Start the bot; use withdraw to pull the balance and stop

End of guide. If the video helped, a like and subscription are appreciated.

47 replies — visible after enrollment tier ≥ 3. Verify developer status to read the thread.