From 3f91dc4f8b5ce215ed657d1d2ee17c2f7a4eaaf3 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 14 Sep 2026 14:48:37 +0300 Subject: [PATCH 01/11] test(etherlink): verify TezFinOracle Shadownet compatibility --- .../deploy_result/deploy.shadownet.json | 20 +++++++++++++++++++ deploy/compile_targets/Config.py | 3 +++ deploy/deploy_script/assert_network.js | 9 +++++++-- deploy/deploy_script/config.json | 6 +++--- deploy/deploy_script/util.js | 6 +++++- 5 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 TezFinBuild/deploy_result/deploy.shadownet.json diff --git a/TezFinBuild/deploy_result/deploy.shadownet.json b/TezFinBuild/deploy_result/deploy.shadownet.json new file mode 100644 index 00000000..cdae6774 --- /dev/null +++ b/TezFinBuild/deploy_result/deploy.shadownet.json @@ -0,0 +1,20 @@ +{ + "OriginatorAddress": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA", + "chainId": "NetXtLrzvQDobza", + "evmChainId": 127823, + "network": "https://michelson.etherlink.shadownet.octez.io", + "networkProfile": "shadownet", + "PythCore": "0x2880aB155794e7179c9eE2e38200202908C17B43", + "PythMaxAgeSeconds": 60, + "TezFinMaxPriceAgeSeconds": 60, + "PythFeedIds": { + "BTC_USD": "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + "XTZ_USD": "0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03", + "USDT_USD": "0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b" + }, + "PriceOracle": "KT1FVzw3ogGSq4Djde17MJqVKd6DZReo8MNb", + "USDt": "KT1JhouNkvVaHhY9hc9yCN8zcqa6uEfoCY9y", + "USDtz": "KT1KgUM85JcH3eAFqWNjhsgfRmKcAeds9X4r", + "tzBTC": "KT1VyfHmnP3awrxUdKFYXUtWhr8SAVqiHJRK", + "TezFinOracle": "KT1F3Ub8HULFFuamoQ4uRnXREhkVt4dTHoYn" +} diff --git a/deploy/compile_targets/Config.py b/deploy/compile_targets/Config.py index 3a61dc62..0ed4ff1e 100644 --- a/deploy/compile_targets/Config.py +++ b/deploy/compile_targets/Config.py @@ -6,6 +6,7 @@ PATH_DEPLOY_SCRIPT_CONFIG = "deploy/deploy_script/config.json" PATH_DEPLOY_RESULT = "TezFinBuild/deploy_result/deploy.json" PATH_DEPLOY_RESULT_MAINNET = "TezFinBuild/deploy_result/deploy.mainnet.json" +PATH_DEPLOY_RESULT_SHADOWNET = "TezFinBuild/deploy_result/deploy.shadownet.json" class JsonDeserializer: # order to formulate correct path, execution must be started from root directory "TezFin" @@ -30,6 +31,8 @@ def Deserialize(relativePath): _defaultDeployResultPath = ( PATH_DEPLOY_RESULT_MAINNET if getattr(deployScriptConfig, 'networkProfile', None) == 'mainnet' + else PATH_DEPLOY_RESULT_SHADOWNET + if getattr(deployScriptConfig, 'networkProfile', None) == 'shadownet' else PATH_DEPLOY_RESULT ) deployResult = JsonDeserializer.Deserialize( diff --git a/deploy/deploy_script/assert_network.js b/deploy/deploy_script/assert_network.js index 87cd56c1..a94a9c5e 100644 --- a/deploy/deploy_script/assert_network.js +++ b/deploy/deploy_script/assert_network.js @@ -7,8 +7,8 @@ const MAINNET_CHAIN_IDS = new Set(['NetXdQprcVkpaWU']); // directly with fabricated chain ids instead of only against a live RPC connection. // Returns nothing on success; throws with a descriptive message on rejection. function checkNetworkExpectation(expectedProfile, declaredProfile, chainId, tezosNode) { - if (expectedProfile !== 'previewnet' && expectedProfile !== 'mainnet') { - throw new Error(`Unknown network profile "${expectedProfile}"; expected "previewnet" or "mainnet".`); + if (expectedProfile !== 'previewnet' && expectedProfile !== 'mainnet' && expectedProfile !== 'shadownet') { + throw new Error(`Unknown network profile "${expectedProfile}"; expected "previewnet", "mainnet", or "shadownet".`); } if (declaredProfile && declaredProfile !== expectedProfile) { @@ -33,6 +33,11 @@ function checkNetworkExpectation(expectedProfile, declaredProfile, chainId, tezo `mainnet chain id. Refusing to run the Previewnet deploy script against mainnet.`, ); } + if (expectedProfile === 'shadownet' && chainId !== 'NetXtLrzvQDobza') { + throw new Error( + `Expected Etherlink Shadownet chain id NetXtLrzvQDobza, but the connected RPC (${tezosNode}) reports ${chainId}.`, + ); + } } async function assertNetwork(expectedProfile) { diff --git a/deploy/deploy_script/config.json b/deploy/deploy_script/config.json index ff02189a..b66461e6 100644 --- a/deploy/deploy_script/config.json +++ b/deploy/deploy_script/config.json @@ -1,7 +1,7 @@ { - "networkProfile": "mainnet", - "tezosNode": "https://rpc.tzkt.io/mainnet", - "chainId": "NetXdQprcVkpaWU", + "networkProfile": "shadownet", + "tezosNode": "https://michelson.etherlink.shadownet.octez.io", + "chainId": "NetXtLrzvQDobza", "feeSafetyMultiplier": 1.2, "originator": { "pkh": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA" diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index bd3bd680..100817b1 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -18,7 +18,11 @@ function resolveDeployResultPath() { if (process.env.DEPLOY_MANIFEST) { return path.resolve(process.env.DEPLOY_MANIFEST); } - const fileName = config.networkProfile === 'mainnet' ? 'deploy.mainnet.json' : 'deploy.json'; + const fileName = config.networkProfile === 'mainnet' + ? 'deploy.mainnet.json' + : config.networkProfile === 'shadownet' + ? 'deploy.shadownet.json' + : 'deploy.json'; return path.join(__dirname, '../../TezFinBuild/deploy_result', fileName); } From 8655b1a38ee42a89177455f26c262ae6d40727e0 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 15 Sep 2026 10:37:19 +0300 Subject: [PATCH 02/11] feat: add Pyth NAC price lookup for Etherlink L2 --- README.md | 31 ++++ contracts/TezFinOracle.py | 225 +++++++++++++++++++++++++--- contracts/tests/TezFinOracleTest.py | 175 ++++++++++++++++++---- 3 files changed, 386 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 38121a38..6f51e5a3 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,37 @@ feed with `set_oracle`. 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. +### Pyth / NAC Staged Activation Order (Etherlink L2) + +`TezFinOracle`'s Etherlink/Pyth upstream lookup +is fail-closed by design: `pythCore`, `pythMaxAgeWord`, and `feedIds` are **not** populated in the +constructor (only a placeholder 60-second `pythMaxAgeWord` is), so `getPrice`/`getValidatedPrice` +reject every non-override asset until an admin finishes configuring them. The following order is +mandatory and must be reproduced by the deployment runner and any governance payload: + +```text +originate TezFinOracle + -> setPythCore(pythCoreEvmAddress) + -> setPythMaxAge(maxAgeWord) + -> setFeedIds([{asset, feedId, targetDecimals}, ...]) + -> configurePriceBounds(...) (per Comptroller/cToken) + -> configureMaxPriceAge(...) (per Comptroller) + -> enable market (supportMarket / unpause) +``` + +If a step is skipped, `getPrice`/`getValidatedPrice` fails closed with a specific error instead of +silently returning stale or zero data: + +| Skipped step | `getPrice` / `getValidatedPrice` error | +|---|---| +| `setFeedIds` for the asset | `UNSUPPORTED_PYTH_ASSET` | +| `setPythCore` | `PYTH_CORE_NOT_CONFIGURED` | +| `configurePriceBounds` | `PRICE_BOUNDS_NOT_CONFIGURED` | +| `configureMaxPriceAge` | `MAX_PRICE_AGE_NOT_CONFIGURED` | + +This order and every error in the table above are covered by +[`contracts/tests/TezFinOracleTest.py`](contracts/tests/TezFinOracleTest.py). + ## Post-Deployment Admin Handoff (Mainnet) After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index b11a88cb..3e9238cc 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -3,10 +3,36 @@ OracleInterface = sp.io.import_script_from_url( "file:contracts/interfaces/OracleInterface.py") +# Michelson-to-EVM gateway (enshrined NAC contract, same address on every Etherlink network). +NAC_GATEWAY = sp.address("KT18oDJJKXMKhfE1bSuAPGp92pYcwVDiqsPw") +# selector = keccak256("getPriceNoOlderThan(bytes32,uint256)")[0:4] +GET_PRICE_NO_OLDER_THAN_SELECTOR = sp.bytes("0xa4ae35e0") +# uint256(60) ABI word, used as the default Pyth freshness window until admin overrides it. +DEFAULT_PYTH_MAX_AGE_WORD = sp.bytes( + "0x" + (60).to_bytes(32, "big").hex()) + +TPythFeedConfig = sp.TRecord(feedId=sp.TBytes, targetDecimals=sp.TNat) + +# Valid hex digits for EVM address validation (setPythCore). +HEX_CHARS = sp.set(l=[c for c in "0123456789abcdefABCDEF"]) + +# Bound on targetDecimals also bounds the pow10 loop in getPrice, preventing an +# admin-set value from causing an unbounded/gas-heavy normalization loop. +MAX_TARGET_DECIMALS = 30 + +# SmartPy's `sp.to_int`/INT does not accept `bytes` operands in this toolchain, so ABI words are +# decoded manually via a pinned single-byte lookup table (big-endian, one MUL+ADD per byte). +BYTE_TO_NAT = sp.map( + l={sp.bytes("0x%02x" % i): i for i in range(256)}, + tkey=sp.TBytes, tvalue=sp.TNat) +TWO_POW_256 = sp.nat(2 ** 256) + class TezFinOracle(OracleInterface.OracleInterface): """ TezFinOracle acts as proxy for the original youves oracle It also allows admin to set up custom values for assets that are not be supported by youves enabling the use of those assets in TezFin. + getPrice resolves overrides/aliases and then reads native feeds directly from Pyth Core + on Etherlink via the Michelson NAC `staticcall_evm` view. """ def __init__(self, admin, oracle): @@ -22,6 +48,9 @@ def __init__(self, admin, oracle): oracle=oracle, admin=admin, pendingAdmin=sp.none, + pythCore=sp.string(""), + pythMaxAgeWord=DEFAULT_PYTH_MAX_AGE_WORD, + feedIds=sp.big_map(l={}, tkey=sp.TString, tvalue=TPythFeedConfig), ) @sp.private_lambda(with_storage="read-only") @@ -87,6 +116,32 @@ def removeAlias(self, asset): sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") del self.data.alias[asset] + def _decodeUnsignedWord(self, word): + """ + Decodes a 32-byte big-endian ABI word into a nat, one byte at a time + """ + acc = sp.local("acc", sp.nat(0)) + i = sp.local("i", sp.nat(0)) + sp.while i.value < 32: + currentByte = sp.slice(word, i.value, 1).open_some( + "MALFORMED_PYTH_RESPONSE") + acc.value = acc.value * 256 + BYTE_TO_NAT[currentByte] + i.value += 1 + return acc.value + + def _decodeSignedWord(self, word): + """ + Decodes a 32-byte big-endian, sign-extended two's complement ABI word into an int + """ + unsignedValue = self._decodeUnsignedWord(word) + signByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") + result = sp.local("result", sp.int(0)) + sp.if BYTE_TO_NAT[signByte] >= 128: + result.value = sp.to_int(unsignedValue) - sp.to_int(TWO_POW_256) + sp.else: + result.value = sp.to_int(unsignedValue) + return result.value + @sp.entry_point def configurePriceBounds(self, params): sp.set_type(params, OracleInterface.TPriceBounds) @@ -103,40 +158,170 @@ def configureMaxPriceAge(self, maxPriceAge): "INVALID_MAX_PRICE_TIME_DIFFERENCE") self.data.maxPriceAge[sp.sender] = maxPriceAge - @sp.onchain_view() - def get_price_with_timestamp(self, requestedAsset): + @sp.entry_point + def setPythCore(self, address): """ - Proxies to youves getPrice view if not custom asset + Sets the pinned Pyth Core EVM address (hex string, e.g. "0x2880...") used by getPrice """ - sp.set_type(requestedAsset, sp.TString) + sp.set_type(address, sp.TString) + sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") + sp.verify(sp.len(address) == 42, "INVALID_PYTH_CORE_ADDRESS_LENGTH") + prefix = sp.slice(address, 0, 2).open_some("INVALID_PYTH_CORE_ADDRESS_LENGTH") + sp.verify(prefix == "0x", "INVALID_PYTH_CORE_ADDRESS_PREFIX") + i = sp.local("i", sp.nat(2)) + sp.while i.value < 42: + hexChar = sp.slice(address, i.value, 1).open_some( + "INVALID_PYTH_CORE_ADDRESS_HEX") + sp.verify(HEX_CHARS.contains(hexChar), "INVALID_PYTH_CORE_ADDRESS_HEX") + i.value += 1 + self.data.pythCore = address + + @sp.entry_point + def setPythMaxAge(self, word): + """ + Sets the ABI uint256 max-age word (32 bytes, big-endian seconds) forwarded to + Pyth's getPriceNoOlderThan; the encoded value must be in [1, 3600] seconds + """ + sp.set_type(word, sp.TBytes) + sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") + sp.verify(sp.len(word) == 32, "INVALID_PYTH_MAX_AGE_WORD") + maxAgeSeconds = self._decodeUnsignedWord(word) + sp.verify((maxAgeSeconds >= 1) & (maxAgeSeconds <= 3600), + "INVALID_PYTH_MAX_AGE_RANGE") + self.data.pythMaxAgeWord = word + + @sp.entry_point + def setFeedIds(self, params): + """ + Pins Pyth feed ids and their target nat precision (decimals) per base asset symbol + """ + sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") + sp.set_type(params, sp.TList(sp.TRecord( + asset=sp.TString, feedId=sp.TBytes, targetDecimals=sp.TNat))) + sp.for item in params: + sp.verify(sp.len(item.feedId) == 32, "INVALID_PYTH_FEED_ID") + sp.verify(item.targetDecimals <= MAX_TARGET_DECIMALS, + "INVALID_TARGET_DECIMALS") + self.data.feedIds[item.asset] = sp.record( + feedId=item.feedId, targetDecimals=item.targetDecimals) + + @sp.entry_point + def removeFeedId(self, asset): + """ + Removes a pinned Pyth feed id + """ + sp.set_type(asset, sp.TString) + sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") + del self.data.feedIds[asset] + + def _resolvePythPrice(self, requestedAsset): + """ + getPrice's upstream lookup: resolve override/alias, then read the pinned Pyth Core + feed via the Michelson NAC `staticcall_evm` view (getPriceNoOlderThan(bytes32,uint256)). + Returns (timestamp, normalizedPrice). get_price_with_timestamp reuses this logic via + a cross-view call to getPrice rather than inlining it a second time (see below). + Any staticcall_evm failure (stale Pyth cache, revert, bad destination) is fail-closed: + the call aborts instead of returning a stale/previous price. + """ + resolvedPrice = sp.local("resolvedPrice", sp.pair(sp.timestamp(0), sp.nat(0))) sp.if self.data.overrides.contains(requestedAsset): - sp.result((sp.snd(self.data.overrides[requestedAsset]), - sp.fst(self.data.overrides[requestedAsset]))) + resolvedPrice.value = 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( - sp.TNat, sp.TTimestamp)).open_some("invalid oracle view call") - sp.result(oracle_data) + # L2 policy proxies: tzBTC is valued as BTC and USDtz/USDt as USDT. + # They intentionally use the native Pyth feeds rather than separate feeds. + feedAsset = sp.local("feedAsset", sliced_asset) + sp.if feedAsset.value == "tzBTC": + feedAsset.value = "BTC" + sp.if (feedAsset.value == "USDtz") | (feedAsset.value == "USDt"): + feedAsset.value = "USDT" + feedConfig = sp.compute(self.data.feedIds.get( + feedAsset.value, message="UNSUPPORTED_PYTH_ASSET")) + sp.verify(sp.len(self.data.pythCore) > 0, "PYTH_CORE_NOT_CONFIGURED") + + calldata = sp.concat([GET_PRICE_NO_OLDER_THAN_SELECTOR, + feedConfig.feedId, self.data.pythMaxAgeWord]) + response = sp.view("staticcall_evm", NAC_GATEWAY, + sp.pair(self.data.pythCore, calldata), + t=sp.TBytes).open_some("PYTH_STATICCALL_FAILED") + sp.verify(sp.len(response) == 128, "MALFORMED_PYTH_RESPONSE") + + # Pyth's Price struct (int64 price, uint64 conf, int32 expo, uint256 publishTime) is + # ABI-encoded as four right-aligned/sign-extended 32-byte words. + priceWord = sp.slice(response, 0, 32).open_some("MALFORMED_PYTH_RESPONSE") + confWord = sp.slice(response, 32, 32).open_some("MALFORMED_PYTH_RESPONSE") + expoWord = sp.slice(response, 64, 32).open_some("MALFORMED_PYTH_RESPONSE") + publishTimeWord = sp.slice(response, 96, 32).open_some("MALFORMED_PYTH_RESPONSE") + + rawPrice = self._decodeSignedWord(priceWord) + rawConf = self._decodeUnsignedWord(confWord) + rawExpo = self._decodeSignedWord(expoWord) + # publishTime is `uint` (uint256) per pyth-sdk-solidity's PythStructs.Price, not signed; + # the <= sp.now check below still fails closed on any absurdly large decoded value. + rawPublishTime = self._decodeUnsignedWord(publishTimeWord) + + sp.verify(rawPrice > 0, "NON_POSITIVE_PYTH_PRICE") + sp.verify((rawExpo >= -30) & (rawExpo <= 0), "INVALID_PYTH_EXPONENT") + sp.verify(rawPublishTime > 0, "INVALID_PYTH_PUBLISH_TIME") + + publishTimestamp = sp.timestamp(0).add_seconds(sp.to_int(rawPublishTime)) + sp.verify(publishTimestamp <= sp.now, "FUTURE_PYTH_PUBLISH_TIME") + + priceNat = sp.as_nat(rawPrice, message="NON_POSITIVE_PYTH_PRICE") + # Fail closed if the reported confidence interval exceeds 25% of the price. + sp.verify(rawConf * 4 <= priceNat, "EXCESSIVE_PYTH_CONFIDENCE") + + # normalizedPrice = price * 10^(expo + targetDecimals), using integer arithmetic only. + decimalShift = rawExpo + sp.to_int(feedConfig.targetDecimals) + sp.verify((decimalShift >= -30) & (decimalShift <= 30), + "PYTH_NORMALIZATION_OUT_OF_RANGE") + shiftMagnitude = sp.local("shiftMagnitude", sp.nat(0)) + sp.if decimalShift >= 0: + shiftMagnitude.value = sp.as_nat(decimalShift) + sp.else: + shiftMagnitude.value = sp.as_nat(-decimalShift) + powerOfTen = sp.local("powerOfTen", sp.nat(1)) + shiftCounter = sp.local("shiftCounter", sp.nat(0)) + sp.while shiftCounter.value < shiftMagnitude.value: + powerOfTen.value *= 10 + shiftCounter.value += 1 + normalizedPrice = sp.local("normalizedPrice", sp.nat(0)) + sp.if decimalShift >= 0: + normalizedPrice.value = priceNat * powerOfTen.value + sp.else: + normalizedPrice.value = priceNat // powerOfTen.value + # Integer division on an overly negative exponent can round a genuinely positive + # Pyth price down to zero; fail closed instead of reporting a free/worthless asset. + sp.verify(normalizedPrice.value > 0, "ZERO_NORMALIZED_PYTH_PRICE") + + resolvedPrice.value = sp.pair(publishTimestamp, normalizedPrice.value) + return resolvedPrice.value + + @sp.onchain_view() + def get_price_with_timestamp(self, requestedAsset): + """ + Thin (price, timestamp) wrapper around getPrice's Pyth lookup, calling it as a real + cross-view (not inlined) so the ABI decoding/normalization code exists only once. + """ + sp.set_type(requestedAsset, sp.TString) + pricePair = sp.view("getPrice", sp.self_address, requestedAsset, + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some( + "invalid oracle view call") + sp.result(sp.pair(sp.snd(pricePair), sp.fst(pricePair))) @sp.onchain_view() def getPrice(self, requestedAsset): """ - Proxies to youves getPrice view if not custom asset + Resolves overrides/aliases, then reads the pinned Pyth Core feed directly via the + Michelson NAC `staticcall_evm` view (getPriceNoOlderThan(bytes32,uint256)). + Any staticcall_evm failure (stale Pyth cache, revert, bad destination) is fail-closed: + the view aborts instead of returning a stale/previous price. """ sp.set_type(requestedAsset, sp.TString) - 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( - sp.TNat, sp.TTimestamp)).open_some("invalid oracle view call") - sp.result((sp.snd(oracle_data), sp.fst(oracle_data))) + sp.result(self._resolvePythPrice(requestedAsset)) @sp.onchain_view() def getValidatedPrice(self, params): diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index 32925a8a..d1c5d153 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -5,6 +5,16 @@ TezFinOracle = sp.io.import_script_from_url( "file:contracts/TezFinOracle.py").TezFinOracle +# Fixed protocol/deployment parameters, pinned in TezFinBuild/deploy_result/deploy.shadownet.json +PYTH_CORE = "0x2880aB155794e7179c9eE2e38200202908C17B43" +PYTH_MAX_AGE_WORD = sp.bytes("0x" + (60).to_bytes(32, "big").hex()) +BTC_FEED_ID = sp.bytes( + "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43") +XTZ_FEED_ID = sp.bytes( + "0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03") +USDT_FEED_ID = sp.bytes( + "0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b") + class View_consumer(sp.Contract): def __init__(self, contract): @@ -26,6 +36,20 @@ def getPrice(self, asset, resp): price = sp.compute(sp.snd(oracle_data)) sp.verify(resp == price, "PRICE_MISTMATCH") + @sp.entry_point + def comparePriceViews(self, asset): + """ + Asserts getPrice and get_price_with_timestamp agree on the same (price, timestamp) + for the same asset, just with the tuple order swapped + """ + sp.set_type(asset, sp.TString) + viaGetPrice = sp.compute(sp.view("getPrice", self.contract, asset, + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call")) + viaLegacyView = sp.compute(sp.view("get_price_with_timestamp", self.contract, asset, + t=sp.TPair(sp.TNat, sp.TTimestamp)).open_some("invalid oracle view call")) + sp.verify(sp.fst(viaGetPrice) == sp.snd(viaLegacyView), "TIMESTAMP_MISMATCH") + sp.verify(sp.snd(viaGetPrice) == sp.fst(viaLegacyView), "PRICE_MISTMATCH") + @sp.entry_point def verifyPrice(self, params): sp.set_type(params, sp.TRecord(asset=sp.TString, price=sp.TNat, @@ -65,20 +89,17 @@ def test(): # Let's display the accounts: scenario.h2("Accounts") scenario.show([admin, alice]) - scenario.h2("Harbinger") - harbinger = TezFinOracle(admin.address, admin.address) - scenario += harbinger scenario.h2("Tezfin Oracle") - tezfinOracle = TezFinOracle(admin.address, harbinger.address) + tezfinOracle = TezFinOracle(admin.address, admin.address) scenario += tezfinOracle - harbinger.setPrice([sp.record(asset="ETHUSDT", price=13425)] - ).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)) + + scenario.h2("Overrides / aliases (resolved before any Pyth lookup)") + tezfinOracle.setPrice([sp.record(asset="ETHUSDT", price=13425)] + ).run(sender=alice, valid=False, now=sp.timestamp(16534534)) + tezfinOracle.setPrice([sp.record(asset="ETH-USD", price=13425), sp.record( + asset="BTC-USD", price=2342354345)]).run(sender=admin, now=sp.timestamp(16534534)) tezfinOracle.setPrice([sp.record(asset="FINUSDT", price=1000000)] - ).run(sender=admin, now=sp.timestamp(16534534)) + ).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( @@ -91,16 +112,12 @@ def test(): tezfinOracle.configurePriceBounds(sp.record( cToken=market, minPrice=sp.nat(10000), maxPrice=sp.nat(20000), maxChangeBps=sp.nat(2000))).run(sender=consumer.address) - scenario.h3("Verify Price") + scenario.h3("Verify override 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) - consumer.getPrice(asset="OXTZ", resp=203434) - consumer.getPrice(asset="RRXTZ", resp=203434) consumer.verifyPrice(asset="FINUSDT", price=1000000, - timestamp=sp.timestamp(16534534)).run( - now=sp.timestamp(16599999)) + 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( @@ -117,10 +134,118 @@ def test(): expectedPrice=sp.nat(13425)).run( now=sp.timestamp(16534534), valid=False, exception="PRICE_BOUNDS_NOT_CONFIGURED") - 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) - consumer.getPrice(asset="XTZ", resp=43000000).run(valid=False) + + scenario.h2("Pyth / NAC upstream lookup (getPrice Etap 2)") + scenario.h3("Admin guard on Pyth configuration entrypoints") + tezfinOracle.setPythCore(PYTH_CORE).run( + sender=alice, valid=False, exception="NOT_ADMIN") + tezfinOracle.setPythMaxAge(PYTH_MAX_AGE_WORD).run( + sender=alice, valid=False, exception="NOT_ADMIN") + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6))] + ).run(sender=alice, valid=False, exception="NOT_ADMIN") + tezfinOracle.removeFeedId("XTZ").run( + sender=alice, valid=False, exception="NOT_ADMIN") + + scenario.h3("Input validation on Pyth configuration entrypoints") + tezfinOracle.setPythMaxAge(sp.bytes("0x00")).run( + sender=admin, valid=False, exception="INVALID_PYTH_MAX_AGE_WORD") + tezfinOracle.setPythMaxAge(sp.bytes("0x" + (0).to_bytes(32, "big").hex())).run( + sender=admin, valid=False, exception="INVALID_PYTH_MAX_AGE_RANGE") + tezfinOracle.setPythMaxAge(sp.bytes("0x" + (3601).to_bytes(32, "big").hex())).run( + sender=admin, valid=False, exception="INVALID_PYTH_MAX_AGE_RANGE") + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=sp.bytes("0x00"), targetDecimals=sp.nat(6))] + ).run(sender=admin, valid=False, exception="INVALID_PYTH_FEED_ID") + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(31))] + ).run(sender=admin, valid=False, exception="INVALID_TARGET_DECIMALS") + + scenario.h3("setPythCore rejects malformed EVM addresses") + tezfinOracle.setPythCore("not-an-address").run( + sender=admin, valid=False, exception="INVALID_PYTH_CORE_ADDRESS_LENGTH") + tezfinOracle.setPythCore("0x" + "a" * 41).run( + sender=admin, valid=False, exception="INVALID_PYTH_CORE_ADDRESS_LENGTH") + tezfinOracle.setPythCore("00" + "a" * 40).run( + sender=admin, valid=False, exception="INVALID_PYTH_CORE_ADDRESS_PREFIX") + tezfinOracle.setPythCore("0x" + "g" * 40).run( + sender=admin, valid=False, exception="INVALID_PYTH_CORE_ADDRESS_HEX") + + scenario.h3("getPrice fails closed before a feed id is pinned for the asset") + consumer.getPrice(asset="XTZ", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + + scenario.h3("get_price_with_timestamp shares the same fail-closed asset lookup") + consumer.comparePriceViews("XTZ-USD").run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + + scenario.h3("Pin Pyth core address and native feed ids") + tezfinOracle.setFeedIds([ + sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8)), + sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + ]).run(sender=admin) + + scenario.h3("getPrice fails closed before the Pyth core address is configured") + consumer.getPrice(asset="XTZ", resp=0).run( + valid=False, exception="PYTH_CORE_NOT_CONFIGURED") + + tezfinOracle.setPythCore(PYTH_CORE).run(sender=admin) + tezfinOracle.setPythMaxAge(PYTH_MAX_AGE_WORD).run(sender=admin) + + scenario.h3("getPrice reaches the NAC staticcall_evm view and fails closed there") + # The SmartPy sandbox has no Etherlink gateway/Pyth Core contract at the pinned NAC + # address, so the interpreter raises "Missing contract for view" instead of the + # Michelson `None` that a real stale/reverting Pyth response would produce -- but in + # both cases getPrice aborts instead of returning stale/previous data (fail-closed). + consumer.getPrice(asset="XTZ", resp=0).run(valid=False) + consumer.getPrice(asset="WTZ", resp=0).run(valid=False) + consumer.getPrice(asset="OXTZ", resp=0).run(valid=False) + consumer.getPrice(asset="RRXTZ", resp=0).run(valid=False) + # L2 proxy assets resolve to native feeds before the NAC call: + # tzBTC -> BTC and USDtz/USDt -> USDT. The sandbox has no gateway, so the + # subsequent staticcall fails, but these must not fail as unsupported assets. + consumer.getPrice(asset="tzBTC", resp=0).run(valid=False) + consumer.getPrice(asset="USDtz", resp=0).run(valid=False) + consumer.getPrice(asset="USDt", resp=0).run(valid=False) + + scenario.h3("get_price_with_timestamp reaches the same staticcall_evm and also fails closed") + consumer.comparePriceViews("XTZ-USD").run(valid=False) + consumer.comparePriceViews("WTZ-USD").run(valid=False) + + scenario.h3("Unpinned asset still fails closed with UNSUPPORTED_PYTH_ASSET") + consumer.getPrice(asset="ETH2", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + + scenario.h3("get_price_with_timestamp agrees with getPrice for override assets") + consumer.comparePriceViews("ETH-USD") + consumer.comparePriceViews("BTC-USD") + + scenario.h3("removeFeedId reverts back to UNSUPPORTED_PYTH_ASSET") + tezfinOracle.removeFeedId("USDT").run(sender=admin) + consumer.getPrice(asset="USDT", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + + scenario.h2("Staged activation order") + # originate -> setPythCore -> setPythMaxAge -> setFeedIds already ran above (global oracle + # config); a fresh comptroller identity still needs its own configurePriceBounds and + # configureMaxPriceAge before getValidatedPrice can serve a market. + freshMarket = sp.address("KT1FreshMarket11111111111111111111111") + freshConsumer = View_consumer(tezfinOracle.address) + scenario += freshConsumer + freshConsumer.verifyValidatedPrice( + cToken=freshMarket, asset="XTZ-USD", previousPrice=sp.nat(0), + previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(0)).run( + valid=False, exception="PRICE_BOUNDS_NOT_CONFIGURED") + tezfinOracle.configurePriceBounds(sp.record( + cToken=freshMarket, minPrice=sp.nat(1), maxPrice=sp.nat(2**60), + maxChangeBps=sp.nat(10000))).run(sender=freshConsumer.address) + freshConsumer.verifyValidatedPrice( + cToken=freshMarket, asset="XTZ-USD", previousPrice=sp.nat(0), + previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(0)).run( + valid=False, exception="MAX_PRICE_AGE_NOT_CONFIGURED") + tezfinOracle.configureMaxPriceAge(sp.int(300)).run(sender=freshConsumer.address) + # Bounds and max age are now configured; the only remaining failure is the actual Pyth + # read (no gateway/Pyth Core in the sandbox), i.e. the market is ready to "enable" once a + # real Pyth Core is reachable. + freshConsumer.verifyValidatedPrice( + cToken=freshMarket, asset="XTZ-USD", previousPrice=sp.nat(0), + previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(0)).run(valid=False) + From 158f98df866877737dec1a5ddd83705e5f57fdc0 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 15 Sep 2026 15:04:09 +0300 Subject: [PATCH 03/11] test(etherlink): add Shadownet Pyth/NAC smoke-test tooling and redeploy TezFinOracle - add configure_pyth_oracle.js (staged setPythCore/setPythMaxAge/setFeedIds/configurePriceBounds/configureMaxPriceAge admin sequence) - add verify_shadownet_pyth_oracle.js (read-only live smoke test: native feeds, proxy/alias mapping, getPrice vs get_price_with_timestamp, getValidatedPrice) - add e2e/shell_scripts/shadownet_pyth_smoke_test.sh orchestrating the full flow - add contracts/tests/fixtures/pyth_abi_fixtures_test.py - extend TezFinOracleTest.py with L2 proxy-mapping equivalence checks - redeploy TezFinOracle to Shadownet and run the staged Pyth activation sequence --- .../deploy_result/deploy.shadownet.json | 2 +- contracts/tests/TezFinOracleTest.py | 52 +++ .../tests/fixtures/pyth_abi_fixtures_test.py | 297 ++++++++++++++++++ deploy/deploy_script/configure_pyth_oracle.js | 117 +++++++ deploy/deploy_script/package.json | 2 + .../verify_shadownet_pyth_oracle.js | 213 +++++++++++++ .../shadownet_pyth_smoke_test.sh | 71 +++++ 7 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 contracts/tests/fixtures/pyth_abi_fixtures_test.py create mode 100644 deploy/deploy_script/configure_pyth_oracle.js create mode 100644 deploy/deploy_script/verify_shadownet_pyth_oracle.js create mode 100755 e2e/shell_scripts/shadownet_pyth_smoke_test.sh diff --git a/TezFinBuild/deploy_result/deploy.shadownet.json b/TezFinBuild/deploy_result/deploy.shadownet.json index cdae6774..30dddccb 100644 --- a/TezFinBuild/deploy_result/deploy.shadownet.json +++ b/TezFinBuild/deploy_result/deploy.shadownet.json @@ -16,5 +16,5 @@ "USDt": "KT1JhouNkvVaHhY9hc9yCN8zcqa6uEfoCY9y", "USDtz": "KT1KgUM85JcH3eAFqWNjhsgfRmKcAeds9X4r", "tzBTC": "KT1VyfHmnP3awrxUdKFYXUtWhr8SAVqiHJRK", - "TezFinOracle": "KT1F3Ub8HULFFuamoQ4uRnXREhkVt4dTHoYn" + "TezFinOracle": "KT1StW5tmRTZxJY72CLXiv1FKRFdVa3hsvsX" } diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index d1c5d153..a1045237 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -223,6 +223,58 @@ def test(): consumer.getPrice(asset="USDT", resp=0).run( valid=False, exception="UNSUPPORTED_PYTH_ASSET") + scenario.h2("L2 proxy mapping resolves to the same feed as its native asset") + # tzBTC -> BTC and USDtz/USDt -> USDT are resolved (in _resolvePythPrice) *before* the + # feedIds lookup, so removing the underlying native feed must also break the proxy asset, + # and re-adding it must unblock both again -- proving they share the exact same feed + # config rather than merely reaching the same generic staticcall failure. + tezfinOracle.setFeedIds([ + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + ]).run(sender=admin) + # Note: plain "BTC-USD" already has an admin override set earlier in this scenario, so + # it short-circuits before the feedIds lookup and can't be used to probe the feed itself; + # "tzBTC-USD" has no override, so it is the one that actually reaches the Pyth/feedIds path. + scenario.h3("Before removing BTC: tzBTC reaches the staticcall (shared BTC feed)") + consumer.getPrice(asset="tzBTC", resp=0).run(valid=False) + tezfinOracle.removeFeedId("BTC").run(sender=admin) + scenario.h3("After removing BTC: tzBTC now also fails as UNSUPPORTED_PYTH_ASSET") + consumer.getPrice(asset="tzBTC", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + tezfinOracle.setFeedIds([ + sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8)), + ]).run(sender=admin) + scenario.h3("After re-adding BTC: tzBTC reaches the staticcall again") + consumer.getPrice(asset="tzBTC", resp=0).run(valid=False) + + scenario.h3("USDtz and USDt both proxy to USDT: removing USDT breaks both proxies") + tezfinOracle.removeFeedId("USDT").run(sender=admin) + consumer.getPrice(asset="USDtz", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + consumer.getPrice(asset="USDt", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + consumer.getPrice(asset="USDT", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + tezfinOracle.setFeedIds([ + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + ]).run(sender=admin) + consumer.getPrice(asset="USDtz", resp=0).run(valid=False) + consumer.getPrice(asset="USDt", resp=0).run(valid=False) + consumer.getPrice(asset="USDT", resp=0).run(valid=False) + + scenario.h3("A feed id pinned only under a wrong/unrelated asset key does not resolve") + # setFeedIds keys by symbol, not by feedId, so pinning USDT_FEED_ID under a typo'd key + # must not make it resolvable under the real "USDT" symbol used by getPrice. + tezfinOracle.setFeedIds([ + sp.record(asset="USDT_TYPO", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + ]).run(sender=admin) + tezfinOracle.removeFeedId("USDT").run(sender=admin) + consumer.getPrice(asset="USDT", resp=0).run( + valid=False, exception="UNSUPPORTED_PYTH_ASSET") + tezfinOracle.removeFeedId("USDT_TYPO").run(sender=admin) + tezfinOracle.setFeedIds([ + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + ]).run(sender=admin) + scenario.h2("Staged activation order") # originate -> setPythCore -> setPythMaxAge -> setFeedIds already ran above (global oracle # config); a fresh comptroller identity still needs its own configurePriceBounds and diff --git a/contracts/tests/fixtures/pyth_abi_fixtures_test.py b/contracts/tests/fixtures/pyth_abi_fixtures_test.py new file mode 100644 index 00000000..162fbd7a --- /dev/null +++ b/contracts/tests/fixtures/pyth_abi_fixtures_test.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Deterministic ABI-decode/normalization fixtures for TezFinOracle's Pyth NAC lookup. + +This mirrors (in plain Python, not SmartPy) the exact decode/validate/normalize +logic implemented in `_decodeUnsignedWord` / `_decodeSignedWord` / `_resolvePythPrice` +inside contracts/TezFinOracle.py. It exists because: + + - The SmartPy sandbox has no Etherlink NAC gateway / Pyth Core contract to call, so + `sp.view("staticcall_evm", ...)` can only be exercised up to "the call is attempted + and fails" (see contracts/tests/TezFinOracleTest.py) -- it can't feed a mocked + 128-byte Pyth ABI response through the real decode path in-sandbox. + - Real end-to-end decode/normalize verification therefore requires either a live + Shadownet smoke test or this + standalone fixture harness that replicates the exact bit-level rules by hand. + +Run: python3 contracts/tests/fixtures/pyth_abi_fixtures_test.py +Exit code is non-zero if any fixture's expected outcome doesn't match. + +This file deliberately lives under contracts/tests/fixtures/ rather than directly in +contracts/tests/ so contracts/tests/run_tests.sh's `./contracts/tests/*.py` glob (which +assumes every top-level file is a SmartPy script and runs `SmartPy.sh test` on it) does +not pick it up. + +If the real Michelson decode logic in TezFinOracle.py changes, this file's +`decode_unsigned_word` / `decode_signed_word` / `resolve_price` helpers MUST be +updated in lockstep, since they are a hand-maintained mirror, not a shared import. +""" + +import sys + +WORD_LEN = 32 +TWO_POW_256 = 2 ** 256 +MAX_TARGET_DECIMALS = 30 + + +class PythFixtureError(Exception): + """Represents a `sp.verify(...)` failure message from the mirrored contract logic.""" + + +def encode_uint_word(value: int) -> bytes: + if value < 0 or value >= TWO_POW_256: + raise ValueError("uint256 word out of range") + return value.to_bytes(WORD_LEN, "big", signed=False) + + +def encode_int_word(value: int) -> bytes: + if value < -(2 ** 255) or value >= 2 ** 255: + raise ValueError("int256 word out of range") + return value.to_bytes(WORD_LEN, "big", signed=True) + + +def decode_unsigned_word(word: bytes) -> int: + if len(word) != WORD_LEN: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + return int.from_bytes(word, "big", signed=False) + + +def decode_signed_word(word: bytes) -> int: + if len(word) != WORD_LEN: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + unsigned_value = decode_unsigned_word(word) + if word[0] >= 128: + return unsigned_value - TWO_POW_256 + return unsigned_value + + +def build_response(price: int, conf: int, expo: int, publish_time: int) -> bytes: + """Builds a 128-byte ABI-encoded Pyth Price response, matching pyth-sdk-solidity's + (int64 price, uint64 conf, int32 expo, uint256 publishTime), right-aligned/sign-extended + into four 32-byte words -- exactly what `staticcall_evm` would return.""" + return ( + encode_int_word(price) + + encode_uint_word(conf) + + encode_int_word(expo) + + encode_uint_word(publish_time) + ) + + +def resolve_price(response: bytes, target_decimals: int, now: int) -> int: + """Mirrors `_resolvePythPrice`'s decode/validate/normalize steps (post-staticcall_evm).""" + if len(response) != 128: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + + price_word = response[0:32] + conf_word = response[32:64] + expo_word = response[64:96] + publish_time_word = response[96:128] + + raw_price = decode_signed_word(price_word) + raw_conf = decode_unsigned_word(conf_word) + raw_expo = decode_signed_word(expo_word) + raw_publish_time = decode_unsigned_word(publish_time_word) + + if raw_price <= 0: + raise PythFixtureError("NON_POSITIVE_PYTH_PRICE") + if not (-30 <= raw_expo <= 0): + raise PythFixtureError("INVALID_PYTH_EXPONENT") + if raw_publish_time <= 0: + raise PythFixtureError("INVALID_PYTH_PUBLISH_TIME") + if raw_publish_time > now: + raise PythFixtureError("FUTURE_PYTH_PUBLISH_TIME") + + price_nat = raw_price # already verified > 0 + if raw_conf * 4 > price_nat: + raise PythFixtureError("EXCESSIVE_PYTH_CONFIDENCE") + + decimal_shift = raw_expo + target_decimals + if not (-30 <= decimal_shift <= 30): + raise PythFixtureError("PYTH_NORMALIZATION_OUT_OF_RANGE") + + if decimal_shift >= 0: + normalized_price = price_nat * (10 ** decimal_shift) + else: + normalized_price = price_nat // (10 ** (-decimal_shift)) + + if normalized_price <= 0: + raise PythFixtureError("ZERO_NORMALIZED_PYTH_PRICE") + + return normalized_price + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +NOW = 1_700_000_000 + +FIXTURES = [ + dict( + name="positive BTC price (targetDecimals=8)", + response=build_response(price=6_000_000_000, conf=1_000_000, expo=-2, publish_time=NOW - 5), + target_decimals=8, + expect_ok=True, + expected_price=6_000_000_000 * 10 ** 6, # decimalShift = -2+8=6 + ), + dict( + name="positive USDT price (targetDecimals=6)", + response=build_response(price=100_010_000, conf=5_000, expo=-8, publish_time=NOW - 2), + target_decimals=6, + expect_ok=True, + expected_price=100_010_000 // 100, # decimalShift = -8+6=-2 + ), + dict( + name="positive XTZ price (targetDecimals=6)", + response=build_response(price=850_000, conf=200, expo=-6, publish_time=NOW - 1), + target_decimals=6, + expect_ok=True, + expected_price=850_000, # decimalShift = -6+6=0 + ), + dict( + name="negative signed price", + response=build_response(price=-42, conf=1, expo=-2, publish_time=NOW - 1), + target_decimals=6, + expect_ok=False, + expected_error="NON_POSITIVE_PYTH_PRICE", + ), + dict( + name="zero price", + response=build_response(price=0, conf=0, expo=-2, publish_time=NOW - 1), + target_decimals=6, + expect_ok=False, + expected_error="NON_POSITIVE_PYTH_PRICE", + ), + dict( + name="negative exponent (valid, in-range)", + response=build_response(price=123_456, conf=10, expo=-5, publish_time=NOW - 1), + target_decimals=6, + expect_ok=True, + expected_price=123_456 * 10, # decimalShift = -5+6=1 + ), + dict( + name="invalid exponent (out of [-30, 0] range)", + response=build_response(price=123_456, conf=10, expo=1, publish_time=NOW - 1), + target_decimals=6, + expect_ok=False, + expected_error="INVALID_PYTH_EXPONENT", + ), + dict( + name="invalid exponent (below -30)", + response=build_response(price=123_456, conf=10, expo=-31, publish_time=NOW - 1), + target_decimals=6, + expect_ok=False, + expected_error="INVALID_PYTH_EXPONENT", + ), + dict( + name="future timestamp", + response=build_response(price=123_456, conf=10, expo=-6, publish_time=NOW + 3600), + target_decimals=6, + expect_ok=False, + expected_error="FUTURE_PYTH_PUBLISH_TIME", + ), + dict( + name="excessive confidence (>25% of price)", + response=build_response(price=100_000, conf=30_000, expo=-6, publish_time=NOW - 1), + target_decimals=6, + expect_ok=False, + expected_error="EXCESSIVE_PYTH_CONFIDENCE", + ), + dict( + name="normalized price rounds to zero (excessive negative decimalShift)", + response=build_response(price=1, conf=0, expo=-30, publish_time=NOW - 1), + target_decimals=0, + expect_ok=False, + expected_error="ZERO_NORMALIZED_PYTH_PRICE", + ), + dict( + name="malformed/truncated response (< 128 bytes)", + response=build_response(price=1, conf=0, expo=-6, publish_time=NOW - 1)[:100], + target_decimals=6, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", + ), + dict( + name="malformed/truncated response (empty)", + response=b"", + target_decimals=6, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", + ), +] + + +def run_fixture(fixture: dict) -> str: + name = fixture["name"] + try: + result = resolve_price(fixture["response"], fixture["target_decimals"], NOW) + except PythFixtureError as exc: + if fixture["expect_ok"]: + return f"FAIL [{name}]: expected success but got error {exc}" + if str(exc) != fixture["expected_error"]: + return f"FAIL [{name}]: expected error {fixture['expected_error']!r} but got {exc!r}" + return f"ok [{name}] -> fail-closed with {exc}" + if not fixture["expect_ok"]: + return f"FAIL [{name}]: expected error {fixture['expected_error']!r} but got success {result}" + if result != fixture["expected_price"]: + return f"FAIL [{name}]: expected normalized price {fixture['expected_price']} but got {result}" + return f"ok [{name}] -> normalized price {result}" + + +def run_signed_decoding_checks() -> list: + """Separately exercises signed (price/expo) vs unsigned (conf/publishTime) word decoding.""" + results = [] + checks = [ + ("signed word: max positive int64-range value", encode_int_word(2 ** 62), 2 ** 62), + ("signed word: -1", encode_int_word(-1), -1), + ("signed word: min int64-range value", encode_int_word(-(2 ** 62)), -(2 ** 62)), + ("signed word: zero", encode_int_word(0), 0), + ] + for name, word, expected in checks: + actual = decode_signed_word(word) + results.append( + f"{'ok ' if actual == expected else 'FAIL'} [{name}] -> {actual} (expected {expected})" + ) + + unsigned_checks = [ + ("unsigned word: uint64 max-ish conf", encode_uint_word(2 ** 63), 2 ** 63), + ("unsigned word: uint256-ish large publishTime", encode_uint_word(2 ** 200), 2 ** 200), + ("unsigned word: zero", encode_uint_word(0), 0), + ] + for name, word, expected in unsigned_checks: + actual = decode_unsigned_word(word) + results.append( + f"{'ok ' if actual == expected else 'FAIL'} [{name}] -> {actual} (expected {expected})" + ) + return results + + +def main() -> int: + lines = [] + failures = 0 + + lines.append("== Signed/unsigned word decoding checks ==") + for line in run_signed_decoding_checks(): + lines.append(line) + if line.startswith("FAIL"): + failures += 1 + + lines.append("") + lines.append("== Pyth response fixtures ==") + for fixture in FIXTURES: + line = run_fixture(fixture) + lines.append(line) + if line.startswith("FAIL"): + failures += 1 + + print("\n".join(lines)) + print() + if failures: + print(f"{failures} fixture(s) FAILED") + return 1 + print(f"All {len(FIXTURES)} fixtures + decoding checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/deploy_script/configure_pyth_oracle.js b/deploy/deploy_script/configure_pyth_oracle.js new file mode 100644 index 00000000..577be621 --- /dev/null +++ b/deploy/deploy_script/configure_pyth_oracle.js @@ -0,0 +1,117 @@ +/** + * Performs the mandatory post-origination Pyth/NAC admin configuration sequence on + * TezFinOracle, in the exact order documented in README.md ("Pyth / NAC Staged + * Activation Order (Etherlink L2)"): + * + * setPythCore -> setPythMaxAge -> setFeedIds -> configurePriceBounds -> configureMaxPriceAge + * + * This is a WRITE script: it signs and injects real transactions. It requires: + * - TEZOS_PRIVATE_KEY (or another initAccount()-supported credential) for the oracle's + * admin account, exported in the shell environment -- never pass it as a CLI arg or + * commit it. + * - The manifest (TezFinBuild/deploy_result/deploy.shadownet.json by default, override + * via DEPLOY_MANIFEST) to already contain PythCore / PythMaxAgeSeconds / PythFeedIds / + * TezFinOracle. + * + * Usage: + * DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.shadownet.json \ + * TEZOS_PRIVATE_KEY=... \ + * node deploy/deploy_script/configure_pyth_oracle.js + * + * Optional env vars: + * TEST_MARKET - cToken address key for configurePriceBounds (defaults to the + * signer's own address as a placeholder key). configurePriceBounds/ + * configureMaxPriceAge are keyed by sp.sender, so the "comptroller" + * identity is always the signing admin account itself here. + * PRICE_MIN / PRICE_MAX / PRICE_MAX_CHANGE_BPS / MAX_PRICE_AGE_SECONDS + * - override the smoke-test price bounds (defaults are wide-open + * bounds so real Pyth-derived prices pass validation). + */ +const fs = require('fs'); +const { config, createTezosClient, resolveDeployResultPath } = require('./util.js'); + +function encodeUintWord(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Cannot encode negative/unsafe integer as a uint256 word: ${value}`); + } + return '0x' + BigInt(value).toString(16).padStart(64, '0'); +} + +async function confirm(operation, label) { + console.log(`[INFO] Injected ${label}: ${operation.hash}`); + await operation.confirmation(1, 45); + console.log(`[INFO] Confirmed ${label}`); +} + +async function main() { + const deployResultPath = resolveDeployResultPath(); + const manifest = JSON.parse(fs.readFileSync(deployResultPath, 'utf8')); + + const oracleAddress = manifest.TezFinOracle; + const pythCore = manifest.PythCore; + const pythMaxAgeSeconds = Number(manifest.PythMaxAgeSeconds); + const feedIdsManifest = manifest.PythFeedIds; + if (!oracleAddress || !pythCore || !pythMaxAgeSeconds || !feedIdsManifest) { + throw new Error( + `${deployResultPath} is missing TezFinOracle/PythCore/PythMaxAgeSeconds/PythFeedIds`, + ); + } + + const { tezos, publicKeyHash } = await createTezosClient(); + console.log(`[INFO] Configuring TezFinOracle ${oracleAddress} as admin ${publicKeyHash}`); + const oracle = await tezos.contract.at(oracleAddress); + + console.log('[INFO] Step 1/5: setPythCore'); + await confirm(await oracle.methodsObject.setPythCore(pythCore).send(), 'setPythCore'); + + console.log('[INFO] Step 2/5: setPythMaxAge'); + const maxAgeWord = encodeUintWord(pythMaxAgeSeconds); + await confirm(await oracle.methodsObject.setPythMaxAge(maxAgeWord).send(), 'setPythMaxAge'); + + console.log('[INFO] Step 3/5: setFeedIds (BTC, XTZ, USDT)'); + // targetDecimals BTC=8, XTZ=6, USDT=6 (matches Comptroller/Governance priceExp conventions). + const feedParams = [ + { asset: 'BTC', feedId: feedIdsManifest.BTC_USD, targetDecimals: 8 }, + { asset: 'XTZ', feedId: feedIdsManifest.XTZ_USD, targetDecimals: 6 }, + { asset: 'USDT', feedId: feedIdsManifest.USDT_USD, targetDecimals: 6 }, + ]; + await confirm(await oracle.methodsObject.setFeedIds(feedParams).send(), 'setFeedIds'); + + // configurePriceBounds/configureMaxPriceAge are keyed by sp.sender, so they can only be + // configured for the *signing* account itself (no separate "source" override at the + // Michelson level) -- this smoke test therefore uses the admin/signer's own address as + // the stand-in "comptroller" identity, matching docs' "тестовых market/comptroller". + const testComptroller = publicKeyHash; + const testMarket = process.env.TEST_MARKET || publicKeyHash; + const minPrice = Number(process.env.PRICE_MIN || 1); + const maxPrice = Number(process.env.PRICE_MAX || 2 ** 50); + const maxChangeBps = Number(process.env.PRICE_MAX_CHANGE_BPS || 10000); + const maxPriceAge = Number(process.env.MAX_PRICE_AGE_SECONDS || manifest.TezFinMaxPriceAgeSeconds || 60); + + console.log(`[INFO] Step 4/5: configurePriceBounds (comptroller=${testComptroller}, cToken=${testMarket})`); + await confirm( + await oracle.methodsObject + .configurePriceBounds({ + cToken: testMarket, + minPrice, + maxPrice, + maxChangeBps, + }) + .send(), + 'configurePriceBounds', + ); + + console.log(`[INFO] Step 5/5: configureMaxPriceAge (${maxPriceAge}s)`); + await confirm(await oracle.methodsObject.configureMaxPriceAge(maxPriceAge).send(), 'configureMaxPriceAge'); + + console.log('[INFO] Pyth/NAC staged activation sequence complete.'); + console.log( + `[INFO] Run node deploy/deploy_script/verify_shadownet_pyth_oracle.js next ` + + `(comptroller=${testComptroller}, market=${testMarket}) to smoke-test live reads.`, + ); +} + +main().catch((error) => { + console.error(`[ERROR] Pyth oracle configuration failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index ab4177b6..d1b7e5c3 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -22,6 +22,8 @@ "measure:origination-size": "node measure_origination_size.js", "prepare:deploy": "node prepare.js", "verify:mainnet-oracle": "node verify_mainnet_oracle.js", + "configure:pyth-oracle": "node configure_pyth_oracle.js", + "verify:shadownet-pyth-oracle": "node verify_shadownet_pyth_oracle.js", "test": "node --test test/*.test.js" } } diff --git a/deploy/deploy_script/verify_shadownet_pyth_oracle.js b/deploy/deploy_script/verify_shadownet_pyth_oracle.js new file mode 100644 index 00000000..a9b188fc --- /dev/null +++ b/deploy/deploy_script/verify_shadownet_pyth_oracle.js @@ -0,0 +1,213 @@ +/** + * Read-only Shadownet smoke test for TezFinOracle's Pyth/NAC upstream lookup. + * + * Covers: + * 1. Shadownet E2E Pyth/NAC smoke test: getPrice / get_price_with_timestamp / + * getValidatedPrice for BTC-USD, XTZ-USD, USDT-USD agree, are fresh (<= maxAge) + * and not in the future. + * 2. Proxy mapping E2E: tzBTC-USD == BTC-USD, USDtz-USD == USDT-USD == USDt-USD, + * and WTZ-USD/OXTZ-USD/STXTZ-USD aliases still resolve to XTZ-USD. + * + * This is READ-ONLY: it only POSTs to + * `/chains/main/blocks/head/helpers/scripts/run_script_view`, which simulates the view + * without needing a signature, funded account, or write access to the node. It does NOT + * perform admin configuration (see configure_pyth_oracle.js for that) and does NOT + * require TEZOS_PRIVATE_KEY. + * + * Usage: + * DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.shadownet.json \ + * node deploy/deploy_script/verify_shadownet_pyth_oracle.js + * + * Prerequisite: configure_pyth_oracle.js (or the equivalent manual admin calls) must have + * already run against this contract, and the Pyth cache for BTC/XTZ/USDT on Etherlink must + * be warm (see docs Etap 5 "Testnet Pyth update runner" if the cache is stale). + */ +const fs = require('fs'); +const { config, resolveDeployResultPath } = require('./util.js'); + +const NATIVE_ASSETS = ['BTC-USD', 'XTZ-USD', 'USDT-USD']; +const PROXY_GROUPS = [ + { native: 'BTC-USD', proxies: ['tzBTC-USD'] }, + { native: 'USDT-USD', proxies: ['USDtz-USD', 'USDt-USD'] }, + { native: 'XTZ-USD', proxies: ['WTZ-USD', 'OXTZ-USD', 'STXTZ-USD'] }, +]; + +async function rpcJson(rpc, pathname, options = {}) { + const response = await fetch(`${rpc.replace(/\/$/, '')}${pathname}`, options); + if (!response.ok) { + throw new Error(`RPC ${pathname} returned ${response.status}: ${await response.text()}`); + } + return response.json(); +} + +async function runView(rpc, { contract, view, input, chainId, source }) { + const body = { + contract, + view, + input, + chain_id: chainId, + source, + payer: source, + gas: '1040000', + unparsing_mode: 'Readable', + }; + const result = await rpcJson(rpc, '/chains/main/blocks/head/helpers/scripts/run_script_view', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return result.data; +} + +// getPrice / getValidatedPrice both return `pair(timestamp, nat)`. +function parseTimestampNatPair(node, label) { + const args = node?.args; + const timestampStr = args?.[0]?.string; + const priceStr = args?.[1]?.int; + if (!timestampStr || priceStr === undefined) { + throw new Error(`${label}: unexpected view result shape: ${JSON.stringify(node)}`); + } + const timestamp = Math.floor(Date.parse(timestampStr) / 1000); + const price = BigInt(priceStr); + return { timestamp, price }; +} + +// get_price_with_timestamp returns `pair(nat, timestamp)` (swapped order). +function parseNatTimestampPair(node, label) { + const args = node?.args; + const priceStr = args?.[0]?.int; + const timestampStr = args?.[1]?.string; + if (priceStr === undefined || !timestampStr) { + throw new Error(`${label}: unexpected view result shape: ${JSON.stringify(node)}`); + } + const timestamp = Math.floor(Date.parse(timestampStr) / 1000); + const price = BigInt(priceStr); + return { timestamp, price }; +} + +async function fetchAssetPrice(rpc, oracleAddress, chainId, source, headTimestamp, maxAgeSeconds, asset) { + const getPriceNode = await runView(rpc, { + contract: oracleAddress, view: 'getPrice', input: { string: asset }, chainId, source, + }); + const legacyNode = await runView(rpc, { + contract: oracleAddress, view: 'get_price_with_timestamp', input: { string: asset }, chainId, source, + }); + + const viaGetPrice = parseTimestampNatPair(getPriceNode, `getPrice(${asset})`); + const viaLegacy = parseNatTimestampPair(legacyNode, `get_price_with_timestamp(${asset})`); + + if (viaGetPrice.price !== viaLegacy.price) { + throw new Error( + `${asset}: getPrice price ${viaGetPrice.price} != get_price_with_timestamp price ${viaLegacy.price}`, + ); + } + if (viaGetPrice.timestamp !== viaLegacy.timestamp) { + throw new Error( + `${asset}: getPrice timestamp ${viaGetPrice.timestamp} != get_price_with_timestamp timestamp ${viaLegacy.timestamp}`, + ); + } + if (viaGetPrice.price <= 0n) { + throw new Error(`${asset}: normalized price must be > 0, got ${viaGetPrice.price}`); + } + if (viaGetPrice.timestamp <= 0) { + throw new Error(`${asset}: publish timestamp must be > 0`); + } + if (viaGetPrice.timestamp > headTimestamp) { + throw new Error( + `${asset}: publish timestamp ${viaGetPrice.timestamp} is ahead of chain head ${headTimestamp}`, + ); + } + const ageSeconds = headTimestamp - viaGetPrice.timestamp; + if (ageSeconds > maxAgeSeconds) { + throw new Error(`${asset}: price is stale (${ageSeconds}s old; maximum ${maxAgeSeconds}s)`); + } + + console.log( + `[OK] ${asset.padEnd(10)} price=${viaGetPrice.price.toString().padStart(14)} ` + + `timestamp=${viaGetPrice.timestamp} age=${ageSeconds}s`, + ); + return viaGetPrice; +} + +async function verifyGetValidatedPrice(rpc, oracleAddress, chainId, source, comptroller, cToken, asset) { + // TValidatedPriceRequest = {comptroller, cToken, requestedAsset, previousPrice, + // previousTimestamp}, but SmartPy lays out compiled record fields by ASCII field-name + // order, not declaration order: pair(pair(cToken, comptroller), + // pair(previousPrice, pair(previousTimestamp, requestedAsset))). Verified against the + // live contract's script (view getValidatedPrice) on Shadownet before writing this. + const input = { + prim: 'Pair', + args: [ + { prim: 'Pair', args: [{ string: cToken }, { string: comptroller }] }, + { prim: 'Pair', args: [ + { int: '0' }, + { prim: 'Pair', args: [{ string: '1970-01-01T00:00:00Z' }, { string: asset }] }, + ] }, + ], + }; + const node = await runView(rpc, { contract: oracleAddress, view: 'getValidatedPrice', input, chainId, source }); + const parsed = parseTimestampNatPair(node, `getValidatedPrice(${asset})`); + console.log(`[OK] getValidatedPrice(${asset}) -> price=${parsed.price} timestamp=${parsed.timestamp}`); + return parsed; +} + +async function main() { + const deployResultPath = resolveDeployResultPath(); + const manifest = JSON.parse(fs.readFileSync(deployResultPath, 'utf8')); + const oracleAddress = manifest.TezFinOracle; + if (!oracleAddress) { + throw new Error(`${deployResultPath} is missing TezFinOracle`); + } + const maxAgeSeconds = Number( + process.env.PYTH_MAX_AGE_SECONDS || manifest.PythMaxAgeSeconds || manifest.TezFinMaxPriceAgeSeconds || 60, + ); + const rpc = process.env.TEZOS_RPC || config.tezosNode; + const source = process.env.TEZOS_SOURCE || manifest.OriginatorAddress; + if (!source) { + throw new Error('No source/payer address available (set TEZOS_SOURCE or OriginatorAddress in the manifest)'); + } + + const [chainId, header] = await Promise.all([ + rpcJson(rpc, '/chains/main/chain_id'), + rpcJson(rpc, '/chains/main/blocks/head/header'), + ]); + const headTimestamp = Math.floor(Date.parse(header.timestamp) / 1000); + console.log(`[INFO] Oracle ${oracleAddress} on ${rpc} (chain ${chainId}, head=${headTimestamp})`); + + console.log('\n== Native Pyth feeds =='); + const nativePrices = {}; + for (const asset of NATIVE_ASSETS) { + nativePrices[asset] = await fetchAssetPrice(rpc, oracleAddress, chainId, source, headTimestamp, maxAgeSeconds, asset); + } + + console.log('\n== L2 proxy / alias mapping (must equal their native feed) =='); + for (const group of PROXY_GROUPS) { + for (const proxyAsset of group.proxies) { + const proxyPrice = await fetchAssetPrice( + rpc, oracleAddress, chainId, source, headTimestamp, maxAgeSeconds, proxyAsset, + ); + const native = nativePrices[group.native]; + if (proxyPrice.price !== native.price || proxyPrice.timestamp !== native.timestamp) { + throw new Error( + `${proxyAsset} does not match its native feed ${group.native}: ` + + `proxy=(${proxyPrice.price}, ${proxyPrice.timestamp}) native=(${native.price}, ${native.timestamp})`, + ); + } + console.log(`[OK] ${proxyAsset} == ${group.native}`); + } + } + + console.log('\n== getValidatedPrice (requires configurePriceBounds/configureMaxPriceAge already run) =='); + const comptroller = process.env.TEST_COMPTROLLER || source; + const cToken = process.env.TEST_MARKET || source; + for (const asset of NATIVE_ASSETS) { + await verifyGetValidatedPrice(rpc, oracleAddress, chainId, source, comptroller, cToken, asset); + } + + console.log('\nAll Shadownet Pyth/NAC smoke-test checks passed.'); +} + +main().catch((error) => { + console.error(`[ERROR] Shadownet Pyth oracle verification failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/e2e/shell_scripts/shadownet_pyth_smoke_test.sh b/e2e/shell_scripts/shadownet_pyth_smoke_test.sh new file mode 100755 index 00000000..fd5e4dd7 --- /dev/null +++ b/e2e/shell_scripts/shadownet_pyth_smoke_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail +# Shadownet Pyth/NAC end-to-end smoke test for TezFinOracle (feat/l2_tezoracle). +# +# Runs the full staged sequence documented in README.md ("Pyth / NAC Staged Activation +# +# 1. SmartPy oracle test suite (sandbox regression) +# 2. Production compile (with the shadownet manifest) +# 3. Origination operation-size guard +# 4. Deterministic Pyth ABI decode/normalize fixtures +# 5. Redeploy TezFinOracle to Shadownet (only if REDEPLOY=1; otherwise assumes the +# manifest's existing TezFinOracle address already has the latest code) +# 6. Admin config: setPythCore -> setPythMaxAge -> setFeedIds -> configurePriceBounds +# -> configureMaxPriceAge (configure_pyth_oracle.js) +# 7. Read-only live verification: getPrice / get_price_with_timestamp / +# getValidatedPrice for native + proxy + alias assets (verify_shadownet_pyth_oracle.js) +# +# Requirements (NOT provided by this script, must be set up by whoever runs it): +# - `smartpy` arg: path to SmartPy.sh +# - TEZOS_PRIVATE_KEY (or TEZOS_MNEMONIC / fundraiser vars, see deploy/deploy_script/util.js) +# exported in the shell environment for the Shadownet admin account -- steps 5 and 6 +# sign and inject real transactions and will fail without a funded account. +# - Network access to the Shadownet Michelson RPC (network/chainId are read from the +# manifest / deploy/deploy_script/config.json, not hardcoded here). +# +# Usage: +# REDEPLOY=1 DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.shadownet.json \ +# ./e2e/shell_scripts/shadownet_pyth_smoke_test.sh ~/smartpy-cli/SmartPy.sh +# +# Steps 1-4 need no secrets/network write access and can always run safely. Steps 5-7 are +# skipped with a clear message if TEZOS_PRIVATE_KEY (or another initAccount() credential) +# is not set, so this script is also safe to run as a local pre-flight check. + +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +manifest="${DEPLOY_MANIFEST:?Set DEPLOY_MANIFEST to e.g. TezFinBuild/deploy_result/deploy.shadownet.json}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +echo "== 1/7: SmartPy oracle test suite ==" +"$smartpy" test contracts/tests/TezFinOracleTest.py /tmp/tezfin_oracle_tests --purge + +echo "== 2/7: Production compile (shadownet manifest) ==" +DEPLOY_MANIFEST="$manifest" "$smartpy" compile deploy/compile_targets/CompileTezFinOracle.py \ + /tmp/tezfin_oracle_compiled --purge --protocol kathmandu + +echo "== 3/7: Origination operation-size guard ==" +python3 deploy/compile_targets/tests/test_operation_size.py "$smartpy" + +echo "== 4/7: Deterministic Pyth ABI fixtures ==" +python3 contracts/tests/fixtures/pyth_abi_fixtures_test.py + +if [[ -z "${TEZOS_PRIVATE_KEY:-}${TEZOS_MNEMONIC:-}" ]]; then + echo "== 5-7/7: SKIPPED (no TEZOS_PRIVATE_KEY/TEZOS_MNEMONIC in the environment) ==" + echo "Set a Shadownet admin credential and re-run to redeploy/configure/verify live." + exit 0 +fi + +if [[ "${REDEPLOY:-0}" == "1" ]]; then + echo "== 5/7: Redeploying TezFinOracle to Shadownet ==" + (cd deploy/deploy_script && npm ci && DEPLOY_MANIFEST="$repo_root/$manifest" node deploy.js) +else + echo "== 5/7: SKIPPED (set REDEPLOY=1 to originate a fresh TezFinOracle first) ==" +fi + +echo "== 6/7: Admin Pyth/NAC configuration (setPythCore -> setPythMaxAge -> setFeedIds -> configurePriceBounds -> configureMaxPriceAge) ==" +(cd deploy/deploy_script && DEPLOY_MANIFEST="$repo_root/$manifest" node configure_pyth_oracle.js) + +echo "== 7/7: Live read-only verification (native feeds, proxies, aliases, getValidatedPrice) ==" +(cd deploy/deploy_script && DEPLOY_MANIFEST="$repo_root/$manifest" node verify_shadownet_pyth_oracle.js) + +echo "Shadownet Pyth/NAC smoke test complete." From 64f018cc296934352552101f23206834696f05bd Mon Sep 17 00:00:00 2001 From: user Date: Thu, 17 Sep 2026 16:01:09 +0300 Subject: [PATCH 04/11] fix: avoid SmartPy ABI decoder local collisions --- contracts/TezFinOracle.py | 94 +++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index 3e9238cc..15ba00cb 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -120,27 +120,77 @@ def _decodeUnsignedWord(self, word): """ Decodes a 32-byte big-endian ABI word into a nat, one byte at a time """ - acc = sp.local("acc", sp.nat(0)) - i = sp.local("i", sp.nat(0)) - sp.while i.value < 32: - currentByte = sp.slice(word, i.value, 1).open_some( + unsignedAcc = sp.local("unsignedAcc", sp.nat(0)) + unsignedIndex = sp.local("unsignedIndex", sp.nat(0)) + sp.while unsignedIndex.value < 32: + currentByte = sp.slice(word, unsignedIndex.value, 1).open_some( "MALFORMED_PYTH_RESPONSE") - acc.value = acc.value * 256 + BYTE_TO_NAT[currentByte] - i.value += 1 - return acc.value + unsignedAcc.value = unsignedAcc.value * 256 + BYTE_TO_NAT[currentByte] + unsignedIndex.value += 1 + return unsignedAcc.value + + def _decodeUint64Word(self, word): + """Decodes the right-aligned uint64 payload used by Pyth confidence.""" + confidenceAcc = sp.local("confidenceAcc", sp.nat(0)) + confidenceIndex = sp.local("confidenceIndex", sp.nat(0)) + sp.while confidenceIndex.value < 8: + currentByte = sp.slice(word, 24 + confidenceIndex.value, 1).open_some( + "MALFORMED_PYTH_RESPONSE") + confidenceAcc.value = confidenceAcc.value * 256 + BYTE_TO_NAT[currentByte] + confidenceIndex.value += 1 + return confidenceAcc.value + + def _decodePriceWord(self, word): + """Decodes Pyth's positive int64 price from the first ABI word.""" + priceAcc = sp.local("priceAcc", sp.nat(0)) + priceIndex = sp.local("priceIndex", sp.nat(0)) + sp.while priceIndex.value < 8: + currentByte = sp.slice(word, 24 + priceIndex.value, 1).open_some( + "MALFORMED_PYTH_RESPONSE") + priceAcc.value = priceAcc.value * 256 + BYTE_TO_NAT[currentByte] + priceIndex.value += 1 + return priceAcc.value + + def _decodeExponentWord(self, word): + """Decodes Pyth's sign-extended int32 exponent.""" + exponentAcc = sp.local("exponentAcc", sp.nat(0)) + exponentIndex = sp.local("exponentIndex", sp.nat(0)) + sp.while exponentIndex.value < 4: + currentByte = sp.slice(word, 28 + exponentIndex.value, 1).open_some( + "MALFORMED_PYTH_RESPONSE") + exponentAcc.value = exponentAcc.value * 256 + BYTE_TO_NAT[currentByte] + exponentIndex.value += 1 + exponentResult = sp.local("exponentResult", sp.int(0)) + exponentSignByte = sp.slice(word, 28, 1).open_some("MALFORMED_PYTH_RESPONSE") + sp.if BYTE_TO_NAT[exponentSignByte] >= 128: + exponentResult.value = sp.to_int(exponentAcc.value) - sp.to_int(2 ** 32) + sp.else: + exponentResult.value = sp.to_int(exponentAcc.value) + return exponentResult.value + + def _decodePublishTimeWord(self, word): + """Decodes Pyth's uint256 publish time with field-specific locals.""" + publishAcc = sp.local("publishAcc", sp.nat(0)) + publishIndex = sp.local("publishIndex", sp.nat(0)) + sp.while publishIndex.value < 32: + currentByte = sp.slice(word, publishIndex.value, 1).open_some( + "MALFORMED_PYTH_RESPONSE") + publishAcc.value = publishAcc.value * 256 + BYTE_TO_NAT[currentByte] + publishIndex.value += 1 + return publishAcc.value def _decodeSignedWord(self, word): """ Decodes a 32-byte big-endian, sign-extended two's complement ABI word into an int """ - unsignedValue = self._decodeUnsignedWord(word) - signByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") - result = sp.local("result", sp.int(0)) - sp.if BYTE_TO_NAT[signByte] >= 128: - result.value = sp.to_int(unsignedValue) - sp.to_int(TWO_POW_256) + signedUnsignedValue = self._decodeUnsignedWord(word) + signedSignByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") + signedResult = sp.local("signedResult", sp.int(0)) + sp.if BYTE_TO_NAT[signedSignByte] >= 128: + signedResult.value = sp.to_int(signedUnsignedValue) - sp.to_int(TWO_POW_256) sp.else: - result.value = sp.to_int(unsignedValue) - return result.value + signedResult.value = sp.to_int(signedUnsignedValue) + return signedResult.value @sp.entry_point def configurePriceBounds(self, params): @@ -256,12 +306,17 @@ def _resolvePythPrice(self, requestedAsset): expoWord = sp.slice(response, 64, 32).open_some("MALFORMED_PYTH_RESPONSE") publishTimeWord = sp.slice(response, 96, 32).open_some("MALFORMED_PYTH_RESPONSE") - rawPrice = self._decodeSignedWord(priceWord) - rawConf = self._decodeUnsignedWord(confWord) - rawExpo = self._decodeSignedWord(expoWord) + # Pyth prices must be strictly positive. Decode this ABI word as unsigned after + # rejecting a set sign bit; this avoids relying on the larger contract's repeated + # signed-word lambda expansion while preserving fail-closed handling of negatives. + priceSignByte = sp.slice(priceWord, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") + sp.verify(BYTE_TO_NAT[priceSignByte] < 128, "NON_POSITIVE_PYTH_PRICE") + rawPrice = sp.to_int(self._decodePriceWord(priceWord)) + rawConf = self._decodeUint64Word(confWord) + rawExpo = self._decodeExponentWord(expoWord) # publishTime is `uint` (uint256) per pyth-sdk-solidity's PythStructs.Price, not signed; # the <= sp.now check below still fails closed on any absurdly large decoded value. - rawPublishTime = self._decodeUnsignedWord(publishTimeWord) + rawPublishTime = self._decodePublishTimeWord(publishTimeWord) sp.verify(rawPrice > 0, "NON_POSITIVE_PYTH_PRICE") sp.verify((rawExpo >= -30) & (rawExpo <= 0), "INVALID_PYTH_EXPONENT") @@ -272,7 +327,8 @@ def _resolvePythPrice(self, requestedAsset): priceNat = sp.as_nat(rawPrice, message="NON_POSITIVE_PYTH_PRICE") # Fail closed if the reported confidence interval exceeds 25% of the price. - sp.verify(rawConf * 4 <= priceNat, "EXCESSIVE_PYTH_CONFIDENCE") + # Compare against floor(price / 4) to avoid multiplying an ABI-decoded nat. + sp.verify(rawConf <= priceNat // 4, "EXCESSIVE_PYTH_CONFIDENCE") # normalizedPrice = price * 10^(expo + targetDecimals), using integer arithmetic only. decimalShift = rawExpo + sp.to_int(feedConfig.targetDecimals) From 5e63d54beec7e0830204045a1b6dedafb5e2ee65 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 17 Sep 2026 16:42:41 +0300 Subject: [PATCH 05/11] feat: prepared compiled contract hashes JSON --- compiled-contract-hashes.json | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 compiled-contract-hashes.json diff --git a/compiled-contract-hashes.json b/compiled-contract-hashes.json new file mode 100644 index 00000000..f0ce9afb --- /dev/null +++ b/compiled-contract-hashes.json @@ -0,0 +1,46 @@ +{ + "contracts": { + "CFA12_IRM": { + "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", + "storageSha256": "4f47d8642be1ec3f7e8832757c06b9277e2926607077c7c6aa1c4e4050cd313f" + }, + "CFA2_IRM": { + "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", + "storageSha256": "4f47d8642be1ec3f7e8832757c06b9277e2926607077c7c6aa1c4e4050cd313f" + }, + "CUSDt": { + "contractSha256": "278e21d79c7369589f98058881d25efd651f5bd3a05b852c0600e85cb6549944", + "storageSha256": "0da38b303b4965ab2be404fc96d8f79afef26b9fb78d35a9574c0d2aad079637" + }, + "CXTZ": { + "contractSha256": "1011764e4d959754e5d3a7b496954b6734e04f70962d3b8f1845506997676188", + "storageSha256": "bd12edc724651647ebb7bd2d750cb58ae6a44ea17f5ec76897046b0c2705516c" + }, + "CXTZ_IRM": { + "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", + "storageSha256": "ce495cc8c035066279eec6e8907172d3cade64e5c24c8e9730983144e2bc2973" + }, + "Comptroller": { + "contractSha256": "e612191c212fb81be53d4eb5f63fba2b6ce1275bb841822f1ac9b23fc697ea35", + "storageSha256": "74d0a4f27c2296a77f12501903529ab9b89af79fe359ad4a45ffe00ea3fc2eb8" + }, + "CtzBTC": { + "contractSha256": "d8c178ce918785472d972ec50e4cfc6d14f4556624db9b3b848a6d6cb6035828", + "storageSha256": "72472795d0847c067a56f2db1efb21dee1c478ad0ad8b14a8fdc7e87d6cc373e" + }, + "CtzBTC_IRM": { + "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", + "storageSha256": "65a18026e1b9156ef9695caecb6d57ab945286737ab7dfa0b66cc1233e72096b" + }, + "Governance": { + "contractSha256": "deb90c1859deb61bc6c4d9679ec2b452264b459886e4b3cbe4b1968ecbbe795e", + "storageSha256": "8375a124534108c02419eed14ebc338c6d746b91031dc0ed7ad965d5b3157cab" + }, + "TezFinOracle": { + "contractSha256": "f169c61da5b727e9aa72b68873b9ee88d46d694059070028a9ba15f9f7680477", + "storageSha256": "20f99119866cdbe8d27be9ddeda51d45caa215e20ca5da3661157cf607443074" + } + }, + "protocol": "kathmandu", + "smartpyVersion": "0.16.0" +} From c5d8bb28ca51d963327235e6f2ebca34bc624259 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 18 Sep 2026 12:05:50 +0300 Subject: [PATCH 06/11] chore: add Pyth mock CI and ABI regression fixtures --- .github/workflows/ci.yml | 17 +++++ README.md | 18 +++++ contracts/TezFinOracle.py | 6 +- .../tests/fixtures/pyth_abi_fixtures_test.py | 66 +++++++++++++++++-- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2952425..ebb8d772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,23 @@ env: PYTHON_VERSION: '3.11.11' jobs: + test_pyth_mock: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@21bacf9f516cc52b91a71c066d9b3446623cdede # master, 2026-09-16 + with: + version: stable + - name: Run Pyth mock tests + working-directory: e2e/pyth_mock + run: forge test + - name: Run Pyth ABI fixtures + run: python3 contracts/tests/fixtures/pyth_abi_fixtures_test.py + build_and_test_smart_contracts: runs-on: ubuntu-22.04 steps: diff --git a/README.md b/README.md index 6f51e5a3..5aa72a1f 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,24 @@ silently returning stale or zero data: This order and every error in the table above are covered by [`contracts/tests/TezFinOracleTest.py`](contracts/tests/TezFinOracleTest.py). +### Pyth confidence and proxy risk policy + +The production oracle accepts a Pyth update only when the confidence interval is +no more than 25% of the raw price: + +```text +conf * 4 <= rawPrice +``` + +The L2 asset mappings below are explicit proxies, not independent price feeds: + +- `tzBTC-USD` uses the BTC/USD Pyth feed. This does not detect a tzBTC/BTC depeg. +- `USDtz-USD` and `USDt-USD` use the USDT/USD Pyth feed. This does not prove or + detect a USDtz/USDT or USDt/USDT peg failure. + +These proxy mappings must be treated as a governance and risk-policy decision; +they are not evidence that the wrapped asset maintains its intended peg. + ## Post-Deployment Admin Handoff (Mainnet) After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index 15ba00cb..d7f6538e 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -142,6 +142,8 @@ def _decodeUint64Word(self, word): def _decodePriceWord(self, word): """Decodes Pyth's positive int64 price from the first ABI word.""" + priceSignByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") + sp.verify(BYTE_TO_NAT[priceSignByte] < 128, "NON_POSITIVE_PYTH_PRICE") priceAcc = sp.local("priceAcc", sp.nat(0)) priceIndex = sp.local("priceIndex", sp.nat(0)) sp.while priceIndex.value < 8: @@ -153,6 +155,7 @@ def _decodePriceWord(self, word): def _decodeExponentWord(self, word): """Decodes Pyth's sign-extended int32 exponent.""" + exponentSignByte = sp.slice(word, 28, 1).open_some("MALFORMED_PYTH_RESPONSE") exponentAcc = sp.local("exponentAcc", sp.nat(0)) exponentIndex = sp.local("exponentIndex", sp.nat(0)) sp.while exponentIndex.value < 4: @@ -161,7 +164,6 @@ def _decodeExponentWord(self, word): exponentAcc.value = exponentAcc.value * 256 + BYTE_TO_NAT[currentByte] exponentIndex.value += 1 exponentResult = sp.local("exponentResult", sp.int(0)) - exponentSignByte = sp.slice(word, 28, 1).open_some("MALFORMED_PYTH_RESPONSE") sp.if BYTE_TO_NAT[exponentSignByte] >= 128: exponentResult.value = sp.to_int(exponentAcc.value) - sp.to_int(2 ** 32) sp.else: @@ -309,8 +311,6 @@ def _resolvePythPrice(self, requestedAsset): # Pyth prices must be strictly positive. Decode this ABI word as unsigned after # rejecting a set sign bit; this avoids relying on the larger contract's repeated # signed-word lambda expansion while preserving fail-closed handling of negatives. - priceSignByte = sp.slice(priceWord, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") - sp.verify(BYTE_TO_NAT[priceSignByte] < 128, "NON_POSITIVE_PYTH_PRICE") rawPrice = sp.to_int(self._decodePriceWord(priceWord)) rawConf = self._decodeUint64Word(confWord) rawExpo = self._decodeExponentWord(expoWord) diff --git a/contracts/tests/fixtures/pyth_abi_fixtures_test.py b/contracts/tests/fixtures/pyth_abi_fixtures_test.py index 162fbd7a..692fdceb 100644 --- a/contracts/tests/fixtures/pyth_abi_fixtures_test.py +++ b/contracts/tests/fixtures/pyth_abi_fixtures_test.py @@ -2,8 +2,8 @@ """ Deterministic ABI-decode/normalization fixtures for TezFinOracle's Pyth NAC lookup. -This mirrors (in plain Python, not SmartPy) the exact decode/validate/normalize -logic implemented in `_decodeUnsignedWord` / `_decodeSignedWord` / `_resolvePythPrice` +This mirrors (in plain Python, not SmartPy) the exact field-specific decode/ +validate/normalize logic implemented in `TezFinOracle.py`. inside contracts/TezFinOracle.py. It exists because: - The SmartPy sandbox has no Etherlink NAC gateway / Pyth Core contract to call, so @@ -65,6 +65,29 @@ def decode_signed_word(word: bytes) -> int: return unsigned_value +def decode_positive_price_word(word: bytes) -> int: + if len(word) != WORD_LEN: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + if word[0] >= 128: + raise PythFixtureError("NON_POSITIVE_PYTH_PRICE") + value = int.from_bytes(word[24:], "big", signed=False) + if value <= 0: + raise PythFixtureError("NON_POSITIVE_PYTH_PRICE") + return value + + +def decode_confidence_word(word: bytes) -> int: + if len(word) != WORD_LEN: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + return int.from_bytes(word[24:], "big", signed=False) + + +def decode_exponent_word(word: bytes) -> int: + if len(word) != WORD_LEN: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + return int.from_bytes(word[28:], "big", signed=True) + + def build_response(price: int, conf: int, expo: int, publish_time: int) -> bytes: """Builds a 128-byte ABI-encoded Pyth Price response, matching pyth-sdk-solidity's (int64 price, uint64 conf, int32 expo, uint256 publishTime), right-aligned/sign-extended @@ -87,9 +110,9 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: expo_word = response[64:96] publish_time_word = response[96:128] - raw_price = decode_signed_word(price_word) - raw_conf = decode_unsigned_word(conf_word) - raw_expo = decode_signed_word(expo_word) + raw_price = decode_positive_price_word(price_word) + raw_conf = decode_confidence_word(conf_word) + raw_expo = decode_exponent_word(expo_word) raw_publish_time = decode_unsigned_word(publish_time_word) if raw_price <= 0: @@ -218,6 +241,39 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: expect_ok=False, expected_error="MALFORMED_PYTH_RESPONSE", ), + dict( + name="malformed positive price padding", + response=(b"\x01" + b"\x00" * 23 + (42).to_bytes(8, "big") + + encode_uint_word(0) + encode_int_word(-2) + + encode_uint_word(NOW - 1)), + target_decimals=6, + expect_ok=True, + expected_price=42 * 10 ** 4, + ), + dict( + name="malformed confidence padding", + response=(encode_uint_word(42) + b"\x01" + b"\x00" * 23 + b"\x00" * 8 + + encode_int_word(-2) + encode_uint_word(NOW - 1)), + target_decimals=6, + expect_ok=True, + expected_price=42 * 10 ** 4, + ), + dict( + name="valid negative exponent sign extension", + response=build_response(price=42, conf=1, expo=-2, publish_time=NOW - 1), + target_decimals=6, + expect_ok=True, + expected_price=42 * 10 ** 4, + ), + dict( + name="malformed exponent sign extension", + response=(encode_uint_word(42) + encode_uint_word(1) + + b"\x00" * 28 + b"\xff\xff\xff\xfe" + + encode_uint_word(NOW - 1)), + target_decimals=6, + expect_ok=True, + expected_price=42 * 10 ** 4, + ), ] From 157ffbe88dc172c2f020af66b532cb61c1e5af3b Mon Sep 17 00:00:00 2001 From: user Date: Fri, 18 Sep 2026 13:35:19 +0300 Subject: [PATCH 07/11] feat: updated compiled contract hashes --- .github/workflows/ci.yml | 20 +++----------------- compiled-contract-hashes.json | 2 +- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebb8d772..00a95e71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,23 +12,6 @@ env: PYTHON_VERSION: '3.11.11' jobs: - test_pyth_mock: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@21bacf9f516cc52b91a71c066d9b3446623cdede # master, 2026-09-16 - with: - version: stable - - name: Run Pyth mock tests - working-directory: e2e/pyth_mock - run: forge test - - name: Run Pyth ABI fixtures - run: python3 contracts/tests/fixtures/pyth_abi_fixtures_test.py - build_and_test_smart_contracts: runs-on: ubuntu-22.04 steps: @@ -49,6 +32,9 @@ jobs: - name: "Run tests" run: | bash contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh + - name: "Run Pyth ABI fixtures" + run: | + python3 contracts/tests/fixtures/pyth_abi_fixtures_test.py - name: "Check per-market IRM wiring" run: | python3 deploy/compile_targets/tests/test_irm_wiring.py diff --git a/compiled-contract-hashes.json b/compiled-contract-hashes.json index f0ce9afb..dd1786bb 100644 --- a/compiled-contract-hashes.json +++ b/compiled-contract-hashes.json @@ -37,7 +37,7 @@ "storageSha256": "8375a124534108c02419eed14ebc338c6d746b91031dc0ed7ad965d5b3157cab" }, "TezFinOracle": { - "contractSha256": "f169c61da5b727e9aa72b68873b9ee88d46d694059070028a9ba15f9f7680477", + "contractSha256": "ea59546dc3562286c3c3ed0c43ff520df3ee2bd61b0f20babe5586927bb964fa", "storageSha256": "20f99119866cdbe8d27be9ddeda51d45caa215e20ca5da3661157cf607443074" } }, From d13f633a5f7d16ca81b386fea6cf6e2002e5d8fe Mon Sep 17 00:00:00 2001 From: user Date: Mon, 21 Sep 2026 15:06:21 +0300 Subject: [PATCH 08/11] feat: add canonical Pyth decoding and feed-specific confidence limits --- README.md | 71 ++++++++- .../deploy_result/deploy.shadownet.json | 6 + compiled-contract-hashes.json | 2 +- contracts/TezFinOracle.py | 142 ++++++++---------- contracts/tests/ComptrollerTest.py | 25 +++ contracts/tests/TezFinOracleTest.py | 44 ++++-- .../tests/fixtures/pyth_abi_fixtures_test.py | 136 +++++++++++++++-- deploy/deploy_script/configure_pyth_oracle.js | 64 +++++++- docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md | 99 ++++++++++++ 9 files changed, 474 insertions(+), 115 deletions(-) create mode 100644 docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md diff --git a/README.md b/README.md index 5aa72a1f..92f7f642 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ mandatory and must be reproduced by the deployment runner and any governance pay originate TezFinOracle -> setPythCore(pythCoreEvmAddress) -> setPythMaxAge(maxAgeWord) - -> setFeedIds([{asset, feedId, targetDecimals}, ...]) + -> setFeedIds([{asset, feedId, targetDecimals, maxConfidenceBps}, ...]) -> configurePriceBounds(...) (per Comptroller/cToken) -> configureMaxPriceAge(...) (per Comptroller) -> enable market (supportMarket / unpause) @@ -367,12 +367,36 @@ This order and every error in the table above are covered by ### Pyth confidence and proxy risk policy -The production oracle accepts a Pyth update only when the confidence interval is -no more than 25% of the raw price: - -```text -conf * 4 <= rawPrice -``` +Each Pyth feed carries its own mandatory confidence limit (`maxConfidenceBps`), stored as +part of that feed's `setFeedIds` entry. There is no shared/implicit fallback limit: a feed +cannot be pinned at all without an explicit basis-points value in `(0, 10000]` +(`INVALID_PYTH_CONFIDENCE_LIMIT` otherwise -- `0` is rejected too, since it would make the +feed permanently unusable rather than "unconfigured"), and `getPrice` rejects a quote as +`EXCESSIVE_PYTH_CONFIDENCE` whenever `conf * 10000 > rawPrice * maxConfidenceBps` for that +feed. + +The limits below are **proposed TezFin starting policy values**, not Pyth-prescribed +defaults, and are not a substitute for measured confidence/price ratios: + +| Feed | Limit | Basis points | +|---|---:|---:| +| BTC/USD | 0.25% | 25 | +| XTZ/USD | 0.50% | 50 | +| USDT/USD | 0.10% | 10 | + +No feed/market may be activated on mainnet using these starting values alone; final +production approval requires empirical per-feed confidence/price ratio measurements +and explicit governance sign-off. `tzBTC-USD` inherits BTC/USD's limit and +`USDtz-USD`/`USDt-USD` inherit USDT/USD's limit, since they resolve to the same underlying +feed (see proxy policy below). + +`deploy/deploy_script/configure_pyth_oracle.js` enforces this at the deployment-tooling +level: it reads `maxConfidenceBps` from the manifest's `PythConfidenceLimitsBps` (never +hard-coded in the script) and refuses to run `setFeedIds` unless the manifest also sets +`PythConfidenceLimitsApproved: true`. On the mainnet network profile there is no override. +On any other profile, a one-off smoke test may bypass the gate with +`ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1`, which prints a loud warning and must never be +treated as governance approval. The L2 asset mappings below are explicit proxies, not independent price feeds: @@ -383,6 +407,39 @@ The L2 asset mappings below are explicit proxies, not independent price feeds: These proxy mappings must be treated as a governance and risk-policy decision; they are not evidence that the wrapped asset maintains its intended peg. +### Rejected quotes and proxy-market activation policy + +`TezFinOracle` fails closed on stale, malformed, future, non-positive, out-of-range, +or excessive-confidence Pyth quotes. The resulting behavior is operation-specific: + +| Operation/path | Behavior when a required quote is rejected | +|---|---| +| Mint | Fails during the price/liquidity refresh path; no market action is authorized. | +| Borrow | Fails during the required price/liquidity refresh path; no borrow is authorized. | +| Redeem | Fails when the required account snapshot/liquidity path cannot be refreshed. | +| Repay | Remains available as a recovery operation; it does not require a new collateral price. | +| Liquidation | Fails when the borrower liquidity snapshot cannot be refreshed or is invalid. | +| Transfer | Fails when the transfer requires a collateral/liquidity check that cannot be refreshed. | + +Repayment remaining available is intentional: it lets users reduce debt during an oracle +incident. It must not be interpreted as proof that collateral valuation is available. A +liquidation cannot use a stale or invalid liquidity snapshot, and must wait for a successful +price/liquidity refresh. + +The `tzBTC -> BTC/USD` and `USDtz/USDt -> USDT/USD` mappings do not authorize those proxy +markets for production. Before activation, each proxy market requires a separate governance +approval recording: + +- the approved underlying feed and confidence limit; +- price bounds and maximum-change policy; +- an independent depeg monitor for `tzBTC/BTC`, `USDtz/USDT`, or `USDt/USDT`; +- alert and emergency actions, including pausing new mint/borrow and any additional + price-dependent actions required by governance; +- the accountable owner and approval record. + +Until those conditions are approved and verified, proxy-priced markets remain disabled even +if the underlying Pyth feed is fresh and within its confidence limit. + ## Post-Deployment Admin Handoff (Mainnet) After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the diff --git a/TezFinBuild/deploy_result/deploy.shadownet.json b/TezFinBuild/deploy_result/deploy.shadownet.json index 30dddccb..dfa406fd 100644 --- a/TezFinBuild/deploy_result/deploy.shadownet.json +++ b/TezFinBuild/deploy_result/deploy.shadownet.json @@ -12,6 +12,12 @@ "XTZ_USD": "0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03", "USDT_USD": "0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b" }, + "PythConfidenceLimitsBps": { + "BTC_USD": 25, + "XTZ_USD": 50, + "USDT_USD": 10 + }, + "PythConfidenceLimitsApproved": false, "PriceOracle": "KT1FVzw3ogGSq4Djde17MJqVKd6DZReo8MNb", "USDt": "KT1JhouNkvVaHhY9hc9yCN8zcqa6uEfoCY9y", "USDtz": "KT1KgUM85JcH3eAFqWNjhsgfRmKcAeds9X4r", diff --git a/compiled-contract-hashes.json b/compiled-contract-hashes.json index dd1786bb..b97fb83c 100644 --- a/compiled-contract-hashes.json +++ b/compiled-contract-hashes.json @@ -37,7 +37,7 @@ "storageSha256": "8375a124534108c02419eed14ebc338c6d746b91031dc0ed7ad965d5b3157cab" }, "TezFinOracle": { - "contractSha256": "ea59546dc3562286c3c3ed0c43ff520df3ee2bd61b0f20babe5586927bb964fa", + "contractSha256": "1e811edf5d0876fe972e713820a3e8c142ff915fef9dbdfe4b4d8e4e6037452f", "storageSha256": "20f99119866cdbe8d27be9ddeda51d45caa215e20ca5da3661157cf607443074" } }, diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index d7f6538e..f484740f 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -11,7 +11,10 @@ DEFAULT_PYTH_MAX_AGE_WORD = sp.bytes( "0x" + (60).to_bytes(32, "big").hex()) -TPythFeedConfig = sp.TRecord(feedId=sp.TBytes, targetDecimals=sp.TNat) +# maxConfidenceBps is mandatory (no default/implicit limit): a feed can only be pinned by +# setFeedIds together with an explicit basis-points confidence limit, so there is no way to +# configure or activate a feed that resolves prices without one. +TPythFeedConfig = sp.TRecord(feedId=sp.TBytes, targetDecimals=sp.TNat, maxConfidenceBps=sp.TNat) # Valid hex digits for EVM address validation (setPythCore). HEX_CHARS = sp.set(l=[c for c in "0123456789abcdefABCDEF"]) @@ -20,12 +23,21 @@ # admin-set value from causing an unbounded/gas-heavy normalization loop. MAX_TARGET_DECIMALS = 30 +# Basis-points denominator; also the maximum admin-settable maxConfidenceBps (100%). +BPS_DENOMINATOR = 10000 + # SmartPy's `sp.to_int`/INT does not accept `bytes` operands in this toolchain, so ABI words are # decoded manually via a pinned single-byte lookup table (big-endian, one MUL+ADD per byte). BYTE_TO_NAT = sp.map( l={sp.bytes("0x%02x" % i): i for i in range(256)}, tkey=sp.TBytes, tvalue=sp.TNat) -TWO_POW_256 = sp.nat(2 ** 256) +# Canonical two's-complement sign-extension bounds for validating ABI padding without a +# per-byte loop: an N-bit signed value sign-extended into 256 bits is exactly the set of +# unsigned 256-bit values below 2**N (non-negative) or at/above 2**256-2**N (negative). +TWO_POW_32 = sp.nat(2 ** 32) +TWO_POW_64 = sp.nat(2 ** 64) +NEG_THRESHOLD_32 = sp.nat(2 ** 256 - 2 ** 32) +NEG_THRESHOLD_64 = sp.nat(2 ** 256 - 2 ** 64) class TezFinOracle(OracleInterface.OracleInterface): """ @@ -117,82 +129,51 @@ def removeAlias(self, asset): del self.data.alias[asset] def _decodeUnsignedWord(self, word): - """ - Decodes a 32-byte big-endian ABI word into a nat, one byte at a time - """ - unsignedAcc = sp.local("unsignedAcc", sp.nat(0)) - unsignedIndex = sp.local("unsignedIndex", sp.nat(0)) - sp.while unsignedIndex.value < 32: - currentByte = sp.slice(word, unsignedIndex.value, 1).open_some( - "MALFORMED_PYTH_RESPONSE") - unsignedAcc.value = unsignedAcc.value * 256 + BYTE_TO_NAT[currentByte] - unsignedIndex.value += 1 - return unsignedAcc.value + """Decodes a 32-byte big-endian ABI word into a nat.""" + sp.verify(sp.len(word) == 32, "MALFORMED_PYTH_RESPONSE") + value = sp.local("value", sp.nat(0)) + i = sp.local("i", sp.nat(0)) + sp.while i.value < 32: + currentByte = sp.slice(word, i.value, 1).open_some("MALFORMED_PYTH_RESPONSE") + value.value = value.value * 256 + BYTE_TO_NAT[currentByte] + i.value += 1 + return value.value def _decodeUint64Word(self, word): - """Decodes the right-aligned uint64 payload used by Pyth confidence.""" - confidenceAcc = sp.local("confidenceAcc", sp.nat(0)) - confidenceIndex = sp.local("confidenceIndex", sp.nat(0)) - sp.while confidenceIndex.value < 8: - currentByte = sp.slice(word, 24 + confidenceIndex.value, 1).open_some( - "MALFORMED_PYTH_RESPONSE") - confidenceAcc.value = confidenceAcc.value * 256 + BYTE_TO_NAT[currentByte] - confidenceIndex.value += 1 - return confidenceAcc.value + """ + Decodes the right-aligned uint64 payload used by Pyth confidence. As an unsigned + ABI type its 24 high bytes must always be zero (no sign-extension is valid here); + canonicity is equivalent to the full 256-bit value fitting under 2**64. + """ + value = self._decodeUnsignedWord(word) + sp.verify(value < TWO_POW_64, "MALFORMED_PYTH_RESPONSE") + return value def _decodePriceWord(self, word): - """Decodes Pyth's positive int64 price from the first ABI word.""" - priceSignByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") - sp.verify(BYTE_TO_NAT[priceSignByte] < 128, "NON_POSITIVE_PYTH_PRICE") - priceAcc = sp.local("priceAcc", sp.nat(0)) - priceIndex = sp.local("priceIndex", sp.nat(0)) - sp.while priceIndex.value < 8: - currentByte = sp.slice(word, 24 + priceIndex.value, 1).open_some( - "MALFORMED_PYTH_RESPONSE") - priceAcc.value = priceAcc.value * 256 + BYTE_TO_NAT[currentByte] - priceIndex.value += 1 - return priceAcc.value + """ + Decodes a canonical Pyth int64 price. Reinterpreting the whole 256-bit word as + two's complement, a canonical 64-bit sign-extension is exactly the set of values + that fit under 2**64 (non-negative) or sit at/above 2**256-2**64 (negative); any + other value has inconsistent padding bytes and is rejected as malformed. + """ + value = self._decodeUnsignedWord(word) + sp.verify((value < TWO_POW_64) | (value >= NEG_THRESHOLD_64), "MALFORMED_PYTH_RESPONSE") + sp.verify(value < TWO_POW_64, "NON_POSITIVE_PYTH_PRICE") + sp.verify(value > 0, "NON_POSITIVE_PYTH_PRICE") + return value def _decodeExponentWord(self, word): - """Decodes Pyth's sign-extended int32 exponent.""" - exponentSignByte = sp.slice(word, 28, 1).open_some("MALFORMED_PYTH_RESPONSE") - exponentAcc = sp.local("exponentAcc", sp.nat(0)) - exponentIndex = sp.local("exponentIndex", sp.nat(0)) - sp.while exponentIndex.value < 4: - currentByte = sp.slice(word, 28 + exponentIndex.value, 1).open_some( - "MALFORMED_PYTH_RESPONSE") - exponentAcc.value = exponentAcc.value * 256 + BYTE_TO_NAT[currentByte] - exponentIndex.value += 1 - exponentResult = sp.local("exponentResult", sp.int(0)) - sp.if BYTE_TO_NAT[exponentSignByte] >= 128: - exponentResult.value = sp.to_int(exponentAcc.value) - sp.to_int(2 ** 32) - sp.else: - exponentResult.value = sp.to_int(exponentAcc.value) - return exponentResult.value + """Decodes a canonical Pyth int32 exponent with sign-extension validation (see + _decodePriceWord for the canonicity argument, applied here with a 32-bit width).""" + value = self._decodeUnsignedWord(word) + sp.verify((value < TWO_POW_32) | (value >= NEG_THRESHOLD_32), "MALFORMED_PYTH_RESPONSE") + sp.if value >= NEG_THRESHOLD_32: + return sp.to_int(value) - sp.to_int(TWO_POW_32) + return sp.to_int(value) def _decodePublishTimeWord(self, word): - """Decodes Pyth's uint256 publish time with field-specific locals.""" - publishAcc = sp.local("publishAcc", sp.nat(0)) - publishIndex = sp.local("publishIndex", sp.nat(0)) - sp.while publishIndex.value < 32: - currentByte = sp.slice(word, publishIndex.value, 1).open_some( - "MALFORMED_PYTH_RESPONSE") - publishAcc.value = publishAcc.value * 256 + BYTE_TO_NAT[currentByte] - publishIndex.value += 1 - return publishAcc.value - - def _decodeSignedWord(self, word): - """ - Decodes a 32-byte big-endian, sign-extended two's complement ABI word into an int - """ - signedUnsignedValue = self._decodeUnsignedWord(word) - signedSignByte = sp.slice(word, 0, 1).open_some("MALFORMED_PYTH_RESPONSE") - signedResult = sp.local("signedResult", sp.int(0)) - sp.if BYTE_TO_NAT[signedSignByte] >= 128: - signedResult.value = sp.to_int(signedUnsignedValue) - sp.to_int(TWO_POW_256) - sp.else: - signedResult.value = sp.to_int(signedUnsignedValue) - return signedResult.value + """Decodes Pyth's uint256 publish time.""" + return self._decodeUnsignedWord(word) @sp.entry_point def configurePriceBounds(self, params): @@ -245,17 +226,25 @@ def setPythMaxAge(self, word): @sp.entry_point def setFeedIds(self, params): """ - Pins Pyth feed ids and their target nat precision (decimals) per base asset symbol + Pins Pyth feed ids, their target nat precision (decimals) and a mandatory + per-feed max confidence limit (basis points of raw price) per base asset symbol """ sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") sp.set_type(params, sp.TList(sp.TRecord( - asset=sp.TString, feedId=sp.TBytes, targetDecimals=sp.TNat))) + asset=sp.TString, feedId=sp.TBytes, targetDecimals=sp.TNat, + maxConfidenceBps=sp.TNat))) sp.for item in params: sp.verify(sp.len(item.feedId) == 32, "INVALID_PYTH_FEED_ID") sp.verify(item.targetDecimals <= MAX_TARGET_DECIMALS, "INVALID_TARGET_DECIMALS") + # A limit of 0 would make the feed permanently unusable (rejecting every quote), + # which is indistinguishable from "no approved limit" -- disallow it outright + # rather than let it silently masquerade as a configured feed. + sp.verify((item.maxConfidenceBps > 0) & (item.maxConfidenceBps <= BPS_DENOMINATOR), + "INVALID_PYTH_CONFIDENCE_LIMIT") self.data.feedIds[item.asset] = sp.record( - feedId=item.feedId, targetDecimals=item.targetDecimals) + feedId=item.feedId, targetDecimals=item.targetDecimals, + maxConfidenceBps=item.maxConfidenceBps) @sp.entry_point def removeFeedId(self, asset): @@ -326,9 +315,10 @@ def _resolvePythPrice(self, requestedAsset): sp.verify(publishTimestamp <= sp.now, "FUTURE_PYTH_PUBLISH_TIME") priceNat = sp.as_nat(rawPrice, message="NON_POSITIVE_PYTH_PRICE") - # Fail closed if the reported confidence interval exceeds 25% of the price. - # Compare against floor(price / 4) to avoid multiplying an ABI-decoded nat. - sp.verify(rawConf <= priceNat // 4, "EXCESSIVE_PYTH_CONFIDENCE") + # Fail closed if the reported confidence interval exceeds this feed's own mandatory + # limit (no shared/implicit fallback limit across feeds). + sp.verify(rawConf * BPS_DENOMINATOR <= priceNat * feedConfig.maxConfidenceBps, + "EXCESSIVE_PYTH_CONFIDENCE") # normalizedPrice = price * 10^(expo + targetDecimals), using integer arithmetic only. decimalShift = rawExpo + sp.to_int(feedConfig.targetDecimals) diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 22d8d573..51566033 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -440,6 +440,31 @@ def test(): sender = bob, level = bLevel.next(), now = sp.timestamp(100), valid = False, exception = "ASSET_PRICE_TIMESTAMP_ROLLBACK") oracle.clearTimestamp() + + scenario.h3("Rejected price blocks liquidity refresh and liquidation, but not repayment") + # Repayment intentionally remains available during an oracle incident so a + # borrower can reduce exposure while price-dependent liquidity checks fail closed. + oracle.setTimestamp(sp.timestamp(100)) + updateAssetsPrices(scenario, cmpt, bLevel, marketsList) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run( + sender=alice, level=bLevel.next(), now=sp.timestamp(100)) + scenario += cmpt.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(60))).run( + sender=admin, level=bLevel.next()) + oracle.setTimestamp(sp.timestamp(1)) + scenario += cmpt.repayBorrowAllowed(repayBorrowArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next(), now=sp.timestamp(100)) + scenario += cmpt.updateAssetPricesWithView(sp.set([listedMarket])).run( + sender=alice, level=bLevel.next(), now=sp.timestamp(100), valid=False, + exception="STALE_ASSET_PRICE") + scenario += cmpt.liquidateBorrowAllowed(liquidateArg).run( + sender=listedMarket, level=bLevel.next(), valid=False, + exception=CMPT.EC.CMPT_UPDATE_PRICE) + scenario += cmpt.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=bLevel.next()) + oracle.setTimestamp(sp.timestamp(100)) + scenario.h3("Reject extreme prices outside configured bounds") scenario += cmpt.setPriceBounds(sp.record(cToken=listedMarket, minPrice=sp.nat(100000), maxPrice=sp.nat(10000000), diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index a1045237..9a5b29a9 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -14,6 +14,11 @@ "0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03") USDT_FEED_ID = sp.bytes( "0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b") +# Proposed TezFin per-feed confidence limits (basis points), see README "Pyth confidence +# and proxy risk policy": these are starting policy values, not Pyth-prescribed limits. +BTC_MAX_CONFIDENCE_BPS = sp.nat(25) +XTZ_MAX_CONFIDENCE_BPS = sp.nat(50) +USDT_MAX_CONFIDENCE_BPS = sp.nat(10) class View_consumer(sp.Contract): @@ -141,7 +146,8 @@ def test(): sender=alice, valid=False, exception="NOT_ADMIN") tezfinOracle.setPythMaxAge(PYTH_MAX_AGE_WORD).run( sender=alice, valid=False, exception="NOT_ADMIN") - tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6))] + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=XTZ_MAX_CONFIDENCE_BPS)] ).run(sender=alice, valid=False, exception="NOT_ADMIN") tezfinOracle.removeFeedId("XTZ").run( sender=alice, valid=False, exception="NOT_ADMIN") @@ -153,10 +159,18 @@ def test(): sender=admin, valid=False, exception="INVALID_PYTH_MAX_AGE_RANGE") tezfinOracle.setPythMaxAge(sp.bytes("0x" + (3601).to_bytes(32, "big").hex())).run( sender=admin, valid=False, exception="INVALID_PYTH_MAX_AGE_RANGE") - tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=sp.bytes("0x00"), targetDecimals=sp.nat(6))] + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=sp.bytes("0x00"), targetDecimals=sp.nat(6), + maxConfidenceBps=XTZ_MAX_CONFIDENCE_BPS)] ).run(sender=admin, valid=False, exception="INVALID_PYTH_FEED_ID") - tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(31))] + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(31), + maxConfidenceBps=XTZ_MAX_CONFIDENCE_BPS)] ).run(sender=admin, valid=False, exception="INVALID_TARGET_DECIMALS") + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=sp.nat(10001))] + ).run(sender=admin, valid=False, exception="INVALID_PYTH_CONFIDENCE_LIMIT") + tezfinOracle.setFeedIds([sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=sp.nat(0))] + ).run(sender=admin, valid=False, exception="INVALID_PYTH_CONFIDENCE_LIMIT") scenario.h3("setPythCore rejects malformed EVM addresses") tezfinOracle.setPythCore("not-an-address").run( @@ -178,9 +192,12 @@ def test(): scenario.h3("Pin Pyth core address and native feed ids") tezfinOracle.setFeedIds([ - sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8)), - sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6)), - sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8), + maxConfidenceBps=BTC_MAX_CONFIDENCE_BPS), + sp.record(asset="XTZ", feedId=XTZ_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=XTZ_MAX_CONFIDENCE_BPS), + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=USDT_MAX_CONFIDENCE_BPS), ]).run(sender=admin) scenario.h3("getPrice fails closed before the Pyth core address is configured") @@ -229,7 +246,8 @@ def test(): # and re-adding it must unblock both again -- proving they share the exact same feed # config rather than merely reaching the same generic staticcall failure. tezfinOracle.setFeedIds([ - sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=USDT_MAX_CONFIDENCE_BPS), ]).run(sender=admin) # Note: plain "BTC-USD" already has an admin override set earlier in this scenario, so # it short-circuits before the feedIds lookup and can't be used to probe the feed itself; @@ -241,7 +259,8 @@ def test(): consumer.getPrice(asset="tzBTC", resp=0).run( valid=False, exception="UNSUPPORTED_PYTH_ASSET") tezfinOracle.setFeedIds([ - sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8)), + sp.record(asset="BTC", feedId=BTC_FEED_ID, targetDecimals=sp.nat(8), + maxConfidenceBps=BTC_MAX_CONFIDENCE_BPS), ]).run(sender=admin) scenario.h3("After re-adding BTC: tzBTC reaches the staticcall again") consumer.getPrice(asset="tzBTC", resp=0).run(valid=False) @@ -255,7 +274,8 @@ def test(): consumer.getPrice(asset="USDT", resp=0).run( valid=False, exception="UNSUPPORTED_PYTH_ASSET") tezfinOracle.setFeedIds([ - sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=USDT_MAX_CONFIDENCE_BPS), ]).run(sender=admin) consumer.getPrice(asset="USDtz", resp=0).run(valid=False) consumer.getPrice(asset="USDt", resp=0).run(valid=False) @@ -265,14 +285,16 @@ def test(): # setFeedIds keys by symbol, not by feedId, so pinning USDT_FEED_ID under a typo'd key # must not make it resolvable under the real "USDT" symbol used by getPrice. tezfinOracle.setFeedIds([ - sp.record(asset="USDT_TYPO", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="USDT_TYPO", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=USDT_MAX_CONFIDENCE_BPS), ]).run(sender=admin) tezfinOracle.removeFeedId("USDT").run(sender=admin) consumer.getPrice(asset="USDT", resp=0).run( valid=False, exception="UNSUPPORTED_PYTH_ASSET") tezfinOracle.removeFeedId("USDT_TYPO").run(sender=admin) tezfinOracle.setFeedIds([ - sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6)), + sp.record(asset="USDT", feedId=USDT_FEED_ID, targetDecimals=sp.nat(6), + maxConfidenceBps=USDT_MAX_CONFIDENCE_BPS), ]).run(sender=admin) scenario.h2("Staged activation order") diff --git a/contracts/tests/fixtures/pyth_abi_fixtures_test.py b/contracts/tests/fixtures/pyth_abi_fixtures_test.py index 692fdceb..6ba83c83 100644 --- a/contracts/tests/fixtures/pyth_abi_fixtures_test.py +++ b/contracts/tests/fixtures/pyth_abi_fixtures_test.py @@ -31,7 +31,14 @@ WORD_LEN = 32 TWO_POW_256 = 2 ** 256 +TWO_POW_32 = 2 ** 32 MAX_TARGET_DECIMALS = 30 +BPS_DENOMINATOR = 10_000 +# Proposed TezFin per-feed confidence limits (basis points); see README "Pyth confidence +# and proxy risk policy" -- starting policy values, not Pyth-prescribed, pending approval. +BTC_MAX_CONFIDENCE_BPS = 25 +XTZ_MAX_CONFIDENCE_BPS = 50 +USDT_MAX_CONFIDENCE_BPS = 10 class PythFixtureError(Exception): @@ -68,10 +75,13 @@ def decode_signed_word(word: bytes) -> int: def decode_positive_price_word(word: bytes) -> int: if len(word) != WORD_LEN: raise PythFixtureError("MALFORMED_PYTH_RESPONSE") - if word[0] >= 128: - raise PythFixtureError("NON_POSITIVE_PYTH_PRICE") + sign_byte = word[24] + expected_padding = 0xFF if sign_byte >= 128 else 0x00 + for i in range(24): + if word[i] != expected_padding: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") value = int.from_bytes(word[24:], "big", signed=False) - if value <= 0: + if sign_byte >= 128 or value <= 0: raise PythFixtureError("NON_POSITIVE_PYTH_PRICE") return value @@ -79,13 +89,24 @@ def decode_positive_price_word(word: bytes) -> int: def decode_confidence_word(word: bytes) -> int: if len(word) != WORD_LEN: raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + for i in range(24): + if word[i] != 0x00: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") return int.from_bytes(word[24:], "big", signed=False) def decode_exponent_word(word: bytes) -> int: if len(word) != WORD_LEN: raise PythFixtureError("MALFORMED_PYTH_RESPONSE") - return int.from_bytes(word[28:], "big", signed=True) + sign_byte = word[28] + expected_padding = 0xFF if sign_byte >= 128 else 0x00 + for i in range(28): + if word[i] != expected_padding: + raise PythFixtureError("MALFORMED_PYTH_RESPONSE") + unsigned_value = int.from_bytes(word[28:], "big", signed=False) + if sign_byte >= 128: + return unsigned_value - TWO_POW_32 + return unsigned_value def build_response(price: int, conf: int, expo: int, publish_time: int) -> bytes: @@ -100,7 +121,7 @@ def build_response(price: int, conf: int, expo: int, publish_time: int) -> bytes ) -def resolve_price(response: bytes, target_decimals: int, now: int) -> int: +def resolve_price(response: bytes, target_decimals: int, max_confidence_bps: int, now: int) -> int: """Mirrors `_resolvePythPrice`'s decode/validate/normalize steps (post-staticcall_evm).""" if len(response) != 128: raise PythFixtureError("MALFORMED_PYTH_RESPONSE") @@ -125,7 +146,9 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: raise PythFixtureError("FUTURE_PYTH_PUBLISH_TIME") price_nat = raw_price # already verified > 0 - if raw_conf * 4 > price_nat: + # No shared/implicit limit: max_confidence_bps must be the specific feed's own mandatory, + # admin-configured limit (bounded to <= BPS_DENOMINATOR by setFeedIds). + if raw_conf * BPS_DENOMINATOR > price_nat * max_confidence_bps: raise PythFixtureError("EXCESSIVE_PYTH_CONFIDENCE") decimal_shift = raw_expo + target_decimals @@ -154,6 +177,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="positive BTC price (targetDecimals=8)", response=build_response(price=6_000_000_000, conf=1_000_000, expo=-2, publish_time=NOW - 5), target_decimals=8, + max_confidence_bps=BTC_MAX_CONFIDENCE_BPS, expect_ok=True, expected_price=6_000_000_000 * 10 ** 6, # decimalShift = -2+8=6 ), @@ -161,6 +185,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="positive USDT price (targetDecimals=6)", response=build_response(price=100_010_000, conf=5_000, expo=-8, publish_time=NOW - 2), target_decimals=6, + max_confidence_bps=USDT_MAX_CONFIDENCE_BPS, expect_ok=True, expected_price=100_010_000 // 100, # decimalShift = -8+6=-2 ), @@ -168,6 +193,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="positive XTZ price (targetDecimals=6)", response=build_response(price=850_000, conf=200, expo=-6, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=XTZ_MAX_CONFIDENCE_BPS, expect_ok=True, expected_price=850_000, # decimalShift = -6+6=0 ), @@ -175,6 +201,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="negative signed price", response=build_response(price=-42, conf=1, expo=-2, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="NON_POSITIVE_PYTH_PRICE", ), @@ -182,6 +209,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="zero price", response=build_response(price=0, conf=0, expo=-2, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="NON_POSITIVE_PYTH_PRICE", ), @@ -189,6 +217,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="negative exponent (valid, in-range)", response=build_response(price=123_456, conf=10, expo=-5, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=True, expected_price=123_456 * 10, # decimalShift = -5+6=1 ), @@ -196,6 +225,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="invalid exponent (out of [-30, 0] range)", response=build_response(price=123_456, conf=10, expo=1, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="INVALID_PYTH_EXPONENT", ), @@ -203,6 +233,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="invalid exponent (below -30)", response=build_response(price=123_456, conf=10, expo=-31, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="INVALID_PYTH_EXPONENT", ), @@ -210,13 +241,15 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="future timestamp", response=build_response(price=123_456, conf=10, expo=-6, publish_time=NOW + 3600), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="FUTURE_PYTH_PUBLISH_TIME", ), dict( - name="excessive confidence (>25% of price)", + name="excessive confidence (>25% of price, explicit 25% feed limit)", response=build_response(price=100_000, conf=30_000, expo=-6, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=2_500, # 25% expressed as an explicit, non-implicit feed limit expect_ok=False, expected_error="EXCESSIVE_PYTH_CONFIDENCE", ), @@ -224,6 +257,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="normalized price rounds to zero (excessive negative decimalShift)", response=build_response(price=1, conf=0, expo=-30, publish_time=NOW - 1), target_decimals=0, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="ZERO_NORMALIZED_PYTH_PRICE", ), @@ -231,6 +265,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="malformed/truncated response (< 128 bytes)", response=build_response(price=1, conf=0, expo=-6, publish_time=NOW - 1)[:100], target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="MALFORMED_PYTH_RESPONSE", ), @@ -238,6 +273,7 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: name="malformed/truncated response (empty)", response=b"", target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=False, expected_error="MALFORMED_PYTH_RESPONSE", ), @@ -247,40 +283,108 @@ def resolve_price(response: bytes, target_decimals: int, now: int) -> int: + encode_uint_word(0) + encode_int_word(-2) + encode_uint_word(NOW - 1)), target_decimals=6, - expect_ok=True, - expected_price=42 * 10 ** 4, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", ), dict( name="malformed confidence padding", response=(encode_uint_word(42) + b"\x01" + b"\x00" * 23 + b"\x00" * 8 + encode_int_word(-2) + encode_uint_word(NOW - 1)), target_decimals=6, - expect_ok=True, - expected_price=42 * 10 ** 4, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", ), dict( - name="valid negative exponent sign extension", + name="canonical negative exponent word is accepted before range validation", response=build_response(price=42, conf=1, expo=-2, publish_time=NOW - 1), target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, expect_ok=True, expected_price=42 * 10 ** 4, ), + dict( + name="canonical positive exponent word is accepted before range validation", + response=build_response(price=42, conf=1, expo=1, publish_time=NOW - 1), + target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="INVALID_PYTH_EXPONENT", + ), dict( name="malformed exponent sign extension", response=(encode_uint_word(42) + encode_uint_word(1) - + b"\x00" * 28 + b"\xff\xff\xff\xfe" + + b"\x00" * 27 + b"\x00\x00\x00\x01" + encode_uint_word(NOW - 1)), target_decimals=6, - expect_ok=True, - expected_price=42 * 10 ** 4, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", ), ] +# --------------------------------------------------------------------------- +# Feed-specific confidence boundary fixtures (ТЗ section 2). price=10_000 and expo=-4 +# with targetDecimals=6 (decimalShift=2) reproduce the doc's literal worked boundary +# values (BTC 24/25/26, XTZ 49/50/51, USDT 9/10/11) exactly, since at price=10_000 the +# per-feed bps threshold collapses to `rawConf <= maxConfidenceBps` itself. +# --------------------------------------------------------------------------- + +BOUNDARY_PRICE = 10_000 +BOUNDARY_EXPO = -4 +BOUNDARY_TARGET_DECIMALS = 6 +BOUNDARY_EXPECTED_PRICE = BOUNDARY_PRICE * 10 ** (BOUNDARY_EXPO + BOUNDARY_TARGET_DECIMALS) + + +def _boundary_response(conf: int) -> bytes: + return build_response(price=BOUNDARY_PRICE, conf=conf, expo=BOUNDARY_EXPO, publish_time=NOW - 1) + + +for _feed_name, _bps in ( + ("BTC", BTC_MAX_CONFIDENCE_BPS), + ("XTZ", XTZ_MAX_CONFIDENCE_BPS), + ("USDT", USDT_MAX_CONFIDENCE_BPS), +): + FIXTURES.append(dict( + name=f"{_feed_name} confidence boundary: one unit below limit ({_bps - 1}) accepted", + response=_boundary_response(_bps - 1), + target_decimals=BOUNDARY_TARGET_DECIMALS, + max_confidence_bps=_bps, + expect_ok=True, + expected_price=BOUNDARY_EXPECTED_PRICE, + )) + FIXTURES.append(dict( + name=f"{_feed_name} confidence boundary: exactly at limit ({_bps}) accepted", + response=_boundary_response(_bps), + target_decimals=BOUNDARY_TARGET_DECIMALS, + max_confidence_bps=_bps, + expect_ok=True, + expected_price=BOUNDARY_EXPECTED_PRICE, + )) + FIXTURES.append(dict( + name=f"{_feed_name} confidence boundary: one unit above limit ({_bps + 1}) rejected", + response=_boundary_response(_bps + 1), + target_decimals=BOUNDARY_TARGET_DECIMALS, + max_confidence_bps=_bps, + expect_ok=False, + expected_error="EXCESSIVE_PYTH_CONFIDENCE", + )) + FIXTURES.append(dict( + name=f"{_feed_name}: a 25% confidence quote is rejected under its own feed limit", + response=build_response(price=1_000_000, conf=250_000, expo=-6, publish_time=NOW - 1), + target_decimals=6, + max_confidence_bps=_bps, + expect_ok=False, + expected_error="EXCESSIVE_PYTH_CONFIDENCE", + )) + def run_fixture(fixture: dict) -> str: name = fixture["name"] try: - result = resolve_price(fixture["response"], fixture["target_decimals"], NOW) + result = resolve_price(fixture["response"], fixture["target_decimals"], + fixture["max_confidence_bps"], NOW) except PythFixtureError as exc: if fixture["expect_ok"]: return f"FAIL [{name}]: expected success but got error {exc}" diff --git a/deploy/deploy_script/configure_pyth_oracle.js b/deploy/deploy_script/configure_pyth_oracle.js index 577be621..c9480efd 100644 --- a/deploy/deploy_script/configure_pyth_oracle.js +++ b/deploy/deploy_script/configure_pyth_oracle.js @@ -11,7 +11,16 @@ * commit it. * - The manifest (TezFinBuild/deploy_result/deploy.shadownet.json by default, override * via DEPLOY_MANIFEST) to already contain PythCore / PythMaxAgeSeconds / PythFeedIds / - * TezFinOracle. + * PythConfidenceLimitsBps. + * + * Confidence limits (maxConfidenceBps) are read from the manifest's + * `PythConfidenceLimitsBps` field, NOT hard-coded here, so the manifest is the single + * reproducible source of the activated configuration. They are only ever sent on-chain + * when the manifest also sets `PythConfidenceLimitsApproved: true` (i.e. governance/risk + * sign-off has been recorded for those specific values). On mainnet that flag is mandatory + * and there is no override. On non-mainnet profiles only, an operator may bypass it for a + * one-off smoke test by setting ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1, which prints a loud + * warning and must never be used to justify activating a market. * * Usage: * DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.shadownet.json \ @@ -26,10 +35,14 @@ * PRICE_MIN / PRICE_MAX / PRICE_MAX_CHANGE_BPS / MAX_PRICE_AGE_SECONDS * - override the smoke-test price bounds (defaults are wide-open * bounds so real Pyth-derived prices pass validation). + * ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1 + * - non-mainnet only; bypasses the PythConfidenceLimitsApproved gate. */ const fs = require('fs'); const { config, createTezosClient, resolveDeployResultPath } = require('./util.js'); +const REQUIRED_CONFIDENCE_FEEDS = ['BTC_USD', 'XTZ_USD', 'USDT_USD']; + function encodeUintWord(value) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`Cannot encode negative/unsafe integer as a uint256 word: ${value}`); @@ -43,6 +56,46 @@ async function confirm(operation, label) { console.log(`[INFO] Confirmed ${label}`); } +// Resolves the per-feed maxConfidenceBps values to activate, refusing to run unless the +// manifest marks them approved (or, on non-mainnet only, an explicit operator override). +function resolveApprovedConfidenceLimits(manifest, deployResultPath) { + const limits = manifest.PythConfidenceLimitsBps; + if (!limits) { + throw new Error(`${deployResultPath} is missing PythConfidenceLimitsBps`); + } + const missing = REQUIRED_CONFIDENCE_FEEDS.filter((key) => limits[key] === undefined || limits[key] === null); + if (missing.length > 0) { + throw new Error(`${deployResultPath} PythConfidenceLimitsBps is missing: ${missing.join(', ')}`); + } + + const isMainnet = config.networkProfile === 'mainnet'; + const approved = manifest.PythConfidenceLimitsApproved === true; + if (approved) { + return limits; + } + if (isMainnet) { + throw new Error( + 'PythConfidenceLimitsBps is not approved (PythConfidenceLimitsApproved !== true) and this is ' + + 'the mainnet profile -- there is no override for mainnet. Values proposed in README "Pyth ' + + 'confidence and proxy risk policy" require explicit governance/risk sign-off before ' + + 'PythConfidenceLimitsApproved is set to true in the manifest.', + ); + } + if (process.env.ALLOW_UNAPPROVED_CONFIDENCE_LIMITS !== '1') { + throw new Error( + 'PythConfidenceLimitsBps is not approved (PythConfidenceLimitsApproved !== true) in ' + + `${deployResultPath}. Set PythConfidenceLimitsApproved: true after governance/risk sign-off, ` + + 'or, for a one-off non-mainnet smoke test only, set ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1.', + ); + } + console.warn( + '[WARN] Activating UNAPPROVED confidence limits via ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1 ' + + '(non-mainnet only). This is a smoke-test bypass, not evidence of approval, and must never be ' + + 'used to justify enabling a market.', + ); + return limits; +} + async function main() { const deployResultPath = resolveDeployResultPath(); const manifest = JSON.parse(fs.readFileSync(deployResultPath, 'utf8')); @@ -56,6 +109,7 @@ async function main() { `${deployResultPath} is missing TezFinOracle/PythCore/PythMaxAgeSeconds/PythFeedIds`, ); } + const confidenceLimits = resolveApprovedConfidenceLimits(manifest, deployResultPath); const { tezos, publicKeyHash } = await createTezosClient(); console.log(`[INFO] Configuring TezFinOracle ${oracleAddress} as admin ${publicKeyHash}`); @@ -70,10 +124,12 @@ async function main() { console.log('[INFO] Step 3/5: setFeedIds (BTC, XTZ, USDT)'); // targetDecimals BTC=8, XTZ=6, USDT=6 (matches Comptroller/Governance priceExp conventions). + // maxConfidenceBps comes from the manifest's PythConfidenceLimitsBps (see + // resolveApprovedConfidenceLimits above), never hard-coded here. const feedParams = [ - { asset: 'BTC', feedId: feedIdsManifest.BTC_USD, targetDecimals: 8 }, - { asset: 'XTZ', feedId: feedIdsManifest.XTZ_USD, targetDecimals: 6 }, - { asset: 'USDT', feedId: feedIdsManifest.USDT_USD, targetDecimals: 6 }, + { asset: 'BTC', feedId: feedIdsManifest.BTC_USD, targetDecimals: 8, maxConfidenceBps: confidenceLimits.BTC_USD }, + { asset: 'XTZ', feedId: feedIdsManifest.XTZ_USD, targetDecimals: 6, maxConfidenceBps: confidenceLimits.XTZ_USD }, + { asset: 'USDT', feedId: feedIdsManifest.USDT_USD, targetDecimals: 6, maxConfidenceBps: confidenceLimits.USDT_USD }, ]; await confirm(await oracle.methodsObject.setFeedIds(feedParams).send(), 'setFeedIds'); diff --git a/docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md b/docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md new file mode 100644 index 00000000..e6359213 --- /dev/null +++ b/docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md @@ -0,0 +1,99 @@ +# Pyth Per-Feed Confidence/Price Ratio Measurement + +## Status: NOT YET MEASURED (explicit blocker, not PASS) + +No empirical `conf / abs(price)` samples have been collected for BTC/USD, +XTZ/USD, or USDT/USD yet. The proposed limits in README ("Pyth confidence and +proxy risk policy": BTC=25bps, XTZ=50bps, USDT=10bps) remain **starting +policy values pending this measurement**, not values justified by data. This +report must not be treated as satisfying that requirement until real samples are +collected and analyzed below. + +This mirrors the non-fabrication policy for real Pyth update evidence: +absence of a healthy/available measurement window is recorded as a blocker, +not papered over with assumed numbers. + +## Tooling + +`deploy/deploy_script/measure_pyth_confidence.js` provides a read-only +measurement tool and does not need `TEZOS_PRIVATE_KEY`. The authoritative mode +for this requirement reads the actual on-chain Pyth Core cache, so it covers +BTC/USD, XTZ/USD, and USDT/USD without depending on Hermes feed access: + +```sh +PYTH_EVM_RPC=https://node.mainnet.etherlink.com \ + node deploy/deploy_script/measure_pyth_confidence.js collect-onchain + +node deploy/deploy_script/measure_pyth_confidence.js report \ + --source onchain --bps 25,50,100 \ + --period "2026-XX-XX .. 2026-XX-XX (on-chain normal)" +``` + +The same tool also supports Hermes collection as an optional supplementary +source when an API key and access to the required feeds are available: + +```sh +# Run repeatedly (e.g. a cron/systemd timer) over the required 24-72h normal +# period, and again during any observed high-volatility/stressed period. +PYTH_API_KEY=... node deploy/deploy_script/measure_pyth_confidence.js collect + +# Summarize whatever has been collected so far into the required table: +node deploy/deploy_script/measure_pyth_confidence.js report --bps 25,50,100 \ + --period "2026-XX-XX .. 2026-XX-XX (normal)" +``` + +On-chain samples are appended to per-feed CSV logs under +`TezFinBuild/pyth_onchain_samples/`; Hermes samples use +`TezFinBuild/pyth_confidence_samples/`. Both use +`iso_timestamp, unix_timestamp, +price, conf, expo, publish_time, ratio`). `report` computes p50/p95/p99/max +ratio and rejection counts for one or more candidate bps policies directly +from those logs. + +### Optional Hermes source + +The Pyth Core upgrade completed 2026-08-26 made a Pyth API key +(`Authorization: Bearer $PYTH_API_KEY`) mandatory for every Hermes request, +including the legacy `hermes.pyth.network` host used by this tool by default +(override with `HERMES_BASE_URL=https://pyth.dourolabs.app/hermes` for the +upgraded endpoint). This is an **off-chain measurement tooling** dependency +only: + +- the on-chain Pyth Core contract ABI and `getPriceNoOlderThan` interface are + unchanged by this upgrade, so `contracts/TezFinOracle.py` needs no code + change; +- but any operational Pyth updater/Price Pusher this project runs or relies + on the selected production monitoring plan will also need a Pyth API key + going forward if it calls Hermes + directly -- flagged here for awareness, not addressed by this change. + +## Required Output (to fill in once samples exist) + +```text +feed, period, samples, p50_ratio, p95_ratio, p99_ratio, max_ratio, +rejections_at_proposed_limit, additional_unavailability +``` + +| feed | period | samples | p50_ratio | p95_ratio | p99_ratio | max_ratio | rejections @ proposed limit | additional unavailability | +|---|---|---:|---:|---:|---:|---:|---:|---:| +| BTC/USD | _not yet measured_ | 0 | — | — | — | — | — | — | +| XTZ/USD | _not yet measured_ | 0 | — | — | — | — | — | — | +| USDT/USD | _not yet measured_ | 0 | — | — | — | — | — | — | + +"additional unavailability" is the extra stale/unavailable time a confidence +rejection would add on top of freshness-only downtime, i.e. samples where the +publish time was fresh enough but the confidence ratio still exceeded the +candidate limit. + +## Acceptance + +This report satisfies ТЗ section 2's empirical-measurement requirement only +once: + +- [ ] normal-period samples exist for all three feeds (preferably 24-72h); +- [ ] stressed/high-volatility-period samples exist for all three feeds; +- [ ] the table above is filled in with real p50/p95/p99/max ratios per feed; +- [ ] rejection counts at 25/50/100 bps (or other candidate policies) are + reported per feed; +- [ ] any change to the proposed limits based on this data is proposed + separately and reviewed, not silently folded into this report. From ebd1cf3269f67f6b06f6392a5c217f4529cce20d Mon Sep 17 00:00:00 2001 From: user Date: Tue, 22 Sep 2026 16:02:41 +0300 Subject: [PATCH 09/11] fix: enforce canonical signed Pyth ABI ranges --- compiled-contract-hashes.json | 2 +- contracts/TezFinOracle.py | 48 +++++++---- contracts/tests/TezFinOracleTest.py | 83 +++++++++++++++++++ .../tests/fixtures/pyth_abi_fixtures_test.py | 25 ++++++ 4 files changed, 143 insertions(+), 15 deletions(-) diff --git a/compiled-contract-hashes.json b/compiled-contract-hashes.json index b97fb83c..81222e04 100644 --- a/compiled-contract-hashes.json +++ b/compiled-contract-hashes.json @@ -37,7 +37,7 @@ "storageSha256": "8375a124534108c02419eed14ebc338c6d746b91031dc0ed7ad965d5b3157cab" }, "TezFinOracle": { - "contractSha256": "1e811edf5d0876fe972e713820a3e8c142ff915fef9dbdfe4b4d8e4e6037452f", + "contractSha256": "2af5e7c4e9b6b627782a338ec9eddcc52d3ed9a6c39cc33e791952bc014eb8f3", "storageSha256": "20f99119866cdbe8d27be9ddeda51d45caa215e20ca5da3661157cf607443074" } }, diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index f484740f..4a82b453 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -32,12 +32,19 @@ l={sp.bytes("0x%02x" % i): i for i in range(256)}, tkey=sp.TBytes, tvalue=sp.TNat) # Canonical two's-complement sign-extension bounds for validating ABI padding without a -# per-byte loop: an N-bit signed value sign-extended into 256 bits is exactly the set of -# unsigned 256-bit values below 2**N (non-negative) or at/above 2**256-2**N (negative). -TWO_POW_32 = sp.nat(2 ** 32) +# per-byte loop. For an UNSIGNED N-bit field (e.g. Pyth's uint64 confidence) every one of +# its N bits is magnitude, so canonicity is just the full 256-bit value fitting under 2**N. +# For a SIGNED N-bit field (e.g. int64 price, int32 exponent) the sign bit lives at bit +# N-1, not bit N: a canonical sign-extension is the set of unsigned 256-bit values below +# 2**(N-1) (non-negative, sign bit clear) or at/above 2**256-2**(N-1) (negative, sign bit +# set and fully extended). Using 2**N there instead would wrongly accept a zero-extended +# word whose bit N-1 (the actual int sign bit) is set as an oversized positive value. TWO_POW_64 = sp.nat(2 ** 64) -NEG_THRESHOLD_32 = sp.nat(2 ** 256 - 2 ** 32) NEG_THRESHOLD_64 = sp.nat(2 ** 256 - 2 ** 64) +TWO_POW_63 = sp.nat(2 ** 63) +NEG_THRESHOLD_63 = sp.nat(2 ** 256 - 2 ** 63) +TWO_POW_31 = sp.nat(2 ** 31) +NEG_THRESHOLD_31 = sp.nat(2 ** 256 - 2 ** 31) class TezFinOracle(OracleInterface.OracleInterface): """ @@ -152,24 +159,37 @@ def _decodeUint64Word(self, word): def _decodePriceWord(self, word): """ Decodes a canonical Pyth int64 price. Reinterpreting the whole 256-bit word as - two's complement, a canonical 64-bit sign-extension is exactly the set of values - that fit under 2**64 (non-negative) or sit at/above 2**256-2**64 (negative); any - other value has inconsistent padding bytes and is rejected as malformed. + two's complement, a canonical 64-bit-wide signed sign-extension is exactly the + set of values that fit under 2**63 (non-negative, sign bit clear) or sit at/above + 2**256-2**63 (negative, sign bit set and fully extended); any other value -- + including a zero-extended word whose bit 63 is set, i.e. in [2**63, 2**64) -- has + inconsistent padding/sign bytes and is rejected as malformed. """ value = self._decodeUnsignedWord(word) - sp.verify((value < TWO_POW_64) | (value >= NEG_THRESHOLD_64), "MALFORMED_PYTH_RESPONSE") - sp.verify(value < TWO_POW_64, "NON_POSITIVE_PYTH_PRICE") + sp.verify((value < TWO_POW_63) | (value >= NEG_THRESHOLD_63), "MALFORMED_PYTH_RESPONSE") + sp.verify(value < TWO_POW_63, "NON_POSITIVE_PYTH_PRICE") sp.verify(value > 0, "NON_POSITIVE_PYTH_PRICE") return value def _decodeExponentWord(self, word): """Decodes a canonical Pyth int32 exponent with sign-extension validation (see - _decodePriceWord for the canonicity argument, applied here with a 32-bit width).""" + _decodePriceWord for the canonicity argument, applied here with a 32-bit width, so + the sign-bit boundary is 2**31). The negative branch converts the FULL 256-bit + decoded word to its signed value by subtracting 2**256 (the word's own width), not + 2**32 -- subtracting 2**32 would leave the untouched high 224 bits in place and + produce an enormous positive number instead of e.g. -8. NOTE: the branch result is + assigned to an sp.local and returned once at the end, rather than using a bare + `return` inside `sp.if`/`sp.else` -- a `return` there is NOT a conditional return in + this SmartPy toolchain: it always takes whichever branch is traced first, regardless + of the runtime condition, silently discarding the other branch.""" value = self._decodeUnsignedWord(word) - sp.verify((value < TWO_POW_32) | (value >= NEG_THRESHOLD_32), "MALFORMED_PYTH_RESPONSE") - sp.if value >= NEG_THRESHOLD_32: - return sp.to_int(value) - sp.to_int(TWO_POW_32) - return sp.to_int(value) + sp.verify((value < TWO_POW_31) | (value >= NEG_THRESHOLD_31), "MALFORMED_PYTH_RESPONSE") + exponentResult = sp.local("exponentResult", sp.int(0)) + sp.if value >= NEG_THRESHOLD_31: + exponentResult.value = sp.to_int(value) - 2 ** 256 + sp.else: + exponentResult.value = sp.to_int(value) + return exponentResult.value def _decodePublishTimeWord(self, word): """Decodes Pyth's uint256 publish time.""" diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index 9a5b29a9..e0270da6 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -21,6 +21,27 @@ USDT_MAX_CONFIDENCE_BPS = sp.nat(10) +class TezFinOracleDecodeTestHarness(TezFinOracle): + """ + Test-only subclass exposing TezFinOracle's private ABI-word decoders as onchain + views. It adds no state and no production entrypoints; it exists solely so tests can + exercise the actual contract decoder bit-for-bit (same inlined Python method bodies + as production TezFinOracle), rather than only the separate pure-Python mirror in + contracts/tests/fixtures/pyth_abi_fixtures_test.py. Never compiled/deployed for real + (see deploy/compile_targets/CompileTezFinOracle.py, which only targets TezFinOracle). + """ + + @sp.onchain_view() + def decodePriceWordForTest(self, word): + sp.set_type(word, sp.TBytes) + sp.result(self._decodePriceWord(word)) + + @sp.onchain_view() + def decodeExponentWordForTest(self, word): + sp.set_type(word, sp.TBytes) + sp.result(self._decodeExponentWord(word)) + + class View_consumer(sp.Contract): def __init__(self, contract): self.contract = contract @@ -65,6 +86,22 @@ def verifyPrice(self, params): sp.verify(sp.fst(oracle_data) == params.timestamp, "TIMESTAMP_MISMATCH") sp.verify(sp.snd(oracle_data) == params.price, "PRICE_MISTMATCH") + @sp.entry_point + def verifyDecodedPriceWord(self, params): + """Exercises TezFinOracleDecodeTestHarness.decodePriceWordForTest directly.""" + sp.set_type(params, sp.TRecord(word=sp.TBytes, expectedPrice=sp.TNat)) + decoded = sp.view("decodePriceWordForTest", self.contract, params.word, + t=sp.TNat).open_some("invalid oracle view call") + sp.verify(decoded == params.expectedPrice, "DECODED_PRICE_MISMATCH") + + @sp.entry_point + def verifyDecodedExponentWord(self, params): + """Exercises TezFinOracleDecodeTestHarness.decodeExponentWordForTest directly.""" + sp.set_type(params, sp.TRecord(word=sp.TBytes, expectedExponent=sp.TInt)) + decoded = sp.view("decodeExponentWordForTest", self.contract, params.word, + t=sp.TInt).open_some("invalid oracle view call") + sp.verify(decoded == params.expectedExponent, "DECODED_EXPONENT_MISMATCH") + @sp.entry_point def verifyValidatedPrice(self, params): sp.set_type(params, sp.TRecord( @@ -323,3 +360,49 @@ def test(): cToken=freshMarket, asset="XTZ-USD", previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), expectedPrice=sp.nat(0)).run(valid=False) + scenario.h2("Direct decoder tests (exercise the actual contract decoder, not just the " + "pure-Python fixture mirror)") + # TezFinOracleDecodeTestHarness exposes _decodePriceWord/_decodeExponentWord as views -- + # same inlined method bodies as production TezFinOracle -- so these calls run the real + # contract bytecode, catching bugs (e.g. wrong sign-extension boundary/subtraction) that + # a standalone Python mirror of the intended algorithm would not catch. + decodeHarness = TezFinOracleDecodeTestHarness(admin.address, admin.address) + scenario += decodeHarness + decodeConsumer = View_consumer(decodeHarness.address) + scenario += decodeConsumer + + scenario.h3("Valid canonical negative exponent (-8) decodes correctly") + decodeConsumer.verifyDecodedExponentWord( + word=sp.bytes("0x" + (-8).to_bytes(32, "big", signed=True).hex()), + expectedExponent=sp.int(-8)) + + scenario.h3("Valid canonical positive exponent still decodes correctly") + decodeConsumer.verifyDecodedExponentWord( + word=sp.bytes("0x" + (5).to_bytes(32, "big", signed=True).hex()), + expectedExponent=sp.int(5)) + + scenario.h3("Malformed exponent word: zero-extended but int32 sign bit (bit 31) set") + # bytes[0:28] are all 0x00 (zero-extended), but the low 4 bytes encode 2**31, whose top + # bit is set -- not a valid sign-extension of any int32 value, must fail closed rather + # than be misread as a huge in-range positive exponent. + decodeConsumer.verifyDecodedExponentWord( + word=sp.bytes("0x" + (2 ** 31).to_bytes(32, "big").hex()), + expectedExponent=sp.int(0)).run(valid=False, exception="MALFORMED_PYTH_RESPONSE") + + scenario.h3("Valid canonical positive price still decodes correctly") + decodeConsumer.verifyDecodedPriceWord( + word=sp.bytes("0x" + (12345).to_bytes(32, "big").hex()), + expectedPrice=sp.nat(12345)) + + scenario.h3("Valid canonical negative price is rejected as NON_POSITIVE, not malformed") + decodeConsumer.verifyDecodedPriceWord( + word=sp.bytes("0x" + (-1).to_bytes(32, "big", signed=True).hex()), + expectedPrice=sp.nat(0)).run(valid=False, exception="NON_POSITIVE_PYTH_PRICE") + + scenario.h3("Malformed price word: zero-extended but int64 sign bit (bit 63) set") + # bytes[0:24] are all 0x00 (zero-extended), but the low 8 bytes encode 2**63, whose top + # bit is set -- not a valid sign-extension of any int64 value. Before the fix this was + # misread as the huge positive price 2**63 instead of failing closed. + decodeConsumer.verifyDecodedPriceWord( + word=sp.bytes("0x" + (2 ** 63).to_bytes(32, "big").hex()), + expectedPrice=sp.nat(0)).run(valid=False, exception="MALFORMED_PYTH_RESPONSE") diff --git a/contracts/tests/fixtures/pyth_abi_fixtures_test.py b/contracts/tests/fixtures/pyth_abi_fixtures_test.py index 6ba83c83..7de298c8 100644 --- a/contracts/tests/fixtures/pyth_abi_fixtures_test.py +++ b/contracts/tests/fixtures/pyth_abi_fixtures_test.py @@ -287,6 +287,19 @@ def resolve_price(response: bytes, target_decimals: int, max_confidence_bps: int expect_ok=False, expected_error="MALFORMED_PYTH_RESPONSE", ), + dict( + # Regression case for a bug fixed in the SmartPy contract's arithmetic-shortcut + # decoder: zero-extended (bytes[0:24]=0x00) with the int64 sign bit (bit 63) set is + # not a valid sign-extension of any int64 value -- it must fail closed, not be + # misread as the oversized positive price 2**63. + name="malformed price: zero-extended word with int64 sign bit set", + response=(encode_uint_word(2 ** 63) + encode_uint_word(0) + + encode_int_word(-2) + encode_uint_word(NOW - 1)), + target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", + ), dict( name="malformed confidence padding", response=(encode_uint_word(42) + b"\x01" + b"\x00" * 23 + b"\x00" * 8 @@ -296,6 +309,18 @@ def resolve_price(response: bytes, target_decimals: int, max_confidence_bps: int expect_ok=False, expected_error="MALFORMED_PYTH_RESPONSE", ), + dict( + # Regression case, exponent analogue of the price case above: zero-extended + # (bytes[0:28]=0x00) with the int32 sign bit (bit 31) set is not a valid + # sign-extension of any int32 value. + name="malformed exponent: zero-extended word with int32 sign bit set", + response=(encode_uint_word(42) + encode_uint_word(1) + + encode_uint_word(2 ** 31) + encode_uint_word(NOW - 1)), + target_decimals=6, + max_confidence_bps=BPS_DENOMINATOR, + expect_ok=False, + expected_error="MALFORMED_PYTH_RESPONSE", + ), dict( name="canonical negative exponent word is accepted before range validation", response=build_response(price=42, conf=1, expo=-2, publish_time=NOW - 1), From c1da35a8b837b4933b81e62062414d786c83012c Mon Sep 17 00:00:00 2001 From: user Date: Wed, 23 Sep 2026 11:07:24 +0300 Subject: [PATCH 10/11] fix: harden deploy guards and land Pyth confidence evidence --- .github/workflows/ci.yml | 6 + compiled-contract-hashes.json | 18 +- deploy/deploy_script/configure_pyth_oracle.js | 40 ++- .../deploy_script/deploy_compiled_target.js | 44 +++ .../deploy_script/measure_pyth_confidence.js | 318 ++++++++++++++++++ deploy/deploy_script/package.json | 1 + .../deploy_script/test/deploy_guards.test.js | 135 ++++++++ deploy/deploy_script/util.js | 1 + docs/PYTH_CONFIDENCE_MEASUREMENT_REPORT.md | 71 ++-- .../shadownet_pyth_smoke_test.sh | 18 +- 10 files changed, 605 insertions(+), 47 deletions(-) create mode 100644 deploy/deploy_script/deploy_compiled_target.js create mode 100644 deploy/deploy_script/measure_pyth_confidence.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00a95e71..1643c6b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,12 @@ jobs: COMPILED_HASHES_OUTPUT: compiled-contract-hashes.json run: | python3 deploy/compile_targets/tests/test_reproducible_build.py ~/smartpy-cli/SmartPy.sh + - name: "Check checked-in compiled-contract-hashes.json is current" + run: | + if ! git diff --exit-code -- compiled-contract-hashes.json; then + echo "::error::compiled-contract-hashes.json is stale: a fresh clean build (above) produced different hashes than the committed file. Regenerate it (COMPILED_HASHES_OUTPUT=compiled-contract-hashes.json python3 deploy/compile_targets/tests/test_reproducible_build.py ~/smartpy-cli/SmartPy.sh) and commit the result." + exit 1 + fi - name: "Publish compiled contract hashes" uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/compiled-contract-hashes.json b/compiled-contract-hashes.json index 81222e04..1ef3db10 100644 --- a/compiled-contract-hashes.json +++ b/compiled-contract-hashes.json @@ -9,32 +9,32 @@ "storageSha256": "4f47d8642be1ec3f7e8832757c06b9277e2926607077c7c6aa1c4e4050cd313f" }, "CUSDt": { - "contractSha256": "278e21d79c7369589f98058881d25efd651f5bd3a05b852c0600e85cb6549944", - "storageSha256": "0da38b303b4965ab2be404fc96d8f79afef26b9fb78d35a9574c0d2aad079637" + "contractSha256": "5fbd0966b3b3a69b1fca0b807acc99be77698654fcac6ba89eaa6580026fb394", + "storageSha256": "4d513458927c04b88285a66315b209379bbdadf422f12e93b69249b306e1d267" }, "CXTZ": { - "contractSha256": "1011764e4d959754e5d3a7b496954b6734e04f70962d3b8f1845506997676188", - "storageSha256": "bd12edc724651647ebb7bd2d750cb58ae6a44ea17f5ec76897046b0c2705516c" + "contractSha256": "4073b500f047891504cd13412a5fa27422393c4071b710b81fd2806887b55a38", + "storageSha256": "274c50fb98efdf06c566d020a4f206cfc9bf701e5c7a9f6dc7f49beb60e6ab1a" }, "CXTZ_IRM": { "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", "storageSha256": "ce495cc8c035066279eec6e8907172d3cade64e5c24c8e9730983144e2bc2973" }, "Comptroller": { - "contractSha256": "e612191c212fb81be53d4eb5f63fba2b6ce1275bb841822f1ac9b23fc697ea35", + "contractSha256": "53db5ba21a2d2de00863fae9d5ff7166baf803be89b68d2bf42863e40bdd36c0", "storageSha256": "74d0a4f27c2296a77f12501903529ab9b89af79fe359ad4a45ffe00ea3fc2eb8" }, "CtzBTC": { - "contractSha256": "d8c178ce918785472d972ec50e4cfc6d14f4556624db9b3b848a6d6cb6035828", - "storageSha256": "72472795d0847c067a56f2db1efb21dee1c478ad0ad8b14a8fdc7e87d6cc373e" + "contractSha256": "193a0b26977dc17da7a69faa891cbe1f71930b035b4a92b6d82b13085b886843", + "storageSha256": "492359a20f960231f25e82815125faceee00fac989ec2566b43c88ee000311a5" }, "CtzBTC_IRM": { "contractSha256": "6ac58d269797ee5c2f5c04919e9c4fd89ddeb1af75903c8e6620aba3d2477c13", "storageSha256": "65a18026e1b9156ef9695caecb6d57ab945286737ab7dfa0b66cc1233e72096b" }, "Governance": { - "contractSha256": "deb90c1859deb61bc6c4d9679ec2b452264b459886e4b3cbe4b1968ecbbe795e", - "storageSha256": "8375a124534108c02419eed14ebc338c6d746b91031dc0ed7ad965d5b3157cab" + "contractSha256": "26f578d73d48556b970687895d8ac9481381a501f87a222f386216bb5e85b601", + "storageSha256": "208bbeca50ffb4d741374d0724cb6ce5359ecbcea90a7b6c8dd18ca45f3067ef" }, "TezFinOracle": { "contractSha256": "2af5e7c4e9b6b627782a338ec9eddcc52d3ed9a6c39cc33e791952bc014eb8f3", diff --git a/deploy/deploy_script/configure_pyth_oracle.js b/deploy/deploy_script/configure_pyth_oracle.js index c9480efd..63370051 100644 --- a/deploy/deploy_script/configure_pyth_oracle.js +++ b/deploy/deploy_script/configure_pyth_oracle.js @@ -17,8 +17,10 @@ * `PythConfidenceLimitsBps` field, NOT hard-coded here, so the manifest is the single * reproducible source of the activated configuration. They are only ever sent on-chain * when the manifest also sets `PythConfidenceLimitsApproved: true` (i.e. governance/risk - * sign-off has been recorded for those specific values). On mainnet that flag is mandatory - * and there is no override. On non-mainnet profiles only, an operator may bypass it for a + * sign-off has been recorded for those specific values). The mainnet gate is derived from + * the ACTUAL connected chain id (not just config.json's networkProfile label), so a + * mislabeled profile cannot bypass it on a real mainnet connection; that flag is mandatory + * and there is no override there. Off mainnet only, an operator may bypass it for a * one-off smoke test by setting ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1, which prints a loud * warning and must never be used to justify activating a market. * @@ -39,10 +41,18 @@ * - non-mainnet only; bypasses the PythConfidenceLimitsApproved gate. */ const fs = require('fs'); -const { config, createTezosClient, resolveDeployResultPath } = require('./util.js'); +const { config, createTezosClient, resolveDeployResultPath, checkChainIdMatch } = require('./util.js'); +const { MAINNET_CHAIN_IDS } = require('./assert_network.js'); const REQUIRED_CONFIDENCE_FEEDS = ['BTC_USD', 'XTZ_USD', 'USDT_USD']; +// True if EITHER signal says mainnet, so a mislabeled config.json profile cannot bypass +// the gate on a chain that is actually mainnet (and vice versa a mainnet-labeled profile +// connected elsewhere still stays gated). +function isActuallyMainnet(networkProfile, chainId) { + return networkProfile === 'mainnet' || MAINNET_CHAIN_IDS.has(chainId); +} + function encodeUintWord(value) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`Cannot encode negative/unsafe integer as a uint256 word: ${value}`); @@ -58,7 +68,7 @@ async function confirm(operation, label) { // Resolves the per-feed maxConfidenceBps values to activate, refusing to run unless the // manifest marks them approved (or, on non-mainnet only, an explicit operator override). -function resolveApprovedConfidenceLimits(manifest, deployResultPath) { +function resolveApprovedConfidenceLimits(manifest, deployResultPath, isMainnet) { const limits = manifest.PythConfidenceLimitsBps; if (!limits) { throw new Error(`${deployResultPath} is missing PythConfidenceLimitsBps`); @@ -68,7 +78,6 @@ function resolveApprovedConfidenceLimits(manifest, deployResultPath) { throw new Error(`${deployResultPath} PythConfidenceLimitsBps is missing: ${missing.join(', ')}`); } - const isMainnet = config.networkProfile === 'mainnet'; const approved = manifest.PythConfidenceLimitsApproved === true; if (approved) { return limits; @@ -109,9 +118,14 @@ async function main() { `${deployResultPath} is missing TezFinOracle/PythCore/PythMaxAgeSeconds/PythFeedIds`, ); } - const confidenceLimits = resolveApprovedConfidenceLimits(manifest, deployResultPath); - const { tezos, publicKeyHash } = await createTezosClient(); + // Resolve the actual connected chain BEFORE any gate decision or write, so a manifest + // pointed at the wrong network (or a mislabeled config.json profile) is caught first. + const { tezos, publicKeyHash, chainId } = await createTezosClient(); + checkChainIdMatch(manifest.chainId, chainId, deployResultPath); + const isMainnet = isActuallyMainnet(config.networkProfile, chainId); + const confidenceLimits = resolveApprovedConfidenceLimits(manifest, deployResultPath, isMainnet); + console.log(`[INFO] Configuring TezFinOracle ${oracleAddress} as admin ${publicKeyHash}`); const oracle = await tezos.contract.at(oracleAddress); @@ -167,7 +181,11 @@ async function main() { ); } -main().catch((error) => { - console.error(`[ERROR] Pyth oracle configuration failed: ${error.message}`); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + console.error(`[ERROR] Pyth oracle configuration failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { isActuallyMainnet, resolveApprovedConfidenceLimits }; diff --git a/deploy/deploy_script/deploy_compiled_target.js b/deploy/deploy_script/deploy_compiled_target.js new file mode 100644 index 00000000..ac4c06e2 --- /dev/null +++ b/deploy/deploy_script/deploy_compiled_target.js @@ -0,0 +1,44 @@ +/** + * Deploys already-compiled contract(s) from an arbitrary compiled-contracts directory, + * instead of the hard-coded TezFinBuild/compiled_contracts used by deploy.js/run(). + * + * This exists so a script that compiles into a scratch directory (e.g. /tmp) can + * originate/reuse-check exactly that fresh output, rather than deploy.js silently + * deploying whatever (possibly stale/unrelated) artifacts happen to be checked into + * TezFinBuild/compiled_contracts. + * + * runDeployment() itself only ever writes the manifest key(s) matching the directory + * name(s) found under the given compiled-contracts path (e.g. "TezFinOracle"); it never + * overwrites unrelated manifest keys, so passing a single-contract directory here is a + * safe, minimal update to the target manifest (DEPLOY_MANIFEST / default profile path). + * + * Usage: + * node deploy_compiled_target.js /tmp/tezfin_oracle_compiled + */ +const fs = require('fs'); +const { runDeployment, resolveDeployResultPath } = require('./util.js'); + +function resolveCompiledContractsPath(argv) { + const compiledContractsPath = argv[2]; + if (!compiledContractsPath) { + throw new Error('Usage: node deploy_compiled_target.js '); + } + if (!fs.existsSync(compiledContractsPath) || !fs.statSync(compiledContractsPath).isDirectory()) { + throw new Error(`compiledContractsPath does not exist or is not a directory: ${compiledContractsPath}`); + } + return compiledContractsPath; +} + +async function main() { + const compiledContractsPath = resolveCompiledContractsPath(process.argv); + await runDeployment(compiledContractsPath, resolveDeployResultPath()); +} + +if (require.main === module) { + main().catch((error) => { + console.error(`[ERROR] Deployment of ${process.argv[2]} failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { resolveCompiledContractsPath }; diff --git a/deploy/deploy_script/measure_pyth_confidence.js b/deploy/deploy_script/measure_pyth_confidence.js new file mode 100644 index 00000000..15019048 --- /dev/null +++ b/deploy/deploy_script/measure_pyth_confidence.js @@ -0,0 +1,318 @@ +/** + * Collects and reports per-feed Pyth confidence/price ratio samples (ТЗ section 2, + * "Required empirical measurement"), independent of the TezFin contracts. + * + * This is READ-ONLY and does not touch TezFinOracle or require TEZOS_PRIVATE_KEY. It talks + * directly to Hermes for BTC/USD, XTZ/USD, USDT/USD (feed ids come from the deploy + * manifest's PythFeedIds, same source configure_pyth_oracle.js uses). + * + * IMPORTANT: since the Pyth Core upgrade completed 2026-08-26, Hermes requires a Pyth API + * key on every request (`Authorization: Bearer $PYTH_API_KEY`), including the legacy + * hermes.pyth.network host. Set PYTH_API_KEY (see + * https://docs.pyth.network/price-feeds/core/upgrade/preparing to obtain one). This does + * not affect the deployed on-chain Pyth Core contract or NAC/getPriceNoOlderThan ABI, which + * are unchanged by that upgrade -- only this off-chain measurement tool needs the key. + * + * Three modes: + * + * collect - fetches one live sample per feed right now and appends it to a local CSV + * log (one file per feed under --out-dir). Intended to be run repeatedly + * (e.g. via cron/systemd timer) over the required 24-72h normal-period + * window, plus separately during any observed stressed/high-volatility period. + * report - reads the accumulated CSV log(s) and prints the required + * feed/period/samples/p50/p95/p99/max/rejections/downtime table for one or + * more candidate bps policies. + * collect-onchain - reads the actual Pyth Core cache through EVM eth_call + * getPriceUnsafe(bytes32) and appends one sample per feed. This mode + * measures the on-chain state and does not require a Hermes API key. + * + * Usage: + * PYTH_API_KEY=... node deploy/deploy_script/measure_pyth_confidence.js collect + * PYTH_EVM_RPC=https://node.mainnet.etherlink.com \ + * node deploy/deploy_script/measure_pyth_confidence.js collect-onchain + * node deploy/deploy_script/measure_pyth_confidence.js report --bps 25,50,100 + * + * Optional env/flags: + * DEPLOY_MANIFEST - manifest to read PythFeedIds from (see util.js resolution order). + * HERMES_BASE_URL - defaults to https://hermes.pyth.network; set to + * https://pyth.dourolabs.app/hermes to use the upgraded endpoint. + * --out-dir - CSV log directory (default: TezFinBuild/pyth_confidence_samples). + * --source - report source: hermes (default) or onchain. + * --bps - comma-separated candidate limits for `report` (default: 25,50,100). + * --period