Proposal

The Consensus::Proposal function is used whenever a consensus participant is able to produce a winning proof and wants to prove they're the current consensus leader and are eligible to propose a block. By itself, this smart contract has nothing to do with blocks themself, it is up to the leader to choose which transactions to include in the block they're proposing. The Consensus::Proposal function simply serves as a way to verify that the block proposer is indeed an eligible leader.

The parameters to execute this function are 1 anonymous input and 1 anonymous output, and other necessary metadata. Essentially we burn the winning coin, and mint a new one in order to compete in further slots. Every time a proposer wins the leader election, they have to burn their competing coin, prove they're the winner, and then mint a new coin that includes the block reward and is eligible to compete in upcoming future slots.

pub struct ConsensusProposalParamsV1 {
    /// Anonymous input
    pub input: ConsensusInput,
    /// Anonymous output
    pub output: ConsensusOutput,
    /// Reward value
    pub reward: u64,
    /// Revealed blinding factor for reward value
    pub reward_blind: pallas::Scalar,
    /// Extending fork last proposal/block hash
    pub fork_hash: blake3::Hash,
    /// Extending fork second to last proposal/block hash
    pub fork_previous_hash: blake3::Hash,
    /// VRF proof for eta calculation
    pub vrf_proof: VrfProof,
    /// Coin y
    pub y: pallas::Base,
    /// Lottery rho used
    pub rho: pallas::Base,
}

The ZK proof we use for this is a single circuit, ConsensusProposal_V1:

k = 13;
field = "pallas";

constant "ConsensusProposal_V1" {
	EcFixedPointShort VALUE_COMMIT_VALUE,
	EcFixedPoint VALUE_COMMIT_RANDOM,
	EcFixedPointBase NULLIFIER_K,
}

witness "ConsensusProposal_V1" {
	# Burnt coin secret key
	Base input_secret_key,
	# Unique serial number corresponding to the burnt coin
	Base input_serial,
	# The value of the burnt coin
	Base input_value,
	# The epoch the burnt coin was minted on
	Base epoch,
	# The reward value
	Base reward,
	# Random blinding factor for the value commitment
	Scalar input_value_blind,
	# Leaf position of the coin in the Merkle tree of coins
	Uint32 leaf_pos,
	# Merkle path to the coin
	MerklePath path,
	# Random blinding factor for the value commitment of the new coin
	Scalar output_value_blind,
	# Election seed y
	Base mu_y,
	# Election seed rho
	Base mu_rho,
	# Sigma1
	Base sigma1,
	# Sigma2
	Base sigma2,
	# Lottery headstart
	Base headstart,
}

