Symbol Mosaic Levy?

December 28, 2023

One of the few NEM features that was not completely migrated to Symbol is the Mosaic Levy. But, did you know you can use Symbol's features to approximate a mosaic levy? Read on to find out how!

NEM

Let's create a NEM mosaic on testnet and observe its behavior!

First, please review this article about sending NEM transactions with the Symbol SDK. We'll be using the prepare_and_send_transaction function to simplify the examples in this article.

Create Namespace

In NEM, all mosaics are tied to a namespace, so let's create a namespace first:

async def create_namespace(facade, signer_key_pair, namespace_name):
    await prepare_and_send_transaction(facade, signer_key_pair, {
        'type': 'namespace_registration_transaction_v1',
        'rental_fee_sink': 'TAMESPACEWH4MKFMBCVFERDPOOP4FK7MTDJEYP35',
        'rental_fee': xem(100),
        'name': namespace_name,

        'fee': xem(10)
    })

We start by creating a transaction with the type of namespace_registration_transaction_v1, which is used to register or renew a namespace. Namespaces are global resources. In order to prevent abuse, each time a namespace is created or renewed a fixed fee must be paid to the network in addition to the transaction fee. This fee is called a rental fee because all namespace registrations are temporary and need to be renewed annually. The rental fee is fixed at one hundred XEM and must be paid to the rental fee sink account TAMESPACEWH4MKFMBCVFERDPOOP4FK7MTDJEYP35. Additionally, we need to pay a ten XEM transaction fee. Finally, we set the name for the namespace that we want.

Create Mosaic

Let's create a mosaic with a levy inside of the namespace we just created:

async def create_mosaic(facade, signer_key_pair, namespace_name, mosaic_name):
    await prepare_and_send_transaction(facade, signer_key_pair, {
        'type': 'mosaic_definition_transaction_v1',
        'rental_fee_sink': 'TBMOSAICOD4F54EE5CDMR23CCBGOAM2XSJBR5OLC',
        'rental_fee': xem(50),

        'mosaic_definition': {
            'owner_public_key': signer_key_pair.public_key,
            'id': {
                'namespace_id': {'name': namespace_name.encode('utf8')},
                'name': mosaic_name.encode('utf8')
            },
            'description': 'example mosaic with levy'.encode('utf8'),
            'properties': [
                {'property_': {'name': 'divisibility'.encode('utf8'), 'value': '0'.encode('utf8')}},
                {'property_': {'name': 'initialSupply'.encode('utf8'), 'value': '100'.encode('utf8')}},
                {'property_': {'name': 'supplyMutable'.encode('utf8'), 'value': 'false'.encode('utf8')}},
                {'property_': {'name': 'transferable'.encode('utf8'), 'value': 'true'.encode('utf8')}}
            ],
            'levy': {
                'transfer_fee_type': 'absolute',
                'recipient_address': facade.network.public_key_to_address(signer_key_pair.public_key),
                'mosaic_id': {
                    'namespace_id': {'name': 'nem'.encode('utf8')},
                    'name': 'xem'.encode('utf8')
                },
                'fee': xem(1)
            }
        },

        'fee': xem(10)
    })

We start by creating a transaction with the type of mosaic_definition_transaction_v1, which is used to create a mosaic. Like namespaces, mosaics are global resources and require the payment of rental fees. Unlike namespaces, mosaics automatically share the lifetime of their owning namespace. The rental fee is fixed at fifty XEM for mosaics and must be paid to the rental fee sink account TBMOSAICOD4F54EE5CDMR23CCBGOAM2XSJBR5OLC. Additionally, we need to pay a ten XEM transaction fee.

ℹ️ A key change in Symbol was the decoupling of the lifetimes of namespaces and mosaics. In NEM, this coupling sometimes led to problems when a namespace was not renewed. When it expired, all of its associated mosaics also disappeared from all accounts, which was almost always unexpected and undesirable.

