Understanding TXes - Links

January 13, 2024

Symbol has four types of key link transactions - Account, Node, Voting and VRF. Each of these has a different purpose, but each effectively creates a commitment on chain by preregistering a supplemental key with an account. They can be standalone transactions or embedded in aggregate transactions. For a deeper understanding of transactions, it's recommended to read Chapter 6 of the Symbol Technical Reference.

For the purposes of this article, we're going to inspect the parts of each of these transactions and learn about the decisions that went into designing them. Let's begin!

The account key link transaction enables non-custodial staking in Symbol. In order to understand what this means and why it's important, let's review how blocks are produced. The probability of a block being produced by an account is strongly correlated to its stake (or the amount of XYM it owns). In order to associate a block with an account, the block must be signed by the account. In custodial systems, the account (containing the XYM) needs to sign the block. This would mean that the private keys of those high value accounts would need to be present on network-connected nodes. From a security perspective, this is suboptimal. If a node becomes compromised, the valuable harvesting account private key could be stolen as well, and the owner would suffer a great loss.

In order to make harvesting less risky, in Symbol, there is a non-custodial system. The harvesting-eligible account is called the main account. This account creates a special account key link transaction that delegates its harvesting authority to another account. This other account is variably called the linked or remote account. It must be a new account - it cannot have been previously used for any reason. Any blocks signed by the remote account, in the presence of an account link, are treated as signed by the main account for consensus purposes. In this system, only the remote account private key needs to be present on a network-connected node. Since the remote account can never own assets, an attacker cannot profit from its theft. If it is compromised, the recommended recourse is to simply relink a new remote account.

⚠️ A 51% attack is executed when an attacker gains 51% of the harvesting capability of a network. An attacker could attempt a 51% attack by collecting active remote harvesting keys since they are granted harvesting authority. This is one of the reasons why it's important to have delegated harvesters spread out around nodes evenly. In addition, it's important to handle your remote private keys with care and/or rotate them regularly to mitigate such attacks.

A main account can delegate harvesting authority to exactly one remote account at a time. Likewise, a remote account can only have delegated harvesting authority from one main account at a time. In order to allow changes to remote accounts, any existing links must first be revoked.

The full schema can be found here.

Static Properties

TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, ACCOUNT_KEY_LINK)

The version of the account key link transaction discussed in this article is 1. The ACCOUNT_KEY_LINK transaction type is 0x414C - entity type (0x1 << 14), code (0x1 << 8), facility code (0x4C).

Layout

# Linked public key.
linked_public_key = PublicKey

The linked public key is the public key of the linked (or remote) account. Harvesting authority will be delegated to this account from the account signing the transaction.

# Account link action.
link_action = LinkAction

Link action can have one of two values:

  • LINK - Harvesting authority should be newly delegated
  • UNLINK - Harvesting authority should be revoked

ℹ️ When unlinking, linked_public_key must specify the public key of the active remote account. While this could technically be retrieved from the account cache, requiring it to be set as part of the transaction simplifies implementation logic considerably. During a rollback, the full information for undoing the unlink (linking) is present. As a result, the cache can be much simpler and doesn't need to maintain a history of account key links.

The node key link transaction enables delegated harvesting in Symbol. A node that wants to delegate harvest on a remote node creates a special node key link transaction. It needs to specify the public key of the node on which it wants to delegate harvest.

An account can only delegate harvest to exactly one remote node at a time. In contrast, a node can host many delegated harvesters at once. In order to allow changes to remote nodes, any existing links must first be revoked.

⚠️ Well behaved nodes voluntarily stop delegated harvesting with an account when it switches its node key link. Unfortunately, this is not enforced by the protocol. When an account moves delegated harvesting from one node to another, it is highly recommended for it to rotate its linked remote and VRF keys. This prevents a remote node from continuing to use its information to harvest. It also helps prevent the concentration of too much harvesting capability in one place.

The full schema can be found here.

Static Properties

TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, NODE_KEY_LINK)

The version of the node key link transaction discussed in this article is 1. The NODE_KEY_LINK transaction type is 0x424C - entity type (0x1 << 14), code (0x2 << 8), facility code (0x4C).

Layout

# Linked public key.
linked_public_key = PublicKey

The linked public key is the public key of the remote node. For avoidance of doubt, this is the public key from the node's bottom level SSL certificate (typically found in node.key.pem). A well behaved remote node will only host a delegated harvester when its node public key matches its own.

# Account link action.
link_action = LinkAction

