Class ValidatingLedger

A ledger that participate in the consensus protocol

class ValidatingLedger
  : NodeLedger ;

This ledger is held by validators, as they need to do additional bookkeeping when e.g. proposing transactions.

Constructors

NameDescription
this (params, database, storage, enroll_man, pool, onAcceptedBlock) See parent class

Fields

NameTypeDescription
nominated_tx_sets geod24.bitblob.BitBlob!(64L)[][geod24.bitblob.BitBlob!(64L)]Nominated TX sets
params immutable(ConsensusParams)Consensus-critical constants used by this Ledger
sig_missing_heights Set!(ulong)Keep track of missing signatures since last paid out block in a set
cached_coinbase Ledger.CachedCoinbaseCache for Coinbase tx to be used during payout height
engine EngineScript execution engine
enroll_man EnrollmentManagerEnrollment manager
fee_man FeeManagerThe checker of transaction data payload
frozen_utxos UTXOTrackerThe list of frozen UTXOs known to this Ledger
last_block BlockThe last block in the ledger
log LoggerLogger instance
onAcceptedBlock @safe void delegate(in ref Block, bool)A delegate to be called when a block was externalized (unless null)
pool TransactionPoolPool of transactions to pick from when generating blocks
rewards RewardBlock rewards calculator
stateDB ManagedDatabaseThe database in which the Ledger state is stored
storage IBlockStoragedata storage for all the blocks
unknown_txs Set!(geod24.bitblob.BitBlob!(64L).BitBlob)Hashes of transactions the Ledger encountered but doesn't have in the pool
utxo_set UTXOCacheUTXO set
validator_set ValidatorSetThe object controlling the validator set

Methods

