This document explains ICON audit criteria and suggests secure SCORE implementation practices. Audit checklist consists of 2 severity levels of items, Critical and Warning. Audit results will come as Pass/Fail/NA for each Critical items, and Pass/Warning/NA for each Warning items. If any Critical item is determined to be Fail, the SCORE deployment will be rejected.
Listed below are the checklist grouped by severity. We assume that you have read Token & Crowdsale and Writing SCORE, and understand the basics of SCORE development.
​Timeout​
​Unfinishing Loop​
​Package import​
​System Call​
​Outbound Network Call​
​iconservice Internal API​
​Randomness​
​Fixed SCORE Infomation​
​ICXTransfer Eventlog​
​Super Class​
​Keyword Arguments​
​Big Number Operation​
​Instance Variable​
​StateDB Operation​
​StateDB Write Operation​
​Temporary Limitation​
​Predictable Arbitrarity​
​Underflow/Overflow​
​Vault​
​Reentrancy​
SCORE function must return fairly immediately. Blockchain is not for any long-running operation. For example, if you implement token airdrop to many users, do not iterate over all users in a single function. Handle each or partial airdrop(s) one by one instead.
# Bad@externaldef airdrop_token(self, _value: int, _data: bytes = None):for target in self._very_large_targets:self._transfer(self.msg.sender, target, _value, _data)​# Good@externaldef airdrop_token(self, _to: Address, _value: int, _data: bytes = None):if self._airdrop_sent_address[_to]:self.revert(f"Token was dropped already: {_to}")self._airdrop_sent_address[_to] = Trueself._transfer(self.msg.sender, _to, _value, _data)
Use for
and while
statement carefully. Make sure that the code always reaches the exit condition. If the operation inside the loop consumes step
, the program will halt at some point. However, if the code block inside the loop does not consume step
, i.e., Python built-in functions, then the program may hang there forever. ICON network will force-kill the hanging task, but it may still degrade significantly the ICON network.
# Badwhile True:# do something without consuming 'step' or proper exit condition​# Goodi = 0while i < 10:# do somethingi += 1
SCORE must run in a sandboxed environment. Package import is prohibited except iconservice
and the files in your deployed SCORE folder tree.
# Badimport os​# Goodfrom iconservice import *from .myclass import *
System call is prohibited. SCORE can not access any system resources.
# Badimport osos.uname()
Outbound network call is prohibited. Outcome of network call from each node can be different.
# Badimport sockets = socket.socket(socket.AF_INET, socket.SOCK_STREAM)s.connect(host, port)
Among the API provided by ICONService, API written for platform should not be used in SCORE. Attempts to access internal APIs with information obtained using getAttr() are prohibited. Use externally released APIs only. You can find recent released APIs at https://iconservice.readthedocs.io/en/latest/.
# Badsomething = getattr(self, 'something', '')
Execution result of SCORE must be deterministic. Unless, nodes can not reach a consensus. If nodes fail to reach a consensus due to the undeterministic outcomes, every transactions in the block will be lost. Therefore, not only random function, but any attempt to prevent block generation by undeterministic operation is strictly prohibited.
# Bad# each node may have different outcomewon = datetime.datetime.now() % 2 == 0
SCORE's critical information should not be changed once it has been deployed. In case of IRC2 token SCORE, name
, symbol
and decimals
should not be changed; for other SCOREs, name
must not be changed.
IRC token type must be fixed once deployed as well. For example, IRC2 token SCORE should not be updated to IRC3 tokens, and IRC2 token should not be updated to non-IRC SCORE.
@external(readonly=True)def name(self) -> str:return self._name.get()#Bad@externaldef setname(self, new_name):self._name.set(new_name)
You should not implement any class methods that update the variables, and those values should not be changed when you update the SCORE. For IRC2 tokens, you must update the IRC2 token SCORE with the same name
, symbol
and decimals
values. If it is not an IRC2 token, it should be update to non-IRC2 SCORE with the same name value.
IRC2 compliant token must implement every functions in the specification. IRC2 ICON Token Standard​
# IRC2 functions@external(readonly=True)def name(self) -> str:​@external(readonly=True)def symbol(self) -> str:​@external(readonly=True)def decimals(self) -> int:​@external(readonly=True)def totalSupply(self) -> int:​@external(readonly=True)def balanceOf(self, _owner: Address) -> int:​@externaldef transfer(self, _to: Address, _value: int, _data: bytes=None):
When implementing IRC2 compliant token, make the parameter names in the function remain the same as defined in IRC2 ICON Token Standard.
# Baddef balanceOf(self, owner: Address) -> int:​# Gooddef balanceOf(self, _owner: Address) -> int:
Token transfer must trigger Eventlog.
# Good@eventlog(indexed=3)def Transfer(self, _from: Address, _to: Address, _value: int, _data: bytes):pass​@externaldef transfer(self, _to: Address, _value: int, _data: bytes = None):self._balances[self.msg.sender] -= _valueself._balances[_to] += _valueself.Transfer(self.msg.sender, _to, _value, _data)
In addition to sending tokens between the two addresses, you must leave Eventlog even if tokens are minted or burned. For mint and burn, use a specific address as follows:
# Good​EOA_ZERO = Address.from_string('hx' + '0' * 40)​@externaldef mint(self, amount: int):self._total_supply.set(self._total_supply.get() + amount)self._balances[self.owner] += amountself.Transfer(EOA_ZERO, self.owner, amount, b'mint')​@externaldef burn(self, amount: int):self._total_supply.set(self._total_supply.get() - amount)self._balances[self.owner] -= amountself.Transfer(self.owner, EOA_ZERO, amount, b'burn')
Do not trigger Transfer Eventlog without token transfer.
# Bad@eventlog(indexed=3)def Transfer(self, _from: Address, _to: Address, _value: int, _data: bytes):pass​@externaldef doSomething(self, _to: Address, _value: int):# no token transfer occurredself.Transfer(self.msg.sender, _to, _value, None)
ICXTransfer Eventlog is reserved for ICX transfer. Do not implement the Eventlog with the same name.
# Bad@eventlog(indexed=3)def ICXTransfer(self, _from: Address, _to: Address, _value: int):
In your SCORE main class that inherits IconScoreBase, you must call super().__init__() in the __init__() function to initialize the state DB. Likewise, super().on_install() must be called in on_install() function and super().on_update() must be called in on_update() function. These initialization statements must be executed with the first command in each function.
# Bad (without initialization)class MyClass(IconScoreBase):def __init__(self, db: IconScoreDatabase) -> None:self._context__name = VarDB('context.name', db, str)self._context__cap = VarDB('context.cap', db, int)​def on_install(self, name: str, cap: str) -> None:# doSomethingself._context__name.set('test')​def on_update(self) -> None:# doSomethingself._context__name.set('test')​# Bad (doSomething before initialization)class MyClass(IconScoreBase):def __init__(self, db: IconScoreDatabase) -> None:# doSomethingself._context__name = VarDB('context.name', db, str)self._context__cap = VarDB('context.cap', db, int)# call super().__init__(db) latersuper().__init__(db)​def on_install(self, name: str, cap: str) -> None:# doSomethingself._context__name.set('test')# call super().on_install() latersuper().on_install()​​def on_update(self) -> None:# doSomethingself._context__name.set('test')# call super().on_update() latersuper().on_update()​​# Good (doSomething after initialization)class MyClass(IconScoreBase):def __init__(self, db: IconScoreDatabase) -> None:# call super().__init__(db) firstsuper().__init__(db)# doSomething laterself._context__name = VarDB('context.name', db, str)self._context__cap = VarDB('context.cap', db, int)​def on_install(self, name: str, cap: str) -> None:# call super().on_install() firstsuper().on_install()# doSomething laterself._context__name.set('test')​def on_update(self) -> None:# call super().on_update() firstsuper().on_update()# doSomething laterself._context__name.set('test')
Keyword arguments are not allowed as an input parameter of on_install()
and on_update()
functions.
# Gooddef on_install(self, name: str, symbol: str, amount: int, decimals: int):...​# Baddef on_install(self, **kwargs) -> None:...
The maximum result of a numeric operation must be less than (2256 - 1). If the result is bigger than (2256 - 1), the python interpreter can not perform the operation. To avoid errors, you must understand that input parameters may cause errors, and validate if the number is within the range.
# Bad (on_install params - decimal value is 1_000_000_000_000_000_000){"contentType": "application/zip","params": {"initialSupply": "0x2540be400","decimals": "0xde0b6b3a7640000"}}​# Good (on_install params decimal value is 18){"contentType": "application/zip","params": {"initialSupply": "0x2540be400","decimals": "0x12"}}​def on_install(self, initialSupply: int, decimals: int) -> None:super().on_install()​total_supply = initialSupply * 10 ** decimalsLogger.debug(f'on_install: total_supply={total_supply}', TAG)​self._total_supply.set(total_supply)self._decimals.set(decimals)self._balances[self.msg.sender] = total_supply
# Bad@externaldef big_number_op(self, _value: int) -> None:self._result = 10 ** _value​# Good@externaldef big_number_op(self, _value: int) -> None:# check if _value causes the big number operation errorif _value > 77:self.revert("_value is too big to operate")self._result = 10 ** _value
On each node, the SCORE instance can be loaded/unloaded at any time. Therefore, if you use instance variables that are not stored in StateDB, you may have different result on each node.
# Baddef __init__(self, db: IconScoreDatabase) -> None:super().__init__(db)​@externaldef update_organizer(self, _organizer: Address) -> None:self._organizer = _organizer​@externaldef get_organizer(self) -> Address:return self._organizer​# Gooddef __init__(self, db: IconScoreDatabase) -> None:super().__init__(db)self._organizer = VarDB(self._ORGANIZER, db, value_type=Address)​@externaldef update_organizer(self, _organizer: Address) -> None:self._organizer.set(_organizer)​@externaldef get_organizer(self) -> Address:return self._organizer.get()
DictDB depth level must not exceed 3 as it will be very expensive to manage in ICON 2.0.
In order not to cause an unexpected situation, VarDB, DictDB and ArrayDB should be accessed in a permitted manner.
# VarDBself.test_var = VarDB('test_var', db, value_type=str)​# Goodself.test_var.set('sample') # setname = self.test_var.get() # get# Badself.test_var = 'sample' # Error​# DictDBself.test_dict = DictDB('test_dict', db, value_type=int)​# Goodself.test_dict['key'] = 1 ## setprint(self.test_dict['key']) ## get# Badself.test_dict = 1 ## Error​# ArrayDBself.test_array = ArrayDB('test_array', db, value_type=int)​# Goodself.test_array.put(0) # put the value at the last indexself.test_array.pop() # remove the value at the last index and return itself.test_array.get(0) # get the value at some index# Badself.test_array = 1 # Error
The data stored in StateDB must be deterministic on all nodes. There are some limitations to this. The first is that if you want to store a class instance in StateDB, you must explicitly serialize it before saving.
self.something = VarDB('context.something', db, str)​# Goodself.something.set( str(Something()) )​# Badself.something.set( Something() )
The second is when you store an unordered collection of items in StateDB, such as a python set datatype. Generally, set datatype is converted to list datatype and serialized using json.dumps and saved in StateDB. However, the result of converting from set to list may not be the same for each node. Therefore, the data must be manipulated so that the same result can be stored in StateDB.
self.test_var = VarDB('test_var', db, str)data_set = set([1,2,3])# Gooddata_list = list(data_set)data_list.sort()self.test_var.set( json_dumps(data_list) )# Baddata_list = list(data_set)self.test_var.set( json_dumps(data_list) )
StateDB read/write operations inside of __init__()
function is strictly prohibited. Any state DB accesses in __init__()
may cause unexpected behavior like deployment error.
# Baddef __init__(self, db: IconScoreDatabase) -> None:super().__init__(db)self._total_supply = VarDB(self._TOTAL_SUPPLY, db, value_type=int)self._total_supply.set(10000000)
If a SCORE function is called from EOA with wrong parameter types or without required parameters, ICON service will return an error. Developers do not need to deliberately verify the input parameter types inside a function.
If a SCORE calls other functions of own or of other SCORE, always make sure that parameter types are correct and required parameters are not omitted. Values of parameters must be in a valid range. There is no size limit in str
or int
type in Python, however, transaction message should not exceed 512 KB.
# Function declarationsdef myTransfer(_value: int) -> bool:def myTransfer1(_value: int, _extra: str) -> bool:​# BadmyTransfer("1000")myTransfer1(1000)​# GoodmyTransfer(1000)myTransfer1(1000, 'abc')
Some applications such as lottery require arbitrarity. Due to the nature of blockchain, implementation of such business logic must be done with great care. Output of pseudo random number generator can be predictable if random seed is revealed.
# Bad# block height is predictable.won = block.height % 2 == 0
In case of sending ICX by calling a low level function such as 'icx.send', you should check the execution result of 'icx.send' and handle the failure properly. 'icx.send' returns boolean result of its execution, and does not raise an exception on failure. On the other hand, 'icx.transfer' raises an exception if transaction fails. If the SCORE does not catch the exception, the transaction will be reverted. Reference: An object used to transfer icx coin​
# Badself._refund_icx_amount[_to] += amountself.icx.send(_to)​# Goodself._refund_icx_amount[_to] += amountif not self.icx.send(_to, amount):self._refund_icx_amount[_to] -= amount​# Goodself._refund_icx_amount[_to] += amountself.icx.transfer(_to, amount)
When you do arithmetic, it is really important to validate that operands and results are in the designed range.
# Bad@externaldef mint_token(self, _amount: int):if not self.msg.sender == self.owner:self.revert('Only owner can mint token')​# if _amount is below zero, self._balances[self.owner] and self._total_supply can be minus potentiallyself._balances[self.owner] = self._balances[self.owner] + _amountself._total_supply.set(self._total_supply.get() + _amount)​self.Transfer(EOA_ZERO, self.owner, _amount, b'mint')​# Good@externaldef mint_token(self, _amount: int):if not self.msg.sender == self.owner:self.revert('Only owner can mint token')if _amount <= 0:self.revert('_amount should be greater than 0')​self._balances[self.owner] = self._balances[self.owner] + _amountself._total_supply.set(self._total_supply.get() + _amount)​self.Transfer(EOA_ZERO, self.owner, _amount, b'mint')
Anybody can view the data stored in public blockchain network. It is strongly recommended to save personal data such as password off the blockchain network even if it is encrypted.
# Baddef change_password(self, _account: Address, _password: str):if self.msg.sender != _account:self.revert('Only owner of the account can change password')​self.passwords[_account] = _password
When you send ICX or token, keep it mind that the target could be a SCORE. If the SCORE's fallback or tokenFallback function is implemented maliciously, it could reenter original SCORE. Then there could be unintended loop between two SCOREs.
# Bad# refund function in SCORE1. (assume ICX:token ratio is 1:1)@externaldef refund(self, _to:Address, _amount:int):if self.msg.sender != self.owner and self.msg.sender != _to:self.revert('You are not allowed to request refund')if self.token_balances[_to] < _amount:self.revert('Not enough balance')​# send icx firstself.icx.transfer(_to, _amount)​# decrease balance laterself.token_balances[_to] -= _amount​# malicious fallback function in SCORE2@payabledef fallback(self):if self.msg.sender == SCORE1_ADDRESS:# call refund of SCORE1 Againscore1 = self.create_interface_score(SCORE1_ADDRESS, Score1Interface)score1.refund(self.msg.sender, bigAmountOfICX)​# Good# refund function in SCORE1@externaldef refund(self, _to:Address, _amount:int):if self.msg.sender != self.owner and self.msg.sender != _to:self.revert('You are not allowed to request refund')if self.token_balances[_to] < _amount:self.revert('Not enough balance')​# decrease balance firstself.balances[_to] -= _amount​# send icx laterself.icx.transfer(_to, _amount)​# Good# refund function in SCORE1@externaldef refund(self, _to:Address, _amount:int):if self.msg.sender != self.owner and self.msg.sender != _to:self.revert('You are not allowed to request refund')if self.token_balances[_to] < _amount:self.revert('Not enough balance')​# block if _to is smart contractif _to.is_contract:self.revert('ICX can not be transferred to SCORE')​# send icx firstself.icx.transfer(_to, _amount)​# decrease balance laterself.balances[_to] -= _amount