Below is a minimal PBFT simulation with 4 replicas (3⋅1+1, so it can tolerate 1 traitor). The primary broadcasts a request, and the three phases — PRE-PREPARE, PREPARE, and COMMIT — unfold one step at a time. Toggle Byzantine on any replica to watch it send a conflicting message, then see whether the honest nodes still reach agreement.
<!-- PBFT demo layout: controls, node grid, event log. -->
<div class="hint">4 replicas tolerate 1 Byzantine (lying) node — that is the 3f+1 = 4 minimum. Click <b>Step</b> to advance through PRE-PREPARE → PREPARE → COMMIT. Toggle <b>Byzantine</b> on a non-primary replica to make it send a wrong value, then watch the quorum still agree.</div>
<div class="controls">
<label class="phase-label">Phase: <span id="phase-name" class="phase-badge">Idle</span></label>
<div class="btn-row">
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="net" id="network">
<!-- Replica nodes are injected here by JavaScript. -->
</div>
<div class="log" id="log">
<div class="log-title">Event log</div>
<div id="log-entries"></div>
</div>
/* PBFT demo styles: nodes, phases, badges, log. */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin-bottom: .7rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .8rem; }
.phase-label { font-weight: 600; font-size: .9rem; }
.phase-badge { display: inline-block; padding: .15rem .55rem; border-radius: 12px;
background: #e8eef3; color: #1d3557; font-size: .82rem; margin-left: .3rem; }
.phase-badge.pre { background: #dbeafe; color: #1e40af; }
.phase-badge.prep { background: #fef9c3; color: #854d0e; }
.phase-badge.comm { background: #dcfce7; color: #166534; }
.phase-badge.done { background: #f3e8ff; color: #6b21a8; }
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; }
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; }
.net { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: .9rem; }
.node { width: 120px; border: 2px solid #cdd9e3; border-radius: 10px; padding: 8px;
background: #f8fafc; display: flex; flex-direction: column; gap: 5px; }
.node.primary { border-color: #1d3557; }
.node.byzantine { border-color: #e63946; background: #fff5f5; }
.node.decided { background: #f0fdf4; border-color: #16a34a; }
.node-title { font-weight: 700; font-size: .85rem; display: flex; align-items: center; gap: 4px; }
.node-title .badge { font-size: .68rem; padding: .1rem .35rem; border-radius: 8px;
background: #e8eef3; color: #444; font-weight: 600; }
.node-title .badge.primary-badge { background: #dbeafe; color: #1e40af; }
.node-title .badge.byz-badge { background: #fee2e2; color: #991b1b; }
.node-title .badge.decided-badge { background: #dcfce7; color: #166534; }
.byz-toggle { display: flex; align-items: center; gap: 4px; font-size: .76rem; color: #555; cursor: pointer; }
.byz-toggle input { accent-color: #e63946; cursor: pointer; }
.node-inbox { font-size: .75rem; color: #555; min-height: 28px; line-height: 1.4; }
.node-inbox span { display: block; }
.msg-pre { color: #1e40af; }
.msg-prep { color: #854d0e; }
.msg-comm { color: #166534; }
.msg-byz { color: #991b1b; font-style: italic; }
.log { border: 1px solid #cdd9e3; border-radius: 8px; padding: 8px 10px; max-height: 120px;
overflow-y: auto; background: #f8fafc; }
.log-title { font-weight: 700; font-size: .8rem; color: #555; margin-bottom: 4px; }
.log-entry { font-size: .78rem; color: #333; border-bottom: 1px solid #eef0f2; padding: 2px 0; }
.log-entry.ok { color: #166534; }
.log-entry.err { color: #991b1b; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PBFT simulation: 4 replicas, f=1 Byzantine fault tolerance.
// Three phases: PRE-PREPARE (primary broadcasts), PREPARE (all-to-all), COMMIT (quorum commits).
// Replica descriptors. isPrimary = the view leader.
const REPLICAS = [
{ id: 0, label: 'R0', isPrimary: true, byzantine: false },
{ id: 1, label: 'R1', isPrimary: false, byzantine: false },
{ id: 2, label: 'R2', isPrimary: false, byzantine: false },
{ id: 3, label: 'R3', isPrimary: false, byzantine: false }
];
const F = 1; // number of tolerated Byzantine faults
const QUORUM = 2 * F + 1; // 2f+1 matching messages needed to proceed
// Protocol phases in order.
const PHASES = ['idle', 'pre-prepare', 'prepare', 'commit', 'done'];
const VALUE = 'COMMIT:TX-42'; // the correct value the primary proposes
const BYZ_VALUE = 'COMMIT:TX-99'; // conflicting value a Byzantine replica sends
let phaseIdx = 0;
// inbox[id] = list of messages received by replica id
let inbox = {};
// decided[id] = value decided by replica id, or null
let decided = {};
function byzNode() {
return REPLICAS.find(r => r.byzantine && !r.isPrimary) || null;
}
function resetState() {
phaseIdx = 0;
inbox = {};
decided = {};
REPLICAS.forEach(r => { inbox[r.id] = []; decided[r.id] = null; });
}
// Re-render all replica boxes.
function render() {
const net = document.getElementById('network');
net.innerHTML = '';
REPLICAS.forEach(r => {
const div = document.createElement('div');
const isByz = r.byzantine;
const isDec = decided[r.id] !== null;
div.className = 'node' + (r.isPrimary ? ' primary' : '') +
(isByz ? ' byzantine' : '') + (isDec ? ' decided' : '');
// Build status badge HTML.
let badgeHtml = '';
if (r.isPrimary) badgeHtml = `<span class="badge primary-badge">primary</span>`;
if (isByz) badgeHtml += `<span class="badge byz-badge">Byzantine</span>`;
if (isDec) badgeHtml += `<span class="badge decided-badge">decided</span>`;
// Byzantine toggle checkbox — only non-primary replicas can be toggled
const toggleHtml = !r.isPrimary ? "\n " + fmt(`<label class="byz-toggle">
<input type="checkbox" data-id="%id%" %v%> Byzantine
</label>`, { id: r.id, v: isByz ? 'checked' : '' }) : '';
// Render inbox messages with phase-coloring.
const msgs = inbox[r.id].map(m => {
const cls = m.type === 'pre-prepare' ? 'msg-pre' :
m.type === 'prepare' ? 'msg-prep' :
m.type === 'commit' ? 'msg-comm' : 'msg-byz';
return `<span class="${cls}">${m.from}: [${m.type}] ${m.value}</span>`;
}).join('');
div.innerHTML = `
<div class="node-title">${r.label} ${badgeHtml}</div>
${toggleHtml}
<div class="node-inbox">${msgs || '<span style="color:#aaa">no messages</span>'}</div>
`;
net.appendChild(div);
});
// Update the phase badge at the top.
const ph = PHASES[phaseIdx];
const badge = document.getElementById('phase-name');
badge.textContent = phaseLabel(ph);
badge.className = 'phase-badge ' +
(ph === 'pre-prepare' ? 'pre' : ph === 'prepare' ? 'prep' :
ph === 'commit' ? 'comm' : ph === 'done' ? 'done' : '');
// Wire up Byzantine toggle checkboxes after render.
net.querySelectorAll('input[data-id]').forEach(cb => {
cb.addEventListener('change', e => {
const id = parseInt(e.target.dataset.id);
REPLICAS[id].byzantine = e.target.checked;
render();
});
});
}
function phaseLabel(ph) {
if (ph === 'idle') return `Idle`;
if (ph === 'pre-prepare') return 'PRE-PREPARE';
if (ph === 'prepare') return 'PREPARE';
if (ph === 'commit') return 'COMMIT';
if (ph === 'done') return `Decided`;
return ph;
}
function addLog(msg, cls) {
const el = document.getElementById('log-entries');
const d = document.createElement('div');
d.className = 'log-entry ' + (cls || '');
d.textContent = msg;
el.appendChild(d);
el.scrollTop = el.scrollHeight;
}
// Advance one protocol phase and deliver the relevant messages.
function step() {
if (phaseIdx >= PHASES.length - 1) { addLog(`Already decided. Click Reset to start over.`); return; }
phaseIdx++;
const ph = PHASES[phaseIdx];
if (ph === 'pre-prepare') {
// PRIMARY broadcasts PRE-PREPARE to all non-primary replicas.
REPLICAS.forEach(r => {
if (!r.isPrimary) {
inbox[r.id].push({ from: 'R0', type: 'pre-prepare', value: VALUE });
}
});
addLog(`PRE-PREPARE: primary (R0) broadcasts value to all replicas.`);
} else if (ph === 'prepare') {
// All replicas (including primary) send PREPARE to every replica.
REPLICAS.forEach(sender => {
const val = sender.byzantine ? BYZ_VALUE : VALUE;
REPLICAS.forEach(r => {
inbox[r.id].push({ from: sender.label, type: 'prepare', value: val });
});
});
addLog(`PREPARE: each non-primary replica broadcasts its prepare (Byzantine replica sends wrong value).`);
} else if (ph === 'commit') {
// Replicas that have 2f+1 matching PREPAREs send COMMIT.
REPLICAS.forEach(r => {
const matching = inbox[r.id].filter(m => m.type === 'prepare' && m.value === VALUE);
if (matching.length >= QUORUM) {
REPLICAS.forEach(r2 => {
inbox[r2.id].push({ from: r.label, type: 'commit', value: VALUE });
});
}
});
addLog(`COMMIT: replicas that collected 3 matching PREPARE messages broadcast commit.`);
} else if (ph === 'done') {
// Replicas that have 2f+1 matching COMMITs decide.
let agreedCount = 0;
REPLICAS.forEach(r => {
const matching = inbox[r.id].filter(m => m.type === 'commit' && m.value === VALUE);
if (matching.length >= QUORUM) {
decided[r.id] = VALUE;
agreedCount++;
}
});
if (agreedCount >= QUORUM) {
addLog(fmt(`DECIDED: %agreedCount% replicas agreed on the correct value.`, { agreedCount: agreedCount }), 'ok');
} else {
addLog(`No agreement reached (too many Byzantine nodes).`, 'err');
}
}
render();
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-reset').addEventListener('click', () => {
resetState();
render();
document.getElementById('log-entries').innerHTML = '';
addLog(`Reset. Toggle Byzantine on a replica and step through again.`);
});
resetState();
render();
addLog(`Ready. Click Step to begin the PRE-PREPARE phase.`);
<!-- PBFT demo layout: controls, node grid, event log. -->
<div class="hint">4 replicas tolerate 1 Byzantine (lying) node — that is the 3f+1 = 4 minimum. Click <b>Step</b> to advance through PRE-PREPARE → PREPARE → COMMIT. Toggle <b>Byzantine</b> on a non-primary replica to make it send a wrong value, then watch the quorum still agree.</div>
<div class="controls">
<label class="phase-label">Phase: <span id="phase-name" class="phase-badge">Idle</span></label>
<div class="btn-row">
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="net" id="network">
<!-- Replica nodes are injected here by JavaScript. -->
</div>
<div class="log" id="log">
<div class="log-title">Event log</div>
<div id="log-entries"></div>
</div>
/* PBFT demo styles: nodes, phases, badges, log. */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin-bottom: .7rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .8rem; }
.phase-label { font-weight: 600; font-size: .9rem; }
.phase-badge { display: inline-block; padding: .15rem .55rem; border-radius: 12px;
background: #e8eef3; color: #1d3557; font-size: .82rem; margin-left: .3rem; }
.phase-badge.pre { background: #dbeafe; color: #1e40af; }
.phase-badge.prep { background: #fef9c3; color: #854d0e; }
.phase-badge.comm { background: #dcfce7; color: #166534; }
.phase-badge.done { background: #f3e8ff; color: #6b21a8; }
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; }
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; }
.net { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: .9rem; }
.node { width: 120px; border: 2px solid #cdd9e3; border-radius: 10px; padding: 8px;
background: #f8fafc; display: flex; flex-direction: column; gap: 5px; }
.node.primary { border-color: #1d3557; }
.node.byzantine { border-color: #e63946; background: #fff5f5; }
.node.decided { background: #f0fdf4; border-color: #16a34a; }
.node-title { font-weight: 700; font-size: .85rem; display: flex; align-items: center; gap: 4px; }
.node-title .badge { font-size: .68rem; padding: .1rem .35rem; border-radius: 8px;
background: #e8eef3; color: #444; font-weight: 600; }
.node-title .badge.primary-badge { background: #dbeafe; color: #1e40af; }
.node-title .badge.byz-badge { background: #fee2e2; color: #991b1b; }
.node-title .badge.decided-badge { background: #dcfce7; color: #166534; }
.byz-toggle { display: flex; align-items: center; gap: 4px; font-size: .76rem; color: #555; cursor: pointer; }
.byz-toggle input { accent-color: #e63946; cursor: pointer; }
.node-inbox { font-size: .75rem; color: #555; min-height: 28px; line-height: 1.4; }
.node-inbox span { display: block; }
.msg-pre { color: #1e40af; }
.msg-prep { color: #854d0e; }
.msg-comm { color: #166534; }
.msg-byz { color: #991b1b; font-style: italic; }
.log { border: 1px solid #cdd9e3; border-radius: 8px; padding: 8px 10px; max-height: 120px;
overflow-y: auto; background: #f8fafc; }
.log-title { font-weight: 700; font-size: .8rem; color: #555; margin-bottom: 4px; }
.log-entry { font-size: .78rem; color: #333; border-bottom: 1px solid #eef0f2; padding: 2px 0; }
.log-entry.ok { color: #166534; }
.log-entry.err { color: #991b1b; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PBFT simulation: 4 replicas, f=1 Byzantine fault tolerance.
// Three phases: PRE-PREPARE (primary broadcasts), PREPARE (all-to-all), COMMIT (quorum commits).
// Replica descriptors. isPrimary = the view leader.
const REPLICAS = [
{ id: 0, label: 'R0', isPrimary: true, byzantine: false },
{ id: 1, label: 'R1', isPrimary: false, byzantine: false },
{ id: 2, label: 'R2', isPrimary: false, byzantine: false },
{ id: 3, label: 'R3', isPrimary: false, byzantine: false }
];
const F = 1; // number of tolerated Byzantine faults
const QUORUM = 2 * F + 1; // 2f+1 matching messages needed to proceed
// Protocol phases in order.
const PHASES = ['idle', 'pre-prepare', 'prepare', 'commit', 'done'];
const VALUE = 'COMMIT:TX-42'; // the correct value the primary proposes
const BYZ_VALUE = 'COMMIT:TX-99'; // conflicting value a Byzantine replica sends
let phaseIdx = 0;
// inbox[id] = list of messages received by replica id
let inbox = {};
// decided[id] = value decided by replica id, or null
let decided = {};
function byzNode() {
return REPLICAS.find(r => r.byzantine && !r.isPrimary) || null;
}
function resetState() {
phaseIdx = 0;
inbox = {};
decided = {};
REPLICAS.forEach(r => { inbox[r.id] = []; decided[r.id] = null; });
}
// Re-render all replica boxes.
function render() {
const net = document.getElementById('network');
net.innerHTML = '';
REPLICAS.forEach(r => {
const div = document.createElement('div');
const isByz = r.byzantine;
const isDec = decided[r.id] !== null;
div.className = 'node' + (r.isPrimary ? ' primary' : '') +
(isByz ? ' byzantine' : '') + (isDec ? ' decided' : '');
// Build status badge HTML.
let badgeHtml = '';
if (r.isPrimary) badgeHtml = `<span class="badge primary-badge">primary</span>`;
if (isByz) badgeHtml += `<span class="badge byz-badge">Byzantine</span>`;
if (isDec) badgeHtml += `<span class="badge decided-badge">decided</span>`;
// Byzantine toggle checkbox — only non-primary replicas can be toggled
const toggleHtml = !r.isPrimary ? "\n " + fmt(`<label class="byz-toggle">
<input type="checkbox" data-id="%id%" %v%> Byzantine
</label>`, { id: r.id, v: isByz ? 'checked' : '' }) : '';
// Render inbox messages with phase-coloring.
const msgs = inbox[r.id].map(m => {
const cls = m.type === 'pre-prepare' ? 'msg-pre' :
m.type === 'prepare' ? 'msg-prep' :
m.type === 'commit' ? 'msg-comm' : 'msg-byz';
return `<span class="${cls}">${m.from}: [${m.type}] ${m.value}</span>`;
}).join('');
div.innerHTML = `
<div class="node-title">${r.label} ${badgeHtml}</div>
${toggleHtml}
<div class="node-inbox">${msgs || '<span style="color:#aaa">no messages</span>'}</div>
`;
net.appendChild(div);
});
// Update the phase badge at the top.
const ph = PHASES[phaseIdx];
const badge = document.getElementById('phase-name');
badge.textContent = phaseLabel(ph);
badge.className = 'phase-badge ' +
(ph === 'pre-prepare' ? 'pre' : ph === 'prepare' ? 'prep' :
ph === 'commit' ? 'comm' : ph === 'done' ? 'done' : '');
// Wire up Byzantine toggle checkboxes after render.
net.querySelectorAll('input[data-id]').forEach(cb => {
cb.addEventListener('change', e => {
const id = parseInt(e.target.dataset.id);
REPLICAS[id].byzantine = e.target.checked;
render();
});
});
}
function phaseLabel(ph) {
if (ph === 'idle') return `Idle`;
if (ph === 'pre-prepare') return 'PRE-PREPARE';
if (ph === 'prepare') return 'PREPARE';
if (ph === 'commit') return 'COMMIT';
if (ph === 'done') return `Decided`;
return ph;
}
function addLog(msg, cls) {
const el = document.getElementById('log-entries');
const d = document.createElement('div');
d.className = 'log-entry ' + (cls || '');
d.textContent = msg;
el.appendChild(d);
el.scrollTop = el.scrollHeight;
}
// Advance one protocol phase and deliver the relevant messages.
function step() {
if (phaseIdx >= PHASES.length - 1) { addLog(`Already decided. Click Reset to start over.`); return; }
phaseIdx++;
const ph = PHASES[phaseIdx];
if (ph === 'pre-prepare') {
// PRIMARY broadcasts PRE-PREPARE to all non-primary replicas.
REPLICAS.forEach(r => {
if (!r.isPrimary) {
inbox[r.id].push({ from: 'R0', type: 'pre-prepare', value: VALUE });
}
});
addLog(`PRE-PREPARE: primary (R0) broadcasts value to all replicas.`);
} else if (ph === 'prepare') {
// All replicas (including primary) send PREPARE to every replica.
REPLICAS.forEach(sender => {
const val = sender.byzantine ? BYZ_VALUE : VALUE;
REPLICAS.forEach(r => {
inbox[r.id].push({ from: sender.label, type: 'prepare', value: val });
});
});
addLog(`PREPARE: each non-primary replica broadcasts its prepare (Byzantine replica sends wrong value).`);
} else if (ph === 'commit') {
// Replicas that have 2f+1 matching PREPAREs send COMMIT.
REPLICAS.forEach(r => {
const matching = inbox[r.id].filter(m => m.type === 'prepare' && m.value === VALUE);
if (matching.length >= QUORUM) {
REPLICAS.forEach(r2 => {
inbox[r2.id].push({ from: r.label, type: 'commit', value: VALUE });
});
}
});
addLog(`COMMIT: replicas that collected 3 matching PREPARE messages broadcast commit.`);
} else if (ph === 'done') {
// Replicas that have 2f+1 matching COMMITs decide.
let agreedCount = 0;
REPLICAS.forEach(r => {
const matching = inbox[r.id].filter(m => m.type === 'commit' && m.value === VALUE);
if (matching.length >= QUORUM) {
decided[r.id] = VALUE;
agreedCount++;
}
});
if (agreedCount >= QUORUM) {
addLog(fmt(`DECIDED: %agreedCount% replicas agreed on the correct value.`, { agreedCount: agreedCount }), 'ok');
} else {
addLog(`No agreement reached (too many Byzantine nodes).`, 'err');
}
}
render();
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-reset').addEventListener('click', () => {
resetState();
render();
document.getElementById('log-entries').innerHTML = '';
addLog(`Reset. Toggle Byzantine on a replica and step through again.`);
});
resetState();
render();
addLog(`Ready. Click Step to begin the PRE-PREPARE phase.`);
Notice what the quorum counting does: each phase requires 2f+1 matching messages (f=1, so 3 matching messages). Even when the traitor sends a lie, the three honest nodes still accumulate enough identical messages to commit the same value.
Comments
Loading comments...