Declarative Smart Contracts
This was originally posted on Token Daily.
Permissioned Shared State
A blockchain brings nodes to consensus on a shared state (more or less). Each block contains a list of state change instructions ("transactions"), which all nodes validate. Blocks establish a canonical order of transactions; proof of work establishes a canonical order of blocks. Each node validates each block and plays each state change to reach a local view of the current state. As long as nodes are using the same validation rules, these disparate views converge over time into a shared state. Honest nodes will agree on all but the most recent state.
Transactions are created by humans or machines. Which is to say, every state change originates off-chain for some unknown purpose. The consensus rules don't care why a transaction was made, or how, only that it doesn't break the rules. There are many rules for transaction verification in any deployed blockchain: Is it formatted correctly? Is it trying to create money? But the most important one is simple: Is the user allowed to update this state?
In Bitcoin, we check that the scriptSig satisfies the UTXO's scriptPubKey. Providing a valid scriptSig proves that we are allowed to consume that UTXO. In Ethereum we check that the transaction signature is valid, and that the account's balance is sufficient, and leave all other checks to the specific contracts involved. Until ECDSA is broken, these things can't be forged.
Essentially, Bitcoin addresses, Bitcoin scripts, Ethereum accounts, Ethereum contracts, and all the related plumbing form permissions systems. They define conditions that restrict state changes. A state change that meets those The blockchain then enforces these permissions. You can't move Bitcoin or Ether or write contract state unless you're allowed to. And when you do, you specify new permissions. A Bitcoin transaction doesn't move coins. It updates their permissions. An Ethereum transaction doesn't transfer tokens, it writes new state for the token contract.
This is the only goal of a blockchain. These networks exist to create consensus around a permissioned shared state. They build the UTXO set, or state tree, and control updates.
Smart Contracts Dilute Permissions
Most Bitcoin and Ethereum transactions are completely explicit. They specify the exact state changes to be made, and prove that the signer has permission to update that state. When you send Bitcoin, you make a transaction describing exactly which coins to move and where to send them. Then you must prove that you are the owner of those coins. Because your directions are perfectly explicit, you know what will happen. You have given your permission for a specific, precisely defined change of state. Either that exact state change will occur, or no update will be made.
Smart contracts, however, complicate things. The outcome of a call to a smart contract may depend on many things. It can depend on the internal state of the contract, or the states of other contracts. It can depend on the time of day, or the whim of a miner, or other transactions the signer hasn't seen.
As such, smart contracts prevent us from knowing the outcome of a transaction before it is confirmed. Its state changes are unknown before the transaction is included in a block. By signing the transaction, the user has consented to whatever state changes the contract defines, without knowledge of the outcome. When users call a contract they entrust their funds to it with no guarantee of good behavior.
Given that the entire point of a blockchain is to create and update a state securely, smart contracts are intuitively problematic. Users deserve to know exactly what the transaction will do before they sign it. Anything else cedes partial control of user funds to miners or other users. It dilutes the permissions set on the shared state, which diminishes the usefulness of the chain.
Declarative Smart Contracts
The core issue is that smart contracts describe the state changes that happen, based on the current state. Instead contracts should describe the state changes that are allowed, based on the current state. Users would then submit proposed state updates, which would be validated by the contract. If the state updates are approved, the exact state update the user wants would be made. This is a declarative smart contract. It declares what allowed state changes, and leaves all control logic and implementation to the users.
Writing contracts this way is somewhat non-intuitive. Developers are used to imperative programming. We give instructions to the computer, and it follows them. It feels strange to do anything else. However, this doesn't reflect the reality of a blockchain. The chain is not adding anything. It doesn't perform operations. Transactions don't contain or trigger a calculation. The chain is only a permissioned state. Transactions signed by users modify the state.
Declarative contracts align the structure of the contract implementation with the reality of the chain by defining exactly what state modifications are permissible, and letting the user modify state directly. Declarative contracts prevent unintended state changes. They protect the user from miner interference. The final effect of a transaction can be clearly communicated to the user before signing. And transactions that violate the user's intent are simply not valid.
Ideally, we should create a new declarative language to write these contracts. This language would define constraints that state updates must meet. Constraints on who can make changes, what the changes may be, and under what circumstances the changes may be made. I imagine this language would look something like Ivy.
Writing A Declarative Contract
Ideal world aside, we can write declarative contracts in Solidity today. In fact, Solidity has been adding features that make it more declarative. And the best practices for Solidity (like Checks-Effects-Interactions) are declarative. The community already recognizes this need, even if they haven't named it yet.
To write declarative contracts in Solidity, we move the logic of the contract into a set of require
function calls. These statements have access to the current state of the contract. Then, instead of providing arguments to a function, the user calls the function with the end state they want. The new state is verified by the require
calls. They compare it to the current state, and check that any changes made are allowed. If all requirements are satisfied, the new state is written over the old state.
Below is a simple declarative game. Users adjust a byte towards a goal, using a few approved moves. The state consists of two bytes. The first byte is the current position, and the second byte is the goal. The constructor sets both the starting position, and the goal. Users call update
to make a move. If the move is valid, the new state is written. If the move is a valid winning move, the caller is paid.
pragma solidity ^0.4.22;
contract DeclarativeGame {
bytes2 state;
modifier notHalted() {
require(state[0] != state[1]);
_;
}
modifier approvedStateChange(bytes2 _newState) {
require(_newState[1] == state[1]);
require(
_newState[0] == ~(state[0] & 0xAA) ||
_newState[0] == state[0] ^ 0x01 ||
_newState[0] == state[0] & ~state[1]);
_;
}
function DeclarativeGame(bytes2 _startState)
public
payable
{
state = _startState;
}
function update(bytes2 _newState)
public
notHalted
approvedStateChange(_newState)
{
state = _newState;
if (state[0] == state[1]) {
msg.sender.transfer(this.balance);
}
}
}
You can also build more complex things like tokens:
pragma solidity ^0.4.22;
contract DeclarativeToken {
mapping(address => uint256) balances;
modifier sameLength(address[] _addresses, uint256[] _balances) {
require(_addresses.length == _balances.length);
_;
}
modifier approvedStateChange(address[] _addresses, uint256[] _balances) {
uint256 _endTotal = 0;
uint256 _startTotal = 0;
// Check the sending address
require(_balances[0] < balances[_addresses[0]]);
require(_addresses[0] == msg.sender);
_startTotal += balances[_addresses[0]];
_endTotal += _balances[0];
// Check each receiving address
for (uint i = 1; i < _addresses.length; i++) {
require(_balances[i] > balances[_addresses[i]]);
require(_addresses[i] != msg.sender);
_startTotal += balances[_addresses[i]];
_endTotal += _balances[i];
}
// Check that we didn't create or destroy tokens
require(_endTotal == _startTotal);
_;
}
function DeclarativeToken(address[] _addresses, uint256[] _balances)
sameLength(_addresses, _balances)
{
for (uint i = 0; i < _addresses.length; i++) {
balances[_addresses[i]] = _balances[i];
}
}
function balanceOf(address _tokenOwner)
public
constant
returns (uint256)
{
return balances[_tokenOwner];
}
function transfer(address[] _addresses, uint256[] _balances)
public
sameLength(_addresses, _balances)
approvedStateChange(_addresses, _balances)
{
for (uint i = 0; i < _addresses.length; i++) {
balances[_addresses[i]] = _balances[i];
}
}
}