Actor model for contract calls
The actor model is a design pattern used to build reliable, distributed systems. The fundamental principles are that each Actor has exclusive access to its own internal state and that Actors cannot call each other directly. Instead, they dispatch messages through a Dispatcher, which maintains the system's state and maps addresses to code and storage. Fundamentally, the Actor pattern can be encapsulated in this interface:
pub trait Actor {
fn handle(msgPayload: &[u8]) -> Vec<Msg>;
}
pub struct Msg {
pub destination: Vec<u8>,
pub payload: Vec<u8>,
}
This is the basic model that was used to model contracts in CosmWasm. You can see the same influence in the function:
pub fn handle<T: Storage>(store: &mut T, params: Params, msg: Vec<u8>) -> Result<Response>
The response contains a Rust **Vec<Msg>**
and some metadata, while store provides access to the contract's internal state and params refer to the global immutable context.
From this basic design, a few other useful aspects can be derived:
First, there is a loose coupling between Actors, limited to the format of the data packets (the recipient must support a superset of what you send). There are no complex APIs or function pointers to pass around. This is much like using REST or RPC calls as a boundary between services, which is a scalable way to compose systems from multiple vendors.
Secondly, each Actor can effectively run on its own thread, with its own queue. This enables both concurrency and serialized execution within each actor. This means that the Handle method above cannot be executed in the middle of a previously executed Handle call. Handle is a synchronous call and returns before the Actor can process the next message. This implies that CosmWasm prevents reentrancy attacks by design.
The contract can directly access other contracts' state through a technique called raw querying. However, a contract can never write to another contracts' state.
Another important aspect related to CosmWasm is locality: For two actors to communicate, a contract creator or user needs to send a message to the actor. Actors can only communicate with other actors after they have received the other actor's address. This is a flexible way to set up topologies in a distributed manner, and only requires you to hard-code the data format to pass to such addresses. Once some standard interfaces are established (such as ERC20, ERC721, ENS, etc.), we can support composability between large classes of contracts and different backing codes.
Security benefits
By enforcing private internal state, a given contract can guarantee all valid transitions in its internal state. This is in contrast to the capabilities model used in Cosmos SDK, where trusted modules are passed a StoreKey in their constructor, which allows full read and write access to the storage of other modules. In the Cosmos SDK, we can audit the modules before calling them, and safely pass in such a powerful set of rights at compile time. However, in a smart contract system, there are no compile-time checks, and we need to establish stricter boundaries between contracts. This allows us to comprehensively reason over all possible transitions in a contract's state (and use quick-check-like methods to test it).
As mentioned above, serialized execution prevents all concurrent executions of a contract's code. By enforcing serialized execution, the contract will write all changes to storage before exiting and have a proper view when the next message is processed. The way that CosmWasm is built, resembles having an automatic mutex over the entire contract code. This prevents reentrancy attacks, which are the most common attack vectors for smart contracts built on Ethereum.
An example of a reentrancy attack that is prevented by design in CosmWasm is the following: Contract A calls into Contract B, which calls back into Contract A. There may be local changes in memory in Contract A from the first call (eg. deduct a balance), that are not yet persisted, so the second call can use the outdated state a second time (eg. authorize sending a balance twice).
Atomic execution
One problem with sending messages is atomically committing a state change over two contracts. There are many cases where we want to ensure that all returned messages were properly processed before committing our state. In order to tackle this issue, before executing a Msg that came from an external transaction, we create a SavePoint
of the global data store and pass in a subset to the first contract. We then execute all returned messages inside the same sub-transaction. If all messages succeed, we can commit the sub-transaction. If any message fails (or we run out of gas), we abort execution and roll back the state to before the first contract was executed.
This allows us to optimistically update code, relying on rollback for error handling. For example, if an exchange matches a trade between two "ERC20" tokens, it can fulfill the offer and return two messages to move token A to the buyer and token B to the seller. ERC20 tokens use a concept of allowance, so the owner "allows" the exchange to move up to X tokens from their account. When executing the returned messages, it turns out that the buyer doesn't have sufficient token B (or provided an insufficient allowance). This message will fail, causing the entire sequence to be reverted. If the transaction fails, the offer is not marked as fulfilled, and no tokens change hands.
While many developers may be more comfortable thinking about directly calling the other contract in their execution path and handling the errors, almost all the same cases can be achieved with an optimistic update and return approach. Furthermore, there is no room for making mistakes in the contract's error handling code.
Dynamically linking host modules
The aspects of locality and loose coupling mean that we don't even need to link to other CosmWasm contracts. We can send messages to anything for which the Dispatcher has an address. For example, we can return a SendMsg, which will be processed by the native x/bank module in Cosmos SDK, moving native tokens. As we define standard interfaces for composability, we can define interfaces to call into core modules and then pass in the address to the native module in the contract constructor.
Inter blockchain messaging
Since the Actor model doesn't attempt to make synchronous calls to another contract, and instead just returns a message "to be executed", it is a good match for making cross-chain contract calls using IBC. The only caveat here is that the atomic execution guarantee we provided above no longer applies. The other call will not be made by the same dispatcher, so we need to store an intermediate state in the contract itself. That means a state that cannot be changed until the result of the IBC call is known. After the result of the IBC call is known, the intermediate state can then be safely applied or reverted.
For example, if we want to move tokens from chain A to chain B, we would first prepare to execute the following transactions:
- Contract A must reduce the token supply of the sender.
- Contract A must create an "escrow" for those tokens linked to the IBC message ID, sender, and receiving chain.
- Contract A commits state and returns a message to initiate an IBC transaction to chain B.
- If the IBC send fails, then the contract is atomically reverted as above.
After some time, a success or error/timeout message will be returned from the IBC module to the token contract:
- Contract A would validate the message came from the IBC handler (authorization) and refers to a known IBC message ID it has in escrow.
- If it was a success, the escrow would be deleted and the escrowed tokens would be placed in an account for "Chain B" (meaning that only a future IBC message from Chain B may release them).
- If it was an error, the escrow would be deleted and the escrowed tokens would be returned to the account of the original sender.
You can imagine a similar scenario in cases of moving NFT ownership, cross-chain staking, etc.