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 38121a38..5aa72a1f 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,55 @@ 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). + +### 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/TezFinBuild/deploy_result/deploy.shadownet.json b/TezFinBuild/deploy_result/deploy.shadownet.json new file mode 100644 index 00000000..30dddccb --- /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": "KT1StW5tmRTZxJY72CLXiv1FKRFdVa3hsvsX" +} diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index b11a88cb..d7f6538e 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,84 @@ 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 + """ + 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 + + 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.""" + 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 + + 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 + + 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 + @sp.entry_point def configurePriceBounds(self, params): sp.set_type(params, OracleInterface.TPriceBounds) @@ -103,40 +210,174 @@ 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") + + # 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. + 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._decodePublishTimeWord(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. + # 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) + 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..a1045237 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,170 @@ 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("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 + # 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) + 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..692fdceb --- /dev/null +++ b/contracts/tests/fixtures/pyth_abi_fixtures_test.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +Deterministic ABI-decode/normalization fixtures for TezFinOracle's Pyth NAC lookup. + +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 + `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 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 + 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_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: + 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", + ), + 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, + ), +] + + +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/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/configure_pyth_oracle.js b/deploy/deploy_script/configure_pyth_oracle.js new file mode 100644 index 00000000..50a1665e --- /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 || 8000000000000000); + 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/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); } 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/pyth_mock/.gitignore b/e2e/pyth_mock/.gitignore new file mode 100644 index 00000000..396a6f5a --- /dev/null +++ b/e2e/pyth_mock/.gitignore @@ -0,0 +1,2 @@ +out/ +cache/ \ No newline at end of file diff --git a/e2e/pyth_mock/NacProbe.py b/e2e/pyth_mock/NacProbe.py new file mode 100644 index 00000000..3f026e81 --- /dev/null +++ b/e2e/pyth_mock/NacProbe.py @@ -0,0 +1,93 @@ +import smartpy as sp + + +NAC_GATEWAY = sp.address("KT18oDJJKXMKhfE1bSuAPGp92pYcwVDiqsPw") +TNacRequest = sp.TPair(sp.TString, sp.TBytes) +BYTE_TO_NAT = sp.map( + l={sp.bytes("0x%02x" % i): i for i in range(256)}, + tkey=sp.TBytes, + tvalue=sp.TNat, +) + + +class NacProbe(sp.Contract): + """Test-only raw NAC probe; intentionally performs no ABI decoding.""" + + def __init__(self): + self.init() + + @sp.onchain_view() + def rawPythResponse(self, params): + sp.set_type(params, TNacRequest) + response = sp.view( + "staticcall_evm", + NAC_GATEWAY, + params, + t=sp.TBytes, + ).open_some("NAC_STATICCALL_FAILED") + sp.result(response) + + @sp.onchain_view() + def decodedPrice(self, params): + sp.set_type(params, TNacRequest) + response = sp.view( + "staticcall_evm", + NAC_GATEWAY, + params, + t=sp.TBytes, + ).open_some("NAC_STATICCALL_FAILED") + word = sp.slice(response, 0, 32).open_some("MALFORMED_RESPONSE") + acc = sp.local("acc", sp.nat(0)) + byteIndex = sp.local("byteIndex", sp.nat(0)) + sp.while byteIndex.value < 32: + current_byte = sp.slice(word, byteIndex.value, 1).open_some("MALFORMED_RESPONSE") + acc.value = acc.value * 256 + BYTE_TO_NAT[current_byte] + byteIndex.value += 1 + sp.result(sp.to_int(acc.value)) + + @sp.onchain_view() + def decodedSignedPrice(self, params): + sp.set_type(params, TNacRequest) + response = sp.view( + "staticcall_evm", + NAC_GATEWAY, + params, + t=sp.TBytes, + ).open_some("NAC_STATICCALL_FAILED") + word = sp.slice(response, 0, 32).open_some("MALFORMED_RESPONSE") + unsignedValue = sp.local("unsignedValue", sp.nat(0)) + byteIndex = sp.local("byteIndex", sp.nat(0)) + sp.while byteIndex.value < 32: + currentByte = sp.slice(word, byteIndex.value, 1).open_some( + "MALFORMED_RESPONSE") + unsignedValue.value = unsignedValue.value * 256 + BYTE_TO_NAT[currentByte] + byteIndex.value += 1 + signByte = sp.slice(word, 0, 1).open_some("MALFORMED_RESPONSE") + signedValue = sp.local("signedValue", sp.int(0)) + sp.if BYTE_TO_NAT[signByte] >= 128: + signedValue.value = sp.to_int(unsignedValue.value) - sp.to_int(2 ** 256) + sp.else: + signedValue.value = sp.to_int(unsignedValue.value) + sp.result(signedValue.value) + + @sp.onchain_view() + def decodedConfidence(self, params): + sp.set_type(params, TNacRequest) + response = sp.view( + "staticcall_evm", + NAC_GATEWAY, + params, + t=sp.TBytes, + ).open_some("NAC_STATICCALL_FAILED") + word = sp.slice(response, 32, 32).open_some("MALFORMED_RESPONSE") + confidence = sp.local("confidence", sp.nat(0)) + byteIndex = sp.local("byteIndex", sp.nat(0)) + sp.while byteIndex.value < 32: + currentByte = sp.slice(word, byteIndex.value, 1).open_some( + "MALFORMED_RESPONSE") + confidence.value = confidence.value * 256 + BYTE_TO_NAT[currentByte] + byteIndex.value += 1 + sp.result(confidence.value) + + +sp.add_compilation_target("NacProbe", NacProbe()) \ No newline at end of file diff --git a/e2e/pyth_mock/README.md b/e2e/pyth_mock/README.md new file mode 100644 index 00000000..11ac1346 --- /dev/null +++ b/e2e/pyth_mock/README.md @@ -0,0 +1,170 @@ +# Test-only Pyth EVM mock + +This isolated Foundry project provides only the read ABI consumed by +`contracts/TezFinOracle.py`: + +```solidity +getPriceNoOlderThan(bytes32,uint256) + returns (int64 price, uint64 conf, int32 expo, uint256 publishTime) +``` + +It does not implement Pyth signatures, Wormhole guardians, VAA/Hermes +verification, consensus, update fees, or price-feed updates. The admin setters +exist solely to control integration fixtures. + +Run unit tests from this directory: + +```sh +cd e2e/pyth_mock +forge test +``` + +Deploy three configured Shadownet feeds with the Foundry script: + +```sh +MOCK_DEPLOYER_PRIVATE_KEY="$EVM_PRIVATE_KEY" \ + forge script script/DeployPythMock.s.sol:DeployPythMock \ + --rpc-url https://node.shadownet.etherlink.com --broadcast +``` + +The full live runner is deliberately opt-in and requires both an EVM deployer +credential and the Shadownet Tezos admin credential. It writes no production +manifest and refuses non-Shadownet RPCs: + +```sh +ALLOW_SHADOWNET_MOCK=1 EVM_PRIVATE_KEY="$EVM_PRIVATE_KEY" \ + TEZOS_PRIVATE_KEY="$TEZOS_PRIVATE_KEY" \ + ./e2e/pyth_mock/run_shadownet_integration.sh +``` + +By default the runner uses `TezFinOracle` from the Shadownet manifest. To test +a freshly originated oracle compiled from the current source, set +`TEZFIN_ORACLE_ADDRESS` to that new Shadownet address; the temporary manifest +will use it without changing the checked-in manifest. + +`setIgnoreAgeCheck(true)` permits an old or future timestamp to cross the NAC +boundary so TezFinOracle's own stale, future, and rollback validation remains +the behavior under test. Restore a valid timestamp with `SetPythPrice.s.sol` or +another `setPrice` call after a failure scenario. + +## Raw NAC probe + +`NacProbe.py` is test-only diagnostic infrastructure. It calls the same +`staticcall_evm` view as TezFinOracle and returns the raw response without +decoding it. Its `decodedPrice` view additionally applies the same unsigned +first-word decoder used by TezFinOracle. Use both views to distinguish an EVM +ABI problem from a Michelson decoder problem. + +Compile it against the Shadownet manifest: + +```sh +DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.shadownet.json \ + ~/smartpy-cli/SmartPy.sh compile e2e/pyth_mock/NacProbe.py \ + /tmp/tezfin-nac-probe --purge --protocol kathmandu +``` + +Originate it with the same Shadownet Tezos signer used for the oracle. The +following command uses the compiled JSON artifacts and does not modify the +checked-in manifest: + +```sh +ORACLE_COMPILE_DIR=/tmp/tezfin-nac-probe \ +node --input-type=commonjs <<'NODE' +const fs = require('fs'); +const path = require('path'); +const { createTezosClient } = require('./deploy/deploy_script/util.js'); + +(async () => { + const dir = path.resolve(process.env.ORACLE_COMPILE_DIR, 'NacProbe'); + const code = JSON.parse(fs.readFileSync(path.join(dir, 'step_000_cont_0_contract.json'))); + const init = JSON.parse(fs.readFileSync(path.join(dir, 'step_000_cont_0_storage.json'))); + const { tezos } = await createTezosClient(); + const params = { balance: '0', code, init }; + const estimate = await tezos.estimate.originate(params); + const operation = await tezos.contract.originate({ + ...params, + fee: Math.ceil(estimate.suggestedFeeMutez * 1.2), + gasLimit: estimate.gasLimit, + storageLimit: estimate.storageLimit, + }); + console.log(`[INFO] Injected: ${operation.hash}`); + await operation.confirmation(1, 45); + console.log(`NAC_PROBE_ADDRESS=${(await operation.contract()).address}`); +})().catch((error) => { + console.error(`[ERROR] ${error.message}`); + process.exitCode = 1; +}); +NODE +``` + +Set the originated address and call the view with the mock address and complete +calldata. The view input is a Michelson pair: `(destination string, calldata bytes)`. + +```sh +export NAC_PROBE_ADDRESS=KT1... +export PYTH_MOCK_ADDRESS=0x... +export PYTH_FEED_ID=e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43 +export PYTH_CALLDATA="0xa4ae35e0${PYTH_FEED_ID#0x}$(printf '%064x' 60)" +export TEZOS_SOURCE=tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA + +node <<'NODE' +const rpc = 'https://michelson.etherlink.shadownet.octez.io'; +const body = { + contract: process.env.NAC_PROBE_ADDRESS, + view: 'rawPythResponse', + input: { + prim: 'Pair', + args: [ + { string: process.env.PYTH_MOCK_ADDRESS }, + { bytes: process.env.PYTH_CALLDATA.slice(2) }, + ], + }, + chain_id: 'NetXtLrzvQDobza', + source: process.env.TEZOS_SOURCE, + payer: process.env.TEZOS_SOURCE, + gas: '1040000', + unparsing_mode: 'Readable', +}; +const response = await fetch( + `${rpc}/chains/main/blocks/head/helpers/scripts/run_script_view`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }, +); +console.log(response.status); +console.log(await response.text()); +NODE +``` + +Repeat the same request with: + +```js +view: 'decodedPrice' +``` + +The successful result must be the Michelson integer `6000000000`. If +`rawPythResponse` is correct and `decodedPrice` is also `6000000000`, the +remaining issue is specific to the larger TezFinOracle code path. If +`decodedPrice` is zero or fails, the decoder/runtime behavior is isolated in +this small probe. + +The live runner also calls `getValidatedPrice` directly and asserts these +contract-level rejections: stale price, timestamp rollback with a non-zero +previous timestamp, excessive confidence, price bounds, and price deviation. + +For the signed decoder used by `TezFinOracle._decodeSignedWord`, use: + +```js +view: 'decodedSignedPrice' +``` + +It must also return `6000000000`. A mismatch between `decodedPrice` and +`decodedSignedPrice` reproduces the production failure without originating a +larger oracle diagnostic contract. + +Use `decodedConfidence` to inspect the second ABI word. For the standard BTC +fixture it must return `1000000`; the TezFin validation rule then accepts it +because `1000000 * 4 <= 6000000000`. + +The successful result must be a 128-byte Michelson `bytes` value. Compare its +four 32-byte words with `cast rpc eth_call` against the same mock and calldata. +If the words differ, the issue is in NAC transport; if they match, the issue +is inside TezFinOracle's byte decoding or validation path. diff --git a/e2e/pyth_mock/foundry.toml b/e2e/pyth_mock/foundry.toml new file mode 100644 index 00000000..c1cf5336 --- /dev/null +++ b/e2e/pyth_mock/foundry.toml @@ -0,0 +1,10 @@ +[profile.default] +src = "src" +script = "script" +test = "test" +solc_version = "0.8.24" +optimizer = true +optimizer_runs = 200 + +[fmt] +line_length = 100 \ No newline at end of file diff --git a/e2e/pyth_mock/run_shadownet_integration.sh b/e2e/pyth_mock/run_shadownet_integration.sh new file mode 100755 index 00000000..93d2ebb6 --- /dev/null +++ b/e2e/pyth_mock/run_shadownet_integration.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Opt-in only: deploys the test-only EVM mock and points a temporary copy of the +# Shadownet manifest at it. The checked-in manifest is never modified. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +if [[ "${ALLOW_SHADOWNET_MOCK:-0}" != "1" ]]; then + echo "Set ALLOW_SHADOWNET_MOCK=1 to enable test-only Shadownet deployment." >&2 + exit 2 +fi +if [[ "${EVM_PRIVATE_KEY:-}" == "" || "${TEZOS_PRIVATE_KEY:-}${TEZOS_MNEMONIC:-}" == "" ]]; then + echo "EVM_PRIVATE_KEY and a Tezos credential are required in the environment." >&2 + exit 2 +fi + +evm_rpc="${EVM_RPC:-https://node.shadownet.etherlink.com}" +tezos_rpc="${TEZOS_RPC:-https://michelson.etherlink.shadownet.octez.io}" +if [[ "$evm_rpc" != "https://node.shadownet.etherlink.com" || "$tezos_rpc" != "https://michelson.etherlink.shadownet.octez.io" ]]; then + echo "This runner only permits the documented Shadownet RPCs." >&2 + exit 2 +fi +configured_tezos_rpc="$(node -e "process.stdout.write(require('./deploy/deploy_script/config.json').tezosNode)")" +if [[ "$configured_tezos_rpc" != "$tezos_rpc" ]]; then + echo "deploy_script/config.json must target the documented Shadownet Michelson RPC." >&2 + exit 2 +fi + +manifest="${DEPLOY_MANIFEST:-TezFinBuild/deploy_result/deploy.shadownet.json}" +manifest="$(cd "$(dirname "$manifest")" && pwd)/$(basename "$manifest")" +if [[ ! -f "$manifest" ]]; then + echo "Shadownet manifest not found: $manifest" >&2 + exit 2 +fi + +tmp_manifest="$(mktemp "${TMPDIR:-/tmp}/tezfin-pyth-mock-manifest.XXXXXX.json")" +trap 'rm -f "$tmp_manifest"' EXIT + +echo "Deploying test-only PythMock to Shadownet EVM..." +mock_address="$(cd e2e/pyth_mock && forge create src/PythMock.sol:PythMock \ + --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" --broadcast --json | jq -r '.deployedTo')" +if [[ ! "$mock_address" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "Could not parse the deployed mock address." >&2 + exit 1 +fi +echo "[INFO] Deployed PythMock: $mock_address" +mock_address="$(printf '%s' "$mock_address" | tr '[:upper:]' '[:lower:]')" +echo "[INFO] Using lowercase EVM address for Michelson NAC: $mock_address" + +btc_id=0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43 +xtz_id=0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03 +usdt_id=0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b + +jq --arg pyth "$mock_address" --arg oracle "${TEZFIN_ORACLE_ADDRESS:-}" \ + '.PythCore = $pyth | if $oracle != "" then .TezFinOracle = $oracle else . end' \ + "$manifest" >"$tmp_manifest" +echo "Configuring TezFinOracle ${TEZFIN_ORACLE_ADDRESS:-$(jq -r .TezFinOracle "$manifest")} against temporary mock manifest..." +DEPLOY_MANIFEST="$tmp_manifest" node deploy/deploy_script/configure_pyth_oracle.js + +# Configure transactions can take longer than the 60-second oracle window. Write +# valid fixture timestamps only after configuration, immediately before verification. +now="$(cast block latest --rpc-url "$evm_rpc" --field timestamp)" +btc_conf="${MOCK_BTC_CONF:-1000000}" +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 "$btc_conf" -2 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$xtz_id" 850000 200 -6 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$usdt_id" 100010000 5000 -8 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null + +echo "[INFO] Verifying mock state directly through EVM eth_call..." +btc_response="$(cast call "$mock_address" \ + 'getPriceNoOlderThan(bytes32,uint256)(int64,uint64,int32,uint256)' \ + "$btc_id" 60 --rpc-url "$evm_rpc")" +printf '%s\n' "$btc_response" +if ! grep -Eq '(^|[^0-9])6000000000([^0-9]|$)' <<<"$btc_response" || \ + ! grep -Eq '(^|[^0-9])-2([^0-9]|$)' <<<"$btc_response"; then + echo "EVM mock returned unexpected BTC price/exponent; stopping before Michelson verification." >&2 + exit 1 +fi + +DEPLOY_MANIFEST="$tmp_manifest" node deploy/deploy_script/verify_shadownet_pyth_oracle.js + +case_view() { + local case_name="$1" + local asset="$2" + local expected_error="${3:-}" + PYTH_CASE="$case_name" PYTH_ASSET="$asset" PYTH_EXPECTED_ERROR="$expected_error" \ + DEPLOY_MANIFEST="$tmp_manifest" node e2e/pyth_mock/verify_shadownet_case.js +} + +validated_case() { + local case_name="$1" + local previous_price="$2" + local previous_timestamp="$3" + local expected_error="$4" + PYTH_CASE="$case_name" PYTH_PREVIOUS_PRICE="$previous_price" \ + PYTH_PREVIOUS_TIMESTAMP="$previous_timestamp" PYTH_EXPECTED_ERROR="$expected_error" \ + DEPLOY_MANIFEST="$tmp_manifest" node e2e/pyth_mock/verify_shadownet_validated_case.js +} + +cast send "$mock_address" 'setIgnoreAgeCheck(bool)' true --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null + +echo "== Additional live validation cases ==" +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 0 0 -2 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "valid -> invalid" BTC-USD NON_POSITIVE_PYTH_PRICE + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 "$btc_conf" -2 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "invalid -> valid" BTC-USD + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 0 0 -6 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "zero price" BTC-USD NON_POSITIVE_PYTH_PRICE + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" -42 1 -2 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "negative price" BTC-USD NON_POSITIVE_PYTH_PRICE + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 42 1 1 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "invalid exponent" BTC-USD INVALID_PYTH_EXPONENT + +cast send "$mock_address" 'setRevert(bool)' true --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +case_view "simulated revert" BTC-USD PYTH_STATICCALL_FAILED +cast send "$mock_address" 'setRevert(bool)' false --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null + +case_view "unknown feed" ETH-USD UNSUPPORTED_PYTH_ASSET + +echo "== Live getValidatedPrice rejection cases ==" +cast send "$mock_address" 'setIgnoreAgeCheck(bool)' true --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 1000000 -2 "$((now - 600))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +validated_case "validated stale price" 0 "1970-01-01T00:00:00Z" STALE_ASSET_PRICE + +rollback_now="$(cast block latest --rpc-url "$evm_rpc" --field timestamp)" +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 1000000 -2 "$((rollback_now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +rollback_timestamp="$(node -e 'console.log(new Date((Number(process.argv[1]) - 1) * 1000).toISOString())' "$rollback_now")" +deviation_timestamp="$(node -e 'console.log(new Date((Number(process.argv[1]) - 30) * 1000).toISOString())' "$rollback_now")" +validated_case "validated timestamp rollback" 6000000000000000 "$rollback_timestamp" ASSET_PRICE_TIMESTAMP_ROLLBACK + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 100000 30000 -6 "$((now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +validated_case "validated excessive confidence" 0 "1970-01-01T00:00:00Z" EXCESSIVE_PYTH_CONFIDENCE + +bounds_now="$(cast block latest --rpc-url "$evm_rpc" --field timestamp)" +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 9000000000 1000000 -2 "$((bounds_now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +validated_case "validated price bounds" 0 "1970-01-01T00:00:00Z" ASSET_PRICE_OUT_OF_BOUNDS + +deviation_now="$(cast block latest --rpc-url "$evm_rpc" --field timestamp)" +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 1000000 -2 "$((deviation_now - 5))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +deviation_timestamp="$(node -e 'console.log(new Date((Number(process.argv[1]) - 30) * 1000).toISOString())' "$deviation_now")" +validated_case "validated price deviation" 1000000000000000 "$deviation_timestamp" ASSET_PRICE_CHANGE_TOO_LARGE + +cast send "$mock_address" 'setIgnoreAgeCheck(bool)' true --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 1000000 -2 "$((now - 600))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +if DEPLOY_MANIFEST="$tmp_manifest" node deploy/deploy_script/verify_shadownet_pyth_oracle.js; then + echo "Expected stale-price verification to fail closed, but it passed." >&2 + exit 1 +fi + +cast send "$mock_address" 'setPrice(bytes32,int64,uint64,int32,uint256)' "$btc_id" 6000000000 1000000 -2 "$((now + 600))" --rpc-url "$evm_rpc" --private-key "$EVM_PRIVATE_KEY" >/dev/null +if DEPLOY_MANIFEST="$tmp_manifest" node deploy/deploy_script/verify_shadownet_pyth_oracle.js; then + echo "Expected future-price verification to fail closed, but it passed." >&2 + exit 1 +fi + +echo "Shadownet mock NAC integration passed: valid, stale, and future paths fail as expected." \ No newline at end of file diff --git a/e2e/pyth_mock/script/DeployPythMock.s.sol b/e2e/pyth_mock/script/DeployPythMock.s.sol new file mode 100644 index 00000000..511ff9a3 --- /dev/null +++ b/e2e/pyth_mock/script/DeployPythMock.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {PythMock} from "../src/PythMock.sol"; + +interface DeployVm { + function envUint(string calldata name) external returns (uint256); + function startBroadcast(uint256 privateKey) external; + function stopBroadcast() external; +} + +contract DeployPythMock { + DeployVm private constant vm = DeployVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + bytes32 private constant BTC_USD = 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43; + bytes32 private constant XTZ_USD = 0x0affd4b8ad136a21d79bc82450a325ee12ff55a235abc242666e423b8bcffd03; + bytes32 private constant USDT_USD = 0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b; + + function run() external returns (PythMock mock) { + vm.startBroadcast(vm.envUint("MOCK_DEPLOYER_PRIVATE_KEY")); + mock = new PythMock(); + mock.setPrice(BTC_USD, 6_000_000_000, 1_000_000, -2, block.timestamp - 5); + mock.setPrice(XTZ_USD, 850_000, 200, -6, block.timestamp - 5); + mock.setPrice(USDT_USD, 100_010_000, 5_000, -8, block.timestamp - 5); + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/e2e/pyth_mock/script/SetPythPrice.s.sol b/e2e/pyth_mock/script/SetPythPrice.s.sol new file mode 100644 index 00000000..945caadd --- /dev/null +++ b/e2e/pyth_mock/script/SetPythPrice.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {PythMock} from "../src/PythMock.sol"; + +interface SetPriceVm { + function envAddress(string calldata name) external returns (address); + function envBytes32(string calldata name) external returns (bytes32); + function envInt(string calldata name) external returns (int256); + function envUint(string calldata name) external returns (uint256); + function startBroadcast(uint256 privateKey) external; + function stopBroadcast() external; +} + +contract SetPythPrice { + SetPriceVm private constant vm = SetPriceVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run() external { + vm.startBroadcast(vm.envUint("MOCK_DEPLOYER_PRIVATE_KEY")); + PythMock(vm.envAddress("PYTH_MOCK_ADDRESS")).setPrice( + vm.envBytes32("PYTH_PRICE_ID"), + int64(vm.envInt("PYTH_PRICE")), + uint64(vm.envUint("PYTH_CONF")), + int32(vm.envInt("PYTH_EXPO")), + vm.envUint("PYTH_PUBLISH_TIME") + ); + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/e2e/pyth_mock/src/PythMock.sol b/e2e/pyth_mock/src/PythMock.sol new file mode 100644 index 00000000..8a14d008 --- /dev/null +++ b/e2e/pyth_mock/src/PythMock.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Test-only read oracle with the ABI consumed by TezFinOracle. +/// @dev This intentionally does not implement any Pyth update or verification logic. +contract PythMock { + struct Price { + int64 price; + uint64 conf; + int32 expo; + uint256 publishTime; + } + + mapping(bytes32 => Price) private prices; + mapping(bytes32 => bool) private configured; + + address public immutable admin; + bool public revertEnabled; + bool public ignoreAgeCheck; + + error NotAdmin(); + error PriceNotConfigured(); + error PriceIsStale(); + error ForcedRevert(); + + constructor() { + admin = msg.sender; + } + + modifier onlyAdmin() { + if (msg.sender != admin) revert NotAdmin(); + _; + } + + function setPrice( + bytes32 priceId, + int64 price, + uint64 conf, + int32 expo, + uint256 publishTime + ) external onlyAdmin { + prices[priceId] = Price(price, conf, expo, publishTime); + configured[priceId] = true; + } + + function setRevert(bool enabled) external onlyAdmin { + revertEnabled = enabled; + } + + function setIgnoreAgeCheck(bool enabled) external onlyAdmin { + ignoreAgeCheck = enabled; + } + + function getPriceNoOlderThan(bytes32 priceId, uint256 age) + external + view + returns (Price memory) + { + if (revertEnabled) revert ForcedRevert(); + if (!configured[priceId]) revert PriceNotConfigured(); + + Price memory result = prices[priceId]; + if (!ignoreAgeCheck) { + if (result.publishTime > block.timestamp || block.timestamp - result.publishTime > age) { + revert PriceIsStale(); + } + } + return result; + } +} \ No newline at end of file diff --git a/e2e/pyth_mock/test/PythMock.t.sol b/e2e/pyth_mock/test/PythMock.t.sol new file mode 100644 index 00000000..19e904b0 --- /dev/null +++ b/e2e/pyth_mock/test/PythMock.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {PythMock} from "../src/PythMock.sol"; + +interface TestVm { + function expectRevert() external; + function warp(uint256 timestamp) external; +} + +contract PythMockTest { + TestVm private constant vm = TestVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + bytes32 private constant BTC = keccak256("BTC/USD"); + uint256 private constant NOW = 1_700_000_000; + + PythMock private mock; + + function setUp() public { + vm.warp(NOW); + mock = new PythMock(); + mock.setPrice(BTC, 6_000_000_000, 1_000_000, -2, NOW - 5); + } + + function testValidPriceAndAbiResponse() public { + PythMock.Price memory result = mock.getPriceNoOlderThan(BTC, 60); + require(result.price == 6_000_000_000, "price"); + require(result.conf == 1_000_000, "confidence"); + require(result.expo == -2, "exponent"); + require(result.publishTime == NOW - 5, "publish time"); + } + + function testAbiResponseIsFourStaticWords() public { + (bool success, bytes memory response) = address(mock).staticcall( + abi.encodeWithSelector(mock.getPriceNoOlderThan.selector, BTC, uint256(60)) + ); + require(success, "staticcall failed"); + require(response.length == 128, "ABI response length"); + (int64 price, uint64 conf, int32 expo, uint256 publishTime) = abi.decode( + response, (int64, uint64, int32, uint256) + ); + require(price == 6_000_000_000, "ABI price"); + require(conf == 1_000_000, "ABI confidence"); + require(expo == -2, "ABI exponent"); + require(publishTime == NOW - 5, "ABI publish time"); + } + + function testStalePriceReverts() public { + mock.setPrice(BTC, 1, 0, -6, NOW - 61); + vm.expectRevert(); + mock.getPriceNoOlderThan(BTC, 60); + } + + function testFutureTimestampIsReturnedOnlyWithAgeCheckDisabled() public { + mock.setPrice(BTC, 1, 0, -6, NOW + 1); + vm.expectRevert(); + mock.getPriceNoOlderThan(BTC, 60); + mock.setIgnoreAgeCheck(true); + require(mock.getPriceNoOlderThan(BTC, 60).publishTime == NOW + 1, "future timestamp"); + } + + function testTimestampRollbackSequenceIsRepresentable() public { + mock.setPrice(BTC, 2, 0, -6, NOW - 2); + require(mock.getPriceNoOlderThan(BTC, 60).publishTime == NOW - 2, "new timestamp"); + mock.setPrice(BTC, 1, 0, -6, NOW - 3); + require(mock.getPriceNoOlderThan(BTC, 60).publishTime == NOW - 3, "rollback timestamp"); + } + + function testValidToInvalidSequence() public { + require(mock.getPriceNoOlderThan(BTC, 60).price > 0, "valid value"); + mock.setPrice(BTC, 0, 0, -2, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).price == 0, "invalid value"); + } + + function testInvalidToValidSequence() public { + mock.setPrice(BTC, 0, 0, -2, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).price == 0, "invalid value"); + mock.setPrice(BTC, 42, 1, -6, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).price == 42, "valid value"); + } + + function testZeroPrice() public { + mock.setPrice(BTC, 0, 0, -6, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).price == 0, "zero price"); + } + + function testNegativePrice() public { + mock.setPrice(BTC, -42, 1, -2, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).price == -42, "negative price"); + } + + function testInvalidExponent() public { + mock.setPrice(BTC, 42, 1, 1, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).expo == 1, "exponent"); + } + + function testExcessiveConfidence() public { + mock.setPrice(BTC, 100_000, 30_000, -6, NOW - 1); + require(mock.getPriceNoOlderThan(BTC, 60).conf == 30_000, "confidence"); + } + + function testSimulatedRevert() public { + mock.setRevert(true); + vm.expectRevert(); + mock.getPriceNoOlderThan(BTC, 60); + } + + function testUnknownFeed() public { + vm.expectRevert(); + mock.getPriceNoOlderThan(keccak256("UNKNOWN/USD"), 60); + } +} \ No newline at end of file diff --git a/e2e/pyth_mock/verify_shadownet_case.js b/e2e/pyth_mock/verify_shadownet_case.js new file mode 100644 index 00000000..4f49db82 --- /dev/null +++ b/e2e/pyth_mock/verify_shadownet_case.js @@ -0,0 +1,51 @@ +const fs = require('fs'); +const { config, resolveDeployResultPath } = require('../../deploy/deploy_script/util.js'); + +async function main() { + const caseName = process.env.PYTH_CASE || 'case'; + const expectedError = process.env.PYTH_EXPECTED_ERROR || ''; + const asset = process.env.PYTH_ASSET || 'BTC-USD'; + const manifest = JSON.parse(fs.readFileSync(resolveDeployResultPath(), 'utf8')); + const source = process.env.TEZOS_SOURCE || manifest.OriginatorAddress; + const body = { + contract: manifest.TezFinOracle, + view: 'getPrice', + input: { string: asset }, + chain_id: await rpcJson('/chains/main/chain_id'), + source, + payer: source, + gas: '1040000', + unparsing_mode: 'Readable', + }; + const response = await fetch( + `${config.tezosNode.replace(/\/$/, '')}/chains/main/blocks/head/helpers/scripts/run_script_view`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const text = await response.text(); + if (expectedError) { + if (response.ok || !text.includes(expectedError)) { + throw new Error(`${caseName}: expected ${expectedError}, got HTTP ${response.status}: ${text}`); + } + console.log(`[OK] ${caseName}: ${expectedError}`); + return; + } + if (!response.ok) { + throw new Error(`${caseName}: expected success, got HTTP ${response.status}: ${text}`); + } + console.log(`[OK] ${caseName}: valid response`); +} + +async function rpcJson(pathname) { + const response = await fetch(`${config.tezosNode.replace(/\/$/, '')}${pathname}`); + if (!response.ok) throw new Error(`RPC ${pathname} returned ${response.status}`); + return response.json(); +} + +main().catch((error) => { + console.error(`[ERROR] ${error.message}`); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/e2e/pyth_mock/verify_shadownet_validated_case.js b/e2e/pyth_mock/verify_shadownet_validated_case.js new file mode 100644 index 00000000..060de9b7 --- /dev/null +++ b/e2e/pyth_mock/verify_shadownet_validated_case.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const { config, resolveDeployResultPath } = require('../../deploy/deploy_script/util.js'); + +async function main() { + const caseName = process.env.PYTH_CASE || 'validated case'; + const expectedError = process.env.PYTH_EXPECTED_ERROR || ''; + const manifest = JSON.parse(fs.readFileSync(resolveDeployResultPath(), 'utf8')); + const source = process.env.TEZOS_SOURCE || manifest.OriginatorAddress; + const comptroller = process.env.PYTH_COMPTROLLER || source; + const cToken = process.env.PYTH_CTOKEN || source; + const requestedAsset = process.env.PYTH_ASSET || 'BTC-USD'; + const previousPrice = process.env.PYTH_PREVIOUS_PRICE || '0'; + const previousTimestamp = process.env.PYTH_PREVIOUS_TIMESTAMP || '1970-01-01T00:00:00Z'; + const rpc = config.tezosNode.replace(/\/$/, ''); + const chainId = await rpcJson(`${rpc}/chains/main/chain_id`); + const body = { + contract: manifest.TezFinOracle, + view: 'getValidatedPrice', + input: { + prim: 'Pair', + args: [ + { + prim: 'Pair', + args: [{ string: cToken }, { string: comptroller }], + }, + { + prim: 'Pair', + args: [ + { int: String(previousPrice) }, + { + prim: 'Pair', + args: [{ string: previousTimestamp }, { string: requestedAsset }], + }, + ], + }, + ], + }, + chain_id: chainId, + source, + payer: source, + gas: '1040000', + unparsing_mode: 'Readable', + }; + const response = await fetch( + `${rpc}/chains/main/blocks/head/helpers/scripts/run_script_view`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const text = await response.text(); + if (expectedError) { + if (response.ok || !text.includes(expectedError)) { + throw new Error(`${caseName}: expected ${expectedError}, got HTTP ${response.status}: ${text}`); + } + console.log(`[OK] ${caseName}: ${expectedError}`); + return; + } + if (!response.ok) { + throw new Error(`${caseName}: expected success, got HTTP ${response.status}: ${text}`); + } + console.log(`[OK] ${caseName}: getValidatedPrice accepted`); +} + +async function rpcJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`RPC ${url} returned ${response.status}`); + return response.json(); +} + +main().catch((error) => { + console.error(`[ERROR] ${error.message}`); + process.exitCode = 1; +}); \ No newline at end of file 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."