A few days ago, we introduced Factori's new static analysis capabilities and promised a more detailed real world smart contract analysis. We showcase in this article how
factori analyze
can be used to unveil the permissions /
failing conditions of the most deployed FA2/NFT contract on Tezos blockchain. Of
course, we don't have the contract's high-level source code. We picked its
address on the blockchain. In such circumstances, it can be quite a hassle to
get information on how the contract works. You can re-run this analysis in the
following way:
$ factori analyze kt1 YOUR-KT1
General information about the storage
The analysis of the contract has found several issues/warnings that may cause problems in the future.
Found unbounded data structures with non-lazy deserialization in the storage
- map is used once in the storage,
* token_info field is a map in the storage
There is a CALL_CONTRACT in this contract, check for reentrancy
The following fields of the storage may be unused:
var_19 -> (big_map%total_supply(nat) (nat))
The analysis reveals an unbounded data structure with non-lazy deserialization
in the storage, potentially causing performance issues and out-of-gas errors.
This correspond to the token_info
field, which is a map in the storage.
The next one indicates that there is a CALL_CONTRACT
in the contract, which
may lead to potential reentrancy attacks. Ensuring proper protection against
these attacks can be crucial.
Finally, it seems that the total_supply
storage field is unused. It indicates
a potential storage inefficiency.
Without having the specification or the high level code, we can't say at this moment if this map could be problematic. A manual review or the specification would have allowed us to eliminate all lingering doubt.
Now we can look at the output of Factori on the entry points. In the
next section, we will look at the set_administrator
and balance_of
entrypoints.
Permission Analysis
Let's take a closer look at the output of our permissions analysis on the
set_administrator
entrypoint.
# Entrypoint: set_administrator
Parameter: (address%set_administrator)
Fail with "FA2_NOT_ADMIN" if ((SENDER <> storage.administrator))
In this case, it is quite normal that the failure case occurs when the
sender is not the administrator (defined in the storage,
storage.administrator
). Only the administrator of the contract can
change the administrator address stored in the contract's storage.
set_metadata
and set_pause
have similar outputs. Only the
administrator can call those entrypoints as well.
Unreachable Code Detection
If we look at the result of the analysis on the balance_of
entrypoint, in addition to permissions, another interesting piece of
information is displayed. Let's take a closer look.
# Entrypoint: balance_of
Parameter: (pair%balance_of(list%requests(pair(address%owner) (nat%token_id))) (contract%callback(list(pair(pair%request(address%owner) (nat%token_id)) (nat%balance)))))
Fail with "FA2_PAUSED" if (storage.paused)
Else
Fail with "FA2_TOKEN_UNDEFINED" if (not ((nat%token_id) in storage.token_metadata))
Else
Fail with "425" if ((GET ((nat%token_id), (address%owner), storage.ledger) is None) /\ (MEM ((nat%token_id), (address%owner), storage.ledger)))
When the entrypoint balance_of
of the contract is called, the
analysis concludes that there are three possible error messages that
can be returned if certain conditions are met.
The analysis highlights three error messages: FA2_PAUSED
occurs when the
contract's 'paused' flag is set to true; FA2_TOKEN_UNDEFINED
appears when the
specified token ID
isn't defined in the metadata.
The third and last error 425
is very interesting because it is
unreachable due to a logical contradiction between the MEM
and GET
instructions, making the condition unsatisfiable. Indeed, translated
into high level code, this is equivalent to:
if Big_map.mem token_id owner storage.ledger then (* Result of `Big_map.mem` is true *)
match Big_map.find_opt token_id owner storage.ledger with
| None -> (* Unreachable code since the `Big_map.mem` returns true *)
Tezos.failwith 425
| Some _ ->
DO_SOMETHING
else (* Result of `Big_map.mem` is false *)
DO_SOMETHING_ELSE
We can easily see that in the case where Big_map.mem
returns true
,
then the None
case of Big_map.find_opt
can never happen. It could
be a compilation issue from a high-level language to Michelson.
Conclusion
From this smart contract pulled from the blockchain, we've gained significant
insights utilizing the factori analyze
function. Keep in mind, however, that
it may not be effective in every situation or with every contract, partly due to
the halting problem. Additionally, it's crucial to conduct thorough research and
not depend solely on the generated formulas. Nevertheless, this tool can serve
as a valuable diagnostic instrument for examining smart contracts.
Factori can also be employed to troubleshoot software issues. In the following Stackexchange post, a user encountered difficulties when their contract call repeatedly failed. By conducting an automatic analysis, it becomes apparent that the error resulting in the contract's failure stems from the user's lack of authorization to execute the transfer.
And once again, please let us know if you enjoy this new feature! Also remember that Functori does meticulous smart contract audits based on years of experience in smart contract development and review, Tezos core development, by our engineers with PhDs in formal methods.
Appendix
Full output of the permission and storage analysis on the FA2 smart contract
Found unbounded data structure with non-lazy deserialization in the storage
map is used once in the storage
token_info field is a map in the storage
There is a CALL_CONTRACT in this contract, check for reentrancy
The following fields of the storage may be unused:
var_19 -> (big_map%total_supply(nat) (nat))
Result of permission analysis
# Entrypoint: balance_of
Parameter: (pair%balance_of(list%requests(pair(address%owner) (nat%token_id))) (contract%callback(list(pair(pair%request(address%owner) (nat%token_id)) (nat%balance)))))
Case Fail with "FA2_PAUSED" if (storage.paused)
Else Fail with "FA2_TOKEN_UNDEFINED" if (not ((nat%token_id) in storage.token_metadata))
Else Fail with 425 if ((GET ((nat%token_id), (address%owner), storage.ledger) is None) AND (MEM ((nat%token_id), (address%owner), storage.ledger)))
# Entrypoint: mint
Parameter: (pair%mint(pair(address%address) (nat%amount)) (pair(map%metadata(string) (bytes)) (nat%token_id)))
Case Fail with "FA2_NOT_ADMIN" if ((SENDER <> storage.administrator))
Else Fail with 535 if ((GET (param.mint.token_id, param.mint.address, storage.ledger) is None) AND (MEM (param.mint.token_id, param.mint.address, storage.ledger)))
Else Fail with "Token-IDs should be consecutive" if (((storage.all_tokens <> param.mint.token_id)) AND ((param.mint.token_id >= storage.all_tokens)) AND ((not (MEM (param.mint.token_id, param.mint.address, storage.ledger))) OR ((MEM (param.mint.token_id, param.mint.address, storage.ledger)) AND (GET (param.mint.token_id, param.mint.address, storage.ledger) is Some))))
# Entrypoint: set_administrator
Parameter: (address%set_administrator)
Case Fail with "FA2_NOT_ADMIN" if ((SENDER <> storage.administrator))
# Entrypoint: set_metadata
Parameter: (pair%set_metadata(string%k) (bytes%v))
Case Fail with "FA2_NOT_ADMIN" if ((SENDER <> storage.administrator))
# Entrypoint: set_pause
Parameter: (bool%set_pause)
Case Fail with "FA2_NOT_ADMIN" if ((SENDER <> storage.administrator))
# Entrypoint: transfer
Parameter: (list%transfer(pair(address%from_) (list%txs(pair(address%to_) (pair(nat%token_id) (nat%amount))))))
Case Fail with "FA2_PAUSED" if (storage.paused)
Else Fail with "FA2_NOT_OPERATOR" if (not (var_1))
Where var_1 := if (var_2) then (True)
else MEM ((nat%token_id), SENDER, (address%from_), operators)
var_2 := if ((SENDER = (address%administrator))) then (True)
else ((address%from_) = SENDER)
Else Fail with "FA2_TOKEN_UNDEFINED" if (not ((nat%token_id) in token_metadata))
Else Fail with 404 if ((GET ((nat%token_id), (address%from_), ledger) is None) AND ((0 < (nat%amount))))
Else Fail with "FA2_INSUFFICIENT_BALANCE" if (((ledger[((address%from_),(nat%token_id))] < (nat%amount))) AND (GET ((nat%token_id), (address%from_), ledger) is Some) AND ((0 < (nat%amount))))
Else Fail with 407 if false
Else Fail with 408 if false
Else Fail with 407 if ((ISNAT ((ledger[((address%from_),(nat%token_id))] - (nat%amount))) is None) AND ((ledger[((address%from_),(nat%token_id))] >= (nat%amount))) AND (GET ((nat%token_id), (address%from_), ledger) is Some) AND ((0 < (nat%amount))))
Else Fail with 410 if ((GET ((nat%token_id), (address%to_), UPDATE ((nat%token_id), (address%from_), [GET_OPT] (ISNAT ((ledger[((address%from_),(nat%token_id))] - (nat%amount)))), ledger)) is None) AND (MEM ((nat%token_id), (address%to_), UPDATE ((nat%token_id), (address%from_), [GET_OPT] (ISNAT ((ledger[((address%from_),(nat%token_id))] - (nat%amount)))), ledger))) AND (ISNAT ((ledger[((address%from_),(nat%token_id))] - (nat%amount))) is Some) AND ((ledger[((address%from_),(nat%token_id))] >= (nat%amount))) AND (GET ((nat%token_id), (address%from_), ledger) is Some) AND ((0 < (nat%amount))))
# Entrypoint: update_operators
Parameter: (list%update_operators(or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))))
Case Fail with "FA2_NOT_ADMIN_OR_OPERATOR" if ((not (var_3)) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Left))
Where var_3 := if (((SENDER = (address%owner))) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Left)) then (True)
else if (((SENDER <> (address%owner))) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Left)) then (SENDER = (address%administrator))
Else Fail with "FA2_NOT_ADMIN_OR_OPERATOR" if ((not (var_4)) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Right))
Where var_4 := if (((SENDER = (address%owner))) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Right)) then (True)
else if (((SENDER <> (address%owner))) AND ((or_(pair%add_operator(address%owner) (pair(address%operator) (nat%token_id))) (pair%remove_operator(address%owner) (pair(address%operator) (nat%token_id)))) is Right)) then (SENDER = (address%administrator))