diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 93c684a..a95b249 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ name: CI Workflow -on: [push, pull_request] +on: [push] jobs: diff --git a/contracts/StablecoinLens.vy b/contracts/StablecoinLens.vy index 2ff97ed..671139e 100644 --- a/contracts/StablecoinLens.vy +++ b/contracts/StablecoinLens.vy @@ -2,28 +2,67 @@ from interfaces import IPegKeeper from interfaces import IController +from interfaces import IControllerFactory +from interfaces import IMonetaryPolicy -MAX_CONTROLLERS: constant(uint256) = 50000 # TODO check actual number -MAX_PEG_KEEPERS: constant(uint256) = 100 # TODO check actual number +# bound from factory +MAX_CONTROLLERS: constant(uint256) = 50000 +# bound from monetary policy +MAX_PEG_KEEPERS: constant(uint256) = 1001 +# could have been any other controller +WETH_CONTROLLER_IDX: constant(uint256) = 3 -controllers: DynArray[address, MAX_CONTROLLERS] -peg_keepers: DynArray[address, MAX_PEG_KEEPERS] +# the crvusd controller factory +factory: immutable(IControllerFactory) + + +@deploy +def __init__(_factory: IControllerFactory): + factory = _factory @view @external def circulating_supply() -> uint256: - total_supply: uint256 = 0 + """ + @notice Compute the circulating supply for crvUSD, `totalSupply` is + incorrect since it takes into account all minted crvUSD (i.e. flashloans) + @dev This function sacrifices some gas to fetch peg keepers from a + unique source of truth to avoid having to manually maintain multiple + lists across several contracts. + For this reason we read the list of peg keepers contained in + the monetary policy returned by a controller in the factory. + factory -> weth controller -> monetary policy -> peg keepers + """ + + circulating_supply: uint256 = 0 + + # Fetch the weth controller (index 3) under the assumption that + # weth will always be a valid collateral for crvUSD, therefore its + # monetary policy should always be up to date. + controller: IController = staticcall factory.controllers(3) + + # We obtain the address of the current monetary policy used by the + # weth controller because it contains a list of all the peg keepers. + monetary_policy: IMonetaryPolicy = staticcall controller.monetary_policy() - # TODO how to optimize this - for pk: address in self.peg_keepers: - if pk != empty(address): - total_supply += staticcall IPegKeeper(pk).debt() + # Iterate over the peg keepers (since it's a fixed size array we + # wait for a zero address to stop iterating). + for i: uint256 in range(MAX_PEG_KEEPERS): + pk: IPegKeeper = staticcall monetary_policy.peg_keepers(i) + + if pk.address == empty(address): + # end of array + break + + circulating_supply += staticcall pk.debt() + + n_controllers: uint256 = staticcall factory.n_collaterals() - # TODO get correct value for this - n_controllers: uint256 = 0 for i: uint256 in range(n_controllers, bound=MAX_CONTROLLERS): - controller: address = self.controllers[i] - total_supply += staticcall IController(controller).total_debt() + controller = staticcall factory.controllers(i) + + # add crvUSD minted by controller + circulating_supply += staticcall controller.total_debt() - return total_supply + return circulating_supply diff --git a/contracts/interfaces/IController.vyi b/contracts/interfaces/IController.vyi index 0628325..2eb27e6 100644 --- a/contracts/interfaces/IController.vyi +++ b/contracts/interfaces/IController.vyi @@ -1,4 +1,15 @@ +# pragma version ~=0.4.0 + +import IMonetaryPolicy + + @view @external def total_debt() -> uint256: ... + + +@view +@external +def monetary_policy() -> IMonetaryPolicy: + ... diff --git a/contracts/interfaces/IControllerFactory.vyi b/contracts/interfaces/IControllerFactory.vyi new file mode 100644 index 0000000..23e0239 --- /dev/null +++ b/contracts/interfaces/IControllerFactory.vyi @@ -0,0 +1,15 @@ +# pragma version ~=0.4.0 + +import IController + + +@external +@view +def controllers(i: uint256) -> IController: + ... + + +@external +@view +def n_collaterals() -> uint256: + ... diff --git a/contracts/interfaces/IMonetaryPolicy.vyi b/contracts/interfaces/IMonetaryPolicy.vyi new file mode 100644 index 0000000..f49abf5 --- /dev/null +++ b/contracts/interfaces/IMonetaryPolicy.vyi @@ -0,0 +1,9 @@ +# pragma version ~=0.4.0 + +import IPegKeeper + + +@view +@external +def peg_keepers(i: uint256) -> IPegKeeper: + ... diff --git a/contracts/interfaces/IPegKeeper.vyi b/contracts/interfaces/IPegKeeper.vyi index b03a8c1..9c8b998 100644 --- a/contracts/interfaces/IPegKeeper.vyi +++ b/contracts/interfaces/IPegKeeper.vyi @@ -1,3 +1,5 @@ +# pragma version ~=0.4.0 + @view @external def debt() -> uint256: diff --git a/contracts/interfaces/IStablecoinLens.vyi b/contracts/interfaces/IStablecoinLens.vyi index 75f12b1..38708e7 100644 --- a/contracts/interfaces/IStablecoinLens.vyi +++ b/contracts/interfaces/IStablecoinLens.vyi @@ -1,4 +1,4 @@ -# Functions +# pragma version ~=0.4.0 @view @external diff --git a/poetry.lock b/poetry.lock index fe99c9c..290e607 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1545,18 +1545,18 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc [[package]] name = "filelock" -version = "3.16.0" +version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"}, - {file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"}, + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] typing = ["typing-extensions (>=4.12.2)"] [[package]] @@ -2379,13 +2379,13 @@ test = ["pytest"] [[package]] name = "mamushi" -version = "0.0.4a1" +version = "0.0.4a3" description = "Vyper formatter" optional = false python-versions = ">=3.9" files = [ - {file = "mamushi-0.0.4a1-py3-none-any.whl", hash = "sha256:bdf47a66e1b727f8de93da24bdbbec6b468c4654ef6fb91afa002e61f39dc9b1"}, - {file = "mamushi-0.0.4a1.tar.gz", hash = "sha256:7b5df8b436535240a0e356af18f0e19f46413639bc6837fdb0a18fcde372e85d"}, + {file = "mamushi-0.0.4a3-py3-none-any.whl", hash = "sha256:e2c0edbb75c218d354105b3b4d75a5f95a23f3ce02919b81cf123aca0ecf374b"}, + {file = "mamushi-0.0.4a3.tar.gz", hash = "sha256:2423a88c324405cff3fd2d6ff63140f57b5575bb5b315be9ae3e6b6c6d8081de"}, ] [package.dependencies] @@ -2866,13 +2866,13 @@ testing = ["pytest", "pytest-cov", "wheel"] [[package]] name = "platformdirs" -version = "4.3.3" +version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5"}, - {file = "platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] @@ -4292,7 +4292,7 @@ forking-recommended = ["requests-cache (>=1.2.1)", "ujson (>=5.10.0)"] [package.source] type = "git" url = "https://github.com/vyperlang/titanoboa.git" -reference = "master" +reference = "4768207288ec8fb23a4817ae193bd707ab9d1e8f" resolved_reference = "4768207288ec8fb23a4817ae193bd707ab9d1e8f" [[package]] @@ -4461,13 +4461,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.4" +version = "20.26.5" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"}, - {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"}, + {file = "virtualenv-20.26.5-py3-none-any.whl", hash = "sha256:4f3ac17b81fba3ce3bd6f4ead2749a72da5929c01774948e243db9ba41df4ff6"}, + {file = "virtualenv-20.26.5.tar.gz", hash = "sha256:ce489cac131aa58f4b25e321d6d186171f78e6cb13fafbf32a840cee67733ff4"}, ] [package.dependencies] @@ -4691,4 +4691,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12.6" -content-hash = "78db4cde815d6529a32a78d6d9fb6ac54e88b7a32de549404aa562bd2775445c" +content-hash = "9aadea717f9d30d99508cddb559a63f84ab9c60324651698a04511b570ea231c" diff --git a/pyproject.toml b/pyproject.toml index fc34c91..263fce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" description = "" authors = ["Curve.fi"] readme = "README.md" -package-mode = false [tool.black] line-length = 100 @@ -14,11 +13,11 @@ target_version = ['py312'] python = "^3.12.6" poetry = "^1.8.3" vyper = "0.4.0" -titanoboa = { git = "https://github.com/vyperlang/titanoboa.git", branch = "master" } +titanoboa = { git = "https://github.com/vyperlang/titanoboa.git", rev = "4768207288ec8fb23a4817ae193bd707ab9d1e8f" } snekmate = "0.1.0" [tool.poetry.group.dev.dependencies] -mamushi = "0.0.4a1" +mamushi = "0.0.4a3" pre-commit = "^3.8.0" black = "24.8.0" pytest = "^8.2.2" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..85828ac --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,26 @@ +import os + +import boa +import pytest + +boa.set_etherscan(api_key=os.getenv("ETHERSCAN_API_KEY")) + + +@pytest.fixture(scope="module") +def rpc_url(): + return os.getenv("ETH_RPC_URL") or "https://rpc.ankr.com/eth" + + +@pytest.fixture(scope="module", autouse=True) +def forked_env(rpc_url): + boa.fork(rpc_url, block_identifier=18801970) + + +@pytest.fixture(scope="module") +def controller_factory(): + return boa.from_etherscan("0xC9332fdCB1C491Dcc683bAe86Fe3cb70360738BC", "controller_factory") + + +@pytest.fixture(scope="module") +def lens(controller_factory): + return boa.load("contracts/StablecoinLens.vy", controller_factory) diff --git a/tests/integration/test_fork_circulating_supply.py b/tests/integration/test_fork_circulating_supply.py new file mode 100644 index 0000000..8863455 --- /dev/null +++ b/tests/integration/test_fork_circulating_supply.py @@ -0,0 +1,5 @@ +def test_fork_circulating_supply(lens): + # TODO mint more crvusd and make sure this increases accordingly + # TODO increase flashloan debt ceiling and make + # sure supply doesn't increase + print("{:2e}".format(lens.circulating_supply())) diff --git a/tests/mocks/MockController.vy b/tests/mocks/MockController.vy new file mode 100644 index 0000000..532462f --- /dev/null +++ b/tests/mocks/MockController.vy @@ -0,0 +1,14 @@ +# pragma version ~=0.4.0 + +from contracts.interfaces import IController +from contracts.interfaces import IMonetaryPolicy + +implements: IController + +_monetary_policy: address +total_debt: public(uint256) + +@external +@view +def monetary_policy() -> IMonetaryPolicy: + return IMonetaryPolicy(self._monetary_policy) diff --git a/tests/mocks/MockControllerFactory.vy b/tests/mocks/MockControllerFactory.vy new file mode 100644 index 0000000..c5fd2a8 --- /dev/null +++ b/tests/mocks/MockControllerFactory.vy @@ -0,0 +1,18 @@ +# pragma version ~=0.4.0 + +from contracts.interfaces import IControllerFactory +from contracts.interfaces import IController + +implements: IControllerFactory + +_controllers: DynArray[IController, 10000] + +@external +@view +def controllers(i: uint256) -> IController: + return self._controllers[i] + +@external +@view +def n_collaterals() -> uint256: + return len(self._controllers) diff --git a/tests/mocks/MockMonetaryPolicy.vy b/tests/mocks/MockMonetaryPolicy.vy new file mode 100644 index 0000000..727535a --- /dev/null +++ b/tests/mocks/MockMonetaryPolicy.vy @@ -0,0 +1,13 @@ +# pragma version ~=0.4.0 + +from contracts.interfaces import IMonetaryPolicy +from contracts.interfaces import IPegKeeper + +implements: IMonetaryPolicy + +peg_keeper: IPegKeeper + +@external +@view +def peg_keepers(i: uint256) -> IPegKeeper: + return self.peg_keeper diff --git a/tests/unitary/conftest.py b/tests/unitary/conftest.py index 860c6ad..fa79284 100644 --- a/tests/unitary/conftest.py +++ b/tests/unitary/conftest.py @@ -36,9 +36,7 @@ def role_manager(): def vault(vault_factory, crvusd, role_manager): vault_deployer = boa.load_partial("contracts/yearn/Vault.vy") - address = vault_factory.deploy_new_vault( - crvusd, "Staked crvUSD", "st-crvUSD", role_manager, 0 - ) + address = vault_factory.deploy_new_vault(crvusd, "Staked crvUSD", "st-crvUSD", role_manager, 0) return vault_deployer.at(address) diff --git a/tests/unitary/rewards_handler/test_process_rewards.py b/tests/unitary/rewards_handler/test_process_rewards.py deleted file mode 100644 index 9f8df19..0000000 --- a/tests/unitary/rewards_handler/test_process_rewards.py +++ /dev/null @@ -1,16 +0,0 @@ -import boa - - -def test_default_behavior(crvusd, vault, rewards_handler, lens, role_manager): - - boa.deal(crvusd, rewards_handler, 10**23) - rewards_handler.eval(f"self.lens = {lens.address}") - # rewards_handler.take_snapshot() - - print(vault.roles(rewards_handler)) - - # vault.add_strategy(vault, sender=rewards_handler.address) - - rewards_handler.process_rewards() - - assert crvusd.balanceOf(rewards_handler) == 0 diff --git a/tests/unitary/twa/test_twa.py b/tests/unitary/twa/test_twa.py index 0364520..3412258 100644 --- a/tests/unitary/twa/test_twa.py +++ b/tests/unitary/twa/test_twa.py @@ -66,9 +66,7 @@ def test_twa_two_deposits(vault, crvusd, rewards_handler, lens, vault_god): ), "TWA does not match expected deposit amount" -def test_twa_multiple_deposits( - vault, crvusd, rewards_handler, lens, vault_god -): +def test_twa_multiple_deposits(vault, crvusd, rewards_handler, lens, vault_god): # Prepare Alice's balance alice = boa.env.generate_address() boa.deal(crvusd, alice, 100_000_000 * 10**18) @@ -84,9 +82,7 @@ def test_twa_multiple_deposits( AMT_DEPOSIT = 10 * 10**18 # Amount to deposit per iteration TWA_WINDOW = rewards_handler.twa_window() # TWA window (e.g., one week) N_ITERATIONS = 5 # Number of deposits - TIME_BETWEEN_DEPOSITS = ( - TWA_WINDOW // N_ITERATIONS - ) # Time between each deposit + TIME_BETWEEN_DEPOSITS = TWA_WINDOW // N_ITERATIONS # Time between each deposit # Store the staked supply rates after each snapshot staked_supply_rates = [] @@ -163,9 +159,5 @@ def test_twa_multiple_deposits( print(f"Staked rate: {staked_rate}, Contract TWA: {twa}") # Compare the TWA from the contract against the expected values - assert ( - twa <= staked_rate - ), "TWA is unexpectedly higher than the staked rate" - assert ( - twa == expected_twa - ), f"TWA {twa} does not match expected {expected_twa}" + assert twa <= staked_rate, "TWA is unexpectedly higher than the staked rate" + assert twa == expected_twa, f"TWA {twa} does not match expected {expected_twa}"