Execution model

Transactions

The Cosmos execution model is linear and single-threaded. Like most chains, a Cosmos chain is a series of blocks starting at the genesis and counting up over time. Each block has a block height (1, 2, 3, etc) and block time. A block contains 0 or more transactions: signed messages that have paid some gas fee to perform something on-chain. Each transaction contains 1 or more messages, which are the actual actions to be performed.

There are many different message types in Cosmos, and individual chains may add their own message types. For example, Osmosis's Dex has a number of custom messages for performing swaps. One of the most versatile messages is around smart contract execution, which we'll discuss in CosmWasm.

Gas

Whenever you perform an action via a message, that action takes some amount of gas. Gas is a unit of measuring the execution cost of a message. Actions that perform more work on the CPU and perform more storage actions will end up using more gas.

NOTE A common source of confusion is confusing the gas amount with the gas fee. Think of the gas amount as the raw amount of compute power you need to perform an action, and the gas fee as paying the bill to the electric company for using that gas.

The common way to determine how much gas a transaction will use is to simulate it. Simulating a transaction asks a node to pretend to execute a transaction, determine the result (success or failure), capture any generated events and log messages, and provide information on how much gas was used. Unfortunately, there are a lot of bugs in Cosmos gas calculations, and so we generally add somewhere between a 30% or 50% buffer on the simulated gas amount to make sure we don't run out of gas while executing a transaction. (Most libraries perform this buffering automatically, and call it the "gas multiplier.")

Each chain has its own way of calculating the gas amount. Additionally, determining the gas fee is chain specific too. On some chains, there is a single coin type allowed to be used for paying gas fees, and it has a fixed rate per unit of gas (e.g. 0.0025ujuno per unit of gas). Other chains have more complex mechanisms, such as Osmosis providing a fee market that automatically increases and decreases the cost of gas based on network congestion.

Gas wanted vs gas used

When you construct a transaction, you need to provide two different values:

  • The amount of gas wanted. This sets an upper bound on the total amount of gas your transaction is allowed to use. If the transaction tries to use more gas than that, your transaction will fail with an "out of gas" error (Cosmos SDK error code 11).
  • The gas fee amount, which needs to be sufficient to cover the gas wanted. This amount should be gas wanted * gas price. If you provide too little gas fee, the node will refuse to accept the transaction with an "insufficient fee" error (Cosmos SDK error code 13).
    • Separately, if you try to broadcast a transaction that uses more for the gas fee than your wallet actually has, you'll receive an "insufficient funds" error (Cosmos SDK error code 5).

Once the transaction lands in a block, in addition to the "gas wanted" value, we'll have a "gas used" value which says how much gas was actually used in practice. In theory, this should be very close to the simulated gas amount. But due to potential on-chain data changes between simulation and real execution, and bugs in the Cosmos SDK, the numbers may end up being significantly different.

Data storage

The chain itself maintains a data storage layer that can be considered the current state of the chain. For example, that data storage layer contains a mapping between every known wallet and the token balances present for that wallet. Generally, that data storage layer is always about the latest block height, though it's possible to perform historical queries to look up historical data.

Note that this data storage layer is separate from the full history of blocks in a chain. A Cosmos node essentially does the following:

  • Start with an empty data storage layer
  • Receive blocks from the rest of the network
  • Execute the transctions in each block sequentially
  • Each execution will either fail (in which case no changes happen to the data storage layer), or succeed, updating the data storage

An interesting side-effect of the above is that nodes are allowed to prune their history, not storing historical block data. As long as a node has the full up-to-date data storage information, it can answer queries about the state of the chain.

Queries

In addition to sending transactions and messages in blocks to make modifications to the chain, you can perform queries to look up information. These kinds of queries require no authentication and do not impact the chain. One thing worth mentioning though: queries--and especially smart contract queries--still calculate how much gas is used to perform the query, and nodes will have a hard-coded gas limit for queries to avoid DoS attacks. The default is 300,000 gas. An upshot of this is that, when designing smart contract query APIs, you need to ensure that queries can reliably complete in under that gas cap, which essentially means you need to use only O(1) operations in smart contract code.

Error handling

Each transaction in a block will either succeed or fail. Success means that every message within the transaction succeeded. In those cases, changes from the transaction will actually update the chain. By contrast, if any of the messages in a transaction fail, the entire transaction will fail, and no changes will be written to the chain. This is fairly similar to ACID guarantees in databases (commit vs rollback), and in fact greatly simplifies the programming model around smart contracts on Cosmos.

Note that smart contracts are allowed to create something called submessages, which are allowed to fail without aborting the transaction overall. But that's a more advanced usage.

Transaction lifecycle

Let's walk through the process of getting a transaction on-chain. It goes something like this:

  1. Application constructs a set of messages it wants to send on-chain.
  2. Application constructs a fake transaction containing those messages. By fake, I mean that it does not need to include real signatures, can request absurd gas amounts, etc.
  3. Application contacts a node and performs a simulate query to determine the result of running the transaction.
  4. Generally, if the transaction failed, the application will report the error and stop, since it's usually not helpful to get a failing transaction on chain. However, technically speaking, an application is free to ignore the error result.
  5. Using the simulate response value, the application constructs the real transaction, including a real gas amount based on the simulated gas (including the 30% buffer mentioned above), a gas fee amount (using whatever calculations are relevant for the chain in question for calculating that), and a real cryptographic signature proving that the wallet owner is trying to perform these actions.
  6. Application broadcasts the transaction to a node. After that, the application will generally repeatedly query the node every 100ms to check if the transaction (identified by the txhash, or transaction hash) has been included in a block yet. In the meanwhile, the node continues operation.
  7. The node will store the new transaction in its mempool as an unconfirmed transaction.
  8. Using Cosmos's peer-to-peer protocol, the node will broadcast the transaction to other nodes in the chain.
  9. Each block in a chain is constructed by a proposer, which will take as many transactions from its mempool as possible and try to construct a block from them. It will then propose that block to the rest of the validator nodes.
  10. The validator nodes see the proposed block, determine if it's valid (signatures match, no invariants of the chain are violated, etc.), and assuming there is consensus, the block is accepted as the next block in the chain.
  11. When this happens, the transaction is now part of a block, and so the applications polling in step (6) will now complete successfully.
  12. Note that, even if a transaction lands in a block, it may still be an error! And even if simulating a transaction succeeded, actual execution could still fail for a number of reasons (chain state changed since simulation, gas estimate was wrong, etc).