Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
49 changes: 32 additions & 17 deletions contracts/Comptroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 30 additions & 10 deletions contracts/TezFinOracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,15 +88,23 @@ 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):
"""
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):
Expand All @@ -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):
"""
Expand All @@ -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)

Expand All @@ -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)))

Expand Down
Loading
Loading