Step through a Tendermint consensus round with 5 validators (A–E). Each validator has equal voting power; a round needs ⌊32⋅5⌋+1=4 votes to proceed. You can toggle faulty nodes that send no messages.
<!-- Tendermint BFT consensus demo: propose → prevote → precommit → commit -->
<div class="tm-wrap">
<div class="tm-header">
<div class="tm-title">Round <span id="roundNum">1</span></div>
<div class="tm-phase" id="phaseLabel">Propose</div>
</div>
<div class="tm-validators" id="validatorRow" title="Click faulty toggles below to mark nodes as Byzantine"></div>
<div class="tm-controls">
<span class="tm-label">Mark faulty:</span>
<div id="faultyToggles" class="tm-toggles"></div>
</div>
<div class="tm-pipeline" id="pipeline">
<div class="tm-step" id="step-propose">
<div class="step-icon">📢</div>
<div class="step-name">Propose</div>
<div class="step-desc" id="desc-propose"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-prevote">
<div class="step-icon">🗳️</div>
<div class="step-name">Prevote</div>
<div class="step-desc" id="desc-prevote"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-precommit">
<div class="step-icon">🔒</div>
<div class="step-name">Precommit</div>
<div class="step-desc" id="desc-precommit"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-commit">
<div class="step-icon">✅</div>
<div class="step-name">Commit</div>
<div class="step-desc" id="desc-commit"></div>
</div>
</div>
<div class="tm-vote-grid" id="voteGrid"></div>
<div class="tm-status" id="statusMsg"></div>
<div class="tm-btns">
<button id="btnNext" type="button">Next phase →</button>
<button id="btnReset" type="button" class="ghost">New round</button>
</div>
<div class="tm-legend">
<span class="leg-item"><span class="leg-dot dot-ok"></span> Honest</span>
<span class="leg-item"><span class="leg-dot dot-fault"></span> Faulty</span>
<span class="leg-item"><span class="leg-dot dot-locked"></span> Locked</span>
</div>
</div>
/* Tendermint demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.tm-wrap { padding: .5rem; max-width: 520px; margin: 0 auto; }
.tm-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
.tm-title { font-weight: 700; font-size: 1rem; }
.tm-phase { font-size: .85rem; font-weight: 600; color: #1d3557; background: #dde8f5; padding: .2rem .6rem; border-radius: 12px; }
.tm-validators { display: flex; gap: .4rem; margin-bottom: .5rem; flex-wrap: wrap; }
.val-chip { display: flex; align-items: center; gap: .25rem; font-size: .82rem; font-weight: 700;
padding: .3rem .55rem; border-radius: 8px; background: #e3edf7; border: 2px solid #aac4de;
cursor: default; transition: all .15s; }
.val-chip.proposer { border-color: #1d3557; background: #1d3557; color: #fff; }
.val-chip.faulty { background: #fde8ea; border-color: #e63946; color: #c92f3c; text-decoration: line-through; }
.val-chip.locked { border-color: #e67e22; background: #fef5e7; }
.tm-controls { display: flex; align-items: center; gap: .5rem; margin-bottom: .6rem; font-size: .82rem; }
.tm-label { color: #555; }
.tm-toggles { display: flex; gap: .35rem; flex-wrap: wrap; }
.tog-btn { font-size: .78rem; font-weight: 700; padding: .2rem .45rem; border: 1px solid #adb1b8;
background: #f0f0f0; color: #333; border-radius: 6px; cursor: pointer; transition: all .1s; }
.tog-btn.active { background: #e63946; border-color: #c92f3c; color: #fff; }
.tm-pipeline { display: flex; align-items: flex-start; gap: .2rem; margin-bottom: .5rem; flex-wrap: wrap; }
.tm-step { flex: 1 1 80px; background: #f4f7fb; border: 1.5px solid #ccd6e0; border-radius: 10px;
padding: .4rem .3rem; text-align: center; transition: all .2s; min-width: 72px; }
.tm-step.active { border-color: #1d3557; background: #dde8f5; }
.tm-step.done { border-color: #0a7d33; background: #e6f5ec; }
.tm-step.failed { border-color: #e63946; background: #fde8ea; }
.step-icon { font-size: 1.1rem; }
.step-name { font-size: .72rem; font-weight: 700; margin: .15rem 0; color: #1d3557; }
.step-desc { font-size: .68rem; color: #555; min-height: 1.1em; }
.tm-arrow { font-size: 1rem; color: #aaa; align-self: center; margin-top: -.6rem; }
.tm-vote-grid { margin: .4rem 0; }
.vote-row { display: flex; align-items: center; gap: .3rem; margin: .18rem 0; font-size: .8rem; }
.vote-label { width: 30px; font-weight: 700; color: #333; }
.vote-phase { display: flex; gap: .25rem; }
.vote-dot { width: 18px; height: 18px; border-radius: 4px; display: flex; align-items: center;
justify-content: center; font-size: .65rem; font-weight: 700; border: 1.5px solid #ccc;
background: #eee; color: #999; }
.vote-dot.yes { background: #d4edda; border-color: #0a7d33; color: #0a7d33; }
.vote-dot.no { background: #fde8ea; border-color: #e63946; color: #e63946; }
.vote-dot.pending { background: #fff; border-color: #ccc; color: #ccc; }
.tm-status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; }
.tm-status.ok { color: #0a7d33; }
.tm-status.warn { color: #c07000; }
.tm-status.bad { color: #c92f3c; }
.tm-btns { display: flex; gap: .5rem; margin-bottom: .4rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.tm-legend { display: flex; gap: .7rem; flex-wrap: wrap; font-size: .75rem; color: #555; }
.leg-item { display: flex; align-items: center; gap: .3rem; }
.leg-dot { width: 12px; height: 12px; border-radius: 3px; border: 1.5px solid; }
.dot-ok { background: #e3edf7; border-color: #aac4de; }
.dot-fault { background: #fde8ea; border-color: #e63946; }
.dot-locked { background: #fef5e7; border-color: #e67e22; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tendermint consensus round simulator
// Safety: ≥2/3 of validators must vote yes for each phase to proceed.
const VALIDATORS = ['A','B','C','D','E'];
const QUORUM = 4; // need 4/5 votes (floor(2/3 * 5)+1)
const BLOCK_ID = 'Block #42'; // the block being proposed this round
const PHASES = ['propose','prevote','precommit','commit'];
// human-readable phase names keyed by phase id
const PHASE_LABELS = {
propose: 'Propose',
prevote: 'Prevote',
precommit: 'Precommit',
commit: 'Commit'
};
let faulty = new Set(); // set of validator ids currently marked faulty
let phaseIdx = 0;
let round = 1;
let prevoteCount = 0;
let precommitCount = 0;
let votes = {}; // vote[phase][validator] = true|false|null
let locked = new Set(); // validators that have prevoted (locked on the value)
function honest(v){ return !faulty.has(v); }
function proposer(){ return VALIDATORS[0]; } // always A in this simplified demo
function resetRound(){
// clear all phase state for a new round
phaseIdx = 0;
prevoteCount = 0;
precommitCount = 0;
votes = { prevote: {}, precommit: {} };
VALIDATORS.forEach(v => {
votes.prevote[v] = null;
votes.precommit[v] = null;
});
renderAll();
}
function buildToggles(){
// build faulty toggle buttons for each validator
const container = document.getElementById('faultyToggles');
container.innerHTML = '';
VALIDATORS.forEach(v => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tog-btn' + (faulty.has(v) ? ' active' : '');
btn.textContent = v;
btn.title = fmt('Toggle faulty: %v%', { v: v });
btn.addEventListener('click', () => {
if(faulty.has(v)) faulty.delete(v); else faulty.add(v);
resetRound();
buildToggles();
});
container.appendChild(btn);
});
}
function renderValidators(){
// render validator chips with proposer/faulty/locked styling
const row = document.getElementById('validatorRow');
row.innerHTML = '';
VALIDATORS.forEach(v => {
const chip = document.createElement('div');
chip.className = 'val-chip' +
(v === proposer() ? ' proposer' : '') +
(faulty.has(v) ? ' faulty' : '') +
(locked.has(v) ? ' locked' : '');
const icon = v === proposer() ? '👑' : (faulty.has(v) ? '✕' : '●');
chip.textContent = icon + ' ' + v;
row.appendChild(chip);
});
}
function renderPipeline(){
// render the four phase boxes with done/active/pending states
PHASES.forEach((ph, i) => {
const el = document.getElementById('step-' + ph);
const desc = document.getElementById('desc-' + ph);
el.classList.remove('active','done','failed');
if(i < phaseIdx){
el.classList.add('done');
if(ph === 'prevote') desc.textContent = fmt('%prevoteCount%/5 votes', { prevoteCount: prevoteCount });
else if(ph === 'precommit') desc.textContent = fmt('%precommitCount%/5 votes', { precommitCount: precommitCount });
else if(ph === 'propose') desc.textContent = BLOCK_ID;
else desc.textContent = '';
} else if(i === phaseIdx){
el.classList.add('active');
if(ph === 'prevote') desc.textContent = prevoteCount + '/5';
else if(ph === 'precommit') desc.textContent = precommitCount + '/5';
else desc.textContent = '';
} else {
desc.textContent = '';
}
});
const phLabel = document.getElementById('phaseLabel');
phLabel.textContent = PHASE_LABELS[PHASES[Math.min(phaseIdx, PHASES.length-1)]];
}
function renderVoteGrid(){
// render prevote and precommit dot grid
const grid = document.getElementById('voteGrid');
grid.innerHTML = '';
['prevote','precommit'].forEach(ph => {
const row = document.createElement('div');
row.className = 'vote-row';
const lbl = document.createElement('span');
lbl.className = 'vote-label';
lbl.textContent = ph === 'prevote' ? 'PV' : 'PC';
row.appendChild(lbl);
const dots = document.createElement('div');
dots.className = 'vote-phase';
VALIDATORS.forEach(v => {
const d = document.createElement('div');
const val = votes[ph][v];
d.className = 'vote-dot ' + (val === null ? 'pending' : (val ? 'yes' : 'no'));
d.textContent = val === null ? '?' : (val ? '✓' : '✗');
d.title = v + ': ' + (val === null ? 'pending' : (val ? 'voted yes' : 'voted no / nil'));
dots.appendChild(d);
});
row.appendChild(dots);
grid.appendChild(row);
});
}
function setStatus(msg, cls){
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.className = 'tm-status ' + (cls||'');
}
function renderAll(){
document.getElementById('roundNum').textContent = round;
renderValidators();
renderPipeline();
renderVoteGrid();
}
function stepPropose(){
// Phase 1: proposer broadcasts the block to all validators
if(honest(proposer())){
setStatus(fmt('Proposer A broadcast %BLOCK_ID%.', { BLOCK_ID: BLOCK_ID }), 'ok');
} else {
setStatus('Proposer A is faulty — validators will time out and move to the next round.', 'warn');
}
phaseIdx = 1;
}
function stepPrevote(){
// Phase 2: each honest validator prevotes for the block; faulty nodes send nil
prevoteCount = 0;
VALIDATORS.forEach(v => {
if(honest(v)){
votes.prevote[v] = true; // honest validator votes yes
prevoteCount++;
} else {
votes.prevote[v] = false; // faulty validator sends nil (treated as no)
}
});
if(prevoteCount >= QUORUM){
setStatus(fmt('Prevote phase passed: %prevoteCount%/5 honest votes.', { prevoteCount: prevoteCount }), 'ok');
// honest validators become locked on this value
VALIDATORS.filter(honest).forEach(v => locked.add(v));
phaseIdx = 2;
} else {
setStatus(fmt('Prevote phase stalled: %prevoteCount%/5 honest votes. Need %QUORUM%.', { prevoteCount: prevoteCount, QUORUM: QUORUM }), 'bad');
document.getElementById('btnNext').disabled = true;
}
}
function stepPrecommit(){
// Phase 3: each honest validator precommits if it saw ≥2/3 prevotes
precommitCount = 0;
VALIDATORS.forEach(v => {
if(honest(v)){
votes.precommit[v] = true; // honest validator precommits (already locked)
precommitCount++;
} else {
votes.precommit[v] = false; // faulty validator sends nil
}
});
if(precommitCount >= QUORUM){
setStatus(fmt('Precommit phase passed: %precommitCount%/5 honest votes.', { precommitCount: precommitCount }), 'ok');
phaseIdx = 3;
} else {
setStatus(fmt('Precommit phase stalled: %precommitCount%/5 honest votes. Need %QUORUM%.', { precommitCount: precommitCount, QUORUM: QUORUM }), 'bad');
document.getElementById('btnNext').disabled = true;
}
}
function stepCommit(){
// Phase 4: commit the block once ≥2/3 precommits are seen
phaseIdx = 4;
locked.clear();
setStatus(fmt('✓ Committed %BLOCK_ID% at height %round%. Instant finality — no rollback possible.', { BLOCK_ID: BLOCK_ID, round: round }), 'ok');
document.getElementById('btnNext').disabled = true;
}
function nextStep(){
document.getElementById('btnNext').disabled = false;
const ph = PHASES[phaseIdx];
if(ph === 'propose') stepPropose();
else if(ph === 'prevote') stepPrevote();
else if(ph === 'precommit') stepPrecommit();
else if(ph === 'commit') stepCommit();
renderAll();
}
document.getElementById('btnNext').addEventListener('click', nextStep);
document.getElementById('btnReset').addEventListener('click', () => {
round++;
locked.clear();
resetRound();
buildToggles();
setStatus(fmt('Starting round %round%.', { round: round }), '');
document.getElementById('btnNext').disabled = false;
});
buildToggles();
resetRound();
setStatus('Press "Next phase →" to start the consensus round.', '');
<!-- Tendermint BFT consensus demo: propose → prevote → precommit → commit -->
<div class="tm-wrap">
<div class="tm-header">
<div class="tm-title">Round <span id="roundNum">1</span></div>
<div class="tm-phase" id="phaseLabel">Propose</div>
</div>
<div class="tm-validators" id="validatorRow" title="Click faulty toggles below to mark nodes as Byzantine"></div>
<div class="tm-controls">
<span class="tm-label">Mark faulty:</span>
<div id="faultyToggles" class="tm-toggles"></div>
</div>
<div class="tm-pipeline" id="pipeline">
<div class="tm-step" id="step-propose">
<div class="step-icon">📢</div>
<div class="step-name">Propose</div>
<div class="step-desc" id="desc-propose"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-prevote">
<div class="step-icon">🗳️</div>
<div class="step-name">Prevote</div>
<div class="step-desc" id="desc-prevote"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-precommit">
<div class="step-icon">🔒</div>
<div class="step-name">Precommit</div>
<div class="step-desc" id="desc-precommit"></div>
</div>
<div class="tm-arrow">→</div>
<div class="tm-step" id="step-commit">
<div class="step-icon">✅</div>
<div class="step-name">Commit</div>
<div class="step-desc" id="desc-commit"></div>
</div>
</div>
<div class="tm-vote-grid" id="voteGrid"></div>
<div class="tm-status" id="statusMsg"></div>
<div class="tm-btns">
<button id="btnNext" type="button">Next phase →</button>
<button id="btnReset" type="button" class="ghost">New round</button>
</div>
<div class="tm-legend">
<span class="leg-item"><span class="leg-dot dot-ok"></span> Honest</span>
<span class="leg-item"><span class="leg-dot dot-fault"></span> Faulty</span>
<span class="leg-item"><span class="leg-dot dot-locked"></span> Locked</span>
</div>
</div>
/* Tendermint demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.tm-wrap { padding: .5rem; max-width: 520px; margin: 0 auto; }
.tm-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
.tm-title { font-weight: 700; font-size: 1rem; }
.tm-phase { font-size: .85rem; font-weight: 600; color: #1d3557; background: #dde8f5; padding: .2rem .6rem; border-radius: 12px; }
.tm-validators { display: flex; gap: .4rem; margin-bottom: .5rem; flex-wrap: wrap; }
.val-chip { display: flex; align-items: center; gap: .25rem; font-size: .82rem; font-weight: 700;
padding: .3rem .55rem; border-radius: 8px; background: #e3edf7; border: 2px solid #aac4de;
cursor: default; transition: all .15s; }
.val-chip.proposer { border-color: #1d3557; background: #1d3557; color: #fff; }
.val-chip.faulty { background: #fde8ea; border-color: #e63946; color: #c92f3c; text-decoration: line-through; }
.val-chip.locked { border-color: #e67e22; background: #fef5e7; }
.tm-controls { display: flex; align-items: center; gap: .5rem; margin-bottom: .6rem; font-size: .82rem; }
.tm-label { color: #555; }
.tm-toggles { display: flex; gap: .35rem; flex-wrap: wrap; }
.tog-btn { font-size: .78rem; font-weight: 700; padding: .2rem .45rem; border: 1px solid #adb1b8;
background: #f0f0f0; color: #333; border-radius: 6px; cursor: pointer; transition: all .1s; }
.tog-btn.active { background: #e63946; border-color: #c92f3c; color: #fff; }
.tm-pipeline { display: flex; align-items: flex-start; gap: .2rem; margin-bottom: .5rem; flex-wrap: wrap; }
.tm-step { flex: 1 1 80px; background: #f4f7fb; border: 1.5px solid #ccd6e0; border-radius: 10px;
padding: .4rem .3rem; text-align: center; transition: all .2s; min-width: 72px; }
.tm-step.active { border-color: #1d3557; background: #dde8f5; }
.tm-step.done { border-color: #0a7d33; background: #e6f5ec; }
.tm-step.failed { border-color: #e63946; background: #fde8ea; }
.step-icon { font-size: 1.1rem; }
.step-name { font-size: .72rem; font-weight: 700; margin: .15rem 0; color: #1d3557; }
.step-desc { font-size: .68rem; color: #555; min-height: 1.1em; }
.tm-arrow { font-size: 1rem; color: #aaa; align-self: center; margin-top: -.6rem; }
.tm-vote-grid { margin: .4rem 0; }
.vote-row { display: flex; align-items: center; gap: .3rem; margin: .18rem 0; font-size: .8rem; }
.vote-label { width: 30px; font-weight: 700; color: #333; }
.vote-phase { display: flex; gap: .25rem; }
.vote-dot { width: 18px; height: 18px; border-radius: 4px; display: flex; align-items: center;
justify-content: center; font-size: .65rem; font-weight: 700; border: 1.5px solid #ccc;
background: #eee; color: #999; }
.vote-dot.yes { background: #d4edda; border-color: #0a7d33; color: #0a7d33; }
.vote-dot.no { background: #fde8ea; border-color: #e63946; color: #e63946; }
.vote-dot.pending { background: #fff; border-color: #ccc; color: #ccc; }
.tm-status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; }
.tm-status.ok { color: #0a7d33; }
.tm-status.warn { color: #c07000; }
.tm-status.bad { color: #c92f3c; }
.tm-btns { display: flex; gap: .5rem; margin-bottom: .4rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.tm-legend { display: flex; gap: .7rem; flex-wrap: wrap; font-size: .75rem; color: #555; }
.leg-item { display: flex; align-items: center; gap: .3rem; }
.leg-dot { width: 12px; height: 12px; border-radius: 3px; border: 1.5px solid; }
.dot-ok { background: #e3edf7; border-color: #aac4de; }
.dot-fault { background: #fde8ea; border-color: #e63946; }
.dot-locked { background: #fef5e7; border-color: #e67e22; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Tendermint consensus round simulator
// Safety: ≥2/3 of validators must vote yes for each phase to proceed.
const VALIDATORS = ['A','B','C','D','E'];
const QUORUM = 4; // need 4/5 votes (floor(2/3 * 5)+1)
const BLOCK_ID = 'Block #42'; // the block being proposed this round
const PHASES = ['propose','prevote','precommit','commit'];
// human-readable phase names keyed by phase id
const PHASE_LABELS = {
propose: 'Propose',
prevote: 'Prevote',
precommit: 'Precommit',
commit: 'Commit'
};
let faulty = new Set(); // set of validator ids currently marked faulty
let phaseIdx = 0;
let round = 1;
let prevoteCount = 0;
let precommitCount = 0;
let votes = {}; // vote[phase][validator] = true|false|null
let locked = new Set(); // validators that have prevoted (locked on the value)
function honest(v){ return !faulty.has(v); }
function proposer(){ return VALIDATORS[0]; } // always A in this simplified demo
function resetRound(){
// clear all phase state for a new round
phaseIdx = 0;
prevoteCount = 0;
precommitCount = 0;
votes = { prevote: {}, precommit: {} };
VALIDATORS.forEach(v => {
votes.prevote[v] = null;
votes.precommit[v] = null;
});
renderAll();
}
function buildToggles(){
// build faulty toggle buttons for each validator
const container = document.getElementById('faultyToggles');
container.innerHTML = '';
VALIDATORS.forEach(v => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tog-btn' + (faulty.has(v) ? ' active' : '');
btn.textContent = v;
btn.title = fmt('Toggle faulty: %v%', { v: v });
btn.addEventListener('click', () => {
if(faulty.has(v)) faulty.delete(v); else faulty.add(v);
resetRound();
buildToggles();
});
container.appendChild(btn);
});
}
function renderValidators(){
// render validator chips with proposer/faulty/locked styling
const row = document.getElementById('validatorRow');
row.innerHTML = '';
VALIDATORS.forEach(v => {
const chip = document.createElement('div');
chip.className = 'val-chip' +
(v === proposer() ? ' proposer' : '') +
(faulty.has(v) ? ' faulty' : '') +
(locked.has(v) ? ' locked' : '');
const icon = v === proposer() ? '👑' : (faulty.has(v) ? '✕' : '●');
chip.textContent = icon + ' ' + v;
row.appendChild(chip);
});
}
function renderPipeline(){
// render the four phase boxes with done/active/pending states
PHASES.forEach((ph, i) => {
const el = document.getElementById('step-' + ph);
const desc = document.getElementById('desc-' + ph);
el.classList.remove('active','done','failed');
if(i < phaseIdx){
el.classList.add('done');
if(ph === 'prevote') desc.textContent = fmt('%prevoteCount%/5 votes', { prevoteCount: prevoteCount });
else if(ph === 'precommit') desc.textContent = fmt('%precommitCount%/5 votes', { precommitCount: precommitCount });
else if(ph === 'propose') desc.textContent = BLOCK_ID;
else desc.textContent = '';
} else if(i === phaseIdx){
el.classList.add('active');
if(ph === 'prevote') desc.textContent = prevoteCount + '/5';
else if(ph === 'precommit') desc.textContent = precommitCount + '/5';
else desc.textContent = '';
} else {
desc.textContent = '';
}
});
const phLabel = document.getElementById('phaseLabel');
phLabel.textContent = PHASE_LABELS[PHASES[Math.min(phaseIdx, PHASES.length-1)]];
}
function renderVoteGrid(){
// render prevote and precommit dot grid
const grid = document.getElementById('voteGrid');
grid.innerHTML = '';
['prevote','precommit'].forEach(ph => {
const row = document.createElement('div');
row.className = 'vote-row';
const lbl = document.createElement('span');
lbl.className = 'vote-label';
lbl.textContent = ph === 'prevote' ? 'PV' : 'PC';
row.appendChild(lbl);
const dots = document.createElement('div');
dots.className = 'vote-phase';
VALIDATORS.forEach(v => {
const d = document.createElement('div');
const val = votes[ph][v];
d.className = 'vote-dot ' + (val === null ? 'pending' : (val ? 'yes' : 'no'));
d.textContent = val === null ? '?' : (val ? '✓' : '✗');
d.title = v + ': ' + (val === null ? 'pending' : (val ? 'voted yes' : 'voted no / nil'));
dots.appendChild(d);
});
row.appendChild(dots);
grid.appendChild(row);
});
}
function setStatus(msg, cls){
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.className = 'tm-status ' + (cls||'');
}
function renderAll(){
document.getElementById('roundNum').textContent = round;
renderValidators();
renderPipeline();
renderVoteGrid();
}
function stepPropose(){
// Phase 1: proposer broadcasts the block to all validators
if(honest(proposer())){
setStatus(fmt('Proposer A broadcast %BLOCK_ID%.', { BLOCK_ID: BLOCK_ID }), 'ok');
} else {
setStatus('Proposer A is faulty — validators will time out and move to the next round.', 'warn');
}
phaseIdx = 1;
}
function stepPrevote(){
// Phase 2: each honest validator prevotes for the block; faulty nodes send nil
prevoteCount = 0;
VALIDATORS.forEach(v => {
if(honest(v)){
votes.prevote[v] = true; // honest validator votes yes
prevoteCount++;
} else {
votes.prevote[v] = false; // faulty validator sends nil (treated as no)
}
});
if(prevoteCount >= QUORUM){
setStatus(fmt('Prevote phase passed: %prevoteCount%/5 honest votes.', { prevoteCount: prevoteCount }), 'ok');
// honest validators become locked on this value
VALIDATORS.filter(honest).forEach(v => locked.add(v));
phaseIdx = 2;
} else {
setStatus(fmt('Prevote phase stalled: %prevoteCount%/5 honest votes. Need %QUORUM%.', { prevoteCount: prevoteCount, QUORUM: QUORUM }), 'bad');
document.getElementById('btnNext').disabled = true;
}
}
function stepPrecommit(){
// Phase 3: each honest validator precommits if it saw ≥2/3 prevotes
precommitCount = 0;
VALIDATORS.forEach(v => {
if(honest(v)){
votes.precommit[v] = true; // honest validator precommits (already locked)
precommitCount++;
} else {
votes.precommit[v] = false; // faulty validator sends nil
}
});
if(precommitCount >= QUORUM){
setStatus(fmt('Precommit phase passed: %precommitCount%/5 honest votes.', { precommitCount: precommitCount }), 'ok');
phaseIdx = 3;
} else {
setStatus(fmt('Precommit phase stalled: %precommitCount%/5 honest votes. Need %QUORUM%.', { precommitCount: precommitCount, QUORUM: QUORUM }), 'bad');
document.getElementById('btnNext').disabled = true;
}
}
function stepCommit(){
// Phase 4: commit the block once ≥2/3 precommits are seen
phaseIdx = 4;
locked.clear();
setStatus(fmt('✓ Committed %BLOCK_ID% at height %round%. Instant finality — no rollback possible.', { BLOCK_ID: BLOCK_ID, round: round }), 'ok');
document.getElementById('btnNext').disabled = true;
}
function nextStep(){
document.getElementById('btnNext').disabled = false;
const ph = PHASES[phaseIdx];
if(ph === 'propose') stepPropose();
else if(ph === 'prevote') stepPrevote();
else if(ph === 'precommit') stepPrecommit();
else if(ph === 'commit') stepCommit();
renderAll();
}
document.getElementById('btnNext').addEventListener('click', nextStep);
document.getElementById('btnReset').addEventListener('click', () => {
round++;
locked.clear();
resetRound();
buildToggles();
setStatus(fmt('Starting round %round%.', { round: round }), '');
document.getElementById('btnNext').disabled = false;
});
buildToggles();
resetRound();
setStatus('Press "Next phase →" to start the consensus round.', '');
Notice how the round advances only when enough votes accumulate. With 2 faulty nodes the protocol stalls — you need at least 3f+1 validators to tolerate f Byzantine faults. Tendermint's locking rule ensures that even if a round times out and a new proposer takes over, the same value will be proposed again until it is committed.
Comments
Loading comments...