Most importantly, we need to define the new mosaic by specifying a mosaic definition. The owner_public_key must match the transaction signer and is redundant. We provide a mosaic id that is composed of two parts - the namespace_id of the namespace that will own the namespace and the name of the mosaic. A mosaic description allows a friendly description of the mosaic to be stored with it in the blockchain. Mosaic properties configure the behavior of the mosaic.

  • divisibility - The number of decimal places the mosaic can have; here we set it to zero
  • initialSupply - The number of initial units of the mosaic; here we set it to one hundred
  • supplyMutable - Boolean set to true if the mosaic supply can change; here we set it to false
  • transferable - Boolean set to true if the mosaic can be sent to arbitrary accounts; here we set it to true

Finally, we configure a levy. transfer_fee_type can be either absolute - indicating the levy amount is constant - or percentile - indicating the levy amount is proportional to the amount transferred. recipient_address is the account to which the levy is paid. We set it to the mosaic owner account, but it could be any arbitrary account. mosaic_id is the mosaic that should be used for the levy payment. We chose XEM, which can be fully specified as nem.xem. We set a fee of one XEM, so that any time any amount of this mosaic is transferred, a one XEM levy is paid.

Send Mosaic

Let's create a helper function for sending an arbitrary mosaic:

async def send_mosaic(facade, signer_key_pair, namespace_name, mosaic_name, recipient_address):
    await prepare_and_send_transaction(facade, signer_key_pair, {
        'type': 'transfer_transaction_v2',
        'recipient_address': recipient_address,
        'amount': xem(1),

        'mosaics': [
            {
                'mosaic': {
                    'mosaic_id': {
                        'namespace_id': {'name': namespace_name.encode('utf8')},
                        'name': mosaic_name.encode('utf8')
                    },
                    'amount': amount
                }
            }
        ],

        'fee': xem(1)
    })

We start by creating a transaction with the type of transfer_transaction_v2, which is used to send mosaics. We specify a recipient (recipient_address) and pay a one XEM transfer fee. We set amount to one XEM, which is always recommended when sending mosaic bags. It is not actually an amount, but a multiplier by which all the amounts within mosaics are multiplied. Finally, we can specify the mosaic(s) we want to transfer in the mosaics array. We only want to send the mosaic we just created, so we just need a single mosaic item. We set its mosaic_id to the id we sent in the mosaic_definition, and we set the amount to the desired amount.

Sending from Owner to Other

Let's write some code to create a namespace and mosaic so that we can send mosaics around:

    await create_namespace(facade, signer_key_pair, '<YOUR_NAMESPACE_NAME>')
    await create_mosaic(facade, signer_key_pair, '<YOUR_NAMESPACE_NAME>', '<YOUR_MOSAIC_NAME>')

Let's send ten units of our new mosaic from the mosaic owner's account (Alice) to another account (Bob):

    await send_mosaic(facade, signer_key_pair, '<YOUR_NAMESPACE_NAME>', '<YOUR_MOSAIC_NAME>', recipient_address, 10)

After the transaction, the following state changes should have happened:

  • Alice -1 XEM - payment of transaction fee
  • Alice -10 MOSAIC - transfer of mosaic
  • Bob +10 MOSAIC - transfer of mosaic

Sending from Other to Owner

Now, let's return five units from Bob to Alice:

    await send_mosaic(facade, recipient_key_pair, '<YOUR_NAMESPACE_NAME>', '<YOUR_MOSAIC_NAME>', signer_address, 5)

After the transaction, the following state changes should have happened:

  • Alice +1 XEM - mosaic levy payment
  • Alice +5 MOSAIC - transfer of mosaic
  • Bob -1 XEM - payment of transaction fee
  • Bob -1 XEM - mosaic levy payment
  • Bob -5 MOSAIC - transfer of mosaic

The mosaic levy was applied successfully!

ℹ️ Notice that the mosaic levy payment is not written in the blockchain anywhere. It is an invisible state change and there is no way to validate it has been applied across all nodes easily. In Symbol, receipts and state hashes ensure that all nodes explicitly agree on the same blockchain state.

Limitations

Hopefully, after the previous examples, you now have a better understanding of how levies work in NEM! While levies can be useful in certain situations, there are some issues with NEM levies that you should understand.

