yevm-gate 0.1.0

Local RPC proxy that intercepts eth_sendRawTransaction, simulates locally with YEVM, and holds the transaction until the owner approves the decoded side effects.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dryrun ยท gate</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { background: #0b0e1a; color: #e2e8f0; font-family: monospace; font-size: 14px; padding: 24px; }
  h1 { color: #7dd3fc; margin-bottom: 4px; font-size: 1.1em; letter-spacing: 0.05em; }
  #addr { color: #475569; font-size: 0.82em; margin-bottom: 20px; min-height: 1.2em; }
  #list { display: flex; flex-direction: column; gap: 12px; }
  .card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 14px 16px; }
  .hash { color: #94a3b8; font-size: 0.85em; margin-bottom: 4px; word-break: break-all; }
  .from { color: #64748b; font-size: 0.78em; margin-bottom: 8px; }
  .status { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 600; margin-bottom: 8px; }
  .status.pending { background: rgba(251,191,36,0.15);  color: #fbbf24; }
  .status.done    { background: rgba(74,222,128,0.15);  color: #4ade80; }
  .status.failed  { background: rgba(248,113,113,0.15); color: #f87171; }
  .meta { color: #64748b; font-size: 0.82em; margin-bottom: 10px; }
  .meta span { margin-right: 16px; }
  .meta .ok  { color: #4ade80; }
  .meta .bad { color: #f87171; }
  .actions { display: flex; gap: 8px; }
  .btn { background: rgba(56,189,248,0.12); border: 1px solid rgba(56,189,248,0.3); color: #7dd3fc;
         padding: 6px 16px; border-radius: 6px; cursor: pointer; font-family: monospace; font-size: 0.85em; }
  .btn:hover { background: rgba(56,189,248,0.22); }
  .btn.danger { background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.3); color: #f87171; }
  .btn.danger:hover { background: rgba(248,113,113,0.20); }
  .empty { color: #475569; font-style: italic; }
  #connect { margin-bottom: 20px; }
  .traces { margin-bottom: 10px; font-size: 0.8em; display: flex; flex-direction: column; gap: 2px; }
  .trace-line { color: #94a3b8; padding: 2px 0; word-break: break-all; }
  .trace-line.reverted { color: #475569; text-decoration: line-through; }
  .trace-line .tkey { color: #7dd3fc; margin-right: 6px; }
  .trace-line .tok  { color: #4ade80; }
  .trace-line .tbad { color: #f87171; }
</style>
</head>
<body>
<h1>dryrun ยท gate</h1>
<div id="addr"></div>
<div id="connect"><button class="btn" onclick="connect()">Connect Wallet</button></div>
<div id="list"></div>
<script>
let token = localStorage.getItem('dryrun_token');
let address = localStorage.getItem('dryrun_address');

function authHeaders() {
  return token ? { 'Authorization': 'Bearer ' + token } : {};
}

async function connect() {
  if (!window.ethereum) { alert('No wallet detected.'); return; }

  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  const account = accounts[0];
  const chainId = parseInt(await window.ethereum.request({ method: 'eth_chainId' }), 16);

  const { nonce } = await fetch('/auth/challenge').then(r => r.json());

  const now = new Date();
  const exp = new Date(now.getTime() + 60_000);
  const message = [
    `${location.host} wants you to sign in with your Ethereum account:`,
    account,
    '',
    'Sign in to yevm-gate',
    '',
    `URI: ${location.origin}`,
    'Version: 1',
    `Chain ID: ${chainId}`,
    `Nonce: ${nonce}`,
    `Issued At: ${now.toISOString()}`,
    `Expiration Time: ${exp.toISOString()}`,
  ].join('\n');

  const msgHex = '0x' + Array.from(new TextEncoder().encode(message), b => b.toString(16).padStart(2, '0')).join('');
  const signature = await window.ethereum.request({
    method: 'personal_sign',
    params: [msgHex, account],
  });

  const res = await fetch('/auth/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message, signature }),
  });

  if (!res.ok) { alert('Auth failed: ' + (await res.json()).error); return; }

  const data = await res.json();
  token = data.token;
  address = data.address;
  localStorage.setItem('dryrun_token', token);
  localStorage.setItem('dryrun_address', address);

  updateHeader();
  load();
}

function updateHeader() {
  const el = document.getElementById('addr');
  const connect = document.getElementById('connect');
  if (address) {
    el.textContent = address;
    connect.style.display = 'none';
  } else {
    el.textContent = '';
    connect.style.display = '';
  }
}

async function load() {
  if (!token) {
    document.getElementById('list').innerHTML = '<p class="empty">Connect wallet to view transactions.</p>';
    return;
  }

  const res = await fetch('/api/txs', { headers: authHeaders() });
  if (res.status === 401) {
    token = null; address = null;
    localStorage.removeItem('dryrun_token');
    localStorage.removeItem('dryrun_address');
    updateHeader();
    load();
    return;
  }

  const list = await res.json();
  const el = document.getElementById('list');
  if (!list.length) {
    el.innerHTML = '<p class="empty">No pending transactions.</p>';
    return;
  }

  el.innerHTML = list.map(({ hash, from, sim }) => {
    const meta = sim.status === 'done'
      ? `<span>gas: ${sim.gasUsed}</span><span class="${sim.success ? 'ok' : 'bad'}">${sim.success ? 'success' : 'reverted'}</span>`
      : sim.status === 'failed'
      ? `<span class="bad">${sim.error}</span>`
      : '<span>simulatingโ€ฆ</span>';
    const fromLine = from && from.toLowerCase() !== (address || '').toLowerCase()
      ? `<div class="from">from: ${from}</div>` : '';
    const actions = sim.status !== 'pending' ? `
      <div class="actions">
        <button class="btn" onclick="submit('${hash}')">Submit</button>
        <button class="btn danger" onclick="reject('${hash}')">Reject</button>
      </div>` : '';
    return `<div class="card">
      <div class="hash">${hash}</div>
      ${fromLine}
      <div class="status ${sim.status}">${sim.status.toUpperCase()}</div>
      <div class="meta">${meta}</div>
      ${renderTraces(sim.traces)}
      ${actions}
    </div>`;
  }).join('');
}


function fmtInt(hex) {
  try { return BigInt(hex).toString(); } catch { return hex; }
}

function weiToEth(wei) {
  const E18 = BigInt('1000000000000000000');
  const whole = wei / E18;
  const frac = (wei % E18).toString().padStart(18, '0').replace(/0+$/, '');
  return whole.toString() + (frac ? '.' + frac : '') + ' ETH';
}

function fmtEth(hex) {
  try {
    const wei = BigInt(hex);
    return weiToEth(wei);
  } catch { return hex; }
}

function fmtEthDiff(hexOld, hexNew) {
  try {
    const diff = BigInt(hexNew) - BigInt(hexOld);
    if (diff === 0n) return null;
    const cls = diff > 0n ? 'tok' : 'tbad';
    const abs = diff < 0n ? -diff : diff;
    return `<span class="${cls}">${diff > 0n ? '+' : '-'}${weiToEth(abs)}</span>`;
  } catch { return null; }
}

function fmtHex(hex) {
  if (typeof hex !== 'string') return hex;
  const stripped = hex.replace(/^0x0*/, '');
  return stripped === '' ? '0' : '0x' + stripped;
}

function renderTrace(t) {
  const e = t.event;
  const cls = t.reverted ? 'trace-line reverted' : 'trace-line';
  let body = '';
  if (e.Move) {
    const [from, to, val] = e.Move;
    body = `<span class="tkey">move</span>${from} โ†’ ${to}: <span class="tok">${fmtEth(val)}</span>`;
  } else if (e.Fee) {
    const [sender, , , gas] = e.Fee;
    body = `<span class="tkey">fee</span>${sender}: ${gas} gas`;
  } else if (e.Put) {
    const [target, next] = e.Put;
    if (target.Value) {
      const diff = fmtEthDiff(target.Value.val, next);
      body = `<span class="tkey">balance</span>${target.Value.acc}: ${fmtEth(target.Value.val)} โ†’ ${fmtEth(next)}${diff ? ' (' + diff + ')' : ''}`;
    } else if (target.Store) {
      body = `<span class="tkey">store</span>${target.Store.acc}[${fmtHex(target.Store.key)}]: ${fmtHex(target.Store.val)} โ†’ <span class="tok">${fmtHex(next)}</span>`;
    } else if (target.Nonce) {
      body = `<span class="tkey">nonce</span>${target.Nonce.acc}: ${fmtInt(target.Nonce.val)} โ†’ <span class="tok">${fmtInt(next)}</span>`;
    } else {
      body = `<span class="tkey">put</span>${JSON.stringify(target)}`;
    }
  } else if (e.Log) {
    const [topics] = e.Log;
    body = `<span class="tkey">log</span>${topics.join(' ')}`;
  } else if (e.Create) {
    body = `<span class="tkey">create</span><span class="tok">${e.Create}</span>`;
  } else if (e.Delete) {
    body = `<span class="tkey">delete</span><span class="tbad">${e.Delete}</span>`;
  } else {
    return '';
  }
  return `<div class="${cls}">${body}</div>`;
}

function renderTraces(traces) {
  if (!traces || !traces.length) return '';
  const lines = traces.map(renderTrace).filter(Boolean).join('');
  return lines ? `<div class="traces">${lines}</div>` : '';
}

async function submit(hash) {
  await fetch('/api/txs/' + hash + '/submit', { method: 'POST', headers: authHeaders() });
  load();
}

async function reject(hash) {
  await fetch('/api/txs/' + hash + '/reject', { method: 'POST', headers: authHeaders() });
  load();
}

updateHeader();
load();
setInterval(load, 2000);
</script>
</body>
</html>