Writing a Bitcoin-like model

Learn how to create a blockchain model by creating a Bitcoin-like one.

Prerequisites

Before we start, you must know:

  • How to create a new project
  • What is a:
    • SimulatedAgent
    • SimulatedEnvironment
    • Context
    • Plan
    • Action
    • Experimenter
    • MessageHandler

What we will do ?

This tutorial is about creating a simple blockchain model in MAX. Although it won’t cover all aspects of a blockchain protocol and application, we will be able to simulate miners and users, issue transactions, create blocks and propagate them in the network.

Do to that, we are going to:

  1. Use a Proof of Work, like in the Bitcoin model. To avoid forks, we assume that only one agent fulfill the mathematical problem each time.
  2. Use a simplified messaging protocol:
    • Transactions are disseminated using a TX message. Contrary to Bitcoin, we disseminate transactions and not hashes. Thus, we won’t need a INVENTORY message for missing transactions.
    • Blocks are disseminated using a BLOCK message
    • Since agents can join the blockchain at any time, we need a message to ask missing blocks. A GET_BLOCK message will be used for that and will carry the hash of the requested block
  3. Use dummy transactions: each user will have some money to spend and will issue transactions to random addresses. No transaction fees in this model.
Sequence diagram of all possible interactions between agents
Interactions between agents

We will also write some tests to check our model.

The simple part

Writing a model can be hard depending on its complexity. Even though, some parts are easy to write. In this section, we are going create:

  • The roles we need
  • A custom context and environment
  • A custom agent

Most of the logic behind these concepts is defined in dependencies (max.model.network:p2p for the communication part, max.model.ledger:blockchain for the blockchain part) and we only need to tweak a few things to make it work.

The harder part will be writing the agents behaviors and defining scenarios to test our model.

Create the project

Before we start coding, we need to create the Maven project.

Let’s use the following configuration:

  • groupId: max.example
  • artifactId: bcmodel

Of course, feel free to change it if you want.

Next we will need some dependencies. Since we are about to create a new blockchain model, we will use:

  • max:core: the core of the simulator
  • max.datatype:com: datatypes related to communication between nodes
  • max.datatype:ledger: datatypes related to ledger technology
  • max.model.network:p2p: an implementation of a peer-to-peer network
  • max.model.ledger:blockchain: some building blocks common to all blockchains

Add also JUnit to be able to write and run tests:

  • org.junit.jupiter:junit-jupiter-api

That is all for the pom file.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>max</groupId>
        <artifactId>max-parent</artifactId>
        <version>0.3.12</version>
    </parent>

    <groupId>max.example</groupId>
    <artifactId>bcmodel</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <!-- Compiler properties -->
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>

        <!-- Dependencies versions -->
        <max.core.version>1.2.0</max.core.version>
        <max.datatype.com.version>1.0.1</max.datatype.com.version>
        <max.datatype.ledger.version>1.1.1</max.datatype.ledger.version>
        <max.model.network.p2p.version>1.1.1</max.model.network.p2p.version>
        <max.model.ledger.blockchain.version>1.2.0</max.model.ledger.blockchain.version>
    </properties>

    <repositories>
        <repository>
            <id>max-maven</id>
            <url>https://gitlab.com/api/v4/groups/16222017/-/packages/maven</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>max</groupId>
            <artifactId>core</artifactId>
            <version>${max.core.version}</version>
        </dependency>

        <dependency>
            <groupId>max.datatype</groupId>
            <artifactId>com</artifactId>
            <version>${max.datatype.com.version}</version>
        </dependency>

        <dependency>
            <groupId>max.datatype</groupId>
            <artifactId>ledger</artifactId>
            <version>${max.datatype.ledger.version}</version>
        </dependency>

        <dependency>
            <groupId>max.model.network</groupId>
            <artifactId>p2p</artifactId>
            <version>${max.model.network.p2p.version}</version>
        </dependency>

        <dependency>
            <groupId>max.model.ledger</groupId>
            <artifactId>blockchain</artifactId>
            <version>${max.model.ledger.blockchain.version}</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>compile</scope>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>

Roles

The first thing we can do is to create all the roles we will need. Like in the Bitcoin blockchain, we can separate nodes into two distinct parts:

  • Users of the blockchain: they issue transactions
  • Miners: they create block and append them to the blockchain

The max.model.ledger:blockchain model defines a few roles related to the blockchain:

  • RBlockchainClient: A role dedicated for blockchain clients
  • RBlockchainMaintainer: Responsible for maintaining and replicating the blockchain data structure
  • RBlockCreator: Agents that can create a block
  • RTransactionEndorser: Agents with this role can decide if they validate a transaction or not

Since we won’t validate the transaction, we can ignore the RTransactionEndorser role.

Now, since miners are both RBlockchainMaintainer and RBlockCreator, we can create a role federating these two roles. Let’s call it RBCMiner (BlockchainMiner role). We can also create a RBCUser role being an alias of RBlockchainClient.

// File: src/main/java/max/example/bcmodel/role/RBCUser.java
package max.example.bcmodel.role;

import max.model.ledger.blockchain.role.RBlockchainClient;

/** Role representing functionalities of our blockchain clients (issuing transactions). */
public interface RBCUser extends RBlockchainClient {
  // No content
}

// File: src/main/java/max/example/bcmodel/role/RBCMiner.java
package max.example.bcmodel.role;

import max.model.ledger.blockchain.role.RBlockCreator;
import max.model.ledger.blockchain.role.RBlockchainMaintainer;

/**
 * Role representing capabilities of our blockchain miners:
 *
 * <ul>
 *   <li>Creating blocks
 *   <li>Maintaining the blockchain
 * </ul>
 */
public interface RBCMiner extends RBlockCreator, RBlockchainMaintainer {}

Context

Now we have defined some roles, let’s talk about the Context.

The max.model.ledger:blockchain model already defines a context called BlockchainContext. It contains:

  • a MemoryPool for the transactions
  • a Wallet to store our coins
  • a local copy of the blockchain
  • a way to store Orphan blocks (blocks without a local parent)
  • a double value called computationalPower
  • a few more things like fees, reward and a transaction factory

What we need to add is a new address to the wallet.

For now, here is the BCContext class:

// File: src/main/java/max/example/bcmodel/env/BCContext.java
package max.example.bcmodel.env;

import max.core.agent.SimulatedAgent;
import max.core.agent.SimulatedEnvironment;
import max.datatype.com.Address;
import max.datatype.com.UUIDAddress;
import max.datatype.ledger.Transaction;
import max.model.ledger.blockchain.env.BlockchainContext;

/**
 * Context for blockchain nodes.
 *
 * <p>Simply defines a default address in the wallet.
 *
 * @param <T> Transaction type
 */
public class BCContext<T extends Transaction> extends BlockchainContext<T> {

  // The key to retrieve our BC Address in our Wallet
  private static final String BC_ADDRESS_KEY = "BC";

  /**
   * Create a new {@link BCContext}.
   *
   * @param owner Context's owner
   * @param simulatedEnvironment Associated environment
   */
  public BCContext(SimulatedAgent owner, SimulatedEnvironment simulatedEnvironment) {
    super(owner, simulatedEnvironment);

    // Add a new address in the Wallet
    getWallet().addAddress(BC_ADDRESS_KEY, new UUIDAddress());
  }

  @Override
  public Address getAddress() {
    return getWallet().getAddress(BC_ADDRESS_KEY);
  }
}

Environment

The environment we are about to write is quite simple: it must create the right context (BCContext) and allow our custom roles (RBCUser and RBCMiner). Some environments might be more complex, specially if they add new capabilities.

Our final BCEnvironment:

// File: src/main/java/max/example/bcmodel/env/BCEnvironment.java
package max.example.bcmodel.env;

import max.core.Context;
import max.core.agent.SimulatedAgent;
import max.example.bcmodel.role.RBCMiner;
import max.example.bcmodel.role.RBCUser;
import max.model.ledger.blockchain.env.BlockchainEnvironment;

/**
 * The {@link BCEnvironment} class represents the environment in which blockchain nodes will evolve.
 *
 * <p>It is basically a {@link BlockchainEnvironment} that allows our roles {@link RBCUser} and
 * {@link RBCMiner}. Created contexts are instances of {@link BCContext}.
 */