First, due to the processing complexity, levies are not processed recursively. To understand what this means, let's consider an example. Assume mosaic Apple has an absolute levy of one hundred XEM. Assume mosaic Lemon has an absolute levy of one Apple. When Lemon is transferred, one Apple is moved as part of its levy, but the Apple's one hundred XEM levy is bypassed. While this has been used positively in the past to remove spam mosaics with high levies, it is ultimately a bug that allows the bypassing of levies.

Second, levies are very primitive in they only support two types of calculations absolute and percentile. Other levy calculations - for example, one tied to current YEN rate - could be more useful in different settings. Unfortunately, any additional logic would require a hard fork of the entire NEM network.

Symbol

If we distill a levy to its most basic form, we can understand that a levy is a transfer tax. In other words, any levy system needs a way to block transfers unless a predetermined tax is paid. Is there a way we can emulate that in Symbol? Let's think 🤔 ...

We can certainly pay a tax along with a mosaic transfer using an aggregate transaction. Could we enforce it in all cases? What if someone tries to send the mosaic directly without the tax payment? Well, we can prevent that too by creating a non-transferable mosaic. In this case, the mosaic owner would need to be a party to any transfers and would be able to reject any transfer requests without proper tax payments.

First, please review this article about sending Symbol transactions with the Symbol SDK. We'll be using the prepare_and_send_transaction function to simplify the examples in this article.

Create Mosaic

Unlike in NEM, Symbol mosaics are independent of namespaces. Since we don't need to create a namespace, let's just create a mosaic in Symbol:

async def create_mosaic(facade, signer_key_pair):
    embedded_transactions = [
        facade.transaction_factory.create_embedded({
            'type': 'mosaic_definition_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'nonce': 3,
            'divisibility': 0
        }),
        facade.transaction_factory.create_embedded({
            'type': 'mosaic_supply_change_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'mosaic_id': generate_mosaic_id(facade.network.public_key_to_address(signer_key_pair.public_key), 3),
            'delta': 1000,
            'action': 'increase'
        })
    ]

    merkle_hash = facade.hash_embedded_transactions(embedded_transactions)

    await prepare_and_send_transaction(facade, signer_key_pair, {
        'type': 'aggregate_complete_transaction_v2',
        'signer_public_key': signer_key_pair.public_key,
        'transactions_hash': merkle_hash,
        'transactions': embedded_transactions
    })

For purposes of atomicity, we will create the mosaic using an aggregate composed of two transactions. A single account - the mosaic owner - is used as the signer for the aggregate and all of its embedded transactions.

The first embedded transaction is a mosaic definition transaction of type mosaic_definition_transaction_v1. nonce is scoped to the mosaic owner and used to derive the globally unique mosaic id. divisibility is set to zero to indicate that fractional units are not supported. No flags are set, so the default values will be used: immutable supply, not transferable, not restrictable and not revokable. For the purposes of this example, the only important flag is the transferable flag, which must not be set.

The second embedded transaction is a mosaic supply change transaction of type mosaic_supply_change_transaction_v1. Notice we specify the mosaic_id using the address of the mosaic owner and the nonce specified in the previous transaction. delta and action indicate an increase of one thousand mosaic units. Unlike in NEM, where the initial mosaic supply can be set as part of the mosaic definition, in Symbol, supply changes must always be made using a mosaic supply change transaction.

Send Mosaic

Let's create a helper function for sending an arbitrary mosaic:

async def send_mosaic(facade, signer_key_pair, mosaic_id, recipient_address, amount):
    await prepare_and_send_transaction(facade, signer_key_pair, {
        'type': 'transfer_transaction_v1',
        'recipient_address': recipient_address,
        'mosaics': [
            {'mosaic_id': mosaic_id, 'amount': amount}
        ]
    })

We start by creating a transaction with the type of transfer_transaction_v1, which is used to send mosaics. We are only sending a single mosaic, so there is only one item in mosaics. The passed in recipient_address, mosaic_id and amount are all set appropriately in the transfer transaction.

Sending from Owner to Other

Let's write some code to create a mosaic so that we can send mosaics around:

    await create_mosaic(facade, signer_key_pair)

