This is a technical guide to the multi-signature contract wrapper: a contract that turns a base contract into one that requires a quorum of signers to interact with it, based on Arthur Breitman's Generic Multisig contract.
Overview of the Generic Multisig Contract
The Generic Multisig contract may be thought of as an on-chain user, whose actions are voted upon by a quorum of signers.
Using this contract involves the following steps:
- Originate the contract with a particular threshold and list of signer keys
- Create a lambda representing the desired operations
- Sign the lambda, wrapped with contract address, counter, etc. with a quorum of signers
- Submit the lambda along with the quorum of signatures to the contract
To this end, the contract stores three values:
-
%stored_counter
: A natural-number counter
- This prevents replay attacks: each signed action results in the counter being incremented and out-of-order actions are rejected
-
%keys
: A list of signers
- This is the list of addresses of accounts that are authorized to send actions to the contract
-
%threshold
: A natual-number threshold or quorum
- At least this many signers must have signed an action for it to be processed by the contract
Additionally, the contract accepts three actions:
-
%default
: A default action, to allow sending tez to the contract
- This action does not require any signatures
- This action does nothing but accept tez
-
%change_keys
: An action to change the threshold and list of signers
- This action requires a quorum
- Any threshold and signers list may be provided
-
%operation
: A generic action, allowing the contract to perform arbitrary actions
- This action requires a quorum
- This action includes an arbitrary lambda, i.e. a code block, that includes the operations to be executed by the contract
What's different in the Multisig Wrapper contract
The Multisig Wrapper contract is similar, except instead of accepting an arbitrary lambda containing the operations to execute, it accepts only the parameters of some fixed base contract, specified when the contract is originated.
Using this contract involves the following steps:
- Use
lorentz-contracts
withlorentz-contract-storage
to originate your contract, wrapped with multisig functionality - Make a parameter file for an action you want to perform
- Sign the file with a quorum of users
- Submit the file to the multisig-wrapped contract
Along with the storage parameters of the Generic Multisig contract, it adds two fields:
- The source code of the base contract, represented as a lambda
- The storage value of the base contract
The actions accepted by the Multisig Wrapper contract are the same as those accepted by the Multisig Generic Contract, except instead of a lambda, the base contract's parameter is included.
Differences between the parameter types
Below, we use BASE_PARAMETER
for the contract's parameter type.
The parameter types are almost the same:
Generic Multisig contract | Multisig Wrapper contract
----------------------------------------------------+-----------------------------------------
parameter | parameter
(or | (or
(unit %default) | (unit %default)
(pair %main | (pair %main
(pair :payload | (pair :payload
(nat %counter) | (nat %counter)
(or :action | (or :action
(lambda %operation unit (list operation)) | (BASE_PARAMETER %base_input_parameter)
(pair %change_keys | (pair %change_keys
(nat %threshold) | (nat %threshold)
(list %keys key)))) | (list %keys key))))
(list %sigs (option signature)))); | (list %sigs (option signature))));
The only difference being that lambda unit (list operation)
is replaced with BASE_PARAMETER
.
Differences between the storage types
Below, we use the following to refer to the base contract's types:
-
BASE_PARAMETER
- The contract's parameter type
-
BASE_MAP_KEY
- This is the key-type of the base contract's
big_map
, orbool
if it doesn't contain abig_map
- This is the key-type of the base contract's
-
BASE_MAP_VALUE
- This is the value-type of the base contract's
big_map
, orunit
if it doesn't contain abig_map
- This is the value-type of the base contract's
-
BASE_STORAGE
- This is the storage-type of the base contract, without any
contained
big_map
. E.g. if the contract's storage-type is(big_map key_type value_type, other_storage)
, thenBASE_STORAGE = other_storage
.
- This is the storage-type of the base contract, without any
contained
Generic Multisig contract | Multisig Wrapper contract
--------------------------+-----------------------------------------------------------------
| storage
| (pair
| (big_map %base_contract_big_map BASE_MAP_KEY BASE_MAP_VALUE)
| (pair
| (pair
| (lambda %base_contract
| (pair %base_contract_input
| BASE_PARAMETER
| (pair
| (big_map BASE_MAP_KEY BASE_MAP_VALUE)
| BASE_STORAGE
| )
| )
| (pair %base_contract_output
| (list operation)
| (pair
| (big_map BASE_MAP_KEY BASE_MAP_VALUE)
| BASE_STORAGE
| )
| )
| )
| (BASE_STORAGE %base_contract_storage)
storage | )
(pair | (pair
(nat %stored_counter) | (nat %stored_counter)
(pair | (pair
(nat %threshold) | (nat %threshold)
(list %keys key) | (list %keys key)
) | )
); | )
| )
| );
In short, the Multisig Wrapper contract contrains all of the storage parameters of the Generic Multisig contract, as well as:
-
%base_contract_big_map
: Abig_map
with the key/value types of the base contract'sbig_map
- Or
bool
,unit
if it doesn't have one
- Or
-
%base_contract
: Alambda
with the base contract's parameter and storage types-
%base_contract_input
: This is the input type of the contract, which includes theBASE_PARAMETER
, base contractbig_map
, andBASE_STORAGE
. -
%base_contract_output
: This is the return type of the contract, which includes the base contractbig_map
andBASE_STORAGE
.
-
-
%base_contract_storage
: ABASE_PARAMETER
, the base contract's storage without anybig_map
's
Implementation differences
Ignoring the stored parameters
At the beginning of execution, all parameters not present in the original Generic Multisig contract are moved to the top of the stack.
Next, the original code to assert no tokens sent
through increment and store counter
runs within a dip { .. }
block.
This allows the original code to have the same environment as in the Generic Multisig contract and ensures it executes independely of the rest.
Applying the lambda
Applying the lambda is similar to the original code, except that the parameter is given and the lambda
is stored.
This amounts to some swapping, etc. to rearrange values on the stack before and after EXEC
uting the lambda
.
Accommodating the single-big-map constraint
In the current version of Michelson (Athens), only a single big_map
may occur
in a contract's storage. Additionally, it must occur as the left element in a
top-level pair.
In other words, if a big_map
occurs in some storage type storage_type
, we must have:
storage_type = (pair (big_map key_type value_type) other_storage_type)
For some key_type
, value_type
, and other_storage_type
.
How is it implemented in Michelson?
There are two cases:
- If there's a
big_map
: the contract is left as-is - If there's no
big_map
: the contract is modified to ignore abig_map
:- Move the
big_map
to the top of the stack dip { original_contract_code }
- Move the
big_map
back to its expected position
- Move the
ignoreBigMap ::
forall k v a b.
Contract a b
-> Contract a (BigMap k v, b)
ignoreBigMap baseContract = do
unpair
swap
unpair
dip $ do
swap
pair
baseContract
unpair
swap
dip pair
pair
How is it implemented in Haskell?
While handling each case is straightforward, it may not be immediately obvious how to decide between the two options.
To start, we can use checkBigMapPresence
from morley
to decide whether a big_map
is present in a Michelson type:
data BigMapPresence (t :: T)
= ContainsBigMap t ~ 'True => BigMapPresent
| ContainsBigMap t ~ 'False => BigMapAbsent
checkBigMapPresence :: Sing (ty :: T) -> BigMapPresence ty
If we get BigMapAbsent
, we have a proof that there is no big_map
in the type. Thus we can use ignoreBigMap
and we're done.
However, if we get BigMapPresent
, we need to:
- Extract the key, value, and leftover storage types
- Provide a proof that the original type is equivalent to
(pair (big_map key value) leftover)
- Provide proofs that important constraints, e.g.
SingI
,Typeable
, hold for the resulting types
morley
does not provide a way of doing this directly, but we can utilize the definition of BigMapConstraint
:
type family BadBigMapPair t :: Bool where
BadBigMapPair ('TPair ('TBigMap _ v) b) =
ContainsBigMap v || ContainsBigMap b
BadBigMapPair t = ContainsBigMap t
type BigMapConstraint t = BadBigMapPair t ~ 'False
bigMapConstrained :: Sing (t :: T) -> Maybe (Dict $ BigMapConstraint t)
While Haskell can't derive that (ContainsBigMap t ~ 'True, BigMapConstraint t)
implies that t ~ ('TPair ('TBigMap k v) b)
for some k, v, b
, we can define
a zero-method type class that encodes the constraint, as well as a proof
that the implication holds:
class ( t ~ 'TPair ('TBigMap (CBigMapKey t) (CBigMapVal t)) (CWithoutBigMap t)
, ContainsBigMap (CBigMapVal t) ~ 'False
, ContainsBigMap (CWithoutBigMap t) ~ 'False
, Typeable (CBigMapKey t)
, Typeable (CBigMapVal t)
, Typeable (CWithoutBigMap t)
, SingI (CBigMapKey t)
, SingI (CBigMapVal t)
, SingI (CWithoutBigMap t)
) =>
ConstrainedBigMap t
where
type CBigMapKey (t :: T) :: CT
type CBigMapVal (t :: T) :: T
type CWithoutBigMap (t :: T) :: T
constrainedBigMap ::
forall (t :: T). (ContainsBigMap t ~ 'True, BigMapConstraint t)
=> Sing (t :: T)
-> Dict (ConstrainedBigMap t)
Formally verifying the contract
For the most part, the properties that hold for the Generic Multisig contract should also hold for the Multisig Wrapper contract.
For example:
- Only the
default
action is unauthenticated - At least
threshold
many signatures must be present when authenticating - Etc.
Properties that are specific to the Multisig Wrapper include:
- If the
%default
or%change_keys
actions are taken:- The storage is not changed
- The behaviour is the same as for the Generic Multisig contract
- If the
%base_input_parameter
action is taken:- The new storage is exactly what the base contract would have output alone
- The emitted operations are (include) those of the base contract
- The
threshold
andkeys
storage fields are not changed