Safe locking contract facilitates locking Safe tokens. Users can lock and unlock tokens anytime and also withdraw after the COOLDOWN_PERIOD
is over. The contract also provides feature controlled by the admin address to recover ERC20 tokens other than Safe tokens.
The Safe locking contract was deployed to Ethereum mainnet at address 0x0a7CB434f96f65972D46A5c1A64a9654dC9959b2
. The deployment transaction was 0xd90bbe97f71ca9cff6e7ee545a773d25fa34260624df681bf04592f5e7301b2e
.
-
The locking contract is designed for "continuous" locking (as opposed to a “discrete” locking mechanism). This means that:
- Tokens can be locked at any time
- Tokens can be unlocked at any time, where a transaction is issued to initiate the unlock and enter a “cool-down” period and a follow-up transaction is issued to withdraw the unlocked tokens after the cool-down period is over.
-
The current specification considers locking age to be computed in a First-In-First-Out order (Calculated offchain). For example, a user:
- Locks 100 SAFE on block 1000000
- Locks 50 SAFE on block 1000500
- Unlocks, then withdraws 75 SAFE
On block 1001000, their locked SAFE amounts with ages would be:
- 25 SAFE locked for 1000 blocks
- 50 SAFE locked for 500 blocks
-
withdraw(...)
is defined to withdraw all matured unlocks (unlocks whose unlock time is greater thanblock.timestamp
). This is anO(n)
operation (wheren
is the number of matured unlocks). In order to support more deterministic gas usage, awithdraw(maxUnlocks)
function is also provided to withdraw up tomaxUnlocks
of the oldest matured unlocks. For example,withdraw(1)
can be used to withdraw only the oldest unlock if it is already matured, which is anO(1)
operation. -
getUserTokenBalance
returns the totalSAFE
token balance belonging to theholder
within the locking contract, this includes locked tokens, unlocked tokens, and ready to withdraw tokens. -
Full accounting of locked tokens per user can be computable based on emitted events.
-
The total
SAFE
token balance in the locking contract belonging to any user can be readable on-chain or via a standardeth_call
JSON RPC request. This MUST be made available by thegetUserTokenBalance(...)
contract method. -
The locking contract can allow simultaneous token unlock requests with separate cooldown ending timestamps based on an immutable cooldown period.
-
The contract cannot allow the total sum of unlock request amounts to exceed the token holder’s
SAFE
balance in the contract. -
A withdrawal will withdraw the complete amount of its corresponding unlock request. That is, if a user calls
unlock(amount)
, thenwithdraw()
, that unlock once matured, should transfer exactlyamount
SAFE
tokens to the holder (assuming no other unlocks were matured apart from that particular unlock operation).
Locked
: When the tokens are transferred from User usingtransferFrom
for an indefinite period.Unlocked
: Inititated by a user who already have certain amount of tokens locked. This state merely starts the unlock process from the current timestamp to a cooldown timestamp (calculated based on cooldown period).Withdrawable
: This is the state of unlocked tokens ready to be withdrawn completely by the user. The tokens can remain in the contract indefinitely in this state, until the user withdraws.Withdrawn
: This state is atomic, and can only be tracked on-chain based on logs emitted during the withdraw operation.
Note: Token enters the contract in Locked
state (unless tokens are transferred directly without calling the lock(...)
) and exits the contract from a Withdrawable
state using the withdraw(...)
to Withdrawn
state as mentioned above.
User | Operation | Time | Amount | users[User] | unlocks[index][User] | Note |
---|---|---|---|---|---|---|
A | Lock | X1 | (X1, 0, 0, 0) | |||
A | Unlock | X2 | (X1 - X2, X2, 0, 1) | unlocks[0][A] → (X2, t1) | ||
B | Lock | Y1 | (Y1, 0, 0, 0) | |||
A | Unlock | X3 | (X1 - X2 - X3, X2 + X3, 0, 2) | unlocks[1][A] → (X3, t2) | ||
B | Unlock | Y2 | (Y1 - Y2, Y2, 0, 1) | unlocks[0][B] → (Y2, t2) | ||
A | Unlock | X4 | (X1 - X2 - X3 - X4, X2 + X3 + X4, 0, 3) | unlocks[2][A] → (X4, t2) | ||
A | Withdraw | t1 | (X1 - X2 - X3 - X4, X3 + X4, 1, 3) | unlocks[0][A] → (0, 0) ??? | ||
B | Unlock | Y3 | (Y1 - Y2 - Y3, Y2 + Y3, 0, 2) | unlocks[1][B] → (Y3, t3) | ||
A | Withdraw | t2 | (X1 - X2 - X3 - X4, 0, 3, 3) | unlocks[2][A] → (0, 0) & unlocks[3][A] → (0, 0) ??? | Here 2 withdraw happens, as time t2 reached. | |
B | Withdraw | (Y1 - Y2 - Y3, Y3, 1, 2) | unlocks[0][B] → (0, 0) ??? |
Basic assumptions like X1 > X2 + X3 + X4
and Y1 > Y2 + Y3
are applicable. For a numerical example, please check here.
Contract implementation details can be found in the docs folder.
Note: Any Safe tokens sent to the contract other than use of lock(...)
function cannot be recovered and will be deposited to the contract forever.
sequenceDiagram
actor H as Holder
participant T as SAFE Token
participant L as Locking Contract
note over H,L: Locking
H ->> T: approve(lockingContract, a)
H ->>+ L: lock(a)
L ->>+ T: transferFrom(msg.sender, this, a)
T -->>- L: a SAFE
L ->> L: emit Locked()
deactivate L
note over H,L: Unlocking
H ->>+ L: unlock(b)
L ->> L: emit Unlocked()
L -->>- H: id
note over H,L: Withdrawal
H ->> H: ... wait for cooldown period ...
H ->>+ L: withdraw()
L ->>+ T: transfer(msg.sender, b)
T -->>- H: b SAFE
L ->> L: emit Withdrawn()
deactivate L
npm ci
npm run test
npm run test:e2e
NOTE: To run E2E test, INFURA_KEY
in .env
with the appropriate value to fork the Ethereum Mainnet.
npm run test:all
First setup a Python virtual environment and install required Certora dependencies:
python3 -m venv venv
. venv/bin/activate
pip install -r certora/requirements.txt
Install the correct solc
binary version. On Linux:
wget https://github.com/ethereum/solidity/releases/download/v0.8.23/solc-static-linux
chmod +x solc-static-linux
sudo mv solc-static-linux /some/directory/in/your/path/solc-0.8.23
To execute the formal verification specification:
export CERTORAKEY="..."
certoraRun certora/conf/SafeTokenLock.conf
Note: To use a Solidity compiler at custom path, run with:
certoraRun certora/conf/SafeTokenLock.conf --solc /path/to/solc
⚠️ Make sure to use the correct commit when deploying the contracts. Any change (even comments) within the contract files will result in different addresses. The tagged versions can be found in the releases.
This will deploy the contracts deterministically and verify the contracts on etherscan using Solidity 0.8.23 by default.
Preparation:
- Set
MNEMONIC
in.env
- Set
INFURA_KEY
in.env
npm run deploy-all <network>
This will perform the following steps
npm run build
npm run hardhat --network <network> deploy
npm run hardhat --network <network> etherscan-verify
npm run hardhat --network <network> local-verify
It is possible to use the CUSTOM_NODE_URL
env var to connect to any EVM based network via an RPC endpoint. This connection then can be used with the custom
network.
E.g. to deploy the Safe contract suite on that network you would run npm run deploy-all custom
.
The resulting addresses should be on all networks the same.
Note: Address will vary if contract code is changed or a different Solidity version is used.
All contracts are WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
All smart contracts are released under LGPL-3.0