Toshi recently wrote another article about extending the mosaic plugin to support mosaic levies. He did a very good job again! Let's look at what he did and see if we can make any improvements.
Let's look a little closer at the implementation of the MosaicLevyChangeObserver
observer:
DEFINE_OBSERVER(MosaicLevyChange, model::MosaicLevyChangeNotification, [](
const model::MosaicLevyChangeNotification& notification,
const ObserverContext& context) {
auto mosaicId = context.Resolvers.resolve(notification.MosaicId);
auto& mosaicCache = context.Cache.sub<cache::MosaicCache>();
auto mosaicIter = mosaicCache.find(mosaicId);
auto& mosaicEntry = mosaicIter.get();
mosaicEntry.setLevy();
if (ShouldIncrease(context.Mode, notification.Action)) {
mosaicEntry.increaseLevy(notification.Delta);
} else {
mosaicEntry.decreaseLevy(notification.Delta);
}
})
Assume that mosaicEntry
does not a levy set and that the observer is executed with the following values:
name | value |
---|---|
context.Mode | NotifyMode::Commit |
notification.Action | MosaicLevyChangeAction::Increase |
notification.Delta | 5000000 |
After execution, mosaicEnty
will have the following values:
name | value |
---|---|
m_isLevied | true |
m_levy | 5000000 |
This looks like what we would expect and is correct!
Now, assume something went wrong and the transaction needs to be rolled back.
During rollback context.Mode
will change to NotifyMode::Rollback
but all the other values will be the same as before.
After (rollback) execution, mosaicEntry
will have the following values:
name | value |
---|---|
m_isLevied | true |
m_levy | 0 |
Hmm, that looks wrong. The changes were not completely rolled back. Remember, in order to ensure state consistency after rollback, the following must always be true:
S_0 = <initial state>
S_1 = execute(S_0, transaction)
S_2 = rollback(S_1, transaction)
S_0 == S_2
In this case, S_0 != S_2
because the m_isLevied
value is different.
Since the flag is sticky, there are a few other issues with the current implementation.
For example, if (MosaicLevyChangeAction::Increase
, 5000000
) is followed by (MosaicLevyChangeAction::Decrease
, 5000000
) the levy is effectively zero.
Should m_isLevied
be true or false?
One potential fix is to make isLevied
a computed property:
bool MosaicEntryLevyMixin::isLevied() const {
return Amount(0) == m_levy;
}
But, this will not work for serialization as we will see next.
Let's look at the modified serializer Load
function:
MosaicEntry MosaicEntrySerializer::Load(io::InputStream& input) {
auto mosaicId = io::Read<MosaicId>(input);
auto supply = io::Read<Amount>(input);
auto definition = LoadDefinition(input);
auto entry = MosaicEntry(mosaicId, definition);
entry.increaseSupply(supply);
if (!input.eof()) {
auto levy = io::Read<Amount>(input);
entry.increaseLevy(levy);
}
return entry;
}
Notice the use of eof
inside of Load
.
In certain scenarios, this will lead to problems because input
can contain one or more values, so the eof
value is not guaranteed to mark the end of the current value.
In addition, the isLevied
function from the previous section will not help us.
It will only be correct after the levy has been loaded.
As a result, we can't use it to determine whether or not there is a levy to load.
For a new blockchain, it would be easy to change the data format. For example, we could write a flag prior to the levy amount to indicate whether or not a levy is present. For an existing blockchain, this would also be possible, but it would require introducing a new mosaic entry binary format version. Nonetheless, since this is a lot of work, we'd like to avoid this.
We really need to encode the presence of a mosaic levy within the existing binary format, somehow.
Let's look at SaveProperties
:
void SaveProperties(io::OutputStream& output, const model::MosaicProperties& properties) {
io::Write8(output, utils::to_underlying_type(properties.flags()));
io::Write8(output, properties.divisibility());
io::Write(output, properties.duration());
}
Notice that we're using a full byte to store the mosaic flags, but they only have four significant bits. Knowing this, we can set the high bit if a mosaic levy is present. This would let us know if a levy is present during load:
// save
io::Write8(output, (entry.isLevied() ? 0x80 : 0x00) | utils::to_underlying_type(properties.flags()));
// load
auto flags = static_cast<model::MosaicFlags>(io::Read8(input));
auto hasLevy = flags & 0x80;
flags &= 0x7F;
Using hasLevy
, we should be able to write MosaicEntrySerializer::Load
without needing to call eof
!
This validator is checking whether or not the sending account has a sufficient balance to pay the levy (if present).
In the case there is no levy, ValidationResult::Success
should be returned.
Since Success
is zero, the following, as written, will work:
if(mosaicIter.get().isLevied() == false)
return;
But, it's always better to be explicit (and raise your compiler warning settings):
if (!mosaicIter.get().isLevied())
return ValidationResult::Success;
It is possible to allow a levy of an arbitrary mosaic, as opposed to just the currency mosaic. In order to do so, this transaction would need to be modified to specify the levy mosaic id. Additional validation would need to be added to ensure that subsequent transactions don't change the levy mosaic id unless the levy mosaic is unset. But, it should be possible!
One of the principles of Symbol is to avoid invisible state changes caused by side effects.
Many state changes are directly observable from the transaction history.
For example, a transfer of 500 FOO
from Alice to Bob is recorded directly in a transfer transaction.
But, if FOO
has a levy and triggers a side effect transferring 5 XYM
from Alice to Charlie, that is not recorded.
In order to bring such information to the forefront, the concept of receipts was added. These are intended to record - and bring visibility to - side effects. Receipts are primarily used to communicate state changes triggered by side effects to blockchain watchers (e.g. wallets and explorers). In this way, they allow watchers to still be aware of complex state changes.
There is a ReceiptsHash
in every Symbol block, which forces all nodes to agree on the same set of receipts in order to reach consensus.
Conceptually, with receipts, all state changes in a block should be fully explained by the block's transactions and receipts.
In other words, there should be no hidden side-effects.
In order to improve the MosaicLevyTransferObserver
to conform to this requirement, we need to add a receipt.
There are a number of predefined receipts, but, for our purposes, the one we need to create is the BalanceTransferReceipt
:
# An invisible state change triggered a mosaic transfer.
inline struct BalanceTransferReceipt
inline Receipt
# Transferred mosaic
mosaic = Mosaic
# Address of the sender account.
sender_address = Address
# Address of the recipient account.
recipient_address = Address
In Catapult, this receipt is raised in multiple places to indicate transfers initiated by side effects.
For example, in the RentalFeeObserver
, it is used to announce rental fee payments:
model::BalanceTransferReceipt receipt(receiptType, senderAddress, recipientAddress, mosaicId, effectiveAmount);
context.StatementBuilder().addReceipt(receipt);
For completeness, and correctness, the MosaicLevyTransferObserver
should raise this receipt too.