public class BCEnvironment extends BlockchainEnvironment {

  /** Default Constructor. */
  public BCEnvironment() {
    allowsRole(RBCMiner.class);
    allowsRole(RBCUser.class);
  }

  /**
   * {@inheritDoc}
   *
   * @return an instance of {@link BCContext}
   */
  @Override
  protected Context createContext(SimulatedAgent agent) {
    return new BCContext<>(agent, this);
  }
}

Agent

Agents are only Action containers and don’t have logic in methods. This simplifies the process of creating an Agent: we only need to register playable roles.

Has such, the BCAgent is once again very simple:

// File: src/main/java/max/example/bcmodel/action/BCAgent.java
package max.example.bcmodel.action;

import max.core.action.Plan;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.role.RBCMiner;
import max.example.bcmodel.role.RBCUser;
import max.model.ledger.blockchain.action.BlockchainAgent;

/**
 * A very simple blockchain agent.
 *
 * <p>Basically a {@link BlockchainAgent} that can play {@link RBCUser} and {@link RBCMiner} roles.
 *
 * @param <T> Transaction type
 */
public class BCAgent<T extends Transaction> extends BlockchainAgent<T> {

  /**
   * Default constructor.
   *
   * @param plan plan to be executed by the agent
   */
  public BCAgent(Plan<? extends BlockchainAgent<T>> plan) {
    super(plan);

    // Add our custom roles to the list of playable roles
    addPlayableRole(RBCMiner.class);
    addPlayableRole(RBCUser.class);
  }
}

That is all for the simple part, next step is the hard business.

The harder part

Now we set up the basic stuff, the next step is to create the logic for our agents. Obviously we will need:

  • Messages for:
    • transaction propagation (TX message)
    • block propagation (BLOCK message)
    • requesting a missing block (GET_BLOCK message)
  • Actions for:
    • creating a block
    • issuing a transaction

Once the logic will be in place, we will create some testing scenarios to make sure we didn’t miss anything.

Messages

Because our environment and context are descendants of the P2PEnvironment and the P2PContext, they both support communication primitives. What must do now is to create the messages we will use in the application.

Some of our dependencies already define data structures like Message in max.datatype:com. To use this type, we need to supply:

  • the message type as a String
  • the content of the message, which can be any object

Here we are going to create 3 types of messages:

  • TX which carries a Transaction (another type representing a transaction and defined in max.datatype:ledger).
  • BLOCK which carries a Block (see max.datatype:ledger too).
  • GET_BLOCK which carries a block hash and represents a request: the sender wants the block having the provided hash.

Rather than creating a class for each message, let’s create a factory to configure the message instance. Starting with the TX message:

// File: src/main/java/max/example/bcmodel/env/message/MessageFactory.java
package max.example.bcmodel.env.message;

import java.util.List;
import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;

/**
 * Factory to ease the creation of our messages.
 *
 * Defines 3 types of messages:
 * <ul>
 *     <li>TX that carries a transaction</li>
 *     <li>BLOCK that carries a block</li>
 *     <li>GET_BLOCK that carries a block hash</li>
 * </ul>
 */
public final class MessageFactory {

    // All messages for the protocol
    public static final String TX_MSG_TYPE = "TX";

    /**
     * Create a new TX message.
     *
     * @param sender Address of the sender
     * @param receivers A non-empty list of Addresses
     * @param tx The transaction to propagate
     * @return A message containing the transaction
     */
    public static Message<AgentAddress, Transaction> createTXMessage(AgentAddress sender, List<AgentAddress> receivers, Transaction tx) {
        final var msg = new Message<>(sender, receivers, tx);
        msg.setType(TX_MSG_TYPE);
        return msg;
    }

    /**
     * Private empty constructor to prevent instantiation.
     */
    private MessageFactory() {
        // Empty constructor, prevent instantiation
    }

}

Add the equivalent method for both BLOCK and GET_BLOCK messages.

// File: src/main/java/max/example/bcmodel/env/message/MessageFactory.java
package max.example.bcmodel.env.message;

import java.util.List;
import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;

/**
 * Factory to ease the creation of our messages.
 *
 * Defines 3 types of messages:
 * <ul>
 *     <li>TX that carries a transaction</li>
 *     <li>BLOCK that carries a block</li>
 *     <li>GET_BLOCK that carries a block hash</li>
 * </ul>
 */
public final class MessageFactory {

    // All messages for the protocol
    public static final String TX_MSG_TYPE = "TX";
    public static final String BLOCK_MSG_TYPE = "BLOCK";
    public static final String GET_BLOCK_MSG_TYPE = "GET_BLOCK";

    /**
     * Create a new TX message.
     *
     * @param sender Address of the sender
     * @param receivers A non-empty list of Addresses
     * @param tx The transaction to propagate
     * @return A message containing the transaction
     */
    public static Message<AgentAddress, Transaction> createTXMessage(AgentAddress sender, List<AgentAddress> receivers, Transaction tx) {
        final var msg = new Message<>(sender, receivers, tx);
        msg.setType(TX_MSG_TYPE);
        return msg;
    }

    /**
     * Create a new BLOCK message.
     *
     * @param sender Address of the sender
     * @param receivers A non-empty list of Addresses
     * @param block The block to propagate
     * @return A message containing the block
     */
    public static <T0 extends Transaction, T1 extends Number> Message<AgentAddress, Block<T0, T1>> createBlockMessage(AgentAddress sender, List<AgentAddress> receivers, Block<T0, T1> block) {
        final var msg = new Message<>(sender, receivers, block);
        msg.setType(BLOCK_MSG_TYPE);
        return msg;
    }

    /**
     * Create a new GET_BLOCK message.
     *
     * @param sender Address of the sender
     * @param receivers A non-empty list of Addresses
     * @param blockHash The hash of the block the sender is requesting
     * @return A message containing the hash value
     */
    public static Message<AgentAddress, Integer> createGetBlockMessage(AgentAddress sender, List<AgentAddress> receivers, Integer blockHash) {
        final var msg = new Message<>(sender, receivers, blockHash);
        msg.setType(GET_BLOCK_MSG_TYPE);
        return msg;
    }

    /**
     * Private empty constructor to prevent instantiation.
     */
    private MessageFactory() {
        // Empty constructor, prevent instantiation
    }

}

Now we have our factory, we must write handlers to handle both 3 messages.

Message handlers

Message handling is done using an event-based action: on message reception, an action is triggered and the correct MessageHandler is called. More information about the messaging system here.

TX Message Handler

One of the easiest messages is the TX one: when a BCAgent receives a TX message with a transaction in its payload, it adds the transaction into its MemoryPool. To write a handler, we have to implement the MessageHandler interface:

// File: src/main/java/max/example/bcmodel/env/message/TXMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.message.MessageHandler;

/**
 * A message handler dedicated to 'TX' messages.
 *
 * <p>When executed, it will add the transaction to the agent's {@link
 * max.datatype.ledger.blockchain.MemoryPool} and broadcast the message.
 */
public class TXMessageHandler implements MessageHandler {

  /** Message type this handler can handle. */
  public static final String MESSAGE_TYPE = MessageFactory.TX_MSG_TYPE;

  @Override
  public void handle(P2PContext p2PContext, Message<AgentAddress, ?> message) {
    // Write code here
  }
}

Now we need to fill the body with the implementation. First we have to retrieve the transaction and the memory pool of the agent. Then, we must check if the transaction already exists in the memory pool. If not, we must add it and propagate the information to neighboring peers.

As you can see, the handle method will get a P2PContext. Unfortunately we want at least a BlockchainContext to be able to retrieve the MemoryPool object. Since we know the context will be a BCContext we can safely cast. The same argument applies for the message payload (it is a Transaction).

Finally, we need to retrieve the list of our neighbors in case we have to propagate the transaction. Lucky for us, the P2PContext has a nice method called getNeighborsAddresses to do it.

// File: src/main/java/max/example/bcmodel/env/message/TXMessageHandler.java
package max.example.bcmodel.env.message;

import java.util.logging.Level;
import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.env.message.MessageHandler;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * A message handler dedicated to 'TX' messages.
 *
 * <p>When executed, it will add the transaction to the agent's {@link
 * max.datatype.ledger.blockchain.MemoryPool} and broadcast the message.
 */
