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:
- 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.
- 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 aINVENTORY
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
- Transactions are disseminated using a
- Use dummy transactions: each user will have some money to spend and will issue transactions to random addresses. No transaction fees in this model.
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 simulatormax.datatype:com
: datatypes related to communication between nodesmax.datatype:ledger
: datatypes related to ledger technologymax.model.network:p2p
: an implementation of a peer-to-peer networkmax.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 clientsRBlockchainMaintainer
: Responsible for maintaining and replicating the blockchain data structureRBlockCreator
: Agents that can create a blockRTransactionEndorser
: 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)
- transaction propagation (
- 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 inmax.datatype:ledger
).BLOCK
which carries a Block (seemax.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
- 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 theRNetworkPeer
role, which is the base role to enable communication. See the P2P documentation for more details about communications between agents. - 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:
- First we get the last block number in the blockchain
- Then we compare that block number with the block number of the received block:
- If
lastBlockNumber + 1 == blockNumber
then we can append the received block - Else then the received block is orphan: we need to retrieve its parent
- If
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
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:
execute
, which is called each time the action is executed.copy
, which must be overridden if the action will be a part of aPlan
.
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:
- Retrieve the context
- Create a new random address
- Create a random amount to transfer
- If the amount is less than the current balance:
- Create the transaction
- Propagate
- 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
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
- 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.
- To get the simulation time, we can use
getCurrentTick()
on the action’s owner. - 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 actionACSetTransactionFactory
. No need to create our own factory, we will use theDoubleTransactionPayloadFactory
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!