circuit "ConsensusProposal_V1" {
	# Witnessed constants
	ZERO = witness_base(0);
	SERIAL_PREFIX = witness_base(2);
	SEED_PREFIX = witness_base(3);
	SECRET_PREFIX = witness_base(4);

	# =============
	# Burn old coin
	# =============

	# Poseidon hash of the nullifier
	nullifier = poseidon_hash(input_secret_key, input_serial);
	constrain_instance(nullifier);

	# Constrain the epoch this coin was minted on.
	# We use this as our timelock mechanism.
	constrain_instance(epoch);

	# We derive the coin's public key for the signature and
	# VRF proof verification and constrain its coordinates:
	input_pub = ec_mul_base(input_secret_key, NULLIFIER_K);
	pub_x = ec_get_x(input_pub);
	pub_y = ec_get_y(input_pub);
	constrain_instance(pub_x);
	constrain_instance(pub_y);

	# Construct the burned coin
	C = poseidon_hash(
		pub_x,
		pub_y,
		input_value,
		epoch,
		input_serial,
	);

	# Merkle inclusion proof
	root = merkle_root(leaf_pos, path, C);
	constrain_instance(root);
	
	# Pedersen commitment for burned coin's value
	vcv = ec_mul_short(input_value, VALUE_COMMIT_VALUE);
	vcr = ec_mul(input_value_blind, VALUE_COMMIT_RANDOM);
	value_commit = ec_add(vcv, vcr);
	# Since value_commit is a curve point, we fetch its coordinates
	# and constrain them:
	constrain_instance(ec_get_x(value_commit));
	constrain_instance(ec_get_y(value_commit));

	# =============
	# Mint new coin
	# =============

	# Constrain reward value
	constrain_instance(reward);

	# Pedersen commitment for new coin's value (old value + reward)
	output_value = base_add(input_value, reward);
	nvcv = ec_mul_short(output_value, VALUE_COMMIT_VALUE);
	nvcr = ec_mul(output_value_blind, VALUE_COMMIT_RANDOM);
	output_value_commit = ec_add(nvcv, nvcr);
	# Since the new value commit is also a curve point, we'll do the same
	# coordinate dance:
	constrain_instance(ec_get_x(output_value_commit));
	constrain_instance(ec_get_y(output_value_commit));

	# The serial of the new coin is derived from the old coin
	output_serial = poseidon_hash(SERIAL_PREFIX, input_secret_key, input_serial);

	# The secret key of the new coin is derived from old coin
	output_secret_key = poseidon_hash(SECRET_PREFIX, input_secret_key);
	output_pub = ec_mul_base(output_secret_key, NULLIFIER_K);
	output_pub_x = ec_get_x(output_pub);
	output_pub_y = ec_get_y(output_pub);
        
	# Poseidon hash of the new coin
	# In here we set the new epoch as ZERO, thus removing a
	# potentially existing timelock.
	output_coin = poseidon_hash(
		output_pub_x,
		output_pub_y,
		output_value,
		ZERO,
		output_serial,
	);
	constrain_instance(output_coin);

	# ============================
	# Constrain lottery parameters
	# ============================

	# Coin y, constructed with the old serial for seeding:
	seed = poseidon_hash(SEED_PREFIX, input_serial);
	y = poseidon_hash(seed, mu_y);
	constrain_instance(mu_y);
	constrain_instance(y);

	# Coin rho (seed):
	rho = poseidon_hash(seed, mu_rho);
	constrain_instance(mu_rho);
	constrain_instance(rho);

	# Calculate lottery target
	term_1 = base_mul(sigma1, input_value);
	term_2 = base_mul(sigma2, input_value);
	shifted_term_2 = base_mul(term_2, input_value);
	target = base_add(term_1, shifted_term_2);
	shifted_target = base_add(target, headstart);
	constrain_instance(sigma1);
	constrain_instance(sigma2);
	constrain_instance(headstart);

	# Play lottery
	less_than_strict(y, shifted_target);

	# At this point we've enforced all of our public inputs.
}

Contract logic

get_metadata()

In the consensus_proposal_get_metadata_v1 function, we gather the necessary metadata that we use to verify the ZK proof and the transaction signature. Inside this function, we also verify the VRF proof executed by the proposer using a deterministic input and the proposer's revealed public key. This public key is derived from the input (burned) coin in ZK and is also used to sign the entire transaction.

process_instruction()

In the consensus_proposal_process_instruction_v1 function, we perform the state transition. We enforce that:

  • The timelock of the burned coin has passed and the coin is eligible to compete
  • The Merkle inclusion proof of the burned coin is valid
  • The revealed nullifier of the burned coin has not been seen before
  • The value commitments match, this is done as input+reward=output
  • The newly minted coin was not seen before

If these checks pass, we create a state update with the burned nullifier and the minted coin:

pub struct ConsensusProposalUpdateV1 {
    /// Revealed nullifier
    pub nullifier: Nullifier,
    /// The newly minted coin
    pub coin: Coin,
}

process_update()

For the state update, we use the consensus_proposal_process_update_v1 function. This takes the state update produced by consensus_proposal_process_instruction_v1 and appends the new nullifier to the set of seen nullifiers, adds the minted coin to the set of coins and appends it to the Merkle tree of all coins in the consensus state.