public class TXMessageHandler implements MessageHandler {

  /** Message type this handler can handle. */
  public static final String MESSAGE_TYPE = MessageFactory.TX_MSG_TYPE;

  @Override
  public void handle(P2PContext p2PContext, Message<AgentAddress, ?> message) {
    p2PContext
        .getOwner()
        .getLogger()
        .log(Level.INFO, "Received TX from " + message.getSender().getAgent().getName());

    // The context is a BCContext, so we can safely cast
    final var ctx = (BCContext<Transaction>) p2PContext;

    // Get the transaction
    final var tx = (Transaction) message.getPayload();

    // Add the transaction to the memory pool of the agent if not already added
    // And broadcast the message to neighbors.
    final var memoryPool = ctx.getMemoryPool();
    if (!memoryPool.containsHashcode(tx.hashCode())) {
      // Add to the memory pool
      memoryPool.put(tx.hashCode(), tx);

      // Propagate transaction to neighbors
      final var neighbors = ctx.getNeighborsAddresses();
      neighbors.remove(message.getSender());
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createTXMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()), neighbors, tx));
    }
  }
}

Remarks
  1. We are forwarding the transaction using our RNetworkPeer address. In MAX, each tuple (Environment, Agent, Role) has its own address. Technically, you can use any of them to send and received messages. Here we decided to use the one associated to the RNetworkPeer role, which is the base role to enable communication. See the P2P documentation for more details about communications between agents.
  2. To avoid unnecessary messages, we exclude the node who sent us the transaction initially.

GET_BLOCK Message Handler

Next message is the GET_BLOCK message. When a node receives this one, it will search in its local blockchain if a block matching the provided hash exists. If it does, then the node will send the block to the requester.

The BlockTree class, which represents our local blockchain copy, has already a method for searching a block using a hash value named getBlockByHashcode. Thus, the code is pretty simple:

// File: src/main/java/max/example/bcmodel/env/message/GETBLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import java.util.Collections;
import java.util.logging.Level;
import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.env.message.MessageHandler;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * {@link MessageHandler} dedicated to 'GET_BLOCK' messages.
 *
 * <p>It will search a block in the local blockchain copy matching the received hash-code. If found,
 * then this handler sends a 'BLOCK' message containing the block.
 */
public class GETBLOCKMessageHandler implements MessageHandler {

  /** Message type this handler can handle. */
  public static final String MESSAGE_TYPE = MessageFactory.GET_BLOCK_MSG_TYPE;

  @Override
  public void handle(P2PContext p2PContext, Message<AgentAddress, ?> message) {
    p2PContext
        .getOwner()
        .getLogger()
        .log(Level.INFO, "Received GET_BLOCK from " + message.getSender().getAgent().getName());

    // The context is a BCContext, so we can safely cast
    final var ctx = (BCContext<Transaction>) p2PContext;

    // Get the transaction
    final var hashCode = (Integer) message.getPayload();

    // Search in our local blockchain copy the block
    final var block = ctx.getBlockTree().getBlockByHashcode(hashCode);

    // Send the block if found
    if (block != null) {
      ((P2PEnvironment) p2PContext.getEnvironment())
          .sendMessage(
              MessageFactory.createBlockMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()),
                  Collections.singletonList(message.getSender()),
                  block));
    }
  }
}

BLOCK Message Handler

Last but not least, the BLOCK message handler!

When a BLOCK message is received:

  1. First we get the last block number in the blockchain
  2. Then we compare that block number with the block number of the received block:
    1. If lastBlockNumber + 1 == blockNumber then we can append the received block
    2. Else then the received block is orphan: we need to retrieve its parent

If the block append operation succeeds, we also need to update the agent’s balance with the contained transaction and see if we can attach some orphan blocks we might have.

Let’s start with a simple updateBalance method. It will take a BCContext and a Block and increase the context’s balance depending on transactions declared in the block:

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.model.network.p2p.env.P2PContext;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.message.MessageHandler;

/**
 * Specialized {@link MessageHandler} for handling `BLOCK` messages.
 *
 * <p>It will attempt to add the block to the local blockchain and if it succeeds, update the
 * agent's balance. This handler is also aware of Orphan blocks and will try to attach them if
 * possible.
 *
 * <p>If the received block is an orphan one, it will send a `GET_BLOCK` message to the neighbors.
 */
public class BLOCKMessageHandler implements MessageHandler {

  /** Message type this handler can handle. */
  public static final String MESSAGE_TYPE = MessageFactory.BLOCK_MSG_TYPE;

  @Override
  public void handle(P2PContext p2PContext, Message<AgentAddress, ?> message) {
    // Empty for now
  }

  /**
   * Given a {@link Block} and a {@link BCContext}, update the context balance with transactions
   * made to the agent's address.
   *
   * @param ctx Context
   * @param block Block containing the transactions
   */
  private void updateBalance(BCContext<Transaction> ctx, Block<Transaction, ?> block) {
    block.getTransactions().stream()
        .filter(t -> t.getReceiver().equals(ctx.getAddress()))
        .forEach(t -> ctx.setBalance(ctx.getBalance() + t.getPayload().getNumericalValue()));
  }
}


Now we need a method in charge of adding a block in the local blockchain. First, we need to decide if the block is an orphan one or not. To do it, we need to retrieve all head blocks of the local blockchain (multiple blocks if we have a fork) and get the highest block number. Once done, we compare it with the block number of the received one.

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.model.network.p2p.env.P2PContext;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.message.MessageHandler;

/** ... */
public class BLOCKMessageHandler implements MessageHandler {

  ...

  /**
   * Try to add the block into the local blockchain copy.
   *
   * @param ctx Context
   * @param block Block to add
   */
  private void addBlockToBC(
      final BCContext<Transaction> ctx, final Block<Transaction, BigDecimal> block) {
    // Retrieve blockchain
    final var blockchain = ctx.getBlockTree();

    // Get last block number in our blockchain
    final var lastBlockNumber =
        blockchain.getAllHeadBlocks().stream()
            .max(Comparator.comparingInt(Block::getBlockNumber))
            .orElseThrow()
            .getBlockNumber();

    // Orphan block or not ? Depends if num(block) <= lastBlockNumber + 1
    final var blockNumber = block.getBlockNumber();
    if (blockNumber <= (lastBlockNumber + 1)) {
      // Not orphan, add it to the blockchain
    } else if (!ctx.getOrphanBlockList().contains(block)) {
      // Orphan block, store it and ask parent to friends
    }
  }
}


Now let’s focus on the regular scenario. If the block is not an orphan one, we try to add it into the blockchain. This operation may fail if the block is invalid, so we need to update the balance only if the operation succeeded.

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.message.MessageHandler;
import java.util.Comparator;
import java.util.logging.Level;

/** .. */
public class BLOCKMessageHandler implements MessageHandler {

  ...

  /**
   * Try to add the block into the local blockchain copy.
   *
   * @param ctx Context
   * @param block Block to add
   */
  private void addBlockToBC(
      final BCContext<Transaction> ctx, final Block<Transaction, BigDecimal> block) {
    // Retrieve blockchain
    final var blockchain = ctx.getBlockTree();

    // Get last block number in our blockchain
    final var lastBlockNumber =
        blockchain.getAllHeadBlocks().stream()
            .max(Comparator.comparingInt(Block::getBlockNumber))
            .orElseThrow()
            .getBlockNumber();

    // Orphan block or not ? Depends if num(block) <= lastBlockNumber + 1
    final var blockNumber = block.getBlockNumber();
    if (blockNumber <= (lastBlockNumber + 1)) {
      // Not orphan, add it to the blockchain
      try {
        blockchain.append(block);
        ctx.getOwner()
            .getLogger()
            .log(
                Level.INFO,
                String.format("Added block nb %s in local blockchain.", block.getBlockNumber()));

        // Update our balance
        updateBalance(ctx, block);

      } catch (final IllegalArgumentException e) {
        ctx.getOwner()
            .getLogger()
            .log(
                Level.WARNING,
                String.format("Unable to append block nb %s", block.getBlockNumber()),
                e);
      }
    } else if (!ctx.getOrphanBlockList().contains(block)) {
      // Orphan block, store it and ask parent to friends
    }
  }
}

