diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2952425..68b0d633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,21 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: StableTechnologies/tezoracle + ref: bc5ccb8162960a98b0676a7b5588465f219f2fdb + path: _tezoracle + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: "Compile pinned TezOracle artifact" + working-directory: _tezoracle + run: | + python3 -m venv .venv + .venv/bin/pip install --require-hashes -r requirements-dev.txt + .venv/bin/python scripts/compile_oracle.py + git diff --exit-code -- michelson/tezoracle.tz - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ env.PYTHON_VERSION }} @@ -29,6 +44,11 @@ jobs: - name: "Version Check" run: | test "$(~/smartpy-cli/SmartPy.sh --version)" = "SmartPy Version: 0.16.0" + - name: "Check exact TezOracle interface compatibility" + env: + TEZORACLE_REPO: ${{ github.workspace }}/_tezoracle + run: | + python3 deploy/compile_targets/tests/test_tezoracle_interface.py ~/smartpy-cli/SmartPy.sh - name: "Run tests" run: | bash contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh diff --git a/README.md b/README.md index 38121a38..a2629a6c 100644 --- a/README.md +++ b/README.md @@ -313,10 +313,22 @@ present) and fails closed if it does not. `TezFinOracle` ([`contracts/TezFinOracle.py`](contracts/TezFinOracle.py)) is a thin proxy: it forwards price lookups to the address stored as `oracle` (the `PriceOracle` from the manifest) and expects that address to expose the on-chain view `get_price_with_timestamp(string) -> pair(nat, timestamp)` for -symbols such as `XTZUSDT` and `BTCUSDT`. It also has a small admin-controlled override map for assets -the upstream feed does not support (e.g. USD and USDT). `TezFinOracle`'s own `admin` (settable via -`set_pending_admin` / `accept_admin`) controls those overrides and can repoint `oracle` to a different -feed with `set_oracle`. +canonical TezOracle IDs. Comptroller requests `name + "-USD"`; the wrapper maps those strings +explicitly and fails closed (`ASSET_ID`) on unknown names: + +| TezFin market | Wrapper key | Upstream ID | +| --- | --- | --- | +| `XTZ` (`WTZ` / `OXTZ` / `STXTZ` via alias) | `XTZ-USD` | `XTZ_USD` | +| `USDT` | `USDT-USD` | `USDT_USD` | +| `USD` (USDtz) | `USD-USD` | `USDTZ_USD` | +| `TZBTC` | `TZBTC-USD` | `TZBTC_USD` | +| `BTC` | `BTC-USD` | `BTC_USD` | + +`TZBTC_USD` and `BTC_USD` are distinct; so are `USDT_USD` and `USDTZ_USD`. Origination starts with an +empty override map: USDt and USDtz are not admin `setPrice` stubs. `setPrice` / `removeAsset` remain +admin-only for exceptional assets that are not in the upstream map. `TezFinOracle`'s `admin` +(`set_pending_admin` / `accept_admin`) can still repoint `oracle` with `set_oracle`. Canonical map +keys cannot be rewritten through `addAlias`. - **Previewnet**: `CompileTestData.py` compiles and deploys a mock `PriceOracle` ([`deploy/test_data/PriceOracle.py`](deploy/test_data/PriceOracle.py)) as part of @@ -325,11 +337,12 @@ feed with `set_oracle`. `setPrice` entry point can be called by any address to set any price for any asset. Do not treat a Previewnet deployment using this mock as representative of mainnet price-feed security. - **Mainnet**: `deploy_mainnet.sh` never compiles or originates the mock oracle (it does not run - `CompileTestData.py` at all). Put the exact address of the vetted production Harbinger (or - Harbinger-compatible) oracle directly under the `PriceOracle` key in the mainnet manifest - (`DEPLOY_MANIFEST`) before running `deploy_mainnet.sh`; `mainnet_preflight.js` verifies it exists - on-chain before anything is compiled. The mandatory programmatic deployment preflight executes the - exact XTZ, USDT, and tzBTC views before origination and rejects zero, stale, or + `CompileTestData.py` at all). Put the exact address of the vetted production TezOracle (or other + contract that serves the same canonical `*_USD` views) under the `PriceOracle` key in the mainnet + manifest (`DEPLOY_MANIFEST`) before running `deploy_mainnet.sh`; `mainnet_preflight.js` verifies it + exists on-chain before anything is compiled. The mandatory programmatic deployment preflight + executes the exact `XTZ_USD`, `USDT_USD`, `USDTZ_USD`, and `TZBTC_USD` views before origination and + rejects zero, stale, or future/millisecond timestamps. Document, alongside the mainnet manifest, which oracle instance/administrator is being used and who controls it — this project does not deploy or administer that upstream feed itself. diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index 8da6529a..48ad50ec 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -80,12 +80,16 @@ def enterMarkets(self, cTokens): sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(cTokens, sp.TList(sp.TAddress)) currentAssetCount = sp.local("currentAssetCount", self.getUserUniqueAssetsCount(sp.sender)) + assets = sp.local("assets", sp.set(t=sp.TAddress)) sp.for token in cTokens: sp.if self.isNewAssetForUser(sp.sender, token): sp.verify(currentAssetCount.value < self.data.maxAssetsPerUser, EC.CMPT_TOO_MANY_ASSETS) currentAssetCount.value += 1 self.addToCollaterals(token, sp.sender) + assets.value.add(token) self.invalidateLiquidity(sp.sender) + sp.transfer(assets.value, sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) def addToCollaterals(self, cToken, lender): self.verifyMarketListed(cToken) @@ -112,6 +116,8 @@ def exitMarket(self, cToken): sp.set_type(cToken, sp.TAddress) self.activateOp(OP.ComptrollerOperations.EXIT_MARKET) + sp.transfer(sp.set([cToken]), sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) destination = sp.contract(sp.TPair(sp.TAddress, sp.TContract( CTI.TAccountSnapshot)), cToken, "getAccountSnapshot").open_some() sp.transfer(sp.pair(sp.sender, sp.self_entry_point( @@ -162,6 +168,8 @@ def mintAllowed(self, params): self.data.markets[params.cToken].supplyCap, EC.CMPT_SUPPLY_CAP_EXCEEDED) self.invalidateLiquidity(params.minter) + sp.transfer(sp.set([params.cToken]), sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) """ Checks if the account should be allowed to redeem tokens in the given market @@ -195,6 +203,8 @@ def redeemAllowed(self, params): self.checkRedeemAllowedInternal( params.cToken, params.redeemer, params.exchangeRateMantissa, balanceAfter + params.redeemTokens, balanceAfter) + sp.transfer(sp.set([params.cToken]), sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) def checkRedeemAllowedInternal(self, cToken, redeemer, exchangeRateMantissa, balanceBefore, balanceAfter): self.verifyMarketListed(cToken) @@ -363,6 +373,8 @@ def transferAllowed(self, params): # An allowed transfer makes any stored account-liquidity result stale, # including for debt-free accounts that bypass the solvency check. self.invalidateLiquidity(params.src) + sp.transfer(sp.set([params.cToken]), sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) """ Updates all asset prices using harbinger view @@ -386,23 +398,22 @@ def updateAssetPricesWithView(self, assets): sp.set_type(assets, sp.TSet(sp.TAddress)) sp.for asset in assets.elements(): self.verifyMarketListed(asset) - sp.if self.data.markets[asset].updateLevel < sp.level: - previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp - pricePair = sp.local("pricePair", - sp.view("getValidatedPrice", self.data.oracleAddress, - sp.record(comptroller=sp.self_address, - cToken=asset, - requestedAsset=self.data.markets[asset].name + "-USD", - previousPrice=previousRawPrice, - previousTimestamp=self.data.markets[asset].priceTimestamp), - t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") - ) - priceTimestamp = sp.fst(pricePair.value) - rawPrice = sp.snd(pricePair.value) - self.data.markets[asset].price = self.makeExp( - rawPrice*self.data.markets[asset].priceExp) - self.data.markets[asset].priceTimestamp = priceTimestamp - self.data.markets[asset].updateLevel = sp.level + previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp + pricePair = sp.local("pricePair", + sp.view("getValidatedPrice", self.data.oracleAddress, + sp.record(comptroller=sp.self_address, + cToken=asset, + requestedAsset=self.data.markets[asset].name + "-USD", + previousPrice=previousRawPrice, + previousTimestamp=self.data.markets[asset].priceTimestamp), + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") + ) + priceTimestamp = sp.fst(pricePair.value) + rawPrice = sp.snd(pricePair.value) + self.data.markets[asset].price = self.makeExp( + rawPrice*self.data.markets[asset].priceExp) + self.data.markets[asset].priceTimestamp = priceTimestamp + self.data.markets[asset].updateLevel = sp.level def getAssetPrice(self, asset): sp.verify(sp.level == self.data.markets[asset].updateLevel, EC.CMPT_UPDATE_PRICE) @@ -556,6 +567,10 @@ def liquidateBorrowAllowed(self, params): self.invalidateLiquidity(params.borrower) self.invalidateLiquidity(params.liquidator) + sp.transfer( + sp.set([params.cTokenBorrowed, params.cTokenCollateral]), + sp.mutez(0), + sp.self_entry_point("updateAssetPricesWithView")) """ Determines whether a seize is allwed diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index b11a88cb..6e238565 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -19,6 +19,17 @@ def __init__(self, admin, oracle): maxPriceAge=sp.big_map(l={}, tkey=sp.TAddress, tvalue=sp.TInt), alias=sp.big_map(l={"OXTZ-USD": "XTZ-USD", "WTZ-USD": "XTZ-USD", "STXTZ-USD": "XTZ-USD"}, tkey=sp.TString, tvalue=sp.TString), + upstreamAssets=sp.big_map( + l={ + "XTZ-USD": "XTZ_USD", + "BTC-USD": "BTC_USD", + "USDT-USD": "USDT_USD", + "USD-USD": "USDTZ_USD", + "TZBTC-USD": "TZBTC_USD", + }, + tkey=sp.TString, + tvalue=sp.TString), + aliasVersion=sp.nat(0), oracle=oracle, admin=admin, pendingAdmin=sp.none, @@ -77,7 +88,12 @@ def addAlias(self, params): sp.set_type(params, sp.TList( sp.TRecord(alias=sp.TString, asset=sp.TString))) sp.for item in params: + sp.verify(~self.data.upstreamAssets.contains(item.alias), + "CANONICAL_ALIAS") + sp.verify(self.data.upstreamAssets.contains(item.asset), + "ASSET_ID") self.data.alias[item.alias] = item.asset + self.data.aliasVersion += 1 @sp.entry_point def removeAlias(self, asset): @@ -85,7 +101,10 @@ def removeAlias(self, asset): Removes alias """ sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") + sp.verify(~self.data.upstreamAssets.contains(asset), + "CANONICAL_ALIAS") del self.data.alias[asset] + self.data.aliasVersion += 1 @sp.entry_point def configurePriceBounds(self, params): @@ -103,6 +122,13 @@ def configureMaxPriceAge(self, maxPriceAge): "INVALID_MAX_PRICE_TIME_DIFFERENCE") self.data.maxPriceAge[sp.sender] = maxPriceAge + def resolveUpstreamAsset(self, requestedAsset): + asset = sp.local("asset", requestedAsset) + sp.if self.data.alias.contains(requestedAsset): + asset.value = self.data.alias[requestedAsset] + sp.verify(self.data.upstreamAssets.contains(asset.value), "ASSET_ID") + return self.data.upstreamAssets[asset.value] + @sp.onchain_view() def get_price_with_timestamp(self, requestedAsset): """ @@ -113,11 +139,8 @@ def get_price_with_timestamp(self, requestedAsset): sp.result((sp.snd(self.data.overrides[requestedAsset]), sp.fst(self.data.overrides[requestedAsset]))) sp.else: - asset = sp.local("asset", requestedAsset) - sp.if self.data.alias.contains(requestedAsset): - asset.value = self.data.alias[requestedAsset] - sliced_asset = sp.slice(asset.value, 0, sp.as_nat(sp.len(asset.value) - 4)).open_some("failed to convert asset name") - oracle_data = sp.view("get_price_with_timestamp", self.data.oracle, sliced_asset+"USDT", t=sp.TPair( + upstreamAsset = self.resolveUpstreamAsset(requestedAsset) + oracle_data = sp.view("get_price_with_timestamp", self.data.oracle, upstreamAsset, t=sp.TPair( sp.TNat, sp.TTimestamp)).open_some("invalid oracle view call") sp.result(oracle_data) @@ -130,11 +153,8 @@ def getPrice(self, requestedAsset): sp.if self.data.overrides.contains(requestedAsset): sp.result(self.data.overrides[requestedAsset]) sp.else: - asset = sp.local("asset", requestedAsset) - sp.if self.data.alias.contains(requestedAsset): - asset.value = self.data.alias[requestedAsset] - sliced_asset = sp.slice(asset.value, 0, sp.as_nat(sp.len(asset.value) - 4)).open_some("failed to convert asset name") - oracle_data = sp.view("get_price_with_timestamp", self.data.oracle, sliced_asset+"USDT", t=sp.TPair( + upstreamAsset = self.resolveUpstreamAsset(requestedAsset) + oracle_data = sp.view("get_price_with_timestamp", self.data.oracle, upstreamAsset, t=sp.TPair( sp.TNat, sp.TTimestamp)).open_some("invalid oracle view call") sp.result((sp.snd(oracle_data), sp.fst(oracle_data))) diff --git a/contracts/tests/CapPostStateTest.py b/contracts/tests/CapPostStateTest.py index 68425485..aa2f07f3 100644 --- a/contracts/tests/CapPostStateTest.py +++ b/contracts/tests/CapPostStateTest.py @@ -6,14 +6,16 @@ CToken = sp.io.import_script_from_url("file:contracts/CToken.py") IRM = sp.io.import_script_from_url( "file:contracts/tests/mock/InterestRateModelMock.py") +OracleMock = sp.io.import_script_from_url( + "file:contracts/tests/mock/OracleMock.py") class CapTestComptroller(CMPT.Comptroller): - def __init__(self, administrator_): + def __init__(self, administrator_, oracleAddress_): CMPT.Comptroller.__init__( self, administrator_=administrator_, - oracleAddress_=sp.address("KT10"), + oracleAddress_=oracleAddress_, closeFactorMantissa_=sp.nat(0), liquidationIncentiveMantissa_=sp.nat(0)) @@ -74,7 +76,10 @@ def test(): irm = IRM.InterestRateModelMock( borrowRate_=sp.nat(0), supplyRate_=sp.nat(0)) scenario += irm - comptroller = CapTestComptroller(administrator_=admin.address) + oracle = OracleMock.OracleMock() + scenario += oracle + comptroller = CapTestComptroller( + administrator_=admin.address, oracleAddress_=oracle.address) scenario += comptroller cToken = CapTestCToken( comptroller_=comptroller.address, @@ -93,31 +98,44 @@ def test(): scenario += comptroller.setMarketCaps(sp.record( cToken=cToken.address, supplyCap=sp.nat(200), borrowCap=sp.nat(100))).run(sender=admin, level=1) + scenario += comptroller.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=1) + scenario += comptroller.setPriceBounds(sp.record( + cToken=cToken.address, minPrice=sp.nat(1), + maxPrice=sp.nat(10**30), maxChangeBps=sp.nat(10000))).run( + sender=admin, level=1) + scenario += oracle.setPrice(sp.nat(1)) + scenario += oracle.setTimestamp(sp.timestamp(1)) scenario += cToken.accrueInterest().run(sender=supplier, level=1) scenario.h2("Supply cap uses the post-mint total exactly once") - scenario += cToken.mint(sp.nat(80)).run(sender=supplier, level=1) - scenario += cToken.mint(sp.nat(120)).run(sender=supplier, level=1) + scenario += cToken.mint(sp.nat(80)).run( + sender=supplier, level=1, now=sp.timestamp(1)) + scenario += cToken.mint(sp.nat(120)).run( + sender=supplier, level=1, now=sp.timestamp(1)) scenario.verify(cToken.data.totalSupply == sp.nat(200)) scenario += cToken.mint(sp.nat(1)).run( sender=supplier, level=1, valid=False, - exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) + exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED, now=sp.timestamp(1)) scenario.verify(cToken.data.totalSupply == sp.nat(200)) scenario.h2("Borrow cap uses the post-borrow total exactly once") scenario += comptroller.setMarketPriceForTest(cToken.address).run(level=1) scenario += comptroller.setLiquidityForTest(sp.record( account=borrower.address, liquidity=sp.int(10**30))).run(level=1) - scenario += cToken.borrow(sp.nat(40)).run(sender=borrower, level=1) + scenario += cToken.borrow(sp.nat(40)).run( + sender=borrower, level=1, now=sp.timestamp(1)) scenario += comptroller.setLiquidityForTest(sp.record( account=borrower.address, liquidity=sp.int(10**30))).run(level=1) - scenario += cToken.borrow(sp.nat(60)).run(sender=borrower, level=1) + scenario += cToken.borrow(sp.nat(60)).run( + sender=borrower, level=1, now=sp.timestamp(1)) scenario.verify(cToken.data.totalBorrows == sp.nat(100)) scenario += comptroller.setLiquidityForTest(sp.record( account=borrower.address, liquidity=sp.int(10**30))).run(level=1) scenario += cToken.borrow(sp.nat(1)).run( sender=borrower, level=1, valid=False, - exception=CMPT.EC.CMPT_BORROW_CAP_EXCEEDED) + exception=CMPT.EC.CMPT_BORROW_CAP_EXCEEDED, now=sp.timestamp(1)) scenario.verify(cToken.data.totalBorrows == sp.nat(100)) @@ -131,7 +149,10 @@ def exchange_rate_rounding_test(): irm = IRM.InterestRateModelMock( borrowRate_=sp.nat(0), supplyRate_=sp.nat(0)) scenario += irm - comptroller = CapTestComptroller(administrator_=admin.address) + oracle = OracleMock.OracleMock() + scenario += oracle + comptroller = CapTestComptroller( + administrator_=admin.address, oracleAddress_=oracle.address) scenario += comptroller cToken = CapTestCToken( comptroller_=comptroller.address, @@ -148,11 +169,21 @@ def exchange_rate_rounding_test(): scenario += comptroller.setMarketCaps(sp.record( cToken=cToken.address, supplyCap=sp.nat(2), borrowCap=sp.nat(0))).run(sender=admin, level=1) + scenario += comptroller.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=1) + scenario += comptroller.setPriceBounds(sp.record( + cToken=cToken.address, minPrice=sp.nat(1), + maxPrice=sp.nat(10**30), maxChangeBps=sp.nat(10000))).run( + sender=admin, level=1) + scenario += oracle.setPrice(sp.nat(1)) + scenario += oracle.setTimestamp(sp.timestamp(1)) scenario += cToken.accrueInterest().run(sender=supplier, level=1) - scenario += cToken.mint(sp.nat(2)).run(sender=supplier, level=1) + scenario += cToken.mint(sp.nat(2)).run( + sender=supplier, level=1, now=sp.timestamp(1)) scenario.verify(cToken.data.totalSupply == sp.nat(1)) scenario.verify(cToken.data.cash == sp.nat(2)) scenario += cToken.mint(sp.nat(2)).run( sender=supplier, level=1, valid=False, - exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) + exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED, now=sp.timestamp(1)) diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 22d8d573..215f97dc 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -149,10 +149,12 @@ def test(): marketsList = [listedMarket, notListedMarket, listedMarketWithoutAccountMembership, cTokenMock.address] scenario.h4("Add Alice and admin to markets") - cmpt.enterMarkets(sp.list([cTokenMock.address, listedMarket])).run(sender = alice, level = bLevel.next()) - cmpt.enterMarkets(sp.list([cTokenMock.address, listedMarket])).run(sender = admin, level = bLevel.next()) - scenario.h4("Set initial price") oracle.setPrice(1) + cmpt.enterMarkets(sp.list([cTokenMock.address, listedMarket])).run( + sender=alice, level=bLevel.next(), now=sp.timestamp(100)) + cmpt.enterMarkets(sp.list([cTokenMock.address, listedMarket])).run( + sender=admin, level=bLevel.next(), now=sp.timestamp(100)) + scenario.h4("Set initial price") scenario.h2("Test paused functionality") @@ -417,10 +419,10 @@ def test(): scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.next(), now=sp.timestamp(100)) scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(2e18))) scenario.verify_equal(cmpt.data.markets[listedMarket].updateLevel, bLevel.current()) - scenario.h3("Try to update price at the same level") + scenario.h3("Revalidate price at the same level") oracle.setPrice(1) scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.current(), now=sp.timestamp(100)) - scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(2e18))) + scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(1e18))) scenario.h3("Reject a price timestamp from the future") oracle.setTimestamp(sp.timestamp(101)) scenario += cmpt.updateAllAssetPricesWithView().run( @@ -518,6 +520,9 @@ def test(): TestAdminFunctionality.checkAdminRequirementH4(scenario, "set price oracle", bLevel, admin, alice, cmpt.setPriceOracleAndTimeDiff, sp.record(priceOracle=priceOracle, timeDiff=300)) scenario.verify(cmpt.data.oracleAddress == priceOracle) + scenario += cmpt.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=bLevel.next()) scenario.h3("Set close factor") closeFactor = sp.nat(1) @@ -637,6 +642,11 @@ def test(): for market in extraMarkets: scenario += cmpt.addMarket(market).run(level = bLevel.next()) + for extraToken in [cTokenExtra1, cTokenExtra2]: + scenario += cmpt.setPriceBounds(sp.record( + cToken=extraToken.address, minPrice=sp.nat(1), + maxPrice=sp.nat(10**50), maxChangeBps=sp.nat(10000))).run( + sender=admin, level=bLevel.next()) scenario.h3("Test max assets per user limit") @@ -659,7 +669,7 @@ def test(): scenario.h4("Alice can enter 1 more market (within limit)") scenario += cmpt.enterMarkets([cTokenExtra1.address]).run( - sender=alice, level=bLevel.next()) + sender=alice, level=bLevel.next(), now=sp.timestamp(100)) scenario.verify(cmpt.data.collaterals[alice.address].contains(cTokenExtra1.address)) @@ -732,7 +742,16 @@ def collateral_boundary_matrix(): updateLevel=sp.nat(0), priceTimestamp=sp.timestamp(0)))).run(level=bLevel.next()) scenario += cmpt.setTransferPaused(sp.bool(False)).run(sender=admin, level=bLevel.next()) - scenario += cmpt.enterMarkets([cToken.address]).run(sender=account, level=bLevel.next()) + scenario += cmpt.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=bLevel.next()) + scenario += cmpt.setPriceBounds(sp.record( + cToken=cToken.address, minPrice=sp.nat(1), + maxPrice=sp.nat(10**30), maxChangeBps=sp.nat(10000))).run( + sender=admin, level=bLevel.next()) + scenario += oracle.setPrice(sp.nat(1)) + scenario += cmpt.enterMarkets([cToken.address]).run( + sender=account, level=bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.addToLoansExternal(sp.pair(account.address, sp.set([cToken.address]))).run(level=bLevel.next()) factors = [0, int(5e17), int(9e17)] diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index 32925a8a..a95d78d8 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -71,28 +71,29 @@ def test(): scenario.h2("Tezfin Oracle") tezfinOracle = TezFinOracle(admin.address, harbinger.address) scenario += tezfinOracle - harbinger.setPrice([sp.record(asset="ETHUSDT", price=13425)] + harbinger.setPrice([sp.record(asset="XTZ_USD", price=203434)] ).run(sender=alice, valid=False, now=sp.timestamp(16534534)) - harbinger.setPrice([sp.record(asset="ETHUSDT", price=13425), sp.record( - asset="BTCUSDT", price=2342354345)]).run(sender=admin, now=sp.timestamp(16534534)) - harbinger.setPrice([sp.record(asset="XTZUSDT", price=203434)] - ).run(sender=admin, now=sp.timestamp(16534534)) + harbinger.setPrice([sp.record(asset="BTC_USD", price=2342354345), sp.record( + asset="XTZ_USD", price=203434)]).run(sender=admin, now=sp.timestamp(16534534)) tezfinOracle.setPrice([sp.record(asset="FINUSDT", price=1000000)] ).run(sender=admin, now=sp.timestamp(16534534)) tezfinOracle.removeAsset("FIN-USD").run(sender=admin) tezfinOracle.addAlias([sp.record( asset="XTZ-USD", alias="WTZ-USD"), sp.record( asset="XTZ-USD", alias="RRXTZ-USD"), sp.record(asset="XTZ-USD", alias="oXTZ-USD")]).run(sender=admin, now=sp.timestamp(16534534)) + scenario.verify(tezfinOracle.data.aliasVersion == 1) + tezfinOracle.addAlias([sp.record( + asset="BTC-USD", alias="TZBTC-USD")]).run( + sender=admin, valid=False, exception="CANONICAL_ALIAS") scenario.h2("Consumer Contract") consumer = View_consumer(tezfinOracle.address) scenario += consumer market = sp.address("KT10") tezfinOracle.configureMaxPriceAge(sp.int(300)).run(sender=consumer.address) tezfinOracle.configurePriceBounds(sp.record( - cToken=market, minPrice=sp.nat(10000), maxPrice=sp.nat(20000), + cToken=market, minPrice=sp.nat(10000), maxPrice=sp.nat(300000), maxChangeBps=sp.nat(2000))).run(sender=consumer.address) scenario.h3("Verify Price") - consumer.getPrice(asset="ETH", resp=13425) consumer.getPrice(asset="BTC", resp=2342354345) consumer.getPrice(asset="XTZ", resp=203434) consumer.getPrice(asset="WTZ", resp=203434) @@ -102,24 +103,24 @@ def test(): timestamp=sp.timestamp(16534534)).run( now=sp.timestamp(16599999)) consumer.verifyValidatedPrice( - cToken=market, asset="ETH-USD", previousPrice=sp.nat(0), - previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(13425)).run( + cToken=market, asset="XTZ-USD", previousPrice=sp.nat(0), + previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(203434)).run( now=sp.timestamp(16534534)) consumer.verifyValidatedPrice( - cToken=market, asset="ETH-USD", previousPrice=sp.nat(10000), + cToken=market, asset="XTZ-USD", previousPrice=sp.nat(10000), previousTimestamp=sp.timestamp(16534534), - expectedPrice=sp.nat(13425)).run( + expectedPrice=sp.nat(203434)).run( now=sp.timestamp(16534534), valid=False, exception="ASSET_PRICE_CHANGE_TOO_LARGE") consumer.verifyValidatedPrice( - cToken=sp.address("KT11"), asset="ETH-USD", + cToken=sp.address("KT11"), asset="XTZ-USD", previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), - expectedPrice=sp.nat(13425)).run( + expectedPrice=sp.nat(203434)).run( now=sp.timestamp(16534534), valid=False, exception="PRICE_BOUNDS_NOT_CONFIGURED") + consumer.getPrice(asset="ETH", resp=1).run(valid=False, exception="ASSET_ID") consumer.getPrice(asset="USD", resp=1000000).run(valid=False) consumer.getPrice(asset="XTZ", resp=43000000).run(valid=False) - consumer.getPrice(asset="ETH", resp=13425) consumer.getPrice(asset="BTC", resp=2342354345) consumer.getPrice(asset="XTZ", resp=203434) consumer.getPrice(asset="USD", resp=1000000).run(valid=False) diff --git a/contracts/tests/TezFinTezOracleIntegrationTest.py b/contracts/tests/TezFinTezOracleIntegrationTest.py new file mode 100644 index 00000000..faf04237 --- /dev/null +++ b/contracts/tests/TezFinTezOracleIntegrationTest.py @@ -0,0 +1,501 @@ +import json + +import smartpy as sp + +CMPT = sp.io.import_script_from_url("file:contracts/Comptroller.py") +CMPTI = sp.io.import_script_from_url("file:contracts/interfaces/ComptrollerInterface.py") +CTI = sp.io.import_script_from_url("file:contracts/interfaces/CTokenInterface.py") +CToken = sp.io.import_script_from_url("file:contracts/CToken.py") +CTMock = sp.io.import_script_from_url("file:contracts/tests/mock/CTokenMock.py") +IRM = sp.io.import_script_from_url( + "file:contracts/tests/mock/InterestRateModelMock.py") +TezFinOracle = sp.io.import_script_from_url( + "file:contracts/TezFinOracle.py").TezFinOracle +TezOracleHarness = sp.io.import_script_from_url( + "file:contracts/tests/mock/TezOracleHarness.py").TezOracleHarness + + +class PriceConsumer(sp.Contract): + def __init__(self, oracle): + self.init(oracle=oracle) + + @sp.entry_point + def verifyPrice(self, params): + sp.set_type(params, sp.TRecord( + asset=sp.TString, + expectedPrice=sp.TNat, + expectedTimestamp=sp.TTimestamp, + )) + price = sp.view( + "getPrice", + self.data.oracle, + params.asset, + t=sp.TPair(sp.TTimestamp, sp.TNat), + ).open_some("invalid oracle price view") + sp.verify(sp.fst(price) == params.expectedTimestamp, "TIMESTAMP_MISMATCH") + sp.verify(sp.snd(price) == params.expectedPrice, "PRICE_MISMATCH") + + @sp.entry_point + def verifyUpstreamTuple(self, params): + sp.set_type(params, sp.TRecord( + asset=sp.TString, + expectedPrice=sp.TNat, + expectedTimestamp=sp.TTimestamp, + )) + price = sp.view( + "get_price_with_timestamp", + self.data.oracle, + params.asset, + t=sp.TPair(sp.TNat, sp.TTimestamp), + ).open_some("invalid upstream tuple view") + sp.verify(sp.fst(price) == params.expectedPrice, "PRICE_MISMATCH") + sp.verify(sp.snd(price) == params.expectedTimestamp, "TIMESTAMP_MISMATCH") + + @sp.entry_point + def verifyValidatedPrice(self, params): + sp.set_type(params, sp.TRecord( + asset=sp.TString, + cToken=sp.TAddress, + previousPrice=sp.TNat, + previousTimestamp=sp.TTimestamp, + expectedPrice=sp.TNat, + )) + price = sp.view( + "getValidatedPrice", + self.data.oracle, + sp.record( + comptroller=sp.self_address, + cToken=params.cToken, + requestedAsset=params.asset, + previousPrice=params.previousPrice, + previousTimestamp=params.previousTimestamp, + ), + t=sp.TPair(sp.TTimestamp, sp.TNat), + ).open_some("invalid validated oracle view") + sp.verify(sp.snd(price) == params.expectedPrice, "PRICE_MISMATCH") + + +class IntegrationMarket(CTMock.CTokenMock): + def __init__(self, snapshot): + CTMock.CTokenMock.__init__(self, test_account_snapshot_=snapshot) + self.update_initial_storage(borrowBalance=sp.nat(100)) + + @sp.onchain_view() + def borrowBalanceStoredView(self, account): + sp.set_type(account, sp.TAddress) + sp.result(sp.pair(self.data.borrowBalance, sp.level)) + + @sp.onchain_view() + def exchangeRateStoredView(self, unused): + sp.set_type(unused, sp.TUnit) + sp.result(sp.pair(sp.nat(int(1e18)), sp.level)) + + +class IntegrationCToken(CToken.CToken): + def __init__(self, comptroller, interestRateModel, administrator): + CToken.CToken.__init__( + self, + comptroller, + interestRateModel, + sp.nat(int(1e18)), + administrator, + sp.big_map({ + "": sp.utils.bytes_of_string("tezos-storage:data"), + "data": sp.utils.bytes_of_string(json.dumps({ + "name": "TezOracle integration market", + })), + }), + { + "name": sp.utils.bytes_of_string("TezOracle integration market"), + "symbol": sp.utils.bytes_of_string("iXTZ"), + "decimals": sp.utils.bytes_of_string("6"), + }, + cash=sp.nat(100000), + ) + + def getCashImpl(self): + return self.data.cash + + def doTransferIn(self, from_, amount): + self.data.cash += amount + + def doTransferOut(self, to_, amount, isContract=False): + self.data.cash = sp.as_nat(self.data.cash - amount) + + +def publish(oracle, asset, price, timestamp, level, valid=True): + return oracle.submit(sp.record( + asset_id=asset, + price=sp.nat(price), + observation_time=sp.timestamp(timestamp), + )).run(level=level, valid=valid) + + +def configureConsumer(scenario, wrapper, consumer, market): + scenario += wrapper.configureMaxPriceAge(sp.int(300)).run(sender=consumer.address) + scenario += wrapper.configurePriceBounds(sp.record( + cToken=market, + minPrice=sp.nat(100000), + maxPrice=sp.nat(1000000), + maxChangeBps=sp.nat(2000), + )).run(sender=consumer.address) + + +@sp.add_test(name="TezFin_TezOracle_Interface_And_Fail_Closed") +def testInterfaceAndFailures(): + scenario = sp.test_scenario() + admin = sp.test_account("admin") + upstream = TezOracleHarness(activation_delay_levels=1) + scenario += upstream + wrapper = TezFinOracle(admin.address, upstream.address) + scenario += wrapper + consumer = PriceConsumer(wrapper.address) + scenario += consumer + market = sp.address("KT10") + configureConsumer(scenario, wrapper, consumer, market) + + # Missing and immature pending prices fail closed. + scenario += consumer.verifyPrice( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=10, now=sp.timestamp(1010), valid=False) + scenario += publish(upstream, "XTZ_USD", 750000, 1000, 10) + scenario += consumer.verifyPrice( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=10, now=sp.timestamp(1010), valid=False) + + # The exact upstream (nat, timestamp) tuple is reordered for TezFin. + scenario += consumer.verifyPrice( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=11, now=sp.timestamp(1010)) + scenario += consumer.verifyPrice( + asset="OXTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=11, now=sp.timestamp(1010)) + scenario += consumer.verifyUpstreamTuple( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=11, now=sp.timestamp(1010)) + scenario += upstream.promote("XTZ_USD").run(level=11) + scenario += consumer.verifyPrice( + asset="UNKNOWN-USD", expectedPrice=1, + expectedTimestamp=sp.timestamp(1), + ).run(level=11, now=sp.timestamp(1010), valid=False, exception="ASSET_ID") + + # BTC/tzBTC and USDt/USDtz remain distinct canonical upstream assets. + scenario += publish(upstream, "BTC_USD", 65000000000, 1100, 11) + scenario += publish(upstream, "TZBTC_USD", 64000000000, 1101, 11) + scenario += publish(upstream, "USDT_USD", 1000000, 1102, 11) + scenario += publish(upstream, "USDTZ_USD", 990000, 1103, 11) + scenario += consumer.verifyPrice( + asset="BTC-USD", expectedPrice=65000000000, + expectedTimestamp=sp.timestamp(1100), + ).run(level=12, now=sp.timestamp(1110)) + scenario += consumer.verifyPrice( + asset="TZBTC-USD", expectedPrice=64000000000, + expectedTimestamp=sp.timestamp(1101), + ).run(level=12, now=sp.timestamp(1110)) + scenario += consumer.verifyPrice( + asset="USDT-USD", expectedPrice=1000000, + expectedTimestamp=sp.timestamp(1102), + ).run(level=12, now=sp.timestamp(1110)) + scenario += consumer.verifyPrice( + asset="USD-USD", expectedPrice=990000, + expectedTimestamp=sp.timestamp(1103), + ).run(level=12, now=sp.timestamp(1110)) + + # Global and per-asset pauses propagate through both wrapper views. + scenario += upstream.pause().run(level=13) + scenario += consumer.verifyPrice( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=13, now=sp.timestamp(1010), valid=False) + scenario += upstream.propose_unpause().run(level=13) + scenario += upstream.activate_unpause().run( + level=13, valid=False, exception="DELAY") + scenario += upstream.activate_unpause().run(level=14) + scenario += upstream.pause_asset("XTZ_USD").run(level=14) + scenario += consumer.verifyPrice( + asset="XTZ-USD", expectedPrice=750000, + expectedTimestamp=sp.timestamp(1000), + ).run(level=14, now=sp.timestamp(1010), valid=False) + scenario += upstream.propose_asset_unpause("XTZ_USD").run(level=14) + scenario += upstream.activate_asset_unpause("XTZ_USD").run(level=15) + + # Consumer freshness and bounds remain mandatory after upstream acceptance. + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), + expectedPrice=sp.nat(750000), + ).run(level=15, now=sp.timestamp(1401), valid=False, + exception="STALE_ASSET_PRICE") + scenario += upstream.clear_price("XTZ_USD").run(level=15) + scenario += publish(upstream, "XTZ_USD", 99999, 1500, 15) + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), + expectedPrice=sp.nat(99999), + ).run(level=16, now=sp.timestamp(1510), valid=False, + exception="ASSET_PRICE_OUT_OF_BOUNDS") + + scenario += upstream.clear_price("XTZ_USD").run(level=17) + scenario += publish(upstream, "XTZ_USD", 750000, 2000, 17) + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), + expectedPrice=sp.nat(750000), + ).run(level=18, now=sp.timestamp(1999), valid=False, + exception="FUTURE_ASSET_PRICE") + + scenario += upstream.clear_price("XTZ_USD").run(level=19) + scenario += publish(upstream, "XTZ_USD", 750000, 0, 19) + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), + expectedPrice=sp.nat(750000), + ).run(level=20, now=sp.timestamp(2000), valid=False, + exception="INVALID_ASSET_PRICE_TIMESTAMP") + + scenario += upstream.clear_price("XTZ_USD").run(level=21) + scenario += publish(upstream, "XTZ_USD", 750000, 2100, 21) + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(750000), previousTimestamp=sp.timestamp(2200), + expectedPrice=sp.nat(750000), + ).run(level=22, now=sp.timestamp(2110), valid=False, + exception="ASSET_PRICE_TIMESTAMP_ROLLBACK") + scenario += consumer.verifyValidatedPrice( + asset="XTZ-USD", cToken=market, + previousPrice=sp.nat(500000), previousTimestamp=sp.timestamp(2000), + expectedPrice=sp.nat(750000), + ).run(level=22, now=sp.timestamp(2110), valid=False, + exception="ASSET_PRICE_CHANGE_TOO_LARGE") + + +class IntegrationComptroller(CMPT.Comptroller): + def __init__(self, admin, oracle): + CMPT.Comptroller.__init__( + self, + administrator_=admin, + oracleAddress_=oracle, + closeFactorMantissa_=sp.nat(int(5e17)), + liquidationIncentiveMantissa_=sp.nat(int(11e17)), + maxAssetsPerUser_=5, + ) + + @sp.entry_point + def setLiquidityForTest(self, params): + sp.set_type(params, sp.TRecord(account=sp.TAddress, liquidity=sp.TInt)) + self.data.account_liquidity[params.account] = sp.record( + liquidity=params.liquidity, + updateLevel=sp.level, + valid=True, + ) + + +def setupProtocol(scenario): + admin = sp.test_account("admin") + alice = sp.test_account("alice") + bob = sp.test_account("bob") + upstream = TezOracleHarness(activation_delay_levels=1) + scenario += upstream + wrapper = TezFinOracle(admin.address, upstream.address) + scenario += wrapper + comptroller = IntegrationComptroller(admin.address, wrapper.address) + scenario += comptroller + market = IntegrationMarket(sp.record( + account=alice.address, + cTokenBalance=sp.nat(1000), + borrowBalance=sp.nat(100), + exchangeRateMantissa=sp.nat(int(1e18)), + )) + scenario += market + scenario += market.setComptroller(comptroller.address) + scenario += comptroller.supportMarket(sp.record( + cToken=market.address, name="XTZ", priceExp=sp.nat(10 ** 12), + )).run(sender=admin) + scenario += comptroller.setMarketCaps(sp.record( + cToken=market.address, supplyCap=sp.nat(10 ** 30), + borrowCap=sp.nat(10 ** 30), + )).run(sender=admin) + scenario += comptroller.setMintPaused( + sp.record(cToken=market.address, state=False)).run(sender=admin) + scenario += comptroller.setBorrowPaused( + sp.record(cToken=market.address, state=False)).run(sender=admin) + scenario += comptroller.setRedeemPaused( + sp.record(cToken=market.address, state=False)).run(sender=admin) + scenario += comptroller.setLiquidatePaused( + sp.record(cToken=market.address, state=False)).run(sender=admin) + scenario += comptroller.setTransferPaused(False).run(sender=admin) + scenario += comptroller.setPriceOracleAndTimeDiff(sp.record( + priceOracle=wrapper.address, timeDiff=sp.int(300), + )).run(sender=admin) + scenario += comptroller.setPriceBounds(sp.record( + cToken=market.address, minPrice=sp.nat(100000), + maxPrice=sp.nat(1000000), maxChangeBps=sp.nat(2000), + )).run(sender=admin) + return admin, alice, bob, upstream, wrapper, comptroller, market + + +@sp.add_test(name="TezFin_TezOracle_Actions_And_Recovery") +def testActionsAndRecovery(): + scenario = sp.test_scenario() + admin, alice, bob, upstream, wrapper, comptroller, market = setupProtocol(scenario) + + scenario += publish(upstream, "XTZ_USD", 750000, 2000, 20) + scenario += comptroller.enterMarkets([market.address]).run( + sender=alice, level=21, now=sp.timestamp(2010)) + scenario += comptroller.setLiquidityForTest(sp.record( + account=alice.address, liquidity=sp.int(-1000000), + )).run(level=21) + + # Pause must stop every price-dependent gate, including same-level cached prices. + scenario += upstream.pause().run(level=21) + scenario += comptroller.mintAllowed(sp.record( + cToken=market.address, minter=alice.address, mintAmount=sp.nat(1), + )).run(sender=market.address, level=21, now=sp.timestamp(2010), valid=False) + scenario += comptroller.borrowAllowed(sp.record( + cToken=market.address, borrower=alice.address, borrowAmount=sp.nat(1), + )).run(sender=market.address, level=21, now=sp.timestamp(2010), valid=False) + scenario += comptroller.transferAllowed(sp.record( + cToken=market.address, src=alice.address, dst=bob.address, + transferTokens=sp.nat(1), + )).run(sender=market.address, level=21, now=sp.timestamp(2010), valid=False) + scenario += comptroller.enterMarkets([market.address]).run( + sender=bob, level=21, now=sp.timestamp(2010), valid=False) + scenario += comptroller.exitMarket(market.address).run( + sender=alice, level=21, now=sp.timestamp(2010), valid=False) + scenario += comptroller.liquidateBorrowAllowed(sp.record( + cTokenBorrowed=market.address, + cTokenCollateral=market.address, + borrower=alice.address, + liquidator=bob.address, + repayAmount=sp.nat(1), + )).run(sender=market.address, level=21, now=sp.timestamp(2010), valid=False) + + # Repayment is a recovery action and deliberately has no oracle dependency. + scenario += comptroller.repayBorrowAllowed(sp.record( + cToken=market.address, payer=alice.address, + borrower=alice.address, repayAmount=sp.nat(1), + )).run(sender=market.address, level=21, now=sp.timestamp(2010)) + + # A quarantined pre-incident pending quote cannot reopen actions. + scenario += upstream.propose_unpause().run(level=21) + scenario += upstream.activate_unpause().run(level=22) + scenario += upstream.clear_price("XTZ_USD").run(level=22) + scenario += publish(upstream, "XTZ_USD", 760000, 2020, 22) + scenario += upstream.pause().run(level=22) + scenario += upstream.propose_unpause().run(level=22) + scenario += upstream.activate_unpause().run(level=23) + scenario += comptroller.mintAllowed(sp.record( + cToken=market.address, minter=alice.address, mintAmount=sp.nat(1), + )).run(sender=market.address, level=23, now=sp.timestamp(2030), valid=False) + + # A fresh post-incident quote is still unusable in its acceptance level. + scenario += publish(upstream, "XTZ_USD", 760000, 2030, 23) + scenario += comptroller.mintAllowed(sp.record( + cToken=market.address, minter=alice.address, mintAmount=sp.nat(1), + )).run(sender=market.address, level=23, now=sp.timestamp(2030), valid=False) + + # Only the fresh mature quote restores price-dependent actions. + scenario += comptroller.mintAllowed(sp.record( + cToken=market.address, minter=alice.address, mintAmount=sp.nat(1), + )).run(sender=market.address, level=24, now=sp.timestamp(2040)) + + +@sp.add_test(name="TezFin_TezOracle_Exact_CToken_Paths") +def testExactCTokenPaths(): + scenario = sp.test_scenario() + admin = sp.test_account("exact admin") + borrower = sp.test_account("exact borrower") + liquidator = sp.test_account("exact liquidator") + upstream = TezOracleHarness(activation_delay_levels=1) + scenario += upstream + wrapper = TezFinOracle(admin.address, upstream.address) + scenario += wrapper + comptroller = IntegrationComptroller(admin.address, wrapper.address) + scenario += comptroller + irm = IRM.InterestRateModelMock( + borrowRate_=sp.nat(0), supplyRate_=sp.nat(0)) + scenario += irm + market = IntegrationCToken( + comptroller.address, irm.address, admin.address) + scenario += market + + scenario += comptroller.supportMarket(sp.record( + cToken=market.address, name="XTZ", priceExp=sp.nat(10 ** 12), + )).run(sender=admin, level=30) + scenario += comptroller.setMarketCaps(sp.record( + cToken=market.address, supplyCap=sp.nat(10 ** 30), + borrowCap=sp.nat(10 ** 30), + )).run(sender=admin, level=30) + scenario += comptroller.setMintPaused( + sp.record(cToken=market.address, state=False)).run( + sender=admin, level=30) + scenario += comptroller.setBorrowPaused( + sp.record(cToken=market.address, state=False)).run( + sender=admin, level=30) + scenario += comptroller.setRedeemPaused( + sp.record(cToken=market.address, state=False)).run( + sender=admin, level=30) + scenario += comptroller.setLiquidatePaused( + sp.record(cToken=market.address, state=False)).run( + sender=admin, level=30) + scenario += comptroller.setTransferPaused(False).run( + sender=admin, level=30) + scenario += comptroller.setPriceOracleAndTimeDiff(sp.record( + priceOracle=wrapper.address, timeDiff=sp.int(300), + )).run(sender=admin, level=30) + scenario += comptroller.setPriceBounds(sp.record( + cToken=market.address, minPrice=sp.nat(100000), + maxPrice=sp.nat(1000000), maxChangeBps=sp.nat(2000), + )).run(sender=admin, level=30) + + scenario += publish(upstream, "XTZ_USD", 750000, 3000, 30) + scenario += market.accrueInterest().run( + sender=borrower, level=31, now=sp.timestamp(3010)) + scenario += market.mint(sp.nat(1000)).run( + sender=borrower, level=31, now=sp.timestamp(3010)) + scenario += comptroller.enterMarkets([market.address]).run( + sender=borrower, level=31, now=sp.timestamp(3010)) + scenario += market.accrueInterest().run( + sender=borrower, level=32, now=sp.timestamp(3020)) + scenario += comptroller.setLiquidityForTest(sp.record( + account=borrower.address, liquidity=sp.int(10 ** 30), + )).run(level=32) + scenario += market.borrow(sp.nat(100)).run( + sender=borrower, level=32, now=sp.timestamp(3020)) + scenario.verify(market.data.borrows[borrower.address].principal == 100) + + # Force a liquidatable position while retaining cTokens to seize. + scenario += comptroller.setCollateralFactor(sp.record( + cToken=market.address, newCollateralFactor=sp.nat(0), + )).run(sender=admin, level=32) + balanceBefore = market.data.ledger[borrower.address].balance + supplyBefore = market.data.totalSupply + borrowsBefore = market.data.totalBorrows + scenario += upstream.pause().run(level=32) + + scenario += market.mint(sp.nat(1)).run( + sender=borrower, level=32, now=sp.timestamp(3020), valid=False) + scenario.verify(market.data.totalSupply == supplyBefore) + scenario += market.borrow(sp.nat(1)).run( + sender=borrower, level=32, now=sp.timestamp(3020), valid=False) + scenario.verify(market.data.totalBorrows == borrowsBefore) + scenario += market.transfer(sp.record( + from_=borrower.address, to_=liquidator.address, value=sp.nat(1), + )).run(sender=borrower, level=32, now=sp.timestamp(3020), valid=False) + scenario.verify(market.data.ledger[borrower.address].balance == balanceBefore) + scenario += market.liquidateBorrow(sp.record( + borrower=borrower.address, + cTokenCollateral=market.address, + repayAmount=sp.nat(1), + )).run(sender=liquidator, level=32, now=sp.timestamp(3020), valid=False) + scenario.verify(market.data.borrows[borrower.address].principal == 100) + + # The full CToken repayment path remains available during oracle failure. + scenario += market.repayBorrow(sp.nat(1)).run( + sender=borrower, level=32, now=sp.timestamp(3020)) + scenario.verify(market.data.borrows[borrower.address].principal == 99) diff --git a/contracts/tests/mock/TezOracleHarness.py b/contracts/tests/mock/TezOracleHarness.py new file mode 100644 index 00000000..f400edc9 --- /dev/null +++ b/contracts/tests/mock/TezOracleHarness.py @@ -0,0 +1,145 @@ +"""Consumer-state harness for the pinned TezOracle contract. + +The exact SmartPy 0.24.2 source is compiled and ABI-checked separately. This +legacy-SmartPy harness mirrors quote maturity, quarantine, and delayed global +and per-asset unpause. Signatures and config-version governance intentionally +remain in the exact TezOracle test suite. +""" + +import smartpy as sp + +TQuote = sp.TRecord( + price=sp.TNat, + observation_time=sp.TTimestamp, + accepted_level=sp.TNat, + activation_level=sp.TNat, +).layout(("price", ("observation_time", ("accepted_level", "activation_level")))) + +TSubmit = sp.TRecord( + asset_id=sp.TString, + price=sp.TNat, + observation_time=sp.TTimestamp, +).layout(("asset_id", ("price", "observation_time"))) + + +class TezOracleHarness(sp.Contract): + def __init__(self, activation_delay_levels=1): + self.init( + activationDelayLevels=sp.nat(activation_delay_levels), + paused=sp.bool(False), + lastGlobalPauseLevel=sp.nat(0), + pendingUnpauseLevel=sp.none, + assetPaused=sp.set(t=sp.TString), + pendingAssetUnpause=sp.big_map( + tkey=sp.TString, tvalue=sp.TNat), + knownAssets=sp.set([ + "BTC_USD", + "USDT_USD", + "XTZ_USD", + "USDTZ_USD", + "TZBTC_USD", + ]), + active=sp.big_map(tkey=sp.TString, tvalue=TQuote), + pending=sp.big_map(tkey=sp.TString, tvalue=TQuote), + ) + + @sp.entry_point + def submit(self, params): + sp.set_type(params, TSubmit) + sp.verify(~self.data.paused, "PAUSED") + sp.verify(self.data.knownAssets.contains(params.asset_id), "ASSET_ID") + sp.verify(~self.data.assetPaused.contains(params.asset_id), "ASSET_PAUSED") + self.data.pending[params.asset_id] = sp.record( + price=params.price, + observation_time=params.observation_time, + accepted_level=sp.level, + activation_level=sp.level + self.data.activationDelayLevels, + ) + + @sp.entry_point + def promote(self, asset_id): + sp.set_type(asset_id, sp.TString) + quote = self.data.pending[asset_id] + sp.verify(quote.accepted_level > self.data.lastGlobalPauseLevel, "NO_PRICE") + sp.verify(sp.level >= quote.activation_level, "NO_PRICE") + self.data.active[asset_id] = quote + del self.data.pending[asset_id] + + @sp.entry_point + def pause(self): + self.data.paused = True + self.data.lastGlobalPauseLevel = sp.level + self.data.pendingUnpauseLevel = sp.none + + @sp.entry_point + def propose_unpause(self): + sp.verify(self.data.paused, "NOT_PAUSED") + self.data.pendingUnpauseLevel = sp.some( + sp.level + self.data.activationDelayLevels) + + @sp.entry_point + def activate_unpause(self): + activationLevel = self.data.pendingUnpauseLevel.open_some("NO_PENDING") + sp.verify(sp.level >= activationLevel, "DELAY") + self.data.paused = False + self.data.pendingUnpauseLevel = sp.none + + @sp.entry_point + def pause_asset(self, asset_id): + sp.set_type(asset_id, sp.TString) + self.data.assetPaused.add(asset_id) + sp.if self.data.pendingAssetUnpause.contains(asset_id): + del self.data.pendingAssetUnpause[asset_id] + sp.if self.data.pending.contains(asset_id): + del self.data.pending[asset_id] + + @sp.entry_point + def propose_asset_unpause(self, asset_id): + sp.set_type(asset_id, sp.TString) + sp.verify(self.data.assetPaused.contains(asset_id), "NOT_PAUSED") + self.data.pendingAssetUnpause[asset_id] = ( + sp.level + self.data.activationDelayLevels) + + @sp.entry_point + def activate_asset_unpause(self, asset_id): + sp.set_type(asset_id, sp.TString) + sp.verify(self.data.pendingAssetUnpause.contains(asset_id), "NO_PENDING") + sp.verify( + sp.level >= self.data.pendingAssetUnpause[asset_id], "DELAY") + self.data.assetPaused.remove(asset_id) + del self.data.pendingAssetUnpause[asset_id] + + @sp.entry_point + def clear_price(self, asset_id): + sp.set_type(asset_id, sp.TString) + sp.if self.data.active.contains(asset_id): + del self.data.active[asset_id] + sp.if self.data.pending.contains(asset_id): + del self.data.pending[asset_id] + + @sp.onchain_view() + def get_price_with_timestamp(self, asset_id): + sp.set_type(asset_id, sp.TString) + sp.verify(~self.data.paused, "PAUSED") + sp.verify(self.data.knownAssets.contains(asset_id), "ASSET_ID") + sp.verify(~self.data.assetPaused.contains(asset_id), "ASSET_PAUSED") + found = sp.local("found", False) + price = sp.local("price", sp.nat(0)) + observationTime = sp.local("observationTime", sp.timestamp(0)) + sp.if self.data.pending.contains(asset_id): + pending = self.data.pending[asset_id] + pendingCurrent = sp.local( + "pendingCurrent", + (pending.accepted_level > self.data.lastGlobalPauseLevel) + & (sp.level >= pending.activation_level)) + sp.if pendingCurrent.value: + found.value = True + price.value = pending.price + observationTime.value = pending.observation_time + sp.if (~found.value) & self.data.active.contains(asset_id): + active = self.data.active[asset_id] + found.value = True + price.value = active.price + observationTime.value = active.observation_time + sp.verify(found.value, "NO_PRICE") + sp.result(sp.pair(price.value, observationTime.value)) diff --git a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py index 09f5e0f6..2b293257 100644 --- a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +++ b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py @@ -67,6 +67,22 @@ def check_shell_script_compile_targets(): return failures +def check_deploys_use_fresh_build_directory(): + """Production-like deploy scripts must never consume checked-in historical output.""" + failures = [] + expected = "./TezFinBuild/compiled_contracts" + for scriptName in SHELL_SCRIPTS_TO_CHECK: + scriptPath = os.path.join(SHELL_SCRIPTS_DIR, scriptName) + with open(scriptPath) as source: + script = source.read() + if expected not in script: + failures.append( + f"{scriptName}: must compile into {expected}; checked-in " + "compiled_contracts/ is historical and must not be deployed." + ) + return failures + + def check_ctzbtc_irm_config_source(): """CompileCtzBTC_IRM.py must read its parameters from CFG.CtzBTC_IRM, not another market's IRM config block (e.g. CFG.CFA12_IRM) by mistake.""" @@ -209,6 +225,7 @@ def check_manifest_path_resolution_parity(): def main(): failures = [] failures += check_shell_script_compile_targets() + failures += check_deploys_use_fresh_build_directory() failures += check_ctzbtc_irm_config_source() failures += check_manifest_path_resolution_parity() @@ -220,6 +237,7 @@ def main(): print('Deploy pipeline wiring check passed:') print(' - All Compile*.py targets referenced from deploy_previewnet.sh/deploy_mainnet.sh exist.') + print(' - Deployment compiles fresh artifacts into TezFinBuild/compiled_contracts.') print(' - CompileCtzBTC_IRM.py reads parameters only from CFG.CtzBTC_IRM.') print(' - Config.py and util.js agree on the default manifest path for previewnet/mainnet/unset profiles.') diff --git a/deploy/compile_targets/tests/test_tezoracle_interface.py b/deploy/compile_targets/tests/test_tezoracle_interface.py new file mode 100644 index 00000000..b402b59c --- /dev/null +++ b/deploy/compile_targets/tests/test_tezoracle_interface.py @@ -0,0 +1,148 @@ +"""Compile TezFinOracle and verify it against the pinned TezOracle Michelson ABI.""" + +import hashlib +import os +import re +import subprocess +import sys +import tempfile + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +TEZORACLE_COMMIT = "bc5ccb8162960a98b0676a7b5588465f219f2fdb" +TEZORACLE_MICHELSON_SHA256 = ( + "1460d4f95a5607c89b9fb9820efae296212d8731d74364a967506aac990f2e93" +) +PLACEHOLDER_MANIFEST = os.path.join(REPO_ROOT, "e2e", "deploy_result", "deploy.json") + + +def require(pattern, source, description): + if re.search(pattern, source, re.MULTILINE) is None: + raise RuntimeError(f"Missing {description}: /{pattern}/") + + +def read_exact_oracle(repo): + result = subprocess.run( + ["git", "-C", repo, "show", f"{TEZORACLE_COMMIT}:michelson/tezoracle.tz"], + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Cannot read TezOracle {TEZORACLE_COMMIT}: " + f"{result.stderr.decode(errors='replace').strip()}" + ) + digest = hashlib.sha256(result.stdout).hexdigest() + if digest != TEZORACLE_MICHELSON_SHA256: + raise RuntimeError( + f"Pinned TezOracle Michelson hash changed: expected " + f"{TEZORACLE_MICHELSON_SHA256}, got {digest}" + ) + return result.stdout.decode() + + +def find_contract(directory): + matches = [] + for root, _, files in os.walk(directory): + matches.extend( + os.path.join(root, name) + for name in files + if name.endswith("_contract.tz") + ) + if len(matches) != 1: + raise RuntimeError(f"Expected one compiled TezFinOracle contract, found {matches}") + with open(matches[0], encoding="utf-8") as source: + return source.read() + + +def compile_tezfin_oracle(smartpy): + target = os.path.join(REPO_ROOT, "deploy", "compile_targets", "CompileTezFinOracle.py") + with tempfile.TemporaryDirectory(prefix="tezoracle_abi_") as output: + result = subprocess.run( + [ + smartpy, + "compile", + target, + output, + "--purge", + "--protocol", + "kathmandu", + ], + cwd=REPO_ROOT, + env={**os.environ, "DEPLOY_MANIFEST": PLACEHOLDER_MANIFEST}, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"TezFinOracle compile failed ({result.returncode}):\n" + f"{result.stderr.strip()[-4000:]}" + ) + return find_contract(output) + + +def main(): + smartpy = os.path.abspath( + os.path.expanduser( + sys.argv[1] + if len(sys.argv) > 1 + else os.environ.get("SMARTPY_CLI", "~/smartpy-cli/SmartPy.sh") + ) + ) + tezoracle_repo = os.path.abspath( + os.path.expanduser( + os.environ.get( + "TEZORACLE_REPO", + os.path.join(REPO_ROOT, "..", "tezoracle"), + ) + ) + ) + if not os.path.isfile(smartpy): + raise RuntimeError(f"SmartPy CLI not found: {smartpy}") + if not os.path.isdir(os.path.join(tezoracle_repo, ".git")): + raise RuntimeError( + f"TezOracle checkout not found at {tezoracle_repo}; set TEZORACLE_REPO" + ) + + upstream = read_exact_oracle(tezoracle_repo) + require( + r'view\s+"get_price_with_timestamp"\s+string\s+' + r'\(pair\s+\(nat\s+%price\)\s+\(timestamp\s+%observation_time\)\)', + upstream, + "exact TezOracle price/observation-time view", + ) + + wrapper = compile_tezfin_oracle(smartpy) + require( + r'VIEW\s+"get_price_with_timestamp"\s+\(pair\s+nat\s+timestamp\)', + wrapper, + "TezFinOracle upstream VIEW instruction", + ) + require( + r'view\s+"get_price_with_timestamp"\s+string\s+\(pair\s+nat\s+timestamp\)', + wrapper, + "TezFinOracle passthrough view", + ) + require( + r'view\s+"getPrice"\s+string\s+\(pair\s+timestamp\s+nat\)', + wrapper, + "TezFin-facing reordered price view", + ) + require( + r'view\s+"getValidatedPrice".*\(pair\s+timestamp\s+nat\)', + wrapper, + "TezFin validated price view", + ) + print( + "[INFO] TezFinOracle ABI matches pinned TezOracle " + f"{TEZORACLE_COMMIT} ({TEZORACLE_MICHELSON_SHA256})." + ) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f"[ERROR] {error}", file=sys.stderr) + sys.exit(1) diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index 04a0d387..be4add92 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -361,10 +361,10 @@ test('raw deployment path: oracle verification failure blocks deployment', async 'NetXdQprcVkpaWU', async () => {}, async () => { - throw new Error('XTZUSDT price is stale'); + throw new Error('XTZ_USD price is stale'); }, ), - /XTZUSDT price is stale/, + /XTZ_USD price is stale/, ); }); @@ -416,26 +416,26 @@ test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not test('mainnet oracle guard: accepts a current nonzero price', () => { const response = { data: { args: [{ int: '226300' }, { int: '1784510000' }] } }; - assert.deepEqual(parsePriceResult('XTZUSDT', response, 1784510030, 300), { - asset: 'XTZUSDT', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000, ageSeconds: 30, + assert.deepEqual(parsePriceResult('XTZ_USD', response, 1784510030, 300), { + asset: 'XTZ_USD', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000, ageSeconds: 30, }); }); test('mainnet oracle guard: rejects milliseconds, future timestamps, stale prices, and zero prices', () => { assert.throws( - () => parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510000000' }] } }, 1784510030, 300), + () => parsePriceResult('XTZ_USD', { data: { args: [{ int: '226300' }, { int: '1784510000000' }] } }, 1784510030, 300), /Unix seconds, not milliseconds/, ); assert.throws( - () => parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510031' }] } }, 1784510030, 300), + () => parsePriceResult('XTZ_USD', { data: { args: [{ int: '226300' }, { int: '1784510031' }] } }, 1784510030, 300), /ahead of the mainnet head/, ); assert.throws( - () => parsePriceResult('TZBTCUSDT', { data: { args: [{ int: '0' }, { int: '1784510000' }] } }, 1784510030, 300), + () => parsePriceResult('TZBTC_USD', { data: { args: [{ int: '0' }, { int: '1784510000' }] } }, 1784510030, 300), /invalid or zero price/, ); assert.throws( - () => parsePriceResult('TZBTCUSDT', { data: { args: [{ int: '1' }, { int: '1784400000' }] } }, 1784510030, 300), + () => parsePriceResult('TZBTC_USD', { data: { args: [{ int: '1' }, { int: '1784400000' }] } }, 1784510030, 300), /price is stale/, ); }); diff --git a/deploy/deploy_script/verify_mainnet_oracle.js b/deploy/deploy_script/verify_mainnet_oracle.js index 80e99bfe..29100697 100644 --- a/deploy/deploy_script/verify_mainnet_oracle.js +++ b/deploy/deploy_script/verify_mainnet_oracle.js @@ -2,7 +2,7 @@ const fs = require('fs'); const { config, resolveDeployResultPath } = require('./util.js'); -const ASSETS = ['XTZUSDT', 'USDTUSDT', 'TZBTCUSDT']; +const ASSETS = ['XTZ_USD', 'USDT_USD', 'USDTZ_USD', 'TZBTC_USD']; const DEFAULT_MAX_AGE_SECONDS = 300; async function rpcJson(rpc, pathname, options = {}) { diff --git a/docs/MainnetGovernancePayloads.json b/docs/MainnetGovernancePayloads.json index ae3b92c4..0bab4220 100644 --- a/docs/MainnetGovernancePayloads.json +++ b/docs/MainnetGovernancePayloads.json @@ -161,7 +161,7 @@ "name": "multisig_opens_approved_markets", "preconditions": [ "Admin handoff storage has been verified on-chain", - "The production oracle guard passes for XTZUSDT, USDTUSDT, and TZBTCUSDT", + "The production oracle guard passes for XTZ_USD, USDT_USD, USDTZ_USD, and TZBTC_USD", "Exact-transfer review is complete for the canonical USDt and tzBTC contracts", "Every collateral factor and pause state has formal risk approval" ],