2/3 multisig wallet contract with signature collection performed on-chain intended to serve single person. Although all three keys have equal permissions, contract is optimized for two keys sending external messages in turns. It is made with the assumption that one of the keys is lost or controlled by attacker.
Unconventionally contract's seqno is not incremented after every external message, instead it represents a single round of collecting signatures. There are three rules within it:
- Each key is allowed to create only one request for confirmation, afterwards it can only send confirmations.
- Once confirmation is received requested action is executed, seqno incremented and next request can be sent.
- If all three keys send different requests, nothing is executed, seqno incremented and next request can be sent.
Because there are three keys, chances of one of them being stolen are higher than if there was only one. However damage is limited only to gas consumed creating single request.
The most optimized transition path involves sending two external messages signed by two different keys. First creating request followed by confirmation from another key. The distinction between request and confirmation is only in which external message is processed first. Both external messages can be sent simultaneously, because seqno is not incremented until the next round.
Instead of sending confirmation another key may send cancellation request. Although neither of two keys can send requests anymore, sending confirmations is still allowed. Confirmed cancellation starts new round without sending any outbound messages.
Outbound message request is represented by valid message cell, while cancellation request is represented by cell containing no data and optional reference with explanation. It is possible to submit either kind of request at any moment. Only once two keys submit identical cell it is inspected and if its data isn't empty outbound message is sent.
Signature collection may start with cancellation request arriving first. This may happen if it was sent immediately after request it is supposed to cancel, but got included in block earlier.
Instead of sending confirmation or cancellation it is also possible to send alternative outbound message request. Either of two keys will have to confirm other request or third key will have to be used to break the tie.
Internal message handler handles a single operation to replace one of the keys. Source address of internal message has to be contract itself. So in order to replace key otherwise ordinary request with outbound message to contract itself will have to pass confirmation checks.
All three keys have equal permissions, but they are expected to have distinct roles:
- Main key is the one sending requests made by person owning the wallet.
- Confirmation key is sending confirmations to requests made by main key. The only request that it may should is cancellation request.
- Backup key is stored offline and isn't sending any requests.
Contract serves single person, but there are different use cases depending on who is operating the keys.
Single person performing confirmations from secondary device is very similar to traditional MFA:
- Main key is stored on desktop PC by wallet software.
- Confirmation key is stored on mobile phone by confirmation software.
- Backup key is stored in sealed paper bag somewhere safe.
Similarly to MFA wide scale attacks such as malware scanning for private keys are ineffective. Backup key can be used to restore access in case one of the devices is lost. Compared to hardware wallet backup key on its own doesn't provide access to funds. Security requirements for storing it are significantly lower. It can be given to a friend or family member.
If mobile wallet is operating main key then there is no obvious candidate for confirmation device. In that case confirmation key can be generated on server that will automatically confirm requests made by main key. User should be warned about request made by backup key and given enough time to react. Compared to traditional passphrase backup this gives user an early warning.
Server confirmations can be performed by independent confirmation service. Confirmation key can be replaced at any moment and this is the only integration step needed.
Similarly to traditional banks they may choose to contact user by phone to get confirmation for:
- Transfers sent to known phishing addresses.
- Messages sent to vulnerable contracts.
- Suspiciously large transfers.
- Requests made by backup key.
- Requests to replace wallet key.
Honesty and procedures used by confirmation service can be tested. User is vulnerable if he loses either of two other keys. But there is no way for confirmation service to know if user actually lost one or is he performing a test.
Contract code located in contract.fif is small, but relatively sophisticated and is written in ASM.
Several methods to query data cell are implemented:
seqnokeysthree keys in ascending order.
Most of them take one of the keys as argument:
my_requestrequest cell made by key.other_request1earliest request made by some other key.other_request1_keykey that madeother_request1.other_request2request that was made afterother_request1.other_request2_keykey that madeother_request2.
These methods can be called by lite-client runmethod command. Alternatively lib.fif contains fift implementations.
Several example scripts are implemented.
- examples/msg-init.fif new wallet.
- examples/msg-simple-transfer.fif new simple transfer request.
- examples/msg-cancellation.fif new cancellation request.
- examples/msg-confirmation.fif confirmation.
- examples/msg-replace-key.fif new request to replace one of the keys.
- examples/print-data.fif prints information stored in data cell.
Many of them also require contract data cell to be downloaded first:
$ lite-client -c "last" -c "saveaccountdata data.boc <address>"Using them example examples/wallet.sh bash script provides simple wallet console application:
Contract code does not contain special initialization checks (if seqno is zero). When building stateinit contract code should be wrapped into init envelope. Example examples/msg-init.fif script uses such envelope:
0 constant nonce
<{
SETCP0 ACCEPT
nonce INT
"../contract.fif" include PUSHREF SETCODE
}>c
Additional nonce constant can be tweaked to choose preferred address (e.g. containing only alphanumeric characters). Similarly to subwallet_id used by standard wallet, nonce also allows to operate two wallets with same set of keys. But unlike subwallet_id, nonce isn't included into external message.
Hash that is signed is required to include contract address. As a result external message cannot be replayed from or to a different contract (or same contract in different workchain) that is using the same public key and similar payload structure.
External message handler is tested by running through all combinations of external messages three keys may send. Report saved into test-report.txt contains gas consumed, state description, and outbound message that was sent.
TOTAL GAS MSG N KEY1 KEY2 KEY3 SENT
=====================================================
2566 2566 KEY1_MSG1 1 MSG1
6032 3466 - KEY2_MSG1 2 MSG1
5256 2690 - KEY2_MSG2 1 MSG1 MSG2
8722 3466 - KEY1_MSG2 2 MSG2
8826 3570 - KEY2_MSG1 2 MSG1
...
To generate report and compare it to previously committed:
$ fift -s tests/test-all.fif > test-report.txt && git difftool test-report.txt