Now the orphan case: if the received block’s parent is not present in the blockchain, we can’t add it. What we are going to do is to store this block and contact neighbors to retrieve its parent. The BlockchainContext has already a storage room for those blocks. You can retrieve it by calling the getOrphanBlockList method on the context.

We also need to call our method addBlockToBC within the handle one. Here is the code:

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.env.message.MessageHandler;
import max.model.network.p2p.role.RNetworkPeer;

import java.util.Comparator;
import java.util.logging.Level;

/** ... */
public class BLOCKMessageHandler implements MessageHandler {

  ...

  /**
   * Try to add the block into the local blockchain copy.
   *
   * @param ctx Context
   * @param block Block to add
   */
  private void addBlockToBC(
      final BCContext<Transaction> ctx, final Block<Transaction, BigDecimal> block) {
    // Retrieve blockchain
    final var blockchain = ctx.getBlockTree();

    // Get last block number in our blockchain
    final var lastBlockNumber =
        blockchain.getAllHeadBlocks().stream()
            .max(Comparator.comparingInt(Block::getBlockNumber))
            .orElseThrow()
            .getBlockNumber();

    // Orphan block or not ? Depends if num(block) <= lastBlockNumber + 1
    final var blockNumber = block.getBlockNumber();
    if (blockNumber <= (lastBlockNumber + 1)) {
      // Not orphan, add it to the blockchain
      ...
    } else if (!ctx.getOrphanBlockList().contains(block)) {
      // Orphan block, store it and ask parent to friends
      ctx.getOrphanBlockList().add(block);
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createGetBlockMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()),
                  ctx.getNeighborsAddresses(),
                  block.getPreviousBlockHashCode()));
    }
  }
}

Remark
Here we suppose that we have at least one neighbor and at least one of them has the block we are looking for. Otherwise, we will never retrieve the missing block. One solution to overcome this is to create an action periodically checking the orphan list and asking for parents.

Next step: how to re-attach orphans ?

Well, when a block is added to the blockchain, it might be the parent of an orphan block we have. Thus, we can loop on all our orphan blocks and try to add them. Rather than rewriting the append process, we can simply use our addBlockToBC method. Here is the code:

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.env.message.MessageHandler;
import max.model.network.p2p.role.RNetworkPeer;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.logging.Level;

/** ... */
public class BLOCKMessageHandler implements MessageHandler {

  ...

  /**
   * Try to add the block into the local blockchain copy.
   *
   * @param ctx Context
   * @param block Block to add
   */
  private void addBlockToBC(
      final BCContext<Transaction> ctx, final Block<Transaction, BigDecimal> block) {
    // Retrieve blockchain
    final var blockchain = ctx.getBlockTree();

    // Get last block number in our blockchain
    final var lastBlockNumber =
        blockchain.getAllHeadBlocks().stream()
            .max(Comparator.comparingInt(Block::getBlockNumber))
            .orElseThrow()
            .getBlockNumber();

    // Orphan block or not ? Depends if num(block) <= lastBlockNumber + 1
    final var blockNumber = block.getBlockNumber();
    if (blockNumber <= (lastBlockNumber + 1)) {
      // Not orphan, add it to the blockchain
      try {
        blockchain.append(block);
        ctx.getOwner()
            .getLogger()
            .log(
                Level.INFO,
                String.format("Added block nb %s in local blockchain.", block.getBlockNumber()));

        // Try to resolve some orphans
        reconnectOrphans(ctx, block.hashCode());

        // Update our balance
        updateBalance(ctx, block);

      } catch (final IllegalArgumentException e) {
        ctx.getOwner()
            .getLogger()
            .log(
                Level.WARNING,
                String.format("Unable to append block nb %s", block.getBlockNumber()),
                e);
      }
    } else if (!ctx.getOrphanBlockList().contains(block)) {
      // Orphan block, store it and ask parent to friends
      ctx.getOrphanBlockList().add(block);
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createGetBlockMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()),
                  ctx.getNeighborsAddresses(),
                  block.getPreviousBlockHashCode()));
    }
  }

  /**
   * Given a {@link BCContext}, try to reconnect stored orphan blocks to the blockchain. Uses the
   * {@link #addBlockToBC(BCContext, Block)} to re-attach blocks.
   *
   * @param ctx Context
   * @param blockHash hash of the block we just added
   */
  private void reconnectOrphans(BCContext<Transaction> ctx, int blockHash) {
    final var orphanList = new ArrayList<>(ctx.getOrphanBlockList());
    for (final var orphan : orphanList) {
      if (orphan.getPreviousBlockHashCode() == blockHash) {
        addBlockToBC(ctx, orphan);
        ctx.getOrphanBlockList().remove(orphan);
      }
    }
  }
}


Final step: how to propagate the block.

In a distributed system, all components might not be connected (i.e., the communication graph is not complete). To address this issue, we need to propagate a Block message like we have done it for TX message to increase the chances to reach all nodes. We also need a way to stop propagation.

What we can do is to check in handle if we already have the block (either in the blockchain or in our orphan list). If not, we run the addBlockToBC method and, we propagate the block.

// File: src/main/java/max/example/bcmodel/env/message/BLOCKMessageHandler.java
package max.example.bcmodel.env.message;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.logging.Level;
import madkit.kernel.AgentAddress;
import max.datatype.com.Message;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.model.network.p2p.env.P2PContext;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.env.message.MessageHandler;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * Specialized {@link MessageHandler} for handling `BLOCK` messages.
 *
 * <p>It will attempt to add the block to the local blockchain and if it succeeds, update the
 * agent's balance. This handler is also aware of Orphan blocks and will try to attach them if
 * possible.
 *
 * <p>If the received block is an orphan one, it will send a `GET_BLOCK` message to the neighbors.
 */
public class BLOCKMessageHandler implements MessageHandler {

  /** Message type this handler can handle. */
  public static final String MESSAGE_TYPE = MessageFactory.BLOCK_MSG_TYPE;

  @Override
  public void handle(P2PContext p2PContext, Message<AgentAddress, ?> message) {
    p2PContext
        .getOwner()
        .getLogger()
        .log(Level.INFO, "Received BLOCK from " + message.getSender().getAgent().getName());

    // The context is a BCContext, so we can safely cast
    final var ctx = (BCContext<Transaction>) p2PContext;

    // Get the transaction
    final var block = (Block<Transaction, BigDecimal>) message.getPayload();

    // Add block and propagate only if we don't already have it
    if (!ctx.getBlockTree().contains(block.hashCode())
        && !ctx.getOrphanBlockList().contains(block)) {
      addBlockToBC(ctx, block);
      final var neighbors = new ArrayList<>(ctx.getNeighborsAddresses());
      neighbors.remove(message.getSender());
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createBlockMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()), neighbors, block));
    }
  }

  /**
   * Try to add the block into the local blockchain copy.
   *
   * @param ctx Context
   * @param block Block to add
   */
  private void addBlockToBC(
      final BCContext<Transaction> ctx, final Block<Transaction, BigDecimal> block) {
    // Retrieve blockchain
    final var blockchain = ctx.getBlockTree();

    // Get last block number in our blockchain
    final var lastBlockNumber =
        blockchain.getAllHeadBlocks().stream()
            .max(Comparator.comparingInt(Block::getBlockNumber))
            .orElseThrow()
            .getBlockNumber();

    // Orphan block or not ? Depends if num(block) <= lastBlockNumber + 1
    final var blockNumber = block.getBlockNumber();
    if (blockNumber <= (lastBlockNumber + 1)) {
      // Not orphan, add it to the blockchain
      try {
        blockchain.append(block);
        ctx.getOwner()
            .getLogger()
            .log(
                Level.INFO,
                String.format("Added block nb %s in local blockchain.", block.getBlockNumber()));

        // Try to resolve some orphans
        reconnectOrphans(ctx, block.hashCode());

        // Update our balance
        updateBalance(ctx, block);

      } catch (final IllegalArgumentException e) {
        ctx.getOwner()
            .getLogger()
            .log(
                Level.WARNING,
                String.format("Unable to append block nb %s", block.getBlockNumber()),
                e);
      }
    } else if (!ctx.getOrphanBlockList().contains(block)) {
      // Orphan block, store it and ask parent to friends
      ctx.getOrphanBlockList().add(block);
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createGetBlockMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()),
                  ctx.getNeighborsAddresses(),
                  block.getPreviousBlockHashCode()));
    }
  }

  /**
   * Given a {@link Block} and a {@link BCContext}, update the context balance with transactions
   * made to the agent's address.
   *
   * @param ctx Context
   * @param block Block containing the transactions
   */
  private void updateBalance(BCContext<Transaction> ctx, Block<Transaction, ?> block) {
    block.getTransactions().stream()
        .filter(t -> t.getReceiver().equals(ctx.getAddress()))
        .forEach(t -> ctx.setBalance(ctx.getBalance() + t.getPayload().getNumericalValue()));
  }

  /**
   * Given a {@link BCContext}, try to reconnect stored orphan blocks to the blockchain. Uses the
   * {@link #addBlockToBC(BCContext, Block)} to re-attach blocks.
   *
   * @param ctx Context
   * @param blockHash hash of the block we just added
   */
  private void reconnectOrphans(BCContext<Transaction> ctx, int blockHash) {
    final var orphanList = new ArrayList<>(ctx.getOrphanBlockList());
    for (final var orphan : orphanList) {
      if (orphan.getPreviousBlockHashCode() == blockHash) {
        addBlockToBC(ctx, orphan);
        ctx.getOrphanBlockList().remove(orphan);
      }
    }
  }
}

