From 0b11a59e5bc80cb246bcadc779203bded6839840 Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Tue, 29 Oct 2024 14:14:43 +0300 Subject: [PATCH 1/2] feat: add scrvusd oracle --- contracts/oracles/OptimismBlockHashOracle.vy | 81 +++++++ contracts/oracles/ScrvusdOracle.vy | 219 +++++++++++++++++++ contracts/provers/ScrvusdProver.sol | 101 +++++++++ scripts/scrvusd_keeper.py | 103 +++++++++ scripts/submit_scrvusd_price.py | 95 ++++++++ 5 files changed, 599 insertions(+) create mode 100644 contracts/oracles/OptimismBlockHashOracle.vy create mode 100644 contracts/oracles/ScrvusdOracle.vy create mode 100644 contracts/provers/ScrvusdProver.sol create mode 100644 scripts/scrvusd_keeper.py create mode 100644 scripts/submit_scrvusd_price.py diff --git a/contracts/oracles/OptimismBlockHashOracle.vy b/contracts/oracles/OptimismBlockHashOracle.vy new file mode 100644 index 0000000..8726257 --- /dev/null +++ b/contracts/oracles/OptimismBlockHashOracle.vy @@ -0,0 +1,81 @@ +# pragma version 0.4.0 +""" +@title Optimism Block Hash oracle +@notice A contract that saves L1 block hashes. +@license MIT +@author curve.fi +@custom:version 0.0.1 +@custom:security security@curve.fi +""" + +version: public(constant(String[8])) = "0.0.1" + +interface IL1Block: + def number() -> uint64: view + def hash() -> bytes32: view + + +event CommitBlockHash: + committer: indexed(address) + number: indexed(uint256) + hash: bytes32 + +event ApplyBlockHash: + number: indexed(uint256) + hash: bytes32 + +L1_BLOCK: constant(IL1Block) = IL1Block(0x4200000000000000000000000000000000000015) + +block_hash: public(HashMap[uint256, bytes32]) +commitments: public(HashMap[address, HashMap[uint256, bytes32]]) + + +@view +@external +def get_block_hash(_number: uint256) -> bytes32: + """ + @notice Query the block hash of a block. + @dev Reverts for block numbers which have yet to be set. + """ + block_hash: bytes32 = self.block_hash[_number] + assert block_hash != empty(bytes32) + + return block_hash + + +@internal +def _update_block_hash() -> (uint256, bytes32): + number: uint256 = convert(staticcall L1_BLOCK.number(), uint256) + hash: bytes32 = staticcall L1_BLOCK.hash() + self.block_hash[number] = hash + + return number, hash + + +@external +def commit() -> uint256: + """ + @notice Commit (and apply) a block hash. + @dev Same as `apply()` but saves committer + """ + number: uint256 = 0 + hash: bytes32 = empty(bytes32) + number, hash = self._update_block_hash() + + self.commitments[msg.sender][number] = hash + log CommitBlockHash(msg.sender, number, hash) + log ApplyBlockHash(number, hash) + return number + + +@external +def apply() -> uint256: + """ + @notice Apply a block hash. + """ + number: uint256 = 0 + hash: bytes32 = empty(bytes32) + number, hash = self._update_block_hash() + + log ApplyBlockHash(number, hash) + return number diff --git a/contracts/oracles/ScrvusdOracle.vy b/contracts/oracles/ScrvusdOracle.vy new file mode 100644 index 0000000..f9fc9c2 --- /dev/null +++ b/contracts/oracles/ScrvusdOracle.vy @@ -0,0 +1,219 @@ +# pragma version 0.4.0 +""" +@title scrvUSD oracle +@notice Oracle of scrvUSD share price for StableSwap pool and other integrations. + Price updates are linearly smoothed with max acceleration to eliminate sharp changes. +@license Copyright (c) Curve.Fi, 2020-2024 - all rights reserved +@author curve.fi +@custom:version 0.0.1 +@custom:security security@curve.fi +""" + +version: public(constant(String[8])) = "0.0.1" + +from snekmate.auth import ownable + +initializes: ownable +exports: ownable.__interface__ + +event PriceUpdate: + new_price: uint256 # price to achieve + at: uint256 # timestamp at which price will be achieved + +event SetProver: + prover: address + +struct Interval: + previous: uint256 + future: uint256 + + +# scrvUSD Vault rate replication +# 0 total_debt +# 1 total_idle +ASSETS_PARAM_CNT: constant(uint256) = 2 +# 0 totalSupply +# 1 full_profit_unlock_date +# 2 profit_unlocking_rate +# 3 last_profit_update +# 4 balance_of_self +# 5 block.timestamp +SUPPLY_PARAM_CNT: constant(uint256) = 6 +MAX_BPS_EXTENDED: constant(uint256) = 1_000_000_000_000 + +prover: public(address) + +price: public(Interval) # price of asset per share +time: public(Interval) + +max_acceleration: public(uint256) # precision 10**18 + + +@deploy +def __init__(_initial_price: uint256, _max_acceleration: uint256): + """ + @param _initial_price Initial price of asset per share (10**18) + @param _max_acceleration Maximum acceleration (10**12) + """ + self.price = Interval(previous=_initial_price, future=_initial_price) + self.time = Interval(previous=block.timestamp, future=block.timestamp) + + self.max_acceleration = _max_acceleration + + ownable.__init__() + + +@view +@internal +def _price_per_share(ts: uint256) -> uint256: + """ + @notice Using linear interpolation assuming updates are often enough + for absolute difference \approx relative difference + """ + price: Interval = self.price + time: Interval = self.time + if ts >= time.future: + return price.future + if ts <= time.previous: + return price.previous + return (price.previous * (time.future - ts) + price.future * (ts - time.previous)) // (time.future - time.previous) + + +@view +@external +def pricePerShare(ts: uint256=block.timestamp) -> uint256: + """ + @notice Get the price per share (pps) of the vault. + @dev NOT precise. Price is smoothed over time to eliminate sharp changes. + @param ts Timestamp to look price at. Only near future is supported. + @return The price per share. + """ + return self._price_per_share(ts) + + +@view +@external +def pricePerAsset(ts: uint256=block.timestamp) -> uint256: + """ + @notice Get the price per asset of the vault. + @dev NOT precise. Price is smoothed over time to eliminate sharp changes. + @param ts Timestamp to look price at. Only near future is supported. + @return The price per share. + """ + return 10 ** 36 // self._price_per_share(ts) + + +@view +@external +def price_oracle(i: uint256=0) -> uint256: + """ + @notice Alias of `pricePerShare` and `pricePerAsset` made for compatability + @param i 0 for scrvusd per crvusd, 1 for crvusd per scrvusd + @return Price with 10^18 precision + """ + return self._price_per_share(block.timestamp) if i == 0 else 10 ** 36 // self._price_per_share(block.timestamp) + + +@view +@internal +def _unlocked_shares( + full_profit_unlock_date: uint256, + profit_unlocking_rate: uint256, + last_profit_update: uint256, + balance_of_self: uint256, + ts: uint256, +) -> uint256: + """ + Returns the amount of shares that have been unlocked. + To avoid sudden price_per_share spikes, profits can be processed + through an unlocking period. The mechanism involves shares to be + minted to the vault which are unlocked gradually over time. Shares + that have been locked are gradually unlocked over profit_max_unlock_time. + """ + unlocked_shares: uint256 = 0 + if full_profit_unlock_date > ts: + # If we have not fully unlocked, we need to calculate how much has been. + unlocked_shares = profit_unlocking_rate * (ts - last_profit_update) // MAX_BPS_EXTENDED + + elif full_profit_unlock_date != 0: + # All shares have been unlocked + unlocked_shares = balance_of_self + + return unlocked_shares + + +@view +@internal +def _total_supply(parameters: uint256[ASSETS_PARAM_CNT + SUPPLY_PARAM_CNT]) -> uint256: + # Need to account for the shares issued to the vault that have unlocked. + return parameters[ASSETS_PARAM_CNT + 0] -\ + self._unlocked_shares( + parameters[ASSETS_PARAM_CNT + 1], # full_profit_unlock_date + parameters[ASSETS_PARAM_CNT + 2], # profit_unlocking_rate + parameters[ASSETS_PARAM_CNT + 3], # last_profit_update + parameters[ASSETS_PARAM_CNT + 4], # balance_of_self + parameters[ASSETS_PARAM_CNT + 5], # block.timestamp + ) + +@view +@internal +def _total_assets(parameters: uint256[ASSETS_PARAM_CNT + SUPPLY_PARAM_CNT]) -> uint256: + """ + @notice Total amount of assets that are in the vault and in the strategies. + """ + return parameters[0] + parameters[1] + + +@external +def update_price( + _parameters: uint256[ASSETS_PARAM_CNT + SUPPLY_PARAM_CNT], +) -> uint256: + """ + @notice Update price using `_parameters` + @param _parameters Parameters of + @return Relative price change of final price with 10^18 precision + """ + assert msg.sender == self.prover + + current_price: uint256 = self._price_per_share(block.timestamp) + new_price: uint256 = self._total_assets(_parameters) * 10 ** 18 //\ + self._total_supply(_parameters) + + # Price is always growing and updates are never from future, + # hence allow only increasing updates + future_price: uint256 = self.price.future + if new_price > future_price: + self.price = Interval(previous=current_price, future=new_price) + + rel_price_change: uint256 = (new_price - current_price) * 10 ** 18 // current_price + 1 # 1 for rounding up + future_ts: uint256 = block.timestamp + rel_price_change // self.max_acceleration + self.time = Interval(previous=block.timestamp, future=future_ts) + + log PriceUpdate(new_price, future_ts) + return new_price * 10 ** 18 // future_price + return 10 ** 18 + + +@external +def set_max_acceleration(_max_acceleration: uint256): + """ + @notice Set maximum acceleration of scrvUSD. + Must be less than StableSwap's minimum fee. + fee / (2 * block_time) is considered to be safe. + @param _max_acceleration Maximum acceleration (per sec) + """ + ownable._check_owner() + + assert 10 ** 8 <= _max_acceleration and _max_acceleration <= 10 ** 18 + self.max_acceleration = _max_acceleration + + +@external +def set_prover(_prover: address): + """ + @notice Set the account with prover permissions. + """ + ownable._check_owner() + + self.prover = _prover + log SetProver(_prover) diff --git a/contracts/provers/ScrvusdProver.sol b/contracts/provers/ScrvusdProver.sol new file mode 100644 index 0000000..670176c --- /dev/null +++ b/contracts/provers/ScrvusdProver.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {RLPReader} from "hamdiallam/Solidity-RLP@2.0.7/contracts/RLPReader.sol"; +import {StateProofVerifier as Verifier} from "../libs/StateProofVerifier.sol"; + +interface IBlockHashOracle { + function get_block_hash(uint256 _number) external view returns (bytes32); +} + +interface IScrvusdOracle { + function update_price( + uint256[2 + 6] memory _parameters + ) external returns (uint256); +} + +/// @title Scrvusd Prover +/// @author Curve Finance +contract ScrvusdProver { + using RLPReader for bytes; + using RLPReader for RLPReader.RLPItem; + + address constant SCRVUSD = + 0x182863131F9a4630fF9E27830d945B1413e347E8; // Temporary compatible vault + bytes32 constant SCRVUSD_HASH = + keccak256(abi.encodePacked(SCRVUSD)); + + address public immutable BLOCK_HASH_ORACLE; + address public immutable SCRVUSD_ORACLE; + + uint256 constant PARAM_CNT = 2 + 6; + uint256 constant PROOF_CNT = PARAM_CNT - 1; // -1 for timestamp obtained from block header + + constructor(address _block_hash_oracle, address _scrvusd_oracle) { + BLOCK_HASH_ORACLE = _block_hash_oracle; + SCRVUSD_ORACLE = _scrvusd_oracle; + } + + /// Prove parameters of scrvUSD rate. + /// @param _block_header_rlp The block header of any block. + /// @param _proof_rlp The state proof of the parameters. + function prove( + bytes memory _block_header_rlp, + bytes memory _proof_rlp + ) external returns (uint256) { + Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader( + _block_header_rlp + ); + require(block_header.hash != bytes32(0)); // dev: invalid blockhash + require( + block_header.hash == + IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash( + block_header.number + ) + ); // dev: blockhash mismatch + + // convert _proof_rlp into a list of `RLPItem`s + RLPReader.RLPItem[] memory proofs = _proof_rlp.toRlpItem().toList(); + require(proofs.length == 1 + PROOF_CNT); // dev: invalid number of proofs + + // 0th proof is the account proof for the scrvUSD contract + Verifier.Account memory account = Verifier.extractAccountFromProof( + SCRVUSD_HASH, // position of the account is the hash of its address + block_header.stateRootHash, + proofs[0].toList() + ); + require(account.exists); // dev: scrvUSD account does not exist + + // iterate over proofs + uint256[PROOF_CNT] memory PARAM_SLOTS = [ + // Assets parameters + uint256(21), // total_debt + 22, // total_idle + + // Supply parameters + 20, // totalSupply + 38, // full_profit_unlock_date + 39, // profit_unlocking_rate + 40, // last_profit_update + uint256(keccak256(abi.encode(18, SCRVUSD))) // balance_of_self + // ts from block header + ]; + uint256[PARAM_CNT] memory params; + Verifier.SlotValue memory slot; + uint256 i = 0; + for (uint256 idx = 1; idx < 1 + PARAM_CNT-1; idx++) { + slot = Verifier.extractSlotValueFromProof( + keccak256(abi.encode(PARAM_SLOTS[i])), + account.storageRoot, + proofs[idx].toList() + ); + // Some slots may not be used => not exist, e.g. total_idle + // require(slot.exists); + + params[i] = slot.value; + i++; + } + params[i] = block_header.timestamp; + return IScrvusdOracle(SCRVUSD_ORACLE).update_price(params); + } +} diff --git a/scripts/scrvusd_keeper.py b/scripts/scrvusd_keeper.py new file mode 100644 index 0000000..0526cde --- /dev/null +++ b/scripts/scrvusd_keeper.py @@ -0,0 +1,103 @@ +import time +from time import sleep + +from web3 import Web3 +from web3.eth import AsyncEth +import json +import os + +from getpass import getpass +from eth_account import account + +from submit_scrvusd_price import generate_proof + +ETH_NETWORK = f"https://eth-mainnet.alchemyapi.io/v2/{os.environ['WEB3_ETHEREUM_MAINNET_ALCHEMY_API_KEY']}" +L2_NETWORK = f"https://opt-mainnet.g.alchemy.com/v2/{os.environ['WEB3_OPTIMISM_MAINNET_ALCHEMY_API_KEY']}" + +SCRVUSD = "" + +B_ORACLE = "" +S_ORACLE = "" +PROVER = "" + +last_update = 0 + +APPLY_BLOCK_HASH = Web3.keccak(text="ApplyBlockHash(uint256,bytes32)").hex() +COMMIT_BLOCK_HASH = Web3.keccak(text="CommitBlockHash(address,uint256,bytes32)").hex() + + +eth_web3 = Web3( + provider=Web3.HTTPProvider( + ETH_NETWORK, + # {"verify_ssl": False}, + ), + # modules={"eth": (AsyncEth,)}, +) + +l2_web3 = Web3( + provider=Web3.HTTPProvider( + L2_NETWORK, + # {"verify_ssl": False}, + ), + # modules={"eth": (AsyncEth,)}, +) + + +def account_load_pkey(fname): + path = os.path.expanduser(os.path.join('~', '.brownie', 'accounts', fname + '.json')) + with open(path, 'r') as f: + pkey = account.decode_keyfile_json(json.load(f), getpass()) + return pkey +wallet_pk = account_load_pkey("keeper") + + +def prove(boracle, prover): + # Apply latest available blockhash + tx = boracle.functions.apply().build_transaction() + signed_tx = l2_web3.eth.account.sign_transaction(tx, private_key=wallet_pk) + tx_hash = l2_web3.eth.send_raw_transaction(signed_tx.rawTransaction) + l2_web3.eth.wait_for_transaction_receipt(tx_hash) + tx_receipt = l2_web3.eth.get_transaction_receipt(tx_hash) + number = -1 + for log in tx_receipt["logs"]: + if log["address"] == boracle.address: + if log["topics"][0].hex() == APPLY_BLOCK_HASH: + number = int(log["topics"][1].hex(), 16) + break + if log["topics"][0].hex() == COMMIT_BLOCK_HASH: + number = int(log["topics"][2].hex(), 16) + break + assert number > 0, "Applied block number not retrieved" + print(f"Applied block: {number}") + + # Generate and submit proof for applied blockhash + proofs = generate_proof(number, eth_web3) + tx = prover.functions.prove(proofs[0], proofs[1]).build_transaction() + signed_tx = l2_web3.eth.account.sign_transaction(tx, private_key=wallet_pk) + l2_web3.eth.send_raw_transaction(signed_tx.rawTransaction) + l2_web3.eth.wait_for_transaction_receipt(tx_hash) + print(f"Submitted proof") + + +def time_to_update(): + # can be any relative change or time + return time.time() - last_update >= 4 * 3600 # Every 4 hours + + +def loop(): + boracle = l2_web3.eth.contract(B_ORACLE, abi=[{'name': 'CommitBlockHash', 'inputs': [{'name': 'committer', 'type': 'address', 'indexed': True}, {'name': 'number', 'type': 'uint256', 'indexed': True}, {'name': 'hash', 'type': 'bytes32', 'indexed': False}], 'anonymous': False, 'type': 'event'}, {'name': 'ApplyBlockHash', 'inputs': [{'name': 'number', 'type': 'uint256', 'indexed': True}, {'name': 'hash', 'type': 'bytes32', 'indexed': False}], 'anonymous': False, 'type': 'event'}, {'stateMutability': 'view', 'type': 'function', 'name': 'get_block_hash', 'inputs': [{'name': '_number', 'type': 'uint256'}], 'outputs': [{'name': '', 'type': 'bytes32'}]}, {'stateMutability': 'nonpayable', 'type': 'function', 'name': 'commit', 'inputs': [], 'outputs': [{'name': '', 'type': 'uint256'}]}, {'stateMutability': 'nonpayable', 'type': 'function', 'name': 'apply', 'inputs': [], 'outputs': [{'name': '', 'type': 'uint256'}]}, {'stateMutability': 'view', 'type': 'function', 'name': 'block_hash', 'inputs': [{'name': 'arg0', 'type': 'uint256'}], 'outputs': [{'name': '', 'type': 'bytes32'}]}, {'stateMutability': 'view', 'type': 'function', 'name': 'commitments', 'inputs': [{'name': 'arg0', 'type': 'address'}, {'name': 'arg1', 'type': 'uint256'}], 'outputs': [{'name': '', 'type': 'bytes32'}]}]) + prover = l2_web3.eth.contract(PROVER, abi=[{"inputs": [{"internalType": "bytes", "name": "_block_header_rlp", "type": "bytes"}, {"internalType": "bytes", "name": "_proof_rlp", "type": "bytes"}], "name": "prove", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "nonpayable", "type": "function"}]) + + while True: + if time_to_update(): + try: + prove(boracle, prover) + global last_update + last_update = time.time() + except Exception as e: + print(e) + sleep(12) + + +if __name__ == '__main__': + loop() diff --git a/scripts/submit_scrvusd_price.py b/scripts/submit_scrvusd_price.py new file mode 100644 index 0000000..d34a665 --- /dev/null +++ b/scripts/submit_scrvusd_price.py @@ -0,0 +1,95 @@ +import eth_abi +import rlp +import web3 +from hexbytes import HexBytes + +BLOCK_NUMBER = 18578883 +SCRVUSD = "0x182863131F9a4630fF9E27830d945B1413e347E8" + +PROVER = "" + +ASSET_PARAM_SLOTS = [ + 21, # total_debt + 22, # total_idle, slot doesn't exist +] +SUPPLY_PARAM_SLOTS = [ + 20, # totalSupply + 38, # full_profit_unlock_date + 39, # profit_unlocking_rate + 40, # last_profit_update + web3.Web3.keccak(eth_abi.encode(["(uint256,address)"], [[18, SCRVUSD]])), # balance_of_self + # ts from block header +] + +# https://github.com/ethereum/go-ethereum/blob/master/core/types/block.go#L69 +BLOCK_HEADER = ( + "parentHash", + "sha3Uncles", + "miner", + "stateRoot", + "transactionsRoot", + "receiptsRoot", + "logsBloom", + "difficulty", + "number", + "gasLimit", + "gasUsed", + "timestamp", + "extraData", + "mixHash", + "nonce", + "baseFeePerGas", # added by EIP-1559 and is ignored in legacy headers + "withdrawalsRoot", # added by EIP-4895 and is ignored in legacy headers + "blobGasUsed", # added by EIP-4844 and is ignored in legacy headers + "excessBlobGas", # added by EIP-4844 and is ignored in legacy headers + "parentBeaconBlockRoot", # added by EIP-4788 and is ignored in legacy headers +) + + +def serialize_block(block): + block_header = [ + HexBytes("0x") if isinstance((v := block[k]), int) and v == 0 else HexBytes(v) + for k in BLOCK_HEADER + if k in block + ] + return rlp.encode(block_header) + + +def serialize_proofs(proofs): + account_proof = list(map(rlp.decode, map(HexBytes, proofs["accountProof"]))) + storage_proofs = [ + list(map(rlp.decode, map(HexBytes, proof["proof"]))) for proof in proofs["storageProof"] + ] + return rlp.encode([account_proof, *storage_proofs]) + + +def generate_proof(block_number, eth_web3, log=False): + block_number = block_number or BLOCK_NUMBER + block = eth_web3.eth.get_block(block_number) + if log: + print(f"Generating proof for block {block.number}, {block.hash.hex()}") + block_header_rlp = serialize_block(block) + proof_rlp = serialize_proofs(eth_web3.eth.get_proof(SCRVUSD, ASSET_PARAM_SLOTS + SUPPLY_PARAM_SLOTS, block_number)) + + with open("header.txt", "w") as f: + f.write(block_header_rlp.hex()) + with open("proof.txt", "w") as f: + f.write(proof_rlp.hex()) + + return block_header_rlp.hex(), proof_rlp.hex() + + +def submit_proof(proofs, prover=None): + prover = prover or PROVER + # if isinstance(prover, str): + # prover = boa_solidity.load_partial("contracts/provers/ScrvusdProver.sol").at(prover) + + if proofs: + block_header_rlp, proof_rlp = proofs + else: + with open("header.txt") as f: + block_header_rlp = f.read() + with open("proof.txt") as f: + proof_rlp = f.read() + + prover.prove(bytes.fromhex(block_header_rlp), bytes.fromhex(proof_rlp)) From 1433ac9378a8addad6fe87740a267a1bb3f1698b Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Tue, 29 Oct 2024 21:31:27 +0300 Subject: [PATCH 2/2] style: alias --- contracts/provers/ScrvusdProver.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/provers/ScrvusdProver.sol b/contracts/provers/ScrvusdProver.sol index 670176c..728e49f 100644 --- a/contracts/provers/ScrvusdProver.sol +++ b/contracts/provers/ScrvusdProver.sol @@ -83,7 +83,7 @@ contract ScrvusdProver { uint256[PARAM_CNT] memory params; Verifier.SlotValue memory slot; uint256 i = 0; - for (uint256 idx = 1; idx < 1 + PARAM_CNT-1; idx++) { + for (uint256 idx = 1; idx < 1 + PROOF_CNT; idx++) { slot = Verifier.extractSlotValueFromProof( keccak256(abi.encode(PARAM_SLOTS[i])), account.storageRoot,