Message Flow
The complete lifecycle of messages in MACI, from voter generating messages to Coordinator processing messages and generating proofs.
Message Lifecycle Overview
Voter Side: Message Generation
Prepare Vote Data
Voters first need to prepare data to vote on:
// Vote options
const selectedOptions = [
{ idx: 0, vc: 5 }, // 5 votes for option 0
{ idx: 1, vc: 3 }, // 3 votes for option 1
{ idx: 2, vc: 2 }, // 2 votes for option 2
];
// Verify voting weight
const totalCost = selectedOptions.reduce((sum, opt) => {
return sum + (isQV ? opt.vc * opt.vc : opt.vc);
}, 0);
if (totalCost > voiceCredits) {
throw new Error("Voting weight exceeds available credits");
}Construct and Pack Command
Vote commands are packed into a single bigint using fixed-width bit fields:
/**
* Pack command fields into a single bigint.
* Bit layout (little-endian, 256 bits total):
*
* bits 0 – 31 : nonce (32 bits)
* bits 32 – 63 : stateIdx (32 bits)
* bits 64 – 95 : voIdx (32 bits)
* bits 96 – 191 : newVotes (96 bits)
* bits 192 – 223 : pollId (32 bits)
* bits 224 – 255 : (unused)
*/
function packElement({
nonce, stateIdx, voIdx, newVotes, pollId
}: {
nonce: number | bigint;
stateIdx: number | bigint;
voIdx: number | bigint;
newVotes: number | bigint;
pollId: number | bigint;
}): bigint {
return (
BigInt(nonce) +
(BigInt(stateIdx) << 32n) +
(BigInt(voIdx) << 64n) +
(BigInt(newVotes) << 96n) +
(BigInt(pollId) << 192n)
);
}Sign and Encrypt
After packing, the command array is signed and then Poseidon-encrypted using an ECDH shared key derived from an ephemeral private key and the operator’s public key.
// 1. Query pollId from the contract
const pollId = await client.getPollId({ contractAddress });
// 2. Pack all command fields
const packaged = packElement({ nonce, stateIdx, voIdx, newVotes, pollId });
// 3. Build command array (7 elements)
// [0] packaged
// [1] newPubKey.x
// [2] newPubKey.y
// [3] salt
// [4] sig.R8.x
// [5] sig.R8.y
// [6] sig.S
const salt = genRandomSalt();
const hash = poseidon([packaged, newPubKey.x, newPubKey.y]);
const signature = eddsa.sign(voterPrivKey, hash);
const command = [packaged, newPubKey.x, newPubKey.y, salt, ...signature.R8, signature.S];
// 4. Generate ephemeral key-pair
const encPrivKey = genRandomPrivKey();
const encPubKey = genPubKey(encPrivKey);
// 5. Encrypt
const sharedKey = genEcdhSharedKey(encPrivKey, operatorPubKey);
const ciphertext = poseidonEncrypt(command, sharedKey, 0n);
// 6. Submit
const messageData = { data: ciphertext };
await maciContract.publishMessage({
messages: [messageData],
enc_pub_keys: [encPubKey],
});Complete Vote Message Generation Example
// Using the SDK
import { MaciClient } from '@doravote/sdk';
const client = new MaciClient({ ... });
await client.vote({
signer: wallet,
address: userAddress,
contractAddress,
selectedOptions: [
{ idx: 0, vc: 5 },
{ idx: 1, vc: 3 },
],
operatorCoordPubKey: [
BigInt(roundInfo.coordinatorPubkeyX),
BigInt(roundInfo.coordinatorPubkeyY)
],
maciKeypair,
});Coordinator Side: Message Processing
Download Messages
After voting period ends, Coordinator downloads all messages from chain:
async function downloadMessages(
contractAddress: string
): Promise<Message[]> {
// Query contract for all messages
const messages = await contract.getMessages();
console.log(`Downloaded ${messages.length} messages`);
return messages;
}Decrypt Messages
function decryptMessage(
message: Message,
coordinatorPrivateKey: bigint,
voterPublicKey: Point
): Command {
// 1. Generate ECDH shared key
const sharedKey = genEcdhSharedKey(
coordinatorPrivateKey,
voterPublicKey
);
// 2. Decrypt data
const decrypted = decrypt(message.data, sharedKey);
// 3. Unpack command
const command = unpackCommand(decrypted);
return command;
}Verify Signatures
function validateCommand(
command: Command,
voterPublicKey: Point
): boolean {
// 1. Recalculate command hash
const commandHash = hashCommand(command);
// 2. Verify EdDSA signature
const isValid = verifySignature(
commandHash,
command.signature,
voterPublicKey
);
if (!isValid) {
console.log("Signature verification failed");
return false;
}
return true;
}Process Messages in Order
async function processMessages(
messages: Message[],
coordinatorPrivateKey: bigint,
initialStateTree: MerkleTree
): Promise<ProcessingResult> {
const stateTree = initialStateTree.clone();
const processedCommands = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
// 1. Get voter state
const stateLeaf = stateTree.getLeaf(message.stateIndex);
const voterPubKey = stateLeaf.pubKey;
// 2. Decrypt
const command = decryptMessage(
message,
coordinatorPrivateKey,
voterPubKey
);
// 3. Verify signature
if (!validateCommand(command, voterPubKey)) {
console.log(`Message ${i}: Invalid signature, skip`);
continue;
}
// 4. Verify Nonce
if (command.nonce !== stateLeaf.nonce) {
console.log(`Message ${i}: Nonce mismatch, skip`);
continue;
}
// 5. Process command
const newStateLeaf = applyCommand(stateLeaf, command);
// 6. Update state tree
stateTree.update(command.stateIndex, newStateLeaf);
// 7. Record processed command
processedCommands.push(command);
console.log(`Message ${i}: Processed successfully`);
}
return {
newStateRoot: stateTree.getRoot(),
processedCommands: processedCommands,
stateTree: stateTree
};
}Update State
function applyCommand(
currentState: StateLeaf,
command: Command
): StateLeaf {
// 1. Update public key (if changed)
const newPubKey = command.newPubKey;
// 2. Calculate vote cost
const cost = calculateVoteCost(
command.newVoteWeight,
isQuadraticVoting
);
// 3. Check balance
if (cost > currentState.voiceCreditBalance) {
throw new Error("Insufficient balance");
}
// 4. Update vote option tree
const newVoTree = updateVoteOptionTree(
currentState.voteOptionTreeRoot,
command.voteOptionIndex,
command.newVoteWeight
);
// 5. Return new state
return {
pubKey: newPubKey,
voiceCreditBalance: currentState.voiceCreditBalance - cost,
voteOptionTreeRoot: newVoTree.getRoot(),
nonce: currentState.nonce + BigInt(1) // Nonce +1
};
}Zero-Knowledge Proof Generation
After processing all messages, Coordinator generates zero-knowledge proofs:
ProcessMessages Proof
async function generateProcessMessagesProof(
messages: Message[],
coordinatorPrivateKey: bigint,
initialStateRoot: bigint,
finalStateRoot: bigint
): Promise<Proof> {
// Prepare circuit inputs
const circuitInputs = {
// Public inputs
coordPubKey: genPublicKey(coordinatorPrivateKey),
msgRoot: computeMessageRoot(messages),
currentStateRoot: initialStateRoot,
newStateRoot: finalStateRoot,
// Private inputs
coordPrivKey: coordinatorPrivateKey,
messages: messages,
currentStateLeavesPathElements: [...],
newStateLeavesPathElements: [...],
// ... more inputs
};
// Call snarkjs or other ZK proof library
const proof = await generateProof(
'ProcessMessages',
circuitInputs
);
return proof;
}Tally Proof
async function generateTallyProof(
stateTree: MerkleTree,
results: bigint[]
): Promise<Proof> {
// Prepare circuit inputs
const circuitInputs = {
// Public inputs
stateRoot: stateTree.getRoot(),
tallyResult: results,
// Private inputs
stateLeaves: stateTree.getAllLeaves(),
statePathElements: [...],
// ... more inputs
};
// Generate proof
const proof = await generateProof(
'TallyVotes',
circuitInputs
);
return proof;
}Timeline Example
Here’s a timeline for a complete voting round:
Detailed Message Processing Example
Let’s understand the entire process through a specific example:
Scenario Setup
// 3 voters, 5 voting options
const voters = [
{ name: 'Alice', stateIdx: 0, voiceCredits: 100 },
{ name: 'Bob', stateIdx: 1, voiceCredits: 100 },
{ name: 'Carol', stateIdx: 2, voiceCredits: 100 },
];
const options = ['Option 0', 'Option 1', 'Option 2', 'Option 3', 'Option 4'];Voting Phase
// Alice votes (nonce=0)
Alice.vote([
{ idx: 0, vc: 5 }, // 5 votes for option 0 (costs 25 credits in QV)
{ idx: 1, vc: 3 }, // 3 votes for option 1 (costs 9 credits in QV)
]);
// Alice remaining: 100 - 25 - 9 = 66 credits
// Bob votes (nonce=0)
Bob.vote([
{ idx: 1, vc: 7 }, // 7 votes for option 1 (costs 49 credits in QV)
]);
// Bob remaining: 100 - 49 = 51 credits
// Alice changes mind (nonce=1)
Alice.vote([
{ idx: 2, vc: 8 }, // 8 votes for option 2 (costs 64 credits in QV)
]);
// Alice remaining: 100 - 64 = 36 credits (previous vote overwritten)
// Carol votes (nonce=0)
Carol.vote([
{ idx: 0, vc: 4 }, // 4 votes for option 0 (costs 16 credits in QV)
{ idx: 3, vc: 6 }, // 6 votes for option 3 (costs 36 credits in QV)
]);
// Carol remaining: 100 - 16 - 36 = 48 creditsProcessing Phase
// Coordinator processes messages
const messages = await downloadMessages();
// Message 1: Alice vote (nonce=0)
processMessage(messages[0]); // Valid
// State update: Alice nonce -> 1, option 0: +5, option 1: +3
// Message 2: Bob vote (nonce=0)
processMessage(messages[1]); // Valid
// State update: Bob nonce -> 1, option 1: +7
// Message 3: Alice revote (nonce=1)
processMessage(messages[2]); // Valid (nonce matches)
// State update: Alice nonce -> 2, option 0: 0, option 1: 0, option 2: +8
// Note: Alice's previous vote completely overwritten
// Message 4: Carol vote (nonce=0)
processMessage(messages[3]); // Valid
// State update: Carol nonce -> 1, option 0: +4, option 3: +6Final Results
const finalTally = {
'Option 0': 4, // Carol: 4
'Option 1': 7, // Bob: 7
'Option 2': 8, // Alice: 8
'Option 3': 6, // Carol: 6
'Option 4': 0, // No votes
};
console.log("Final vote results:", finalTally);Error Handling
Common Error Scenarios
1. Nonce Mismatch
// User submitted message with nonce=5, but current nonce=3
if (command.nonce !== currentState.nonce) {
console.log(`Message rejected: nonce=${command.nonce}, expected=${currentState.nonce}`);
continue; // Skip this message
}2. Invalid Signature
if (!verifySignature(commandHash, signature, voterPubKey)) {
console.log("Message rejected: Signature verification failed");
continue;
}3. Insufficient Balance
const cost = calculateVoteCost(command.newVoteWeight);
if (cost > currentState.voiceCreditBalance) {
console.log("Message rejected: Insufficient balance");
continue;
}4. Public Key Mismatch
// Message signed with old public key, but state already updated to new key
if (voterPubKey !== currentState.pubKey) {
console.log("Message rejected: Public key changed");
continue;
}Next Steps
After understanding the complete message flow, you can learn:
- Privacy Protection Mechanisms - Learn how voting privacy is protected
- Contract Design - Learn how contracts store and verify messages
- SDK Usage Guide - Use SDK to create and submit messages