Actions

Now we properly handle messages, it is time to write our actions.

Actions are the preferred way of implementing an agent’s behavior. Each action runs in a particular environment and has a unique owner. Using this two information, you are able to retrieve the context and make pretty much anything.

ACIssueTransaction

The first action we are going to write is the ACIssueTransaction. It will issue a random transaction and propagate it into the network. To ease things we are going to:

  • issue transactions to random addresses (the system will lose money)
  • remove the transaction fees

To create a new action we need to extend the Action class defined in max:core. There are two methods to override:

  1. execute, which is called each time the action is executed.
  2. copy, which must be overridden if the action will be a part of a Plan.

Since our action will play with BC agents, we will parametrize Action with BCAgent<T>. Here is the code:

// File: src/main/java/max/example/bcmodel/action/ACIssueTransaction.java
package max.example.bcmodel.action;

import java.util.Random;
import java.util.logging.Level;
import max.core.action.Action;
import max.datatype.com.UUIDAddress;
import max.datatype.ledger.Payload;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.env.BCContext;
import max.example.bcmodel.env.message.MessageFactory;
import max.example.bcmodel.role.RBCUser;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * Action to issue a random transaction.
 *
 * <ul>
 *   <li>Payload: value between 1 and 100 (inclusive)
 *   <li>Fees: 0
 *   <li>Receiver: random UUID address
 * </ul>
 *
 * @max.role {@link RBCUser}
 * @param <T> Transaction type
 */
public class ACIssueTransaction<T extends Transaction> extends Action<BCAgent<T>> {

  @Override
  public void execute() {
    // Issue transaction
  }

  @Override
  public <T0 extends Action<BCAgent<T>>> T0 copy() {
    // Make a copy
  }
}


Now let’s write the code.

In the execute method, we need to:

  1. Retrieve the context
  2. Create a new random address
  3. Create a random amount to transfer
  4. If the amount is less than the current balance:
    1. Create the transaction
    2. Propagate
    3. Update balance

In a real application, the balance should be updated after the transaction is included in a block and buried. Since it is just an example, we ignore the double spending issue.

We also need to implement the copy method and write a constructor.

// File: src/main/java/max/example/bcmodel/action/ACIssueTransaction.java
package max.example.bcmodel.action;

import java.util.Random;
import java.util.logging.Level;
import max.core.action.Action;
import max.datatype.com.UUIDAddress;
import max.datatype.ledger.Payload;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.env.BCContext;
import max.example.bcmodel.env.message.MessageFactory;
import max.example.bcmodel.role.RBCUser;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * Action to issue a random transaction.
 *
 * <ul>
 *   <li>Payload: value between 1 and 100 (inclusive)
 *   <li>Fees: 0
 *   <li>Receiver: random UUID address
 * </ul>
 *
 * @max.role {@link RBCUser}
 * @param <T> Transaction type
 */
public class ACIssueTransaction<T extends Transaction> extends Action<BCAgent<T>> {

  // Random number generator
  private static final Random RANDOM_GEN = new Random();

  /**
   * Create a new {@link ACCreateBCBlock} instance.
   *
   * @param environment where this action will be executed
   * @param owner action's owner
   */
  public ACIssueTransaction(String environment, BCAgent<T> owner) {
    super(environment, RBCUser.class, owner);
  }

  @Override
  public void execute() {
    // Get context
    final var ctx = (BCContext<T>) getOwner().getContext(getEnvironment());

    // Create a random address
    final var receiver = new UUIDAddress();

    // Issue transaction
    final var amount = Math.min(ctx.getBalance(), 1d + RANDOM_GEN.nextInt(100));
    if (amount > 0) {
      final var tx =
          ctx.getTransactionFactory()
              .createTransaction(ctx.getAddress(), receiver, 0d, new Payload(amount));
      ((P2PEnvironment) ctx.getEnvironment())
          .sendMessage(
              MessageFactory.createTXMessage(
                  ctx.getMyAddress(RNetworkPeer.class.getName()), ctx.getNeighborsAddresses(), tx));
      ctx.setBalance(ctx.getBalance() - amount);
      getOwner().getLogger().log(Level.INFO, "Issuing a transaction: " + amount + "BC");
    }
  }

  @Override
  public <T0 extends Action<BCAgent<T>>> T0 copy() {
    return (T0) new ACIssueTransaction<>(getEnvironment(), getOwner());
  }
}

Remark
To create a new transaction, we have to retrieve the ITransactionFactory from the context. Once done, we can use the createTransaction method to build a transaction.

ACCreateBCBlock

The next action is the one executed by miners to create a block: ACCreateBCBlock.

Since we are not going to use a real Proof-of-Work, we need an oracle that will decide which miner will create the next block. This oracle is already defined in max.model.ledger:blockchain as long as the PoW.

The oracle works that way: each time the oracle is executed, it retrieves the list of all miners and use a strategy to select one or several miners. Once done, it issues a BlockCreatorEvent to each selected one. Then it is up to miners to execute the action registered for that event.

To ease things, max.model.ledger:blockchain defines an action called ACCreateBlock which we can extend to provide our own implementation of what is “creating a block”. The main advantage of doing this is that thanks to this action, we have access to the block number we should create.

So, let’s create a new action class:

// File: src/main/java/max/example/bcmodel/action/ACCreateBCBlock.java
package max.example.bcmodel.action;

import max.core.action.Action;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.role.RBCMiner;
import max.model.ledger.blockchain.action.ACCreateBlock;

/**
 * Action executed when the agent must create a new BC block.
 *
 * @max.role {@link RBCMiner}
 * @param <T> Transaction type
 */
public class ACCreateBCBlock<T extends Transaction> extends ACCreateBlock<BCAgent<T>> {

  /**
   * Default constructor.
   *
   * @param environment Environment in which the action will be executed
   * @param owner Action's owner
   */
  public ACCreateBCBlock(String environment, BCAgent<T> owner) {
    super(environment, RBCMiner.class, owner);
  }

  @Override
  public void execute() {
    // Create block here
  }

  @Override
  public <T0 extends Action<BCAgent<T>>> T0 copy() {
    return (T0) new ACCreateBCBlock<>(getEnvironment(), getOwner());
  }
}

Now let’s write this execute method.

The first think we are going to make is to retrieve the parent block. To do it we will use the getBlockNumber method, which gives us the number of the block to create. Next step is to select some transactions to include in the block. The context provides the max number of transaction allowed in a block, so we will use it as a maximum. Finally, we will create the block, add the transactions and send it to all nodes.

Here is the file:

package max.example.bcmodel.action;