Let's send ten units of our new mosaic from the mosaic owner's account (Alice) to another account (Bob):

    mosaic_id = generate_mosaic_id(facade.network.public_key_to_address(signer_key_pair.public_key), 3)
    await send_mosaic(facade, signer_key_pair, mosaic_id, recipient_address, 10)

After the transaction, the following state changes should have happened:

  • Alice -X XEM - payment of transaction fee
  • Alice -10 MOSAIC - transfer of mosaic
  • Bob +10 MOSAIC - transfer of mosaic

Sending from Other to Owner

Let's send five units of the mosaic from Bob to Alice:

await send_mosaic(facade, recipient_key_pair, mosaic_id, signer_address, 5)

After the transaction, the following state changes should have happened:

  • Alice +5 MOSAIC - transfer of mosaic
  • Bob -X XEM - payment of transaction fee
  • Bob -5 MOSAIC - transfer of mosaic

Sending from Other to Other (Direct)

Let's try to send three units of the mosaic from Bob to another account (Charlie):

    await send_mosaic(facade, recipient_key_pair, mosaic_id, other_address, 3)

This will predictably fail with the error Failure_Mosaic_Non_Transferable because the mosaic is not transferable!

Sending from Other to Other (Indirect)

Let's assume that there is a cloud service (or Lambda function) on your favorite hosting platform. Bob provides the service all the information required to transfer three units of the mosaic to Charlie. The service returns a partially signed aggregate composed of three embedded transactions:

  • Transfer of three mosaic units from Bob to Alice
  • Transfer of three mosaic units from Alice to Charlie
  • Transfer of levy/tax payment from Bob to Alice

If Bob agrees with the fee, he can sign the transaction and submit it to the network. Since Alice - the mosaic owner - is serving as the intermediary, there are no non-transferable violations. If Bob disagrees, he can discard the partially signed transaction and the transfer will never get executed. In fact, this external service is performing a role very similar to oracles on other blockchains.

For concreteness, the aggregate would look something like this:

async def send_mosaic_with_levy(facade, alice_key_pair, bob_key_pair, charlie_address, mosaic_id, amount):
    currency_mosaic_id = await get_currency_mosaic_id()

    alice_address = facade.network.public_key_to_address(alice_key_pair.public_key)
    embedded_transactions = [
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': bob_key_pair.public_key,
            'recipient_address': alice_address,
            'mosaics': [
                {'mosaic_id': mosaic_id, 'amount': amount}
            ]
        }),
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': alice_key_pair.public_key,
            'recipient_address': charlie_address,
            'mosaics': [
                {'mosaic_id': mosaic_id, 'amount': amount}
            ]
        }),
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': bob_key_pair.public_key,
            'recipient_address': alice_address,
            'mosaics': [
                {'mosaic_id': currency_mosaic_id, 'amount': 1_000000}
            ]
        })
    ]

    merkle_hash = facade.hash_embedded_transactions(embedded_transactions)

    await prepare_and_send_transaction(facade, alice_key_pair, {
        'type': 'aggregate_complete_transaction_v2',
        'signer_public_key': alice_key_pair.public_key,
        'transactions_hash': merkle_hash,
        'transactions': embedded_transactions
    }, cosignatory_key_pairs=[bob_key_pair])

As described above, the aggregate is composed of three transfer transactions. It is signed by both Alice and Bob.

Reflections

One could argue that having a centralized oracle for facilitating the transfer of mosaics with levies is not decentralized enough. While true, this is a classic trade off between decentralization and flexibility. Using a mosaic without the transferable flag set ensures there are no ways around the levy/tax payment. Using a centralized service allows the use of dynamic fee strategies, including ones tied to the price of YEN. Both of these are improvements over the NEM levy implementation!

Irrespective of the reduced decentralization, I hope you can see the value in such services. They allow you to use more traditional cloud development skills and provide yet another avenue for extending Symbol. There are a lot of possibilities. In fact, you could build a service that simulates Symbol partial transaction logic. Except, instead of creating aggregate bonded transactions, it could create aggregate complete transactions and avoid the hash lock transaction requirement!