Link action can have one of two values:

  • LINK - Allow delegated harvesting on specified node
  • UNLINK - Disallow delegated harvesting on specified node

ℹ️ When unlinking, all other fields must have the same values as used during linking.

In Symbol, every eligible harvester account must preregister a verifiable random function (VRF) public key. This VRF is used to generate a block's generation hash given the parent block's generation hash. The result is pseudo random but fully verifiable. The proof of this generated value is stored in each block's generation_hash_proof field.

Since one input into the VRF function is private - the VRF private key - other nodes cannot calculate it directly. They can only prove that it calculated correctly. As a result, malicious actors can not prepare a long chain of blocks in private and/or try to manipulate block generation in their favor. Overall, this leads to more fair (and random) block generation.

ℹ️ In order for a block to be accepted in the chain, its hit must be less than its target. The hit is derived from the block's generation hash, and the target is derived from the block signer's account state. In turn, the block's generation hash is derived from the parent block's generation hash (this is one of the ways blocks are chained). Assume that generation hashes could be calculated without any private data. An attacker could theoretically calculate the generation hashes that all eligible harvesters could produce for the next N blocks. The attacker could then attempt to mine on all of those chains simultaneously to maximize its profit (or EV).

It's critical that the VRF public keys are preregistered. If they were not, the attacker could register or use them on demand in their hidden chain. Since the attacker would know their values, it would be able to generate all the generation hashes. This would effectively defeat the protection that VRFs were intended to provide in the first place!

An account can have exactly one VRF registered at a time. In order to allow changes to VRFs, any existing links must first be revoked.

The full schema can be found here.

Static Properties

TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, VRF_KEY_LINK)

The version of the VRF key link transaction discussed in this article is 1. The VRF_KEY_LINK transaction type is 0x4243 - entity type (0x1 << 14), code (0x2 << 8), facility code (0x43).

Layout

# Linked public key.
linked_public_key = PublicKey

The linked public key is the public key of the VRF key pair.

# Account link action.
link_action = LinkAction

Link action can have one of two values:

  • LINK - Registers a VRF with an account
  • UNLINK - Unregisters a VRF from an account

ℹ️ When unlinking, all other fields must have the same values as used during linking.

In Symbol, voting adds deterministic consensus on top of probabilistic consensus (PoS+). The blockchain is broken down into epochs (720 blocks in mainnet). A voting key is active for exactly one epoch. Since it would be annoying and wasteful to register a key for each epoch explicitly, instead a root voting key pair is registered for a range of epochs. All voting key pairs within the range can be derived from the root voting key pair. Each voting key pair is deleted after its epoch passes.

ℹ️ This deletion is a form of forward secrecy. If an attacker compromises a node and gains access to voting private keys, the damage is limited. The attacker will only be able to affect the finalization of present and future epochs, but not past epochs. This raises confidence that once blocks are finalized, they are finalized forever. Since the voting private keys for past epochs have already been destroyed, an attacker would not be able to finalize an alternate chain of blocks.

Like with VRFs, voting keys need to be preregistered. Otherwise, an attacker could attempt to use/register them on demand. This would largely limit the benefits of voting.

As a consequence of voting key pairs being time limited, multiple root voting key pair can be registered at once. In mainnet, up to three root voting key pairs can be associated with a single account, and each one can be active for up to 720 epochs. All registered root voting key pairs must have non-overlapping ranges. Two registrations are sufficient to allow a clean rollover, such that an account does not need to skip voting in any epoch when its root voting key pair changes. Three registrations are allowed because the possibility of an extra registration simplifies a handful of operational workflows.

The full schema can be found here.

Static Properties

TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, VOTING_KEY_LINK)

The version of the voting key link transaction discussed in this article is 1. The VOTING_KEY_LINK transaction type is 0x4143 - entity type (0x1 << 14), code (0x1 << 8), facility code (0x43).

Layout

# Linked public key.
linked_public_key = PublicKey

The linked public key is the public key of the root voting key pair.

# Starting finalization epoch.
start_epoch = FinalizationEpoch

The first epoch that the root voting key pair should be active.

# Ending finalization epoch.
end_epoch = FinalizationEpoch

The last epoch that the root voting key pair should be active.

# Account link action.
link_action = LinkAction

Link action can have one of two values:

  • LINK - Registers a root voting key pair with an account
  • UNLINK - Unregisters a root voting key pair from an account

ℹ️ When unlinking, all other fields must have the same values as used during linking.