Parameterized Transaction Reviews: For when a multi-sig is just not granular enough for your transaction review needs

Multi-signature transactions are one of the most useful patterns for smart contracts and the dApps that utilize them. But in the ‘vanilla’ implementation of multi-signature design, transaction reviewers (owners), are assigned contract-wide and review all transactions. Colony’s requirements for multi-sig transactions are more complex than this provides. We have multiple user roles within a Colony, and depending on context, which signatures are required for various operations may differ.

For example, one of the base building blocks in Colony is the Task. A task is a small unit of work to be done for an organization, and it has three distinct roles defined to coordinate that work: a manager, an evaluator and a worker.

There are many tasks within each colony and each can have a different set of users assigned to these three roles for each task. We want the flexibility to have any combination of two of the three task roles to be configurable as reviewers on a particular task change type as shown in the example set of four Taskchange functions below.

+------------------------+-----------------+-----------------+
|      Function          | Reviewer Role 1 | Reviewer Role 2 |
+------------------------+-----------------+-----------------+
| setTaskBrief           | manager         | worker          |
| setTaskDueDate         | manager         | worker          |
| setTaskEvaluatorPayout | manager         | evaluator       |
| setTaskWorkerPayout    | manager         | worker          |
+------------------------+-----------------+-----------------+

If a task manager decides to change some of its properties, such as to change the work brief (specification) hash, this change should also be reviewed by the worker, who will have to adjust to the new terms in order to claim a payout and reputation gain. In another case, if a manager wants to change the evaluator’s payout, the change should be approved by the evaluator rather than the worker.

Additionally we don’t want to have each role sign and submit a transaction to the blockchain individually — this will cost too much gas to be manageable in the long term. The change should instead be agreed upon in advance, and then committed to the blockchain only once with the required signatures.

To achieve all this, we created the parameterized transaction review design pattern, and implemented it within Colony tasks.

Our implementation builds upon the ideas insimple-multisigdesign by Christian Lundkvist, where transaction data is submitted and only executed once (on chain) after all required signatures are received (off chain).

Let’s get into the details of how this all works!

Constructing the task change transaction data

We’ll refer to a simplified version of the Colony Task data structure and a set of update functions to demonstrate the design.

struct Task {
    bytes32 specificationHash;
    uint256 dueDate;
    // Role Ids mapping to user addresses, using role Ids:
    // 0 - task manager, 1 - task evaluator, 2 - task worker
    mapping (uint8 => address) roles;
    // Maps task role ids (0,1,2) to payment amount
    mapping (uint8 => uint256) payouts;
}
  
mapping (uint256 => Task) tasks;
uint256 taskCount;
uint8 constant MANAGER = 0;
uint8 constant EVALUATOR = 1;
uint8 constant WORKER = 2;
function setTaskBrief(uint256 _id, bytes32 _specificationHash) public {
    tasks[_id].specificationHash = _specificationHash;
}
  
function setTaskDueDate(uint256 _id, uint256 _dueDate) public {
    tasks[_id].dueDate = _dueDate;
}
  
function setTaskEvaluatorPayout(uint256 _id, uint256 _amount) public {
    tasks[_id].payouts[EVALUATOR] = _amount;
}
  
function setTaskWorkerPayout(uint256 _id, uint256 _amount) public {
    tasks[_id].payouts[WORKER] = _amount;
}

The four functions above have to check two different pairs of signatures as specified in requirements table above.

Additionally, there are many tasks within a colony, and each task will have its own set of users assigned to roles — so the required signatures should be dynamically retrieved from the task being updated.

As I mentioned, gas is expensive. We don’t want users to be constantly sending transactions to the blockchain if they don’t have to. So in this case, the manager creates a transaction locally for the operation that needs to be performed:

setTaskBrief(1, "0x017dfd85d4f6cb4dcd715a88101f7b1f06cd1e009b2327a0809d01eb9c91f232")

which is composed into the following transaction data bytes:

0xda4db2490000000000000000000000000000000000000000000000000000000000000001017dfd85d4f6cb4dcd715a88101f7b1f06cd1e009b2327a0809d01eb9c91f232

This raw byte data is then signed as a message with the Manager’s key, and through the magic of colonyJS, the signed message can be sent as a JSON object to the Worker, off-chain. The worker may inspect the transaction if necessary to make sure that the raw byte data corresponds to the agreed upon change, and then add their signature to the transaction.

When both signatures have been collected, the transaction can be submitted to the blockchain, with all parameters passed as arguments to the executeTaskChange function:

function executeTaskChange(
uint8[] _sigV,
bytes32[] _sigR,
bytes32[] _sigS, 
uint8[] _mode,
uint256 _value,
bytes _data) public stoppable
{
   // The full function appears later in this post.
}


Deconstructing the transaction

As the task change is submitted to the blockchain, the executeTaskChangefunction then needs a way to pull out the byte data that corresponds to which task is being changed, what the change is, and check it against which signatures are authorized to approve the change.

Our solution pulls the relevant information straight from the embedded byte data, using Solidity assembly mload function to read values from EVM memory which is where the input _data parameter is held. On-chain, the deconstructCall method gets the task Id and the signature of the task change function.

function deconstructCall(bytes _data) internal pure returns (bytes4 sig, uint256 taskId) {
    assembly {
      sig := mload(add(_data, 0x20))
      taskId := mload(add(_data, 0x24))
    }
  }

Let’s break all that down a bit more!

deconstructCall operates on the raw transaction _data bytes. Recall that the manager wants to setTaskBrief on task id 1 with a new work specification. This translates to the following raw transaction:

