Q&A - Toshi Mosaic Levy II

January 11, 2024

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.

Observer

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:

namevalue
context.ModeNotifyMode::Commit
notification.ActionMosaicLevyChangeAction::Increase
notification.Delta5000000

After execution, mosaicEnty will have the following values:

namevalue
m_isLeviedtrue
m_levy5000000

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:

namevalue
m_isLeviedtrue
m_levy0

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.

Serializer

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!

Mosaic Levy Transfer Validator

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;

Bonus: Mosaic Levy Change Transaction

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!

Bonus: Receipts

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.