Skip to Content
ProtocolMessage Flow

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 Command

function createVoteCommand( stateIndex: number, nonce: number, voteOption: { idx: number; vc: number }, currentPubKey: Point, salt: bigint ): Command { return { nonce: BigInt(nonce), stateIndex: BigInt(stateIndex), voteOptionIndex: BigInt(voteOption.idx), newVoteWeight: BigInt(voteOption.vc), newPubKey: currentPubKey, // Usually unchanged salt: salt }; }

Pack and Hash

// Pack command fields function packCommand(command: Command): bigint { // Pack 5 fields into one bigint let packed = BigInt(0); // nonce (8 bits) packed |= (command.nonce & BigInt(0xFF)); // stateIndex (24 bits) packed |= ((command.stateIndex & BigInt(0xFFFFFF)) << BigInt(8)); // voteOptionIndex (8 bits) packed |= ((command.voteOptionIndex & BigInt(0xFF)) << BigInt(32)); // newVoteWeight (24 bits) packed |= ((command.newVoteWeight & BigInt(0xFFFFFF)) << BigInt(40)); // salt (remaining bits) packed |= (command.salt << BigInt(64)); return packed; } // Calculate command hash function hashCommand(command: Command): bigint { const packed = packCommand(command); return poseidon([ packed, command.newPubKey[0], command.newPubKey[1] ]); }

Sign

// Sign command hash with private key const commandHash = hashCommand(command); const signature = sign(voterPrivateKey, commandHash); // Signature structure // signature = { // R8: [x, y], // Point on curve // S: bigint // Scalar // }

Encrypt

// Generate ECDH shared key const sharedKey = genEcdhSharedKey( voterPrivateKey, coordinatorPublicKey ); // Encrypt command const encryptedData = encrypt( { packed: packCommand(command), newPubKeyX: command.newPubKey[0], newPubKeyY: command.newPubKey[1], signatureR8X: signature.R8[0], signatureR8Y: signature.R8[1], signatureS: signature.S }, sharedKey );

Submit On-chain

// Construct message const message = { msgType: 1, // 1 = vote message data: encryptedData // 10 encrypted fields }; // Submit to MACI contract await maciContract.publishMessage(message);

Complete Vote Message Generation Example

async function generateAndSubmitVote( voterAccount: { privateKey: bigint; publicKey: Point; stateIndex: number; nonce: number; }, coordinatorPubKey: Point, contractAddress: string, voteOptions: { idx: number; vc: number }[] ) { // Generate one message per vote option for (const option of voteOptions) { // 1. Generate random salt const salt = genRandomSalt(); // 2. Create command const command = { nonce: BigInt(voterAccount.nonce), stateIndex: BigInt(voterAccount.stateIndex), voteOptionIndex: BigInt(option.idx), newVoteWeight: BigInt(option.vc), newPubKey: voterAccount.publicKey, salt: salt }; // 3. Calculate hash const commandHash = hashCommand(command); // 4. Sign const signature = sign(voterAccount.privateKey, commandHash); // 5. ECDH shared key const sharedKey = genEcdhSharedKey( voterAccount.privateKey, coordinatorPubKey ); // 6. Encrypt const encryptedData = encrypt( command, signature, sharedKey ); // 7. Construct message const message = { msgType: 1, data: encryptedData }; // 8. Submit on-chain const tx = await contract.publishMessage(message); await tx.wait(); console.log(`Message ${voterAccount.nonce} submitted`); // 9. Increment local nonce (Note: on-chain nonce only updates during processing) voterAccount.nonce++; } }

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 credits

Processing 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: +6

Final 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:

Last updated on