0xda4db2490000000000000000000000000000000000000000000000000000000000000001017dfd85d4f6cb4dcd715a88101f7b1f06cd1e009b2327a0809d01eb9c91f232

Using remix, we can inspect the result in EVM memory:

0x90: 00000000000000000000000000000044
0xa0: da4db249000000000000000000000000
0xb0: 00000000000000000000000000000000
0xc0: 00000001017dfd85d4f6cb4dcd715a88
0xd0: 101f7b1f06cd1e009b2327a0809d01eb
0xe0: 9c91f232

The transaction data is a dynamic bytes type in Solidity, which is packed tightly in calldata. The first slot (in example above, slot 0x90) represents its length (0x44, or 68 in decimal notation). These 68 bytes comprise 4 bytes for the function signature, 32 bytes for the first function parameter (uint256 taskID), and 32 bytes for the second function parameter (bytes32 specificationHash).

So we must add 0x20 (or 32 bytes) to find the start of the actual transaction data value, skipping over the length. The first 4 bytes there hold the function signature we want to call on the task:

sig := mload(add(_data, 0x20)) returns da4db249.

The taskID is analogous, but it will be located another 32 bytes later in the next memory slot, so we add 0x24:

taskId := mload(add(_data, 0x24)) returns the taskID.

(Note that here we rely on the taskId to be the first parameter of the function call for updating a task.)

Setting the permissions

The last piece of the puzzle is to get the transaction to go through if and only ifthe signatures of the correct people are present, and to revert in all other cases.

For specifying the reviewer rules we map function signatures to 2 reviewer roles array, e.g. setTaskBrief => [0,2]

For simplicity and security we initialize the task update function reviewers in the constructor:

// Mapping function signature to 2 task role reviewers
mapping (bytes4 => uint8[2]) public reviewers;
  
constructor() 
{        setFunctionReviewers(bytes4(keccak256("setTaskBrief(uint256,bytes32)")), MANAGER, WORKER);
    setFunctionReviewers(bytes4(keccak256("setTaskDueDate(uint256,uint256)")), MANAGER, WORKER);
setFunctionReviewers(bytes4(keccak256("setTaskEvaluatorPayout(uint256,uint256)")), MANAGER, EVALUATOR);      
  setFunctionReviewers(bytes4(keccak256("setTaskWorkerPayout(uint256,uint256)")), MANAGER, WORKER);
}
  
function setFunctionReviewers(bytes4 _sig, uint8 _firstReviewer, uint8 _secondReviewer)
private
{
  uint8[2] memory _reviewers = [_firstReviewer, _secondReviewer];
  reviewers[_sig] = _reviewers;
}


Putting it together to execute a signed and reviewed task update

We reroute the public task update functions to be called only internally and expose a single public executeTaskChange function to call them instead, which checks that the the required signatures match those defined in the reviewer rules before executing the change function call.

modifier self() {
  require(address(this) == msg.sender);
  _;
}
function executeTaskChange(uint8[] _sigV, bytes32[] _sigR, bytes32[] _sigS, uint256 _value, bytes _data)
public 
{
  require(_sigR.length == 2);
  bytes4 sig;
  uint256 taskId;
  (sig, taskId) = deconstructCall(_data);
bytes32 msgHash = keccak256(abi.encodePacked(address(this),      address(this), _value, _data, taskChangeNonces[taskId]));
address[] memory reviewerAddresses = new address[](2);
  for (uint i = 0; i < 2; i++) 
  {
     bytes32 txHash;
     txHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", msgHash));
     reviewerAddresses[i] = ecrecover(txHash, _sigV[i], _sigR[i], _sigS[i]);
   }
    
   require(reviewerAddresses[0] != reviewerAddresses[1]);
    
   require(
      reviewerAddresses[0] == tasks[taskId].roles[reviewers[sig][0]]
      ||
      reviewerAddresses[0] == tasks[taskId].roles[reviewers[sig][1]]
    );
  
   require(
      reviewerAddresses[1] == tasks[taskId].roles[reviewers[sig][0]]    
      ||
      reviewerAddresses[1] == tasks[taskId].roles[reviewers[sig][1]]
    );
taskChangeNonces[taskId] += 1;
   require(executeCall(address(this), _value, _data));
}
  
function executeCall(address to, uint256 value, bytes data)   
internal returns (bool success) 
{
   assembly {
     success := call(gas, to, value, add(data, 0x20), mload(data), 0, 0)
    }
 }


Conclusion

The parameterized transaction review design pattern outlined here provides a viable alternative inspired by the simple multi-sig and taken to a lower level to allow reviewers to be dynamically chosen for a given data type, based on which contract function is called and which item is being modified.

For use within a Colony task, three roles are all we need for each of the Manager, Evaluator, and Worker — but in principle this pattern could be applied to any number of roles and function parameters within a single contract.

All this was done in the interest of efficiency and flexibility: We are able to have both a single on-chain transaction and a fine-grained set of parameters for multi-signature operations, without wasting time or (gas) money.

If you’re interested in this kind of technical discussion and want to know more about parameterized transaction reviews, please reach out with your questions on Gitter, or post a question in our forums at build.colony.io — and if you’d like to, contribute to the colonyNetwork.


Elena Dimitrova is a Solidity developer.

She was a corporate warrior in a previous life, but left her high heels and life in London to go backpacking for a year before falling in love with Ethereum and joining Colony.

She now lives in Bulgaria where, while not writing smart contracts or helping in the Truffle Gitter channel, she goes off-roading in her Jeep.


Colony makes it easy for people all over the world to build organisations together, online.

Join the conversation on Discord, follow us on Twitter, sign up for (occasional and awesome) email updates, or if you’re feeling old-skool, drop us an email.