Snapshot of the shared working tree's outstanding changes (multiple concurrent workstreams), excluding generated runtime state files (.football-tote-state.json, .tote-platform-state.json, .usdm-historical-chart.html). Spans: wallet shell (connect/, keystore MultiKeyStore+vault, screens, currency picker, sign-message), verify artifacts, new Potidaea/DRA/tote contracts + regtest suites, cross-chain/T6 + WalletConnect Parameters, a telegram-orderbook subproject, design mockups, and docs. See shells/app/WALLET-COORDINATION.md for the per-file ownership split. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
15 KiB
HTML
398 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ETH Sweep</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/ethers@6/dist/ethers.umd.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #0d0d12;
|
||
--surface: #161621;
|
||
--border: #2a2a3d;
|
||
--text: #e2e2f0;
|
||
--muted: #888899;
|
||
--accent: #7c5cfc;
|
||
--ok: #4caf8a;
|
||
--err: #f05a4a;
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: 14px/1.6 'SF Mono', 'Consolas', monospace;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 32px 16px 64px;
|
||
}
|
||
.card { width: 100%; max-width: 560px; }
|
||
h1 { font-size: 20px; margin-bottom: 4px; }
|
||
h1 span { color: var(--accent); }
|
||
.subtitle { color: var(--muted); font-size: 12px; margin-bottom: 28px; }
|
||
h2 {
|
||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
|
||
color: var(--muted); margin: 24px 0 8px;
|
||
}
|
||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||
input {
|
||
width: 100%;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
input:focus { border-color: var(--accent); }
|
||
.row { display: flex; gap: 8px; align-items: stretch; }
|
||
.row input { flex: 1; min-width: 0; }
|
||
.icon-btn {
|
||
flex-shrink: 0;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
padding: 0 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
}
|
||
.icon-btn:hover { border-color: var(--accent); color: var(--text); }
|
||
.seg {
|
||
display: flex;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
.seg button {
|
||
flex: 1;
|
||
padding: 9px 8px;
|
||
background: var(--surface);
|
||
border: none;
|
||
border-right: 1px solid var(--border);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.seg button:last-child { border-right: none; }
|
||
.seg button.active { background: var(--accent); color: #fff; }
|
||
.seg button:not(.active):hover { background: rgba(124,92,252,0.12); color: var(--text); }
|
||
.plan {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px 16px;
|
||
margin-top: 16px;
|
||
}
|
||
.plan-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||
.plan-row .l { color: var(--muted); }
|
||
.plan-row.total { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 12px; font-weight: 600; }
|
||
.plan-dest { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); word-break: break-all; }
|
||
.plan-dest span { color: var(--text); }
|
||
.btn {
|
||
display: block; width: 100%; padding: 12px; border: none; border-radius: 6px;
|
||
font: inherit; font-size: 14px; cursor: pointer; margin-top: 12px; transition: opacity 0.15s;
|
||
}
|
||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.btn:not(:disabled):hover { opacity: 0.88; }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-secondary { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
|
||
.result {
|
||
margin-top: 16px; padding: 12px 16px; border-radius: 6px; border: 1px solid;
|
||
font-size: 13px; line-height: 1.7; word-break: break-all;
|
||
}
|
||
.result.ok { border-color: var(--ok); background: rgba(76,175,138,0.08); color: var(--ok); }
|
||
.result.err { border-color: var(--err); background: rgba(240,90,74,0.08); color: var(--err); }
|
||
.result a { color: inherit; }
|
||
.result code { display: block; font: inherit; opacity: 0.85; margin: 4px 0; }
|
||
.derived { font-size: 12px; color: var(--muted); margin-top: 6px; word-break: break-all; }
|
||
.derived .addr { color: var(--ok); }
|
||
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||
.mt8 { margin-top: 8px; }
|
||
hr { border: none; border-top: 1px solid var(--border); margin: 28px 0 16px; }
|
||
.hidden { display: none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>ETH <span>Sweep</span></h1>
|
||
<p class="subtitle">Runs entirely in your browser — private keys are never transmitted.</p>
|
||
|
||
<!-- Network -->
|
||
<h2>Network</h2>
|
||
<div class="seg" id="netSeg">
|
||
<button class="active" data-net="mainnet">Mainnet</button>
|
||
<button data-net="sepolia">Sepolia</button>
|
||
<button data-net="custom">Custom RPC</button>
|
||
</div>
|
||
<div id="customRpcWrap" class="mt8 hidden">
|
||
<label>RPC URL</label>
|
||
<input id="customRpc" type="url" placeholder="https://rpc.example.com" />
|
||
</div>
|
||
|
||
<!-- Source wallet -->
|
||
<h2>Source wallet</h2>
|
||
<div class="seg" id="modeSeg">
|
||
<button class="active" data-mode="key">Private key</button>
|
||
<button data-mode="seed">Seed phrase</button>
|
||
</div>
|
||
|
||
<div id="keySection" class="mt8">
|
||
<label>Private key (hex)</label>
|
||
<div class="row">
|
||
<input id="privateKey" type="password" placeholder="0x… or 64 hex chars" autocomplete="off" spellcheck="false" />
|
||
<button class="icon-btn" id="revealKey" aria-label="Show/hide">👁</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="seedSection" class="mt8 hidden">
|
||
<label>Seed phrase (BIP-39)</label>
|
||
<div class="row">
|
||
<input id="mnemonic" type="password" placeholder="word1 word2 … word12" autocomplete="off" spellcheck="false" />
|
||
<button class="icon-btn" id="revealSeed" aria-label="Show/hide">👁</button>
|
||
</div>
|
||
<div class="mt8">
|
||
<label>Derivation path</label>
|
||
<input id="derivPath" type="text" value="m/44'/60'/0'/0/0" />
|
||
<p class="hint">Default = MetaMask / Ledger Live. Change index at the end for other accounts (…/0/1, …/0/2 …).</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="derivedAddr" class="derived hidden"></div>
|
||
|
||
<!-- Destination -->
|
||
<h2>Destination</h2>
|
||
<label>Address</label>
|
||
<input id="destination" type="text" placeholder="0x…" autocomplete="off" spellcheck="false" />
|
||
|
||
<!-- Actions -->
|
||
<button class="btn btn-secondary" id="checkBtn">Check balance</button>
|
||
|
||
<div id="planWrap" class="hidden"></div>
|
||
<button class="btn btn-primary hidden" id="sweepBtn">Sweep</button>
|
||
<div id="result" class="hidden result"></div>
|
||
|
||
<hr />
|
||
<p class="hint">Only plain ETH is swept. ERC-20 tokens require a separate transfer per contract — not yet supported here.</p>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
const NETS = {
|
||
mainnet: { rpc: 'https://eth.llamarpc.com', name: 'Mainnet', explorer: 'https://etherscan.io/tx/' },
|
||
sepolia: { rpc: 'https://rpc2.sepolia.org', name: 'Sepolia', explorer: 'https://sepolia.etherscan.io/tx/' },
|
||
custom: { rpc: null, name: 'Custom', explorer: null },
|
||
};
|
||
|
||
let net = 'mainnet';
|
||
let mode = 'key';
|
||
let cachedPlan = null;
|
||
|
||
// ── Segment controls ─────────────────────────────────────────────────────
|
||
|
||
document.querySelectorAll('#netSeg button').forEach(btn =>
|
||
btn.addEventListener('click', () => {
|
||
activate('#netSeg', btn);
|
||
net = btn.dataset.net;
|
||
toggle('customRpcWrap', net === 'custom');
|
||
clearPlan();
|
||
})
|
||
);
|
||
|
||
document.querySelectorAll('#modeSeg button').forEach(btn =>
|
||
btn.addEventListener('click', () => {
|
||
activate('#modeSeg', btn);
|
||
mode = btn.dataset.mode;
|
||
toggle('keySection', mode === 'key');
|
||
toggle('seedSection', mode === 'seed');
|
||
hide('derivedAddr');
|
||
clearPlan();
|
||
})
|
||
);
|
||
|
||
function activate(sel, active) {
|
||
document.querySelectorAll(sel + ' button').forEach(b => b.classList.remove('active'));
|
||
active.classList.add('active');
|
||
}
|
||
|
||
// ── Reveal toggles ───────────────────────────────────────────────────────
|
||
|
||
['revealKey:privateKey', 'revealSeed:mnemonic'].forEach(pair => {
|
||
const [btnId, inputId] = pair.split(':');
|
||
document.getElementById(btnId).addEventListener('click', () => {
|
||
const inp = document.getElementById(inputId);
|
||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||
document.getElementById(btnId).textContent = inp.type === 'password' ? '👁' : '🙈';
|
||
});
|
||
});
|
||
|
||
// ── Live address preview ─────────────────────────────────────────────────
|
||
|
||
['privateKey', 'mnemonic', 'derivPath'].forEach(id =>
|
||
document.getElementById(id).addEventListener('input', async () => {
|
||
const div = document.getElementById('derivedAddr');
|
||
try {
|
||
const w = await buildWallet(null);
|
||
div.innerHTML = 'Derived address: <span class="addr">' + w.address + '</span>';
|
||
div.classList.remove('hidden');
|
||
} catch {
|
||
div.classList.add('hidden');
|
||
}
|
||
})
|
||
);
|
||
|
||
// ── Wallet / provider construction ───────────────────────────────────────
|
||
|
||
function getProvider() {
|
||
const rpc = net === 'custom'
|
||
? document.getElementById('customRpc').value.trim()
|
||
: NETS[net].rpc;
|
||
if (!rpc) throw new Error('Enter a custom RPC URL');
|
||
return new ethers.JsonRpcProvider(rpc);
|
||
}
|
||
|
||
async function buildWallet(provider) {
|
||
if (mode === 'key') {
|
||
let raw = document.getElementById('privateKey').value.trim();
|
||
if (!raw) throw new Error('Enter a private key');
|
||
if (!raw.startsWith('0x')) raw = '0x' + raw;
|
||
const w = new ethers.Wallet(raw);
|
||
return provider ? w.connect(provider) : w;
|
||
} else {
|
||
const phrase = document.getElementById('mnemonic').value.trim();
|
||
if (!phrase) throw new Error('Enter a seed phrase');
|
||
const path = document.getElementById('derivPath').value.trim() || "m/44'/60'/0'/0/0";
|
||
// HDNodeWallet.fromPhrase(phrase, password, path) derives the full path.
|
||
const w = ethers.HDNodeWallet.fromPhrase(phrase, '', path);
|
||
return provider ? w.connect(provider) : w;
|
||
}
|
||
}
|
||
|
||
// ── Check balance ─────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('checkBtn').addEventListener('click', async () => {
|
||
clearPlan();
|
||
const btn = document.getElementById('checkBtn');
|
||
busy(btn, 'Checking…');
|
||
try {
|
||
const provider = getProvider();
|
||
const wallet = await buildWallet(provider);
|
||
|
||
// Show derived address
|
||
const addrDiv = document.getElementById('derivedAddr');
|
||
addrDiv.innerHTML = 'Derived address: <span class="addr">' + wallet.address + '</span>';
|
||
addrDiv.classList.remove('hidden');
|
||
|
||
// Parallel fetch: balance + fee data
|
||
const [balance, feeData] = await Promise.all([
|
||
provider.getBalance(wallet.address),
|
||
provider.getFeeData(),
|
||
]);
|
||
|
||
if (balance === 0n) { showErr('No ETH at ' + wallet.address); return; }
|
||
|
||
const dest = document.getElementById('destination').value.trim();
|
||
if (!dest) { showErr('Enter a destination address'); return; }
|
||
if (!ethers.isAddress(dest)) { showErr('Invalid destination: ' + dest); return; }
|
||
if (dest.toLowerCase() === wallet.address.toLowerCase()) {
|
||
showErr('Source and destination are the same address'); return;
|
||
}
|
||
|
||
// Gas: simple ETH transfer always uses exactly 21 000 gas units.
|
||
const GAS = 21_000n;
|
||
const isEip1559 = !!feeData.maxFeePerGas;
|
||
const gasCost = GAS * (isEip1559 ? feeData.maxFeePerGas : feeData.gasPrice);
|
||
const txExtras = isEip1559
|
||
? { maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 1_000_000_000n }
|
||
: { gasPrice: feeData.gasPrice };
|
||
|
||
const netAmt = balance - gasCost;
|
||
if (netAmt <= 0n) {
|
||
showErr('Balance (' + fmt(balance) + ') is less than gas estimate (' + fmt(gasCost) + ')');
|
||
return;
|
||
}
|
||
|
||
cachedPlan = { wallet, balance, gasCost, netAmt, txExtras, dest, GAS };
|
||
|
||
document.getElementById('planWrap').innerHTML =
|
||
'<div class="plan">' +
|
||
'<div class="plan-row"><span class="l">Balance</span><span>' + fmt(balance) + '</span></div>' +
|
||
'<div class="plan-row"><span class="l">Gas (21 000 units)</span><span>−' + fmt(gasCost) + '</span></div>' +
|
||
'<div class="plan-row total"><span class="l">Net sweep</span><span>' + fmt(netAmt) + '</span></div>' +
|
||
'<div class="plan-dest">→ <span>' + dest + '</span></div>' +
|
||
'</div>';
|
||
show('planWrap');
|
||
show('sweepBtn');
|
||
|
||
} catch (e) {
|
||
showErr(e.message || String(e));
|
||
} finally {
|
||
idle(btn, 'Check balance');
|
||
}
|
||
});
|
||
|
||
// ── Sweep ─────────────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('sweepBtn').addEventListener('click', async () => {
|
||
if (!cachedPlan) return;
|
||
const btn = document.getElementById('sweepBtn');
|
||
busy(btn, 'Broadcasting…');
|
||
try {
|
||
const { wallet, netAmt, txExtras, dest, GAS } = cachedPlan;
|
||
const tx = await wallet.sendTransaction({ to: dest, value: netAmt, gasLimit: GAS, ...txExtras });
|
||
|
||
const netCfg = NETS[net];
|
||
const explorerLink = netCfg?.explorer
|
||
? '<br><a href="' + netCfg.explorer + tx.hash + '" target="_blank" rel="noopener noreferrer">View on ' +
|
||
(net === 'mainnet' ? 'Etherscan' : netCfg.name + ' Etherscan') + ' ↗</a>'
|
||
: '';
|
||
|
||
const div = document.getElementById('result');
|
||
div.className = 'result ok';
|
||
div.innerHTML = '✓ Transaction sent<br><code>' + tx.hash + '</code>' + explorerLink;
|
||
div.classList.remove('hidden');
|
||
|
||
cachedPlan = null;
|
||
hide('sweepBtn');
|
||
|
||
} catch (e) {
|
||
showErr(e.message || String(e));
|
||
} finally {
|
||
idle(btn, 'Sweep');
|
||
}
|
||
});
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
function fmt(wei) { return parseFloat(ethers.formatEther(wei)).toFixed(8) + ' ETH'; }
|
||
|
||
function showErr(msg) {
|
||
const div = document.getElementById('result');
|
||
div.className = 'result err';
|
||
div.textContent = msg;
|
||
div.classList.remove('hidden');
|
||
}
|
||
|
||
function clearPlan() {
|
||
cachedPlan = null;
|
||
hide('planWrap');
|
||
hide('sweepBtn');
|
||
hide('result');
|
||
}
|
||
|
||
function show(id) { document.getElementById(id).classList.remove('hidden'); }
|
||
function hide(id) { document.getElementById(id).classList.add('hidden'); }
|
||
function toggle(id, show) { document.getElementById(id).classList.toggle('hidden', !show); }
|
||
function busy(btn, label) { btn.textContent = label; btn.disabled = true; }
|
||
function idle(btn, label) { btn.textContent = label; btn.disabled = false; }
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|