import java.util.ArrayList;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import max.core.action.Action;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.example.bcmodel.env.message.MessageFactory;
import max.example.bcmodel.role.RBCMiner;
import max.model.ledger.blockchain.action.ACCreateBlock;
import max.model.ledger.blockchain.action.BlockCreationOracle;
import max.model.ledger.blockchain.role.RBlockCreationOracle;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * Action executed when the agent must create a new BC block.
 *
 * @max.role {@link RBCMiner}
 * @param <T> Transaction type
 */
public class ACCreateBCBlock<T extends Transaction> extends ACCreateBlock<BCAgent<T>> {

  /**
   * Default constructor.
   *
   * @param environment Environment in which the action will be executed
   * @param owner Action's owner
   */
  public ACCreateBCBlock(String environment, BCAgent<T> owner) {
    super(environment, RBCMiner.class, owner);
  }

  @Override
  public void execute() {
    // Get context, as usual
    final var ctx = (BCContext<T>) getOwner().getContext(getEnvironment());

    // Get block number getBlockNumber() - 1: it is the parent block
    final var parent = ctx.getBlockTree().getBlockByNumber(getBlockNumber() - 1);

    // Select transactions
    final var selectedTX =
        ctx.getMemoryPool().values().stream()
            .takeWhile(
                new Predicate<T>() {

                  private int currentCount;

                  @Override
                  public boolean test(T t) {
                    if (currentCount < ctx.getMaxNumberOfTxs()) {
                      currentCount += 1;
                      return true;
                    }
                    return false;
                  }
                })
            .collect(Collectors.toList());

    // Create block
    final var block =
        new Block<>(
            getBlockNumber(), parent.hashCode(), getOwner().getCurrentTick());
    block.addTransactions(new ArrayList<>(selectedTX));

    // Send block
    final var me = ctx.getMyAddress(RNetworkPeer.class.getName());
    final var receivers = ctx.getNeighborsAddresses();
    receivers.add(me); // So I can add the block to my blockchain
    ((P2PEnvironment) ctx.getEnvironment())
        .sendMessage(MessageFactory.createBlockMessage(me, receivers, block));
  }

  @Override
  public <T0 extends Action<BCAgent<T>>> T0 copy() {
    return (T0) new ACCreateBCBlock<>(getEnvironment(), getOwner());
  }
}

Remarks
  1. Here we suppose the parent block exists in the local blockchain. It might not be the case in complex scenarios, specially if the miner is not up-to-date.
  2. To get the simulation time, we can use getCurrentTick() on the action’s owner.
  3. We are not adding the new block in this action: it would duplicate the code we wrote in the BLOCKMessageHandler. So here is the little trick: we send the newly created block to our neighbors, including ourselves. Thus, we will trigger the handler and add the block to our local blockchain!

Finally, we need to tell the Oracle a block has been produced. To do so, we just need to retrieve the oracle agent and increase current height by one. Here is the final file (changes are at the end of the execute method):

package max.example.bcmodel.action;

import java.util.ArrayList;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import max.core.action.Action;
import max.datatype.ledger.Transaction;
import max.datatype.ledger.blockchain.Block;
import max.example.bcmodel.env.BCContext;
import max.example.bcmodel.env.message.MessageFactory;
import max.example.bcmodel.role.RBCMiner;
import max.model.ledger.blockchain.action.ACCreateBlock;
import max.model.ledger.blockchain.action.BlockCreationOracle;
import max.model.ledger.blockchain.role.RBlockCreationOracle;
import max.model.network.p2p.env.P2PEnvironment;
import max.model.network.p2p.role.RNetworkPeer;

/**
 * Action executed when the agent must create a new BC block.
 *
 * @max.role {@link RBCMiner}
 * @param <T> Transaction type
 */
public class ACCreateBCBlock<T extends Transaction> extends ACCreateBlock<BCAgent<T>> {

  // Store oracle instance
  private BlockCreationOracle oracle;

  /**
   * Default constructor.
   *
   * @param environment Environment in which the action will be executed
   * @param owner Action's owner
   */
  public ACCreateBCBlock(String environment, BCAgent<T> owner) {
    super(environment, RBCMiner.class, owner);
  }

  @Override
  public void execute() {
    // Get context, as usual
    final var ctx = (BCContext<T>) getOwner().getContext(getEnvironment());

    // Get block number getBlockNumber() - 1: it is the parent block
    final var parent = ctx.getBlockTree().getBlockByNumber(getBlockNumber() - 1);

    // Select transactions
    final var selectedTX =
        ctx.getMemoryPool().values().stream()
            .takeWhile(
                new Predicate<T>() {

                  private int currentCount;

                  @Override
                  public boolean test(T t) {
                    if (currentCount < ctx.getMaxNumberOfTxs()) {
                      currentCount += 1;
                      return true;
                    }
                    return false;
                  }
                })
            .collect(Collectors.toList());

    // Create block
    final var block =
        new Block<>(
            getBlockNumber(), parent.hashCode(), getOwner().getCurrentTick());
    block.addTransactions(new ArrayList<>(selectedTX));

    // Send block
    final var me = ctx.getMyAddress(RNetworkPeer.class.getName());
    final var receivers = ctx.getNeighborsAddresses();
    receivers.add(me); // So I can add the block to my blockchain
    ((P2PEnvironment) ctx.getEnvironment())
        .sendMessage(MessageFactory.createBlockMessage(me, receivers, block));

    // Increase height
    if (oracle == null) {
      oracle =
          (BlockCreationOracle)
              getOwner()
                  .getAgentsWithRole(getEnvironment(), RBlockCreationOracle.class)
                  .get(0)
                  .getAgent();
    }
    oracle.setHeight(getBlockNumber());
  }

  @Override
  public <T0 extends Action<BCAgent<T>>> T0 copy() {
    return (T0) new ACCreateBCBlock<>(getEnvironment(), getOwner());
  }
}

Experimenter

Now is time to write an experimenter. Rather than creating an experimenter with a concrete scenario, we are going to write an abstract one with some useful methods to create users and miners.

Here is the plan:

  • one method to create miners
  • one method to create users
  • a method to create the agent’s plan
  • a method to create the static part of the plan
  • and one method for the dynamic (event-bound actions) part
// File: src/main/java/max/bitcoin/example/exp/BCExperimenter.java
package max.example.bcmodel.exp;

import max.core.agent.ExperimenterAgent;
import max.core.action.ActionActivator;
import max.core.action.EventAction;
import max.core.action.Plan;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.action.BCAgent;

import java.util.*;

/**
 * Base experimenter.
 *
 * Defines some useful methods to create users and miners.
 */
public abstract class BCExperimenter<T> extends ExperimenterAgent {

    /**
   * Create and return a new {@link BCAgent}, which will behave like a BC miner.
   *
   * @param environment the environment this agent will join
   * @return the agent instance. Not launched.
   */
  protected BCAgent<Transaction> createMiner(String environment) {
    
  }

  /**
   * Create and return a new {@link BCAgent}, which will behave like a BC user.
   *
   * @param environment the environment this agent will join
   * @return the agent instance. Not launched.
   */
  protected BCAgent<Transaction> createUser(String environment) {
    
  }

  /**
   * Get an agent plan built using the provided environment.
   *
   * <p>Uses {@link #getStaticPlanPart(String, boolean)} and {@link #getEventBoundPart(String,
   * boolean)} to create the plan.
   *
   * @param env environment the agent will join
   * @param miner is the agent a miner ?
   * @return the plan.
   */
  protected Plan<BCAgent<Transaction>> getPlan(String env, boolean miner) {
    
  }

  /**
   * Build the static part of the agent plan.
   *
   * @param env environment to join
   * @param miner is the agent a miner ?
   * @return the static part of the plan
   * @see #getPlan(String, boolean)
   */
  protected List<ActionActivator<BCAgent<Transaction>>> getStaticPlanPart(
      String env, boolean miner) {
    
  }

  /**
   * Get the dynamic part of the plan (event-bound actions).
   *
   * @param env Environment where actions are executed
   * @return the event-bound part of the plan
   * @see #getPlan(String, boolean)
   */
  protected Map<Class<? extends EnvironmentEvent>, List<ReactiveAction<BCAgent<Transaction>>>>
      getEventBoundPart(String env, boolean miner) {
    
  }

}

Now, what kind of actions we must schedule ?

