The simulator below shows four validators, each with a different stake. The bar on the right of each row shows their selection probability — proportional to their stake share. Click Select validator to run one weighted draw and see who gets to propose the next block.
<div class="panel">
<table id="vtable" aria-label="Validators">
<thead><tr><th>Validator</th><th>Stake (ETH)</th><th>Share</th><th></th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<div class="actions">
<button id="select-btn" type="button">Select validator</button>
<button id="add-btn" type="button" class="ghost">Add validator</button>
</div>
<div id="result" class="result" aria-live="polite"></div>
<div class="log-label">Selection log</div>
<ul id="log" class="log"></ul>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.panel { max-width: 480px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; margin-bottom: .7rem; }
thead th { text-align: left; font-weight: 600; border-bottom: 2px solid #c5cdd6; padding: .3rem .4rem; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: #556; }
tbody td { padding: .38rem .4rem; border-bottom: 1px solid #e5e8ec; vertical-align: middle; }
.bar-wrap { background: #e5e8ec; border-radius: 4px; height: 10px; min-width: 80px; overflow: hidden; }
.bar { height: 10px; border-radius: 4px; transition: width .35s; }
.pct { font-size: .78rem; color: #556; min-width: 3.2em; display: inline-block; }
.slash-btn { font-size: .75rem; padding: .18rem .5rem; border: 1px solid #c92f3c; background: #fff; color: #c92f3c; border-radius: 5px; cursor: pointer; white-space: nowrap; }
.slash-btn:hover { background: #c92f3c; color: #fff; }
.slash-btn:disabled { opacity: .4; cursor: default; }
.actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .9rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.result { font-size: .95rem; font-weight: 600; min-height: 1.5em; padding: .35rem .6rem; border-radius: 6px; transition: background .2s; margin-bottom: .5rem; }
.result.selected { background: #d4f5e0; color: #0a5c2a; }
.log-label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #778; margin-bottom: .3rem; }
.log { list-style: none; padding: 0; margin: 0; font-size: .82rem; max-height: 90px; overflow-y: auto; color: #445; }
.log li { padding: .12rem 0; border-bottom: 1px solid #f0f2f4; }
.log li:last-child { border-bottom: none; }
tr.highlight td { background: #fff8c5; transition: background .6s; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Proof-of-stake weighted validator selection demo.
// Each validator is chosen with probability proportional to their stake.
// Slashing reduces a validator’s stake by 30% (simulating a protocol penalty).
const COLORS = ['#3a86ff','#06d6a0','#ffbe0b','#fb5607','#8338ec','#e63946'];
let validators = [
{ name: 'Alice', stake: 320 },
{ name: 'Bob', stake: 128 },
{ name: 'Carol', stake: 64 },
{ name: 'Dave', stake: 32 }
];
let nextId = validators.length;
let logCount = 0;
const SLASH_PCT = 0.30; // 30% slashed
function totalStake() { return validators.reduce((s, v) => s + v.stake, 0); }
// Weighted random selection: pick index proportional to stake.
function weightedSelect() {
const total = totalStake();
let r = Math.random() * total;
for (let i = 0; i < validators.length; i++) {
r -= validators[i].stake;
if (r <= 0) return i;
}
return validators.length - 1;
}
function render() {
const total = totalStake();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
validators.forEach((v, i) => {
const pct = total > 0 ? (v.stake / total * 100) : 0;
const color = COLORS[i % COLORS.length];
const tr = document.createElement('tr');
tr.id = 'row-' + i;
tr.innerHTML = "\n " + fmt(`<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:%color%;margin-right:5px;"></span>%name%</td>
<td>%stake%</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<div class="bar-wrap"><div class="bar" style="width:%pct%%;background:%color%;"></div></div>
<span class="pct">%pct%%</span>
</div>
</td>
<td><button class="slash-btn" data-idx="%i%" %v%>Slash</button></td>`, { color: color, name: v.name, stake: v.stake.toLocaleString(), pct: pct.toFixed(1), i: i, v: validators.length <= 1 ? 'disabled' : '' });
tbody.appendChild(tr);
});
// Wire slash buttons
document.querySelectorAll('.slash-btn').forEach(btn => {
btn.addEventListener('click', () => slash(parseInt(btn.dataset.idx)));
});
}
function slash(idx) {
const v = validators[idx];
const loss = Math.max(1, Math.round(v.stake * SLASH_PCT));
v.stake = Math.max(0, v.stake - loss);
if (v.stake === 0) {
addLog(fmt(`⚠️ %name% slashed to 0 and removed from validator set.`, { name: v.name }));
} else {
addLog(fmt(`⚠️ %name% slashed %loss% ETH → now %stake% ETH staked.`, { name: v.name, loss: loss, stake: v.stake }));
}
render();
clearResult();
}
function addLog(msg) {
logCount++;
const ul = document.getElementById('log');
const li = document.createElement('li');
li.textContent = '#' + logCount + ' ' + msg;
ul.prepend(li);
if (ul.children.length > 20) ul.lastChild.remove();
}
function clearResult() {
const r = document.getElementById('result');
r.textContent = '';
r.className = 'result';
}
document.getElementById('select-btn').addEventListener('click', () => {
if (validators.length === 0) return;
const idx = weightedSelect();
const v = validators[idx];
const total = totalStake();
const pct = (v.stake / total * 100).toFixed(1);
const r = document.getElementById('result');
r.textContent = fmt(`→ %name% selected (%pct%% of total stake)`, { name: v.name, pct: pct });
r.className = 'result selected';
addLog(fmt(`Block proposed by %name% (stake %stake% / %total% ETH = %pct%%)`, { name: v.name, stake: v.stake, total: total, pct: pct }));
// Flash highlight
const row = document.getElementById('row-' + idx);
if (row) {
row.classList.add('highlight');
setTimeout(() => row.classList.remove('highlight'), 700);
}
console.assert(idx >= 0 && idx < validators.length, `Selected index in range`);
});
document.getElementById('add-btn').addEventListener('click', () => {
const names = ['Eve','Frank','Grace','Hank','Iris','Jack','Kim','Leo'];
const name = names[nextId % names.length] + (nextId >= names.length ? ' ' + (Math.floor(nextId / names.length) + 1) : '');
const stake = Math.max(8, Math.round(32 + Math.random() * 96));
validators.push({ name, stake });
nextId++;
addLog(fmt(`+ %name% joined with %stake% ETH staked.`, { name: name, stake: stake }));
render();
clearResult();
});
render();
// Self-test: weighted selection should match probabilities over many draws.
(function selfTest() {
const counts = validators.map(() => 0);
const N = 10000;
for (let i = 0; i < N; i++) counts[weightedSelect()]++;
const total = totalStake();
let ok = true;
validators.forEach((v, i) => {
const expected = v.stake / total;
const actual = counts[i] / N;
if (Math.abs(actual - expected) > 0.03) ok = false;
});
console.assert(ok, `Weighted selection probabilities match stakes within 3%`);
console.log(`Self-test: weighted selection OK over`, N, `draws`);
})();
<div class="panel">
<table id="vtable" aria-label="Validators">
<thead><tr><th>Validator</th><th>Stake (ETH)</th><th>Share</th><th></th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<div class="actions">
<button id="select-btn" type="button">Select validator</button>
<button id="add-btn" type="button" class="ghost">Add validator</button>
</div>
<div id="result" class="result" aria-live="polite"></div>
<div class="log-label">Selection log</div>
<ul id="log" class="log"></ul>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.panel { max-width: 480px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; margin-bottom: .7rem; }
thead th { text-align: left; font-weight: 600; border-bottom: 2px solid #c5cdd6; padding: .3rem .4rem; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: #556; }
tbody td { padding: .38rem .4rem; border-bottom: 1px solid #e5e8ec; vertical-align: middle; }
.bar-wrap { background: #e5e8ec; border-radius: 4px; height: 10px; min-width: 80px; overflow: hidden; }
.bar { height: 10px; border-radius: 4px; transition: width .35s; }
.pct { font-size: .78rem; color: #556; min-width: 3.2em; display: inline-block; }
.slash-btn { font-size: .75rem; padding: .18rem .5rem; border: 1px solid #c92f3c; background: #fff; color: #c92f3c; border-radius: 5px; cursor: pointer; white-space: nowrap; }
.slash-btn:hover { background: #c92f3c; color: #fff; }
.slash-btn:disabled { opacity: .4; cursor: default; }
.actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .9rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.result { font-size: .95rem; font-weight: 600; min-height: 1.5em; padding: .35rem .6rem; border-radius: 6px; transition: background .2s; margin-bottom: .5rem; }
.result.selected { background: #d4f5e0; color: #0a5c2a; }
.log-label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #778; margin-bottom: .3rem; }
.log { list-style: none; padding: 0; margin: 0; font-size: .82rem; max-height: 90px; overflow-y: auto; color: #445; }
.log li { padding: .12rem 0; border-bottom: 1px solid #f0f2f4; }
.log li:last-child { border-bottom: none; }
tr.highlight td { background: #fff8c5; transition: background .6s; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Proof-of-stake weighted validator selection demo.
// Each validator is chosen with probability proportional to their stake.
// Slashing reduces a validator’s stake by 30% (simulating a protocol penalty).
const COLORS = ['#3a86ff','#06d6a0','#ffbe0b','#fb5607','#8338ec','#e63946'];
let validators = [
{ name: 'Alice', stake: 320 },
{ name: 'Bob', stake: 128 },
{ name: 'Carol', stake: 64 },
{ name: 'Dave', stake: 32 }
];
let nextId = validators.length;
let logCount = 0;
const SLASH_PCT = 0.30; // 30% slashed
function totalStake() { return validators.reduce((s, v) => s + v.stake, 0); }
// Weighted random selection: pick index proportional to stake.
function weightedSelect() {
const total = totalStake();
let r = Math.random() * total;
for (let i = 0; i < validators.length; i++) {
r -= validators[i].stake;
if (r <= 0) return i;
}
return validators.length - 1;
}
function render() {
const total = totalStake();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
validators.forEach((v, i) => {
const pct = total > 0 ? (v.stake / total * 100) : 0;
const color = COLORS[i % COLORS.length];
const tr = document.createElement('tr');
tr.id = 'row-' + i;
tr.innerHTML = "\n " + fmt(`<td><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:%color%;margin-right:5px;"></span>%name%</td>
<td>%stake%</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<div class="bar-wrap"><div class="bar" style="width:%pct%%;background:%color%;"></div></div>
<span class="pct">%pct%%</span>
</div>
</td>
<td><button class="slash-btn" data-idx="%i%" %v%>Slash</button></td>`, { color: color, name: v.name, stake: v.stake.toLocaleString(), pct: pct.toFixed(1), i: i, v: validators.length <= 1 ? 'disabled' : '' });
tbody.appendChild(tr);
});
// Wire slash buttons
document.querySelectorAll('.slash-btn').forEach(btn => {
btn.addEventListener('click', () => slash(parseInt(btn.dataset.idx)));
});
}
function slash(idx) {
const v = validators[idx];
const loss = Math.max(1, Math.round(v.stake * SLASH_PCT));
v.stake = Math.max(0, v.stake - loss);
if (v.stake === 0) {
addLog(fmt(`⚠️ %name% slashed to 0 and removed from validator set.`, { name: v.name }));
} else {
addLog(fmt(`⚠️ %name% slashed %loss% ETH → now %stake% ETH staked.`, { name: v.name, loss: loss, stake: v.stake }));
}
render();
clearResult();
}
function addLog(msg) {
logCount++;
const ul = document.getElementById('log');
const li = document.createElement('li');
li.textContent = '#' + logCount + ' ' + msg;
ul.prepend(li);
if (ul.children.length > 20) ul.lastChild.remove();
}
function clearResult() {
const r = document.getElementById('result');
r.textContent = '';
r.className = 'result';
}
document.getElementById('select-btn').addEventListener('click', () => {
if (validators.length === 0) return;
const idx = weightedSelect();
const v = validators[idx];
const total = totalStake();
const pct = (v.stake / total * 100).toFixed(1);
const r = document.getElementById('result');
r.textContent = fmt(`→ %name% selected (%pct%% of total stake)`, { name: v.name, pct: pct });
r.className = 'result selected';
addLog(fmt(`Block proposed by %name% (stake %stake% / %total% ETH = %pct%%)`, { name: v.name, stake: v.stake, total: total, pct: pct }));
// Flash highlight
const row = document.getElementById('row-' + idx);
if (row) {
row.classList.add('highlight');
setTimeout(() => row.classList.remove('highlight'), 700);
}
console.assert(idx >= 0 && idx < validators.length, `Selected index in range`);
});
document.getElementById('add-btn').addEventListener('click', () => {
const names = ['Eve','Frank','Grace','Hank','Iris','Jack','Kim','Leo'];
const name = names[nextId % names.length] + (nextId >= names.length ? ' ' + (Math.floor(nextId / names.length) + 1) : '');
const stake = Math.max(8, Math.round(32 + Math.random() * 96));
validators.push({ name, stake });
nextId++;
addLog(fmt(`+ %name% joined with %stake% ETH staked.`, { name: name, stake: stake }));
render();
clearResult();
});
render();
// Self-test: weighted selection should match probabilities over many draws.
(function selfTest() {
const counts = validators.map(() => 0);
const N = 10000;
for (let i = 0; i < N; i++) counts[weightedSelect()]++;
const total = totalStake();
let ok = true;
validators.forEach((v, i) => {
const expected = v.stake / total;
const actual = counts[i] / N;
if (Math.abs(actual - expected) > 0.03) ok = false;
});
console.assert(ok, `Weighted selection probabilities match stakes within 3%`);
console.log(`Self-test: weighted selection OK over`, N, `draws`);
})();
Try Slash on the leading validator: their stake drops, redistributing probability to the rest. Add validator introduces a new participant. Notice how adding small stake barely moves the needle for the giants — this is why concentration of stake is a real concern, and why many protocols impose caps or diminishing returns.
Comments
Loading comments...