NameDescription
acceptBlock (block) See Ledger.acceptBlock
getCandidateMissingValidators (height, findUTXO)
getCandidateTransactions (height, utxo_finder)
getValidTXSet (data, tx_set) Get the valid TX set that data is representing
isValidTXSet (data) Get the valid TX set that data is representing
prepareNominatingSet (data) Collect up to a maximum number of transactions to nominate
validateConsensusData (data, initial_missing_validators) Check whether the consensus data is valid.
validateSlashingData (height, data, initial_missing_validators) Validate slashing data, including checking if the node is slef slashing
acceptTransaction (tx, double_spent_threshold_pct, min_fee_pct) Called when a new transaction is received.
addPreimage (preimage) Add a pre-image information to the validator data
buildBlock (txs, enrollments, missing_validators) Create a new block based on the current previous block.
expectedHeight (utcTime) Get the Height this Ledger should be at if fully synchronized
getBlocks (rng) Get a range of blocks, starting from the provided block height.
getBlocksFrom (start) Get a range of blocks from start to Ledger.height
getCandidateEnrollments (height, utxo_finder)
getDoubleSpentHighestFee (tx) Returns the highest fee among all the transactions which would be considered as a double spent, if tx transaction was in the transaction pool.
getEnrolledUTXOs (height)
getExpectedBlockTime (height) Gets the expected block nomination time offset from Genesis start time.
getLastPaidHeight () return the last paid out block before the current block
getPenaltyDeposit (utxo)
getStakes () Get a set of all the stakes currently active at this height
getTransactionByHash (hash) Get a transaction from pool by hash
getTxFeeRate (tx, rate) Forwards to FeeManager.getTxFeeRate, using this Ledger's UTXO.
getTxFeeRate (tx_hash, rate) Looks up transaction with hash tx_hash, then forwards to FeeManager.getTxFeeRate, using this Ledger's UTXO.
getUnknownTXHashes () Get a set of TX Hashes that Ledger is missing
getUnknownTXsFromSet (hashes)
getUTXOFinder () Prepare tracking double-spent transactions and return the UTXOFinder delegate
getValidators (height, empty) Expose the list of validators at a given height
hasMajoritySignature (header)
height () Returns the height at which this Ledger is currently at
isAcceptableDoubleSpent (tx, threshold_pct) Checks whether the tx is an acceptable double spend transaction.
isCoinbaseBlock (height) Check if this is the height for payouts of a payout period
isRelatedRewardBlock (reward_height, coinbase_height) Check if block is related to given payout block
isStake (hash, utxo)
lastBlock () Returns a reference to the last block in the Ledger
peekUTXO (utxo, value) Get an UTXO, no double-spend protection.
updateBlockMultiSig (header) Update the Schnorr multi-signature for an externalized block in the Ledger.
utxos () Expose an object that interacts with the UTXO set
validateBlock (block) Check whether the block is valid.
validateBlockSignature (header) Validate the signature of a block
validatorCount (height) Get the count of active validators at a given height.
addValidatedBlock (block) See Ledger.addValidatedBlock`
applySlashing (header) Apply slashing to the current state
getCoinbaseTX (height) Create the Coinbase transaction for this payout block and append it to the transaction set
handleNotSignedByMajority (header, validators) Used to handle behaviour when less than half the validators have signed the block. This is overridden in the ValidatingLedger
replayStoredBlock (block) Update the ledger state from a block which was read from storage
updateUTXOSet (block) See Ledger.updateUTXOSet
updateValidatorSet (block) See Ledger.updateValidatorSet

Inner structs

NameDescription
CachedCoinbase Cache for Coinbase tx to be used during payout height

Enums

NameDescription
InvalidConsensusDataReason Error message describing the reason of validation failure

Example

scope ledger = new TestLedger(WK.Keys.NODE3);
assert(ledger.height() == 0);

auto blocks = ledger.getBlocksFrom(Height(0)).take(10).array;
assert(blocks[$ - 1] == ledger.params.Genesis);

Transaction[] last_txs;
void genBlockTransactions (size_t count)
{
    foreach (_; 0 .. count)
        last_txs = ledger.makeTestBlock(last_txs);
}

genBlockTransactions(2);
blocks = ledger.getBlocksFrom(Height(0)).take(10).array;
assert(blocks[0] == ledger.params.Genesis);
assert(blocks.length == 3);  // two blocks + genesis block

/// now generate 98 more blocks to make it 100 + genesis block (101 total)
genBlockTransactions(98);
assert(ledger.height() == 100);

blocks = ledger.getBlocksFrom(Height(0)).takeExactly(10).array;
assert(blocks[0] == ledger.params.Genesis);
assert(blocks.length == 10);

/// lower limit
blocks = ledger.getBlocksFrom(Height(0)).takeExactly(5).array;
assert(blocks[0] == ledger.params.Genesis);
assert(blocks.length == 5);

/// different indices
blocks = ledger.getBlocksFrom(Height(1)).takeExactly(10).array;
assert(blocks[0].header.height == 1);
assert(blocks.length == 10);

blocks = ledger.getBlocksFrom(Height(50)).takeExactly(10).array;
assert(blocks[0].header.height == 50);
assert(blocks.length == 10);

blocks = ledger.getBlocksFrom(Height(95)).take(10).array;  // only 6 left from here (block 100 included)
assert(blocks.front.header.height == 95);
assert(blocks.walkLength() == 6);

blocks = ledger.getBlocksFrom(Height(99)).take(10).array;  // only 2 left from here (ditto)
assert(blocks.front.header.height == 99);
assert(blocks.walkLength() == 2);

blocks = ledger.getBlocksFrom(Height(100)).take(10).array;  // only 1 block available
assert(blocks.front.header.height == 100);
assert(blocks.walkLength() == 1);

// over the limit => return up to the highest block
assert(ledger.getBlocksFrom(Height(0)).take(1000).walkLength() == 101);

// higher index than available => return nothing
assert(ledger.getBlocksFrom(Height(1000)).take(10).walkLength() == 0);

Example

basic block verification

scope ledger = new TestLedger(genesis_validator_keys[0]);

Block invalid_block;  // default-initialized should be invalid
assert(ledger.acceptBlock(invalid_block));

Example

Situation

Ledger is constructed with blocks present in storage

Expectation

The UTXOSet is populated with all up-to-date UTXOs

import agora.consensus.data.genesis.Test;

const(Block)[] blocks = [
    GenesisBlock,
    makeNewTestBlock(GenesisBlock, GenesisBlock.spendable().map!(txb => txb.sign()))
];
// Make 3 more blocks to put in storage
foreach (idx; 2 .. 5)
{
    blocks ~= makeNewTestBlock(
        blocks[$ - 1],
        blocks[$ - 1].spendable().map!(txb => txb.sign()));
}

// And provide it to the ledger
scope ledger = new TestLedger(genesis_validator_keys[0], blocks);

assert(ledger.utxo_set.length
       == /* Genesis, Frozen */ 12 + 8 /* Block #1 Payments*/);

// Ensure that all previously-generated outputs are in the UTXO set
{
    auto findUTXO = ledger.getUTXOFinder();
    UTXO utxo;
    assert(
        blocks[$ - 1].txs.all!(
            tx => iota(tx.outputs.length).all!(
                (idx) {
                    return findUTXO(UTXO.getHash(tx.hashFull(), idx), utxo) &&
                        utxo.output == tx.outputs[idx];
                }
            )
        )
    );
}

Example

test enrollments in the genesis block

import std.exception : assertThrown;

// Default test genesis block has 6 validators
{
    scope ledger = new TestLedger(WK.Keys.A);
    assert(ledger.getValidators(Height(1)).length == 6);
}

// One block before `ValidatorCycle`, validator is still active
{
    const ValidatorCycle = 20;
    auto params = new immutable(ConsensusParams)(ValidatorCycle);
    const blocks = genBlocksToIndex(ValidatorCycle - 1, params);
    scope ledger = new TestLedger(WK.Keys.A, blocks, params);
    Hash[] keys;
    assert(ledger.getValidators(Height(ValidatorCycle)).length == 6);
}

// Past `ValidatorCycle`, validator is inactive
{
    const ValidatorCycle = 20;
    auto params = new immutable(ConsensusParams)(ValidatorCycle);
    const blocks = genBlocksToIndex(ValidatorCycle, params);
    // Enrollment: Insufficient number of active validators
    auto ledger = new TestLedger(WK.Keys.A, blocks, params);
    assertThrown(ledger.getValidators(Height(ValidatorCycle + 1)));
}

Example

test atomicity of adding blocks and rolling back

import std.conv;
import std.exception : assertThrown;
import core.stdc.time : time;

static class ThrowingLedger : Ledger
{
    bool throw_in_update_utxo;
    bool throw_in_update_validators;

    public this (KeyPair kp, const(Block)[] blocks, immutable(ConsensusParams) params)
    {
        auto stateDB = new ManagedDatabase(":memory:");
        ValidatorConfig vconf = ValidatorConfig(true, kp);
        super(params,
            stateDB,
            new MemBlockStorage(blocks),
            new ValidatorSet(stateDB, params));
    }

    ///
    protected override void replayStoredBlock (in Block block) @safe
    {
        if (block.header.height > 0)
            this.simulatePreimages(block.header.height);
        super.replayStoredBlock(block);
    }

    override void updateUTXOSet (in Block block) @safe
    {
        super.updateUTXOSet(block);
        if (this.throw_in_update_utxo)
            throw new Exception("");
    }

    override void updateValidatorSet (in Block block) @safe
    {
        super.updateValidatorSet(block);
        if (this.throw_in_update_validators)
            throw new Exception("");
    }

    /// Expose the UTXO set to this module
    @property public UTXOCache utxo_set () @safe pure nothrow @nogc
    {
        return super.utxo_set;
    }
}

const params = new immutable(ConsensusParams)();

// throws in updateUTXOSet() => rollback() called, UTXO set reverted,
// Validator set was not modified
{
    const blocks = genBlocksToIndex(params.ValidatorCycle, params);
    assert(blocks.length == params.ValidatorCycle + 1);  // +1 for genesis

    scope ledger = new ThrowingLedger(
        WK.Keys.A, blocks.takeExactly(params.ValidatorCycle), params);
    assert(ledger.getValidators(Height(params.ValidatorCycle)).length == 6);
    auto utxos = ledger.utxo_set.getUTXOs(WK.Keys.Genesis.address);
    assert(utxos.length == 8);
    utxos.each!(utxo => assert(utxo.unlock_height == params.ValidatorCycle));

    ledger.throw_in_update_utxo = true;
    auto next_block = blocks[$ - 1];
    assertThrown!Exception(assert(ledger.acceptBlock(next_block) is null));
    assert(ledger.lastBlock() == blocks[$ - 2]);  // not updated
    utxos = ledger.utxo_set.getUTXOs(WK.Keys.Genesis.address);
    assert(utxos.length == 8);
    utxos.each!(utxo => assert(utxo.unlock_height == params.ValidatorCycle));  // reverted
    // not updated
    assert(ledger.getValidators(Height(params.ValidatorCycle)).length == 6);
}

// throws in updateValidatorSet() => rollback() called, UTXO set and
// Validator set reverted
{
    const blocks = genBlocksToIndex(params.ValidatorCycle, params);
    assert(blocks.length == 21);  // +1 for genesis

    scope ledger = new ThrowingLedger(
        WK.Keys.A, blocks.takeExactly(params.ValidatorCycle), params);
    assert(ledger.getValidators(Height(params.ValidatorCycle)).length == 6);
    auto utxos = ledger.utxo_set.getUTXOs(WK.Keys.Genesis.address);
    assert(utxos.length == 8);
    utxos.each!(utxo => assert(utxo.unlock_height == params.ValidatorCycle));

    ledger.throw_in_update_validators = true;
    auto next_block = blocks[$ - 1];
    assertThrown!Exception(assert(ledger.acceptBlock(next_block) is null));
    assert(ledger.lastBlock() == blocks[$ - 2]);  // not updated
    utxos = ledger.utxo_set.getUTXOs(WK.Keys.Genesis.address);
    assert(utxos.length == 8);
    utxos.each!(utxo => assert(utxo.unlock_height == params.ValidatorCycle));  // reverted
    assert(ledger.getValidators(ledger.lastBlock().header.height).length == 6);
}

Example

throw if the gen block in block storage is different to the configured one

import agora.consensus.data.genesis.Test;
import agora.consensus.data.genesis.Coinnet : CoinGenesis = GenesisBlock;

// ConsensusParams is instantiated by default with the test genesis block
immutable params = new immutable(ConsensusParams)(CoinGenesis, WK.Keys.CommonsBudget.address);

try
{
    scope ledger = new TestLedger(WK.Keys.A, [GenesisBlock], params);
    assert(0);
}
catch (Exception ex)
{
    assert(ex.message ==
           "Genesis block loaded from disk " ~
           "(0x8365f069fe37ee02f2c4dc6ad816702088fab5fc875c3c67b01f82c285aa" ~
           "2d90b605f57e068139eba1f20ce20578d712f75be4d8568c8f3a7a34604e72aa3175) "~
           "is different from the one in the config file " ~
           "(0x70c39bda1082ff0715afecd942650bca1773ce4a2fe83fc206234141b8c0e" ~
           "a5199c5c46f1705c48cb717bea633e5d5c3b6dba08e4fc9e1aa28b09e3bf268eaaa)");
}

immutable good_params = new immutable(ConsensusParams)();
// will not fail
scope ledger = new TestLedger(WK.Keys.A, [GenesisBlock], good_params);
// Neither will the default
scope other_ledger = new TestLedger(WK.Keys.A, [GenesisBlock]);

Example

Testing accumulated fees paid to Commons Budget and non slashed Validators

import agora.consensus.data.genesis.Test;
import agora.consensus.PreImage;
import agora.utils.WellKnownKeys : CommonsBudget;

const testPayoutPeriod = 5;
ConsensusConfig config = { validator_cycle: 20, payout_period: testPayoutPeriod };
auto params = new immutable(ConsensusParams)(GenesisBlock,
    CommonsBudget.address, config);
assert(params.PayoutPeriod == testPayoutPeriod);
const(Block)[] blocks = [ GenesisBlock ];
scope ledger = new TestLedger(genesis_validator_keys[0], blocks, params);

// Add preimages for all validators (except for two of them) till end of cycle
uint[] skip_indexes = [ 2, 5 ];

auto validators = ledger.getValidators(Height(1));
UTXO[] mpv_stakes;
foreach (skip; skip_indexes)
    assert(ledger.utxo_set.peekUTXO(validators[skip].utxo, mpv_stakes[(++mpv_stakes.length) - 1]));

ledger.simulatePreimages(Height(params.ValidatorCycle), skip_indexes);

assert(ledger.params.BlockInterval.total!"seconds" == 600);
Amount allocated_validator_rewards = Amount.UnitPerCoin * 27 * (600 / 5);
assert(allocated_validator_rewards == 3_240.coins);
Amount commons_reward = Amount.UnitPerCoin * 50 * (600 / 5);
assert(commons_reward == 6_000.coins);
Amount total_rewards = (allocated_validator_rewards + commons_reward) * testPayoutPeriod;

auto tx_set_fees = Amount(0);
auto total_fees = Amount(0);
Amount[] next_payout_total;
// Create blocks from height 1 to 11 (only block 5 and 10 should have a coinbase tx)
foreach (height; 1..11)
{
    auto txs = blocks[$-1].spendable.map!(txb => txb.sign()).array();
    txs.each!(tx => assert(ledger.acceptTransaction(tx) is null));
    tx_set_fees = txs.map!(tx => tx.getFee(&ledger.utxo_set.peekUTXO, &ledger.getPenaltyDeposit)).reduce!((a,b) => a + b);

    // Add the fees for this height
    total_fees += tx_set_fees;

    auto data = ConsensusData.init;
    ledger.prepareNominatingSet(data);

    // Do some Coinbase tests with the data tx_set
    if (height >= 2 * testPayoutPeriod && height % testPayoutPeriod == 0)
    {
        // Remove the coinbase TX
        auto tx_set = ledger.nominated_tx_sets[data.tx_set][0 .. $ - 1];
        ledger.nominated_tx_sets[tx_set.hashFull] = tx_set;
        data.tx_set = tx_set.hashFull;
        assert(ledger.validateConsensusData(data, skip_indexes) == "Missing matching Coinbase transaction");
        // Add different hash to tx_set
        tx_set ~= "Not Coinbase tx".hashFull();
        ledger.nominated_tx_sets[tx_set.hashFull] = tx_set;
        data.tx_set = tx_set.hashFull;
        assert(ledger.validateConsensusData(data, skip_indexes) == "Missing matching Coinbase transaction");
    }

    // Now externalize the block
    ledger.prepareNominatingSet(data);

    total_fees += ledger.params.SlashPenaltyAmount * data.missing_validators.length;
    if (height % testPayoutPeriod == 0)
    {
        next_payout_total ~= total_fees + total_rewards;
        total_fees = Amount(0);
    }

    assert(ledger.externalize(data) is null);
    assert(ledger.height() == blocks.length);
    blocks ~= ledger.getBlocksFrom(Height(blocks.length)).front;

    auto cb_txs = blocks[$-1].txs.filter!(tx => tx.isCoinbase).array;
    if (height >= 2 * testPayoutPeriod && height % testPayoutPeriod == 0)
    {
        assert(cb_txs.length == 1);
        // Payout block should pay the CommonsBudget + all validators (excluding slashed validators)
        assert(cb_txs[0].outputs.length == 1 + genesis_validator_keys.length - skip_indexes.length);
        assert(cb_txs[0].outputs.map!(o => o.value).reduce!((a,b) => a + b) == next_payout_total[0]);
        next_payout_total = next_payout_total[1 .. $];
        // Slashed validators should never be paid
        mpv_stakes.each!((mpv_stake)
        {
            assert(cb_txs[0].outputs.filter!(output => output.address ==
                mpv_stake.output.address).array.length == 0);
        });
    }
    else
        assert(cb_txs.length == 0);
}

Example

throw if the gen block in block storage is for a different chain id

import agora.consensus.data.genesis.Test;

immutable params = new immutable(ConsensusParams)();
try
{
    setHashMagic(0x44); // Test GenesisBlock is signed for ChainID=0
    scope ledger = new TestLedger(WK.Keys.A, [GenesisBlock], params);
    assert(0);
} catch (Exception ex) {}

setHashMagic(0); // should work just fine
scope ledger = new TestLedger(WK.Keys.A, [GenesisBlock], params);