Well, first we need to join the environment. This is done by scheduling an ACTakeRole action. The role to take depends on if the agent is a miner (RBCMiner) or a user (RBCUser). Next we need to schedule some network actions. Those actions will automatically discover neighbors and connect to them. They are named ACDiscoverNeighbors, ACCleanupConnections and ACCleanupWaitingConnections. Finally, we have some special actions dedicated to bitcoin users:

  • The first one is to set the transaction factory using the action ACSetTransactionFactory. No need to create our own factory, we will use the DoubleTransactionPayloadFactory which creates a transaction about double values.
  • The second one is our ACIssueTransaction so, our users spend some money!

Regarding schedules: it is up to you to decide when all those actions will be executed. In our example we will propose a schedule but feel free to change it.

// File: src/main/java/max/bitcoin/example/exp/BCExperimenter.java
package max.example.bcmodel.exp;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import max.core.EnvironmentEvent;
import max.core.action.ACTakeRole;
import max.core.action.EventAction;
import max.core.action.Plan;
import max.core.agent.ExperimenterAgent;
import max.core.scheduling.ActionActivator;
import max.core.scheduling.ActivationScheduleFactory;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.action.ACIssueTransaction;
import max.example.bcmodel.action.BCAgent;
import max.example.bcmodel.role.RBCMiner;
import max.example.bcmodel.role.RBCUser;
import max.model.ledger.blockchain.DoubleTransactionPayloadFactory;
import max.model.ledger.blockchain.action.ACSetTransactionFactory;
import max.model.network.p2p.action.ACCleanupConnections;
import max.model.network.p2p.action.ACCleanupWaitingConnections;
import max.model.network.p2p.action.ACDiscoverNeighbors;
import max.model.network.p2p.action.ACHandleMessages;
import max.model.network.p2p.env.message.MessageEvent;

/**
 * Base experimenter.
 *
 * Defines some useful methods to create users and miners.
 */
public abstract class BCExperimenter<T> extends ExperimenterAgent {

    ...

    /**
   * Build the static part of the agent plan.
   *
   * @param env environment to join
   * @param miner is the agent a miner ?
   * @return the static part of the plan
   * @see #getPlan(String, boolean)
   */
  protected List<ActionActivator<BCAgent<Transaction>>> getStaticPlanPart(
      String env, boolean miner) {
    final List<ActionActivator<BCAgent<Transaction>>> initialPlan = new ArrayList<>();
    final var time = getSimulationTime().getCurrentTick();

    // Take the role in the environment
    initialPlan.add(
        new ACTakeRole<BCAgent<Transaction>>(env, (miner) ? RBCMiner.class : RBCUser.class, null)
            .oneTime(time));

    // Schedule the P2P-related actions
    initialPlan.add(
        new ACDiscoverNeighbors<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));
    initialPlan.add(
        new ACCleanupConnections<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));
    initialPlan.add(
        new ACCleanupWaitingConnections<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));

    // BCUser-related actions
    if (!miner) {
      initialPlan.add(
          new ACSetTransactionFactory<Transaction, BCAgent<Transaction>>(
                  env, new DoubleTransactionPayloadFactory(), null)
              .oneTime(time));
      initialPlan.add(
          new ACIssueTransaction<>(env, null).repeatInfinitely(time, BigDecimal.valueOf(10)));
    }
    return initialPlan;
  }

    ...

}

The final step is to write the event-bound part. In this part, we are going to set up the message handling and bind the BlockCreatorEvent to our ACCreateBCBlock.

To handle messages, we need to create a new ACHandleMessage action and register our message handlers. By default, this action automatically handles messages exchanged by the network layer (mostly to discover neighbors).

To register a message handler, we just need to call the register_handler method and supply the message type and an instance of the handler.

Next step is to bind the action to the MessageEvent type.

Finally, if the agent is a miner, we bind the BlockCreatorEvent to our ACCreateBCBlock.

// File: src/main/java/max/bitcoin/example/exp/BCExperimenter.java
package max.example.bcmodel.exp;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import max.core.EnvironmentEvent;
import max.core.action.ACTakeRole;
import max.core.action.Plan;
import max.core.action.ReactiveAction;
import max.core.agent.ExperimenterAgent;
import max.core.scheduling.ActionActivator;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.action.ACCreateBCBlock;
import max.example.bcmodel.action.ACIssueTransaction;
import max.example.bcmodel.action.BCAgent;
import max.example.bcmodel.env.message.BLOCKMessageHandler;
import max.example.bcmodel.env.message.GETBLOCKMessageHandler;
import max.example.bcmodel.env.message.TXMessageHandler;
import max.example.bcmodel.role.RBCMiner;
import max.example.bcmodel.role.RBCUser;
import max.model.ledger.blockchain.DoubleTransactionPayloadFactory;
import max.model.ledger.blockchain.action.ACSetTransactionFactory;
import max.model.ledger.blockchain.env.BlockCreatorEvent;
import max.model.network.p2p.action.ACCleanupConnections;
import max.model.network.p2p.action.ACCleanupWaitingConnections;
import max.model.network.p2p.action.ACDiscoverNeighbors;
import max.model.network.p2p.action.ACHandleMessages;
import max.model.network.p2p.env.message.MessageEvent;

/**
 * Base experimenter.
 *
 * <p>Defines some useful methods to create users and miners.
 */
public abstract class BCExperimenter extends ExperimenterAgent {

  /**
   * Create and return a new {@link BCAgent}, which will behave like a BC miner.
   *
   * @param environment the environment this agent will join
   * @return the agent instance. Not launched.
   */
  protected BCAgent<Transaction> createMiner(String environment) {
    return new BCAgent<>(getPlan(environment, true));
  }

  /**
   * Create and return a new {@link BCAgent}, which will behave like a BC user.
   *
   * @param environment the environment this agent will join
   * @return the agent instance. Not launched.
   */
  protected BCAgent<Transaction> createUser(String environment) {
    return new BCAgent<>(getPlan(environment, false));
  }

  /**
   * Get an agent plan built using the provided environment.
   *
   * <p>Uses {@link #getStaticPlanPart(String, boolean)} and {@link #getEventBoundPart(String,
   * boolean)} to create the plan.
   *
   * @param env environment the agent will join
   * @param miner is the agent a miner ?
   * @return the plan.
   */
  protected Plan<BCAgent<Transaction>> getPlan(String env, boolean miner) {
    return new Plan<>() {
      @Override
      public List<ActionActivator<BCAgent<Transaction>>> getInitialPlan() {
        return getStaticPlanPart(env, miner);
      }

      @Override
      public Map<Class<? extends EnvironmentEvent>, List<ReactiveAction<BCAgent<Transaction>>>>
          getEventBoundActions() {
        return getEventBoundPart(env, miner);
      }
    };
  }

  /**
   * Build the static part of the agent plan.
   *
   * @param env environment to join
   * @param miner is the agent a miner ?
   * @return the static part of the plan
   * @see #getPlan(String, boolean)
   */
  protected List<ActionActivator<BCAgent<Transaction>>> getStaticPlanPart(
      String env, boolean miner) {
    final List<ActionActivator<BCAgent<Transaction>>> initialPlan = new ArrayList<>();
    final var time = getSimulationTime().getCurrentTick();

    // Take the role in the environment
    initialPlan.add(
        new ACTakeRole<BCAgent<Transaction>>(env, (miner) ? RBCMiner.class : RBCUser.class, null)
            .oneTime(time));

    // Schedule the P2P-related actions
    initialPlan.add(
        new ACDiscoverNeighbors<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));
    initialPlan.add(
        new ACCleanupConnections<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));
    initialPlan.add(
        new ACCleanupWaitingConnections<BCAgent<Transaction>>(env, null)
            .repeatInfinitely(time, BigDecimal.ONE));

    // BCUser-related actions
    if (!miner) {
      initialPlan.add(
          new ACSetTransactionFactory<Transaction, BCAgent<Transaction>>(
                  env, new DoubleTransactionPayloadFactory(), null)
              .oneTime(time));
      initialPlan.add(
          new ACIssueTransaction<>(env, null).repeatInfinitely(time, BigDecimal.valueOf(10)));
    }
    return initialPlan;
  }

  /**
   * Get the dynamic part of the plan (event-bound actions).
   *
   * @param env Environment where actions are executed
   * @return the event-bound part of the plan
   * @see #getPlan(String, boolean)
   */
  protected Map<Class<? extends EnvironmentEvent>, List<ReactiveAction<BCAgent<Transaction>>>>
      getEventBoundPart(String env, boolean miner) {
    final ACHandleMessages<BCAgent<Transaction>> handleMsgAction =
        new ACHandleMessages<>(env, null);
    handleMsgAction.registerMessageType(TXMessageHandler.MESSAGE_TYPE, new TXMessageHandler());
    handleMsgAction.registerMessageType(
        BLOCKMessageHandler.MESSAGE_TYPE, new BLOCKMessageHandler());
    handleMsgAction.registerMessageType(
        GETBLOCKMessageHandler.MESSAGE_TYPE, new GETBLOCKMessageHandler());

    final Map<Class<? extends EnvironmentEvent>, List<ReactiveAction<BCAgent<Transaction>>>>
        result = new HashMap<>();
    result.put(MessageEvent.class, Collections.singletonList(handleMsgAction));
    if (miner) {
      result.put(
          BlockCreatorEvent.class, Collections.singletonList(new ACCreateBCBlock<>(env, null)));
    }

    return result;
  }
}

Testing the model

Time to write some tests!

We are not going to test the entire model, but only a few properties:

  • Are our users losing money ?
  • If a user joins the simulation after some time, does it “catch” missing blocks ?
  • Have we created some blocks ?

The scenario will be the following:

  • at the simulation start, two agents: one miner and one user
  • the oracle triggers the first block creation at tick 10
  • then based on the MAX_MODEL_LEDGER_BLOCK_CREATION_RATE, new blocks will be created
  • at tick 35, a new user joins
  • the simulation ends at tick 100

To test the properties, we will schedule some actions in the experimenter that will access to the agents and verify the properties. We will make sure that:

  • the miner’s blockchain has at least 2 blocks (genesis + a created one)
  • all three agents have the same blocks in their blockchain (they are synchronized, specially user number 2)
  • balance of users at tick 40 is greater than their balance at tick 99 (transactions are issued)

Before we show you the file, a few words about the oracle configuration.

Like we said before, the max.model.ledger:blockchain provides an implementation of an oracle. To configure it, we must supply the environment, the role that block creators have (here it is RBCMiner), an ActivationSchedule to tell when the oracle is activated and finally a strategy. The latter is used to select what we call the “committee members”, i.e. nodes that will decide the next block to create. In bitcoin, this committee is composed of one block (the one who solved the PoW). MAX provides several strategies and even a factory. In this tutorial, we will use MembershipSelectionStrategyFactory#createUniformSingleWinnerPoWStrategy(String) and a block rate set to 5 ticks.

// File: src/test/java/max/example/bcmodel/ApplicationTest.java
package max.example.bcmodel;

import static max.core.MAXParameters.clearParameters;
import static max.core.MAXParameters.setParameter;
import static max.core.test.TestMain.launchTester;

import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import max.core.ExperimenterAction;
import max.core.agent.MAXAgent;
import max.core.scheduling.ActivationScheduleFactory;
import max.datatype.ledger.Transaction;
import max.example.bcmodel.action.BCAgent;
import max.example.bcmodel.env.BCContext;
import max.example.bcmodel.env.BCEnvironment;
import max.example.bcmodel.exp.BCExperimenter;
import max.example.bcmodel.role.RBCMiner;
import max.model.ledger.blockchain.action.BlockCreationOracle;
import max.model.ledger.blockchain.selection.MembershipSelectionStrategyFactory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.io.TempDir;

/** Test the application against various scenarios. */
public class ApplicationTest {

  /** Reset MAX parameters at each test. */
  @BeforeEach
  public void before(@TempDir Path tempDir) {
    clearParameters();
    setParameter("MAX_CORE_SIMULATION_STEP", "1");
    setParameter("MAX_CORE_UI_MODE", "Silent");
    setParameter("MAX_CORE_RESULTS_FOLDER_NAME", tempDir.toString());

    setParameter("MAX_CORE_MAX_OUTBOUND_CONNECTIONS", "10");
    setParameter("MAX_CORE_MAX_INBOUND_CONNECTIONS", "10");
    setParameter("MAX_MODEL_NETWORK_DELAY", "1");
    setParameter("MAX_MODEL_NETWORK_RELIABILITY", "1");

    setParameter("MAX_MODEL_LEDGER_FEE", "0.0");
    setParameter("MAX_MODEL_LEDGER_MAX_NUMBER_TXS", "4000");
    setParameter("MAX_MODEL_LEDGER_BLOCK_CREATION_RATE", "5");
  }

  /**
   * Test all messages:
   *
   * <ul>
   *   <li>Issue transactions to test TX messages
   *   <li>Create blocks to test BLOCK messages
   *   <li>Make a user join the simulation later, so it will need to issue some GET_BLOCK messages
   * </ul>
   *
   * @param testInfo Injected by JUnit5
   */
  @Test
  public void messagingTest(TestInfo testInfo) throws Throwable {
    final var experimenter =
        new BCExperimenter() {
          private BCEnvironment env;
          private BCAgent<Transaction> miner;
          private BCAgent<Transaction> user1;
          private BCAgent<Transaction> user2;

          private double user1Balance;
          private double user2Balance;

          @Override
          protected List<MAXAgent> setupScenario() {
            env = new BCEnvironment();
            BlockCreationOracle oracle =
                new BlockCreationOracle(
                    env.getName(),
                    ActivationScheduleFactory.createOneTime(BigDecimal.TEN),
                    RBCMiner.class,
                    MembershipSelectionStrategyFactory.createUniformSingleWinnerPoWStrategy(
                        env.getName()));

            miner = createMiner(env.getName());
            user1 = createUser(env.getName());

            return Arrays.asList(env, oracle, miner, user1);
          }

          @Override
          protected void setupExperimenter() {
            // Schedule an action to create user2
            schedule(
                new ExperimenterAction<>(this) {
                  @Override
                  public void execute() {
                    user2 = createUser(env.getName());
                    launchAgents(Collections.singletonList(user2));
                  }
                }.oneTime(35));

            schedule(
                new ExperimenterAction<>(this) {
                  @Override
                  public void execute() {
                    final var ctx1 = (BCContext<Transaction>) user1.getContext(env.getName());
                    final var ctx2 = (BCContext<Transaction>) user2.getContext(env.getName());

                    user1Balance = ctx1.getBalance();
                    user2Balance = ctx2.getBalance();
                  }
                }.oneTime(40));

            // Schedule an action to test properties of the system
            schedule(
                new ExperimenterAction<>(this) {
                  @Override
                  public void execute() {
                    final var ctx0 = (BCContext<Transaction>) miner.getContext(env.getName());
                    final var ctx1 = (BCContext<Transaction>) user1.getContext(env.getName());
                    final var ctx2 = (BCContext<Transaction>) user2.getContext(env.getName());

                    // At least one block created ?
                    Assertions.assertTrue(ctx0.getBlockTree().size() > 0);

                    // Make sure they have the same blockchain (same size and same blocks)
                    Assertions.assertEquals(ctx0.getBlockTree().size(), ctx1.getBlockTree().size());
                    Assertions.assertEquals(ctx0.getBlockTree().size(), ctx2.getBlockTree().size());

                    for (final var block : ctx0.getBlockTree()) {
                      Assertions.assertTrue(ctx1.getBlockTree().contains(block.hashCode()));
                      Assertions.assertTrue(ctx2.getBlockTree().contains(block.hashCode()));
                    }

                    // Transactions issued ?
                    Assertions.assertTrue(ctx1.getBalance() < user1Balance);
                    Assertions.assertTrue(ctx2.getBalance() < user2Balance);
                  }
                }.oneTime(95));
          }
        };

    launchTester(experimenter, 100, testInfo);
  }
}

Conclusion

As you can see, it is a matter of a few hours to create a working blockchain model. MAX provides plenty of tools to ease the creation of your model so, you can focus on what matters to you.

The model we created is not perfect and make some assumptions, but it is a good way to start. Try to improve it and write more complex scenarios to test it!

Download project