From 2b16baa2ba9ace0bc5d98469c0446c70d8988024 Mon Sep 17 00:00:00 2001 From: Serhii Rubets Date: Mon, 13 Jul 2026 08:42:32 +0300 Subject: [PATCH 01/19] chore: updated deployment scripts --- README.md | 10 +- contracts/Comptroller.py | 46 +- deploy/compile_targets/CompileIRMs.py | 22 +- deploy/deploy_script/config.json | 34 +- deploy/deploy_script/deploy.js | 5 +- deploy/deploy_script/deploy_e2e.js | 5 +- deploy/deploy_script/package-lock.json | 1612 +++++++++--------- deploy/deploy_script/package.json | 13 +- deploy/deploy_script/prepare.js | 9 + deploy/deploy_script/util.js | 300 +++- deploy/shell_scripts/deploy_all_contracts.sh | 55 +- deploy/shell_scripts/deploy_cfa12.sh | 15 +- deploy/shell_scripts/deploy_cfa2.sh | 16 +- deploy/shell_scripts/deploy_comptroller.sh | 9 +- deploy/shell_scripts/deploy_cxtz.sh | 16 +- deploy/shell_scripts/deploy_governance.sh | 9 +- deploy/shell_scripts/deploy_test_data.sh | 9 +- 17 files changed, 1171 insertions(+), 1014 deletions(-) create mode 100644 deploy/deploy_script/prepare.js diff --git a/README.md b/README.md index ac7e66d0..2ba70ac0 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,18 @@ Before listing an underlying, complete the [market listing checklist](docs/Marke To compile and deploy all contracts at once: 1. Configure parameters for contracts compilation in [Config.json](deploy/compile_targets/Config.json). Reffer to [Compilation arguments](https://github.com/RSerhii/TezFin/wiki/Compilation-arguments) -2. Configure deployment parameters in [config.json](deploy/deploy_script/config.json) +2. The deployer is configured for Tezos X Previewnet in [config.json](deploy/deploy_script/config.json). Keep only the public deployment address there; provide the matching private key from your shell (never commit it): +```sh +export TEZOS_PRIVATE_KEY='edsk...' +``` + Alternatively, set `TEZOS_MNEMONIC` for a standard Tezos wallet seed phrase (and `TEZOS_MNEMONIC_PASSWORD` only if your wallet used one). The default derivation path is `44'/1729'/0'/0'`; override it with `TEZOS_DERIVATION_PATH` if needed. Legacy fundraiser accounts additionally require `TEZOS_FUNDS_EMAIL` and `TEZOS_FUNDS_PASSWORD`. The signer must match `originator.pkh` and have Previewnet XTZ. + Previewnet fee estimates can change between simulation and injection; the deployer applies a 20% fee margin. Override it only when needed with `TEZOS_FEE_SAFETY_MULTIPLIER`. 3. Install deployment dependencies ```sh cd deploy/deploy_script npm install +npm run check +npm run prepare:deploy ``` 4. Run deployment script ```sh @@ -122,4 +129,3 @@ npm install cd src/ui npm start ``` - diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index af334fa4..bacd3467 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -76,7 +76,7 @@ def __init__(self, administrator_, oracleAddress_, closeFactorMantissa_, liquida cTokens: TList(TAddress) - The list of addresses of the cToken markets to be enabled """ - @sp.entry_point(lazify=True) + @sp.entry_point def enterMarkets(self, cTokens): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -125,7 +125,7 @@ def exitMarket(self, cToken): accountSnapshot: TAccountSnapshot - Container for account balance information """ - @sp.entry_point(lazify=True) + @sp.entry_point def setAccountSnapAndExitMarket(self, accountSnapshot): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -152,7 +152,7 @@ def setAccountSnapAndExitMarket(self, accountSnapshot): minter: TAddress - The account which would get the minted tokens mintAmount: TNat - The amount of underlying being supplied to the market in exchange for tokens """ - @sp.entry_point(lazify=True) + @sp.entry_point def mintAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -175,7 +175,7 @@ def mintAllowed(self, params): redeemTokens: TNat - The number of cTokens removed from the redeemer exchangeRateMantissa: TNat - The pre-redemption exchange rate, scaled by 1e18 """ - @sp.entry_point(lazify=True) + @sp.entry_point def redeemAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -248,7 +248,7 @@ def checkCollateralReductionInternal(self, cToken, account, exchangeRateMantissa borrower: TAddress - The account which would borrow the tokens borrowAmount: TNat - The amount of underlying the account would borrow """ - @sp.entry_point(lazify=True) + @sp.entry_point def borrowAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -281,7 +281,7 @@ def addToLoans(self, cToken, borrower): sp.else: self.data.loans[borrower] = sp.set([cToken]) - @sp.entry_point(lazify=True) + @sp.entry_point def removeFromLoans(self, borrower): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -298,7 +298,7 @@ def removeFromLoans(self, borrower): borrower: TAddress - The account which would borrowed the asset repayAmount: TNat - The amount of the underlying asset the account would repay """ - @sp.entry_point(lazify=True) + @sp.entry_point def repayBorrowAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -320,7 +320,7 @@ def repayBorrowAllowed(self, params): dst: TAddress - The account which receives the tokens transferTokens: TNat - The number of cTokens to transfer """ - @sp.entry_point(lazify=True) + @sp.entry_point def transferAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -401,7 +401,7 @@ def updateAccountLiquidityWithView(self, account): account: TAddress - The account to calculate liquidity for """ - @sp.entry_point(lazify=True) + @sp.entry_point def setAccountLiquidityWithView(self, account): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -499,7 +499,7 @@ def liquidateCalculateSeizeTokens(self, params): updateAccountLiquidityWithView() needs to be called before executing this to get up-to-date results """ - @sp.entry_point(lazify=True) + @sp.entry_point def liquidateBorrowAllowed(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -631,7 +631,7 @@ def getMaxAssetsPerUser(self): params: TAddress - The address of the new pending governance contract """ - @sp.entry_point(lazify=True) + @sp.entry_point def setPendingGovernance(self, pendingAdminAddress): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -646,7 +646,7 @@ def setPendingGovernance(self, pendingAdminAddress): params: TUnit """ - @sp.entry_point(lazify=True) + @sp.entry_point def acceptGovernance(self, unusedArg): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -665,7 +665,7 @@ def acceptGovernance(self, unusedArg): cToken: TAddress - The address of the market to change the mint pause state state: TBool - state, where True - pause, False - activate """ - @sp.entry_point(lazify=True) + @sp.entry_point def setMintPaused(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -683,7 +683,7 @@ def setMintPaused(self, params): cToken: TAddress - The address of the market to change the borrow pause state state: TBool - state, where True - pause, False - activate """ - @sp.entry_point(lazify=True) + @sp.entry_point def setBorrowPaused(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -695,7 +695,7 @@ def setBorrowPaused(self, params): """ Pause or activate redemptions of a given CToken. """ - @sp.entry_point(lazify=True) + @sp.entry_point def setRedeemPaused(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -711,7 +711,7 @@ def setRedeemPaused(self, params): state: TBool, where True - pause, False - activate """ - @sp.entry_point(lazify=True) + @sp.entry_point def setTransferPaused(self, state): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -728,7 +728,7 @@ def setTransferPaused(self, state): NOTE: can only use harbinger or harbinger like oracle """ - @sp.entry_point(lazify=True) + @sp.entry_point def setPriceOracleAndTimeDiff(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -744,7 +744,7 @@ def setPriceOracleAndTimeDiff(self, params): closeFactorMantissa: TNat - New close factor, scaled by 1e18 """ - @sp.entry_point(lazify=True) + @sp.entry_point def setCloseFactor(self, closeFactorMantissa): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -759,7 +759,7 @@ def setCloseFactor(self, closeFactorMantissa): newLimit: TNat - The new limit for the maximum number of unique assets a user can hold """ - @sp.entry_point(lazify=True) + @sp.entry_point def setMaxAssetsPerUser(self, newLimit): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -776,7 +776,7 @@ def setMaxAssetsPerUser(self, newLimit): cToken: TAddress - The market to set the factor on newCollateralFactor: TNat - The new collateral factor, scaled by 1e18 """ - @sp.entry_point(lazify=True) + @sp.entry_point def setCollateralFactor(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -795,7 +795,7 @@ def setCollateralFactor(self, params): liquidationIncentiveMantissa: TNat - New liquidationIncentive scaled by 1e18 """ - @sp.entry_point(lazify=True) + @sp.entry_point def setLiquidationIncentive(self, liquidationIncentiveMantissa): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -813,7 +813,7 @@ def setLiquidationIncentive(self, liquidationIncentiveMantissa): name: TString - The market name in price oracle priceExp: TNat - exponent needed to normalize the token prices to 10^18 (eth:0, btc: 10, usd: 12) """ - @sp.entry_point(lazify=True) + @sp.entry_point def supportMarket(self, params): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") @@ -843,7 +843,7 @@ def supportMarket(self, params): cToken: TAddress - The address of the market (token) to disable """ - @sp.entry_point(lazify=True) + @sp.entry_point def disableMarket(self, cToken): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") diff --git a/deploy/compile_targets/CompileIRMs.py b/deploy/compile_targets/CompileIRMs.py index 55bcba3c..d1be7537 100644 --- a/deploy/compile_targets/CompileIRMs.py +++ b/deploy/compile_targets/CompileIRMs.py @@ -4,16 +4,22 @@ CFG = sp.io.import_script_from_url("file:deploy/compile_targets/Config.py") sp.add_compilation_target("CFA12_IRM", IRM.InterestRateModel( - scale_ = 1000000000000000000, - multiplierPerBlock_ = CFG.CFA12_IRM.multiplierPerBlock, - baseRatePerBlock_ = CFG.CFA12_IRM.baseRatePerBlock)) + scale_ = CFG.CUSDtz_IRM.scale, + multiplierPerBlock_ = CFG.CUSDtz_IRM.multiplierPerBlock, + baseRatePerBlock_ = CFG.CUSDtz_IRM.baseRatePerBlock, + jumpMultiplierPerBlock_ = CFG.CUSDtz_IRM.jumpMultiplierPerBlock, + kink_ = CFG.CUSDtz_IRM.kink)) sp.add_compilation_target("CFA2_IRM", IRM.InterestRateModel( - scale_ = 1000000000000000000, - multiplierPerBlock_ = CFG.CFA2_IRM.multiplierPerBlock, - baseRatePerBlock_ = CFG.CFA2_IRM.baseRatePerBlock)) + scale_ = CFG.CUSDt_IRM.scale, + multiplierPerBlock_ = CFG.CUSDt_IRM.multiplierPerBlock, + baseRatePerBlock_ = CFG.CUSDt_IRM.baseRatePerBlock, + jumpMultiplierPerBlock_ = CFG.CUSDt_IRM.jumpMultiplierPerBlock, + kink_ = CFG.CUSDt_IRM.kink)) sp.add_compilation_target("CXTZ_IRM", IRM.InterestRateModel( - scale_ = 1000000000000000000, + scale_ = CFG.CXTZ_IRM.scale, multiplierPerBlock_ = CFG.CXTZ_IRM.multiplierPerBlock, - baseRatePerBlock_ = CFG.CXTZ_IRM.baseRatePerBlock)) + baseRatePerBlock_ = CFG.CXTZ_IRM.baseRatePerBlock, + jumpMultiplierPerBlock_ = CFG.CXTZ_IRM.jumpMultiplierPerBlock, + kink_ = CFG.CXTZ_IRM.kink)) diff --git a/deploy/deploy_script/config.json b/deploy/deploy_script/config.json index baeb0c50..701efab5 100644 --- a/deploy/deploy_script/config.json +++ b/deploy/deploy_script/config.json @@ -1,32 +1,8 @@ { - "tezosNode": "https://ghostnet.ecadinfra.com", - "conseilServer": { - "url": "https://conseil-ithaca.cryptonomic-infra.tech:443", - "apiKey": "328ac16b-5497-4e73-a97d-45af0e83425c", - "network": "ghostnet" - }, + "tezosNode": "https://michelson.previewnet.tezosx.nomadic-labs.com", + "chainId": "NetXY2oPPzkxUW1", + "feeSafetyMultiplier": 1.2, "originator": { - "pkh": "tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE", - "mnemonic": [ - "egg", - "zoo", - "cigar", - "oppose", - "shrimp", - "chair", - "wood", - "practice", - "trash", - "rice", - "spell", - "unhappy", - "disorder", - "zebra", - "kite" - ], - "email": "zdnmiequ.ulruuquy@teztnets.xyz", - "password": "dlfsJnRlHy", - "amount": "22841330521", - "activation_code": "77c3e768abfdb7e389f7ccd890c091ba42b84a88" + "pkh": "tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE" } -} \ No newline at end of file +} diff --git a/deploy/deploy_script/deploy.js b/deploy/deploy_script/deploy.js index c6f4ee43..6d9ac317 100644 --- a/deploy/deploy_script/deploy.js +++ b/deploy/deploy_script/deploy.js @@ -1,2 +1,5 @@ const { run } = require("./util.js"); -run(); +run().catch((error) => { + console.error(`[ERROR] Deployment failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/deploy_e2e.js b/deploy/deploy_script/deploy_e2e.js index 7ac73401..cb5dc0ac 100644 --- a/deploy/deploy_script/deploy_e2e.js +++ b/deploy/deploy_script/deploy_e2e.js @@ -1,2 +1,5 @@ const {runE2E} = require("./util.js") -runE2E(); +runE2E().catch((error) => { + console.error(`[ERROR] E2E deployment failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/package-lock.json b/deploy/deploy_script/package-lock.json index 50b63cd8..51c4e810 100644 --- a/deploy/deploy_script/package-lock.json +++ b/deploy/deploy_script/package-lock.json @@ -8,219 +8,503 @@ "name": "TezFinDeploy", "version": "1.0.0", "dependencies": { - "conseiljs": "5.3.0", - "conseiljs-softsigner": "^5.0.4-1", + "@taquito/signer": "^25.0.0", + "@taquito/taquito": "^25.0.0", "glob": "7.2.3", - "loglevel": "^1.8.1", - "node-fetch": "^2.6.9" + "loglevel": "^1.8.1" }, "devDependencies": {} }, - "node_modules/@types/node": { - "version": "11.11.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", - "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base-x": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz", - "integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==", + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", "dependencies": { - "safe-buffer": "^5.0.1" + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", "engines": { - "node": ">=0.6" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/bignumber.js": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", - "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==", + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", "engines": { - "node": "*" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/bip39": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.3.tgz", - "integrity": "sha512-P0dKrz4g0V0BjXfx7d9QNkJ/Txcz/k+hM9TnjqjUaXtuOfAvxXSw2rJw8DX0e3ZPwnK/IgDxoRqf0bvoVCqbMg==", + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", "dependencies": { - "@types/node": "11.11.6", - "create-hash": "^1.1.0", - "pbkdf2": "^3.0.9", - "randombytes": "^2.0.1" + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/blakejs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.1.0.tgz", - "integrity": "sha1-ad+S75U6qIylGjLfarHFShVfx6U=" + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^1.0.1" + } }, - "node_modules/bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + "node_modules/@stablelib/bytes": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/bytes/-/bytes-1.0.1.tgz", + "integrity": "sha512-Kre4Y4kdwuqL8BR2E9hV/R5sOrUj6NanZaZis0V6lX5yzqC3hBuVSDXUIBqQv/sCpmuWRiHLwqiT1pqqjuBXoQ==", + "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@stablelib/constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/constant-time/-/constant-time-1.0.1.tgz", + "integrity": "sha512-tNOs3uD0vSJcK6z1fvef4Y+buN7DXhzHDPqRLSXUel1UfqMB1PWNsnnAezrKfEwTLpN0cGH2p9NNjs6IqeD0eg==", + "license": "MIT" + }, + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" + }, + "node_modules/@stablelib/keyagreement": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/keyagreement/-/keyagreement-1.0.1.tgz", + "integrity": "sha512-VKL6xBwgJnI6l1jKrBAfn265cspaWBPAPEc62VBQrWHLqVgNRE09gQ/AnOEyKUWrrqfD+xSQ3u42gJjLDdMDQg==", + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@stablelib/bytes": "^1.0.1" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + "node_modules/@stablelib/nacl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@stablelib/nacl/-/nacl-1.0.4.tgz", + "integrity": "sha512-PJ2U/MrkXSKUM8C4qFs87WeCNxri7KQwR8Cdwm9q2sweGuAtTvOJGuW0F3N+zn+ySLPJA98SYWSSpogMJ1gCmw==", + "license": "MIT", + "dependencies": { + "@stablelib/poly1305": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/wipe": "^1.0.1", + "@stablelib/x25519": "^1.0.3", + "@stablelib/xsalsa20": "^1.0.2" + } }, - "node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=", + "node_modules/@stablelib/poly1305": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/poly1305/-/poly1305-1.0.1.tgz", + "integrity": "sha512-1HlG3oTSuQDOhSnLwJRKeTRSAdFNVB/1djy2ZbS35rBSJ/PFqx9cf9qatinWghC2UbfOYD8AcrtbUQl8WoxabA==", + "license": "MIT", "dependencies": { - "base-x": "^3.0.2" + "@stablelib/constant-time": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "node_modules/bs58check": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", - "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "node_modules/@stablelib/random": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", + "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", + "license": "MIT", "dependencies": { - "bs58": "^4.0.0", - "create-hash": "^1.1.0", - "safe-buffer": "^5.1.2" + "@stablelib/binary": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "node_modules/cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "node_modules/@stablelib/salsa20": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/salsa20/-/salsa20-1.0.2.tgz", + "integrity": "sha512-nfjKzw0KTKrrKBasEP+j7UP4I8Xudom8lVZIBCp0kQNARXq72IlSic0oabg2FC1NU68L4RdHrNJDd8bFwrphYA==", + "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "@stablelib/binary": "^1.0.1", + "@stablelib/constant-time": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "node_modules/@stablelib/x25519": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stablelib/x25519/-/x25519-1.0.3.tgz", + "integrity": "sha512-KnTbKmUhPhHavzobclVJQG5kuivH+qDLpe84iRqX3CLrKp881cF160JvXJ+hjn1aMyCwYOKeIZefIH/P5cJoRw==", + "license": "MIT", + "dependencies": { + "@stablelib/keyagreement": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/wipe": "^1.0.1" + } }, - "node_modules/conseiljs": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/conseiljs/-/conseiljs-5.3.0.tgz", - "integrity": "sha512-TaP3WbMSORcX/pNHM2L0PV3vwd7aX8bdwo49frtXCPUwlJEQj5/uAfeNAIdmvZHD90cYQg/s/9nR0+YP3ADbVQ==", + "node_modules/@stablelib/xsalsa20": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/xsalsa20/-/xsalsa20-1.0.2.tgz", + "integrity": "sha512-7XdBGbcNgBShmuhDXv1G1WPVCkjZdkb1oPMzSidO7Fve0MHntH6TjFkj5bfLI+aRE+61weO076vYpP/jmaAYog==", + "license": "MIT", "dependencies": { - "big-integer": "1.6.51", - "bignumber.js": "9.0.2", - "blakejs": "1.1.0", - "bs58check": "2.1.2", - "jsonpath-plus": "6.0.1", - "moo": "0.5.0", - "nearley": "2.19.1" + "@stablelib/binary": "^1.0.1", + "@stablelib/salsa20": "^1.0.2", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@taquito/core": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/core/-/core-25.0.0.tgz", + "integrity": "sha512-TlU+eMEn47mAmIjTFP2U4ReHRU05FsnyVn/c5DQnJC0psZ6LRJXimAIA1KQ94afsCDByewsXLN5fjKwn47l72A==", + "license": "Apache-2.0", + "dependencies": { + "json-stringify-safe": "^5.0.1" }, "engines": { - "node": ">=14.18.2", - "npm": ">=6.14.15" + "node": ">=22" } }, - "node_modules/conseiljs-softsigner": { - "version": "5.0.4-1", - "resolved": "https://registry.npmjs.org/conseiljs-softsigner/-/conseiljs-softsigner-5.0.4-1.tgz", - "integrity": "sha512-dZ6PyEcoBn6gJNO3B6+3LiW7fXBcGdAbAfjLU5hYgtLImP8W7jtADpqY80weWeJz7PRpSn9bmNK5atrCT+cBSw==", + "node_modules/@taquito/http-utils": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/http-utils/-/http-utils-25.0.0.tgz", + "integrity": "sha512-/nkwDeTXEW10lSQdyTrLgp9GIWQc2yi6wQLjEFvXveevrMjuA+lfFU8QoOOZxXH0GM42KEmU/y/VA1wcgl+gKQ==", + "license": "Apache-2.0", "dependencies": { - "bip39": "3.0.3", - "conseiljs": "5.3.0", - "ed25519-hd-key": "1.1.2", - "libsodium-wrappers-sumo": "0.7.8", - "secp256k1": "4.0.2" + "@taquito/core": "^25.0.0" }, "engines": { - "node": ">=12.20.1", - "npm": ">=6.14.10" + "node": ">=22" } }, - "node_modules/create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "node_modules/@taquito/local-forging": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/local-forging/-/local-forging-25.0.0.tgz", + "integrity": "sha512-sFeObpxya59PP5xFFsPWb3zLxASe3QwQqQ1fJWTPCs/Fph7/cxk5Kj0Oco961SC7CVSkG/ayz4/WINmm+oTlPA==", + "license": "Apache-2.0", "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" + "@taquito/core": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "fast-text-encoding": "^1.0.6" + }, + "engines": { + "node": ">=22" } }, - "node_modules/create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "node_modules/@taquito/local-forging/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/@taquito/michel-codec": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/michel-codec/-/michel-codec-25.0.0.tgz", + "integrity": "sha512-ITM3lM3tEAblPVdzQk5Rv2nLMMOalrXbnIt5b5St8OVYwqaDyfsH3fxSGIVDPxRQyyBdgJ1403aVu6768/8m4Q==", + "license": "Apache-2.0", "dependencies": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "@taquito/core": "^25.0.0" + }, + "engines": { + "node": ">=22" } }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=" + "node_modules/@taquito/michelson-encoder": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/michelson-encoder/-/michelson-encoder-25.0.0.tgz", + "integrity": "sha512-BWwAAwBhY3zngcMK8SkmBWDZ5aNVRnHlHvi7Xu6jnGW109yM3n9awxxCrFD8jOXPb3dly+/pulGnkv28+EvIIQ==", + "license": "Apache-2.0", + "dependencies": { + "@taquito/core": "^25.0.0", + "@taquito/rpc": "^25.0.0", + "@taquito/signer": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "fast-json-stable-stringify": "^2.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@taquito/michelson-encoder/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" }, - "node_modules/ed25519-hd-key": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/ed25519-hd-key/-/ed25519-hd-key-1.1.2.tgz", - "integrity": "sha512-/0y9y6N7vM6Kj5ASr9J9wcMVDTtygxSOvYX+PJiMD7VcxCx2G03V5bLRl8Dug9EgkLFsLhGqBtQWQRcElEeWTA==", + "node_modules/@taquito/rpc": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/rpc/-/rpc-25.0.0.tgz", + "integrity": "sha512-qqV4boldGOavsM6ZzK6xky4xAEyllYDyIO5C16cvkoUzoMCCsWMpRuXa1csSrQc+0IECkkDurNLTbrMJPne9tA==", + "license": "Apache-2.0", "dependencies": { - "bip39": "3.0.2", - "create-hmac": "1.1.7", - "tweetnacl": "1.0.3" + "@taquito/core": "^25.0.0", + "@taquito/http-utils": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2" + }, + "engines": { + "node": ">=22" } }, - "node_modules/ed25519-hd-key/node_modules/bip39": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.2.tgz", - "integrity": "sha512-J4E1r2N0tUylTKt07ibXvhpT2c5pyAFgvuA5q1H9uDy6dEGpjV8jmymh3MTYJDLCNbIVClSB9FbND49I6N24MQ==", + "node_modules/@taquito/rpc/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/@taquito/signer": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/signer/-/signer-25.0.0.tgz", + "integrity": "sha512-UDHwWd3O0VwZjbEw0NDsoQoIhoQv+xHxb/XAKW7ruSjUT6OkH5whhHpgTZ+T2eJLPDBZLY1RH8IdSl+6ZFtUXg==", + "license": "Apache-2.0", "dependencies": { - "@types/node": "11.11.6", - "create-hash": "^1.1.0", - "pbkdf2": "^3.0.9", - "randombytes": "^2.0.1" + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.2.0", + "@scure/bip39": "^2.2.0", + "@stablelib/nacl": "^1.0.4", + "@taquito/core": "^25.0.0", + "@taquito/utils": "^25.0.0", + "@types/bn.js": "^5.1.5", + "bn.js": "^5.2.2", + "typedarray-to-buffer": "^4.0.0" + }, + "engines": { + "node": ">=22" } }, - "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "node_modules/@taquito/signer/node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, + "node_modules/@taquito/taquito": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/taquito/-/taquito-25.0.0.tgz", + "integrity": "sha512-MBNKXK1CrOlwBrI0IkjWbVcuwY238ZF8B6fuf2qA0uYp+UmVjukUJ4QCvRf+bayWzfiWBbkYP0upY6VGV3C1VQ==", + "license": "Apache-2.0", "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" + "@taquito/core": "^25.0.0", + "@taquito/http-utils": "^25.0.0", + "@taquito/local-forging": "^25.0.0", + "@taquito/michel-codec": "^25.0.0", + "@taquito/michelson-encoder": "^25.0.0", + "@taquito/rpc": "^25.0.0", + "@taquito/signer": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "rxjs": "^7.8.2" + }, + "engines": { + "node": ">=22" } }, + "node_modules/@taquito/taquito/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/@taquito/utils": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/utils/-/utils-25.0.0.tgz", + "integrity": "sha512-I/9hndZcx0yL6f1djPMhgxCpAopbh/E1ta5kYGs3AxDGrYKt0QpbWAvvUV7fwU9kgzHRQlm7fX8DP7zUbz7ntg==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.2.0", + "@taquito/core": "^25.0.0", + "@types/bs58check": "^2.1.2", + "bignumber.js": "^10.0.2", + "blakejs": "^1.2.1", + "bs58check": "^3.0.1", + "buffer": "^6.0.3", + "typedarray-to-buffer": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@taquito/utils/node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, + "node_modules/@taquito/utils/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/@taquito/utils/node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "license": "MIT" + }, + "node_modules/@taquito/utils/node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/@taquito/utils/node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/@taquito/utils/node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-xpXaQlOIY1KoXlA/ytHGHpEIU87PJt+g9SH7nC6HdCgaBwT2IEZIwBMHbjuX6BpnfbiUMlmwqurdLDwXpcdmSA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "11.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", + "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-text-encoding": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", + "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==", + "license": "Apache-2.0" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -245,37 +529,25 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" }, "node_modules/inflight": { "version": "1.0.6", @@ -291,26 +563,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/jsonpath-plus": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-6.0.1.tgz", - "integrity": "sha512-EvGovdvau6FyLexFH2OeXfIITlgIbgZoAZe3usiySeaIDm5QS+A10DKNpaPBBqqRSZr2HN6HVNXxtwUAr2apEw==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/libsodium-sumo": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.9.tgz", - "integrity": "sha512-DcfJ57zlSlcmQU4s8KOX78pT0zKx5S9RLi0oyDuoIgm4K95+VNSaOidK/y9lUK4lxft14PtTPjoBy8tmLk1TDQ==" - }, - "node_modules/libsodium-wrappers-sumo": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz", - "integrity": "sha512-a36GRLb59E5Zx8br6Xvwk6QsnYWodkCJFvNQxnhrvL9t9i4v6v3V/cv3h0/pxehf35ljVCWqoi0CawMQ8C1JmA==", - "dependencies": { - "libsodium-sumo": "^0.7.0" - } + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" }, "node_modules/loglevel": { "version": "1.8.1", @@ -324,26 +581,6 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, - "node_modules/md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -355,63 +592,6 @@ "node": "*" } }, - "node_modules/moo": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.0.tgz", - "integrity": "sha512-AMv6iqhTEd5vT/cQlH6cammKS5ekyHhyqTRKi5zKMWl1RTyFnQ3ohPSBNSm8ySe2wlxSKwDonr9D5ZT44mdO3g==" - }, - "node_modules/nearley": { - "version": "2.19.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.19.1.tgz", - "integrity": "sha512-xq47GIUGXxU9vQg7g/y1o1xuKnkO7ev4nRWqftmQrLkfnE/FjRqDaGOUakM8XHPn/6pW3bGjU2wgoJyId90rqg==", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6", - "semver": "^5.4.1" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - } - }, - "node_modules/node-addon-api": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", - "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" - }, - "node_modules/node-fetch": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", - "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz", - "integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -428,80 +608,25 @@ "node": ">=0.10.0" } }, - "node_modules/pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", - "dependencies": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "tslib": "^2.1.0" } }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "engines": { - "node": ">=0.12" - } + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, - "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/typedarray-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-4.0.0.tgz", + "integrity": "sha512-6dOYeZfS3O9RtRD1caom0sMxgK59b27+IwoNy8RDPsmslSGOyU+mpTamlaIW7aNKi90ZQZ9DFaZL3YRoiSCULQ==", "funding": [ { "type": "github", @@ -515,279 +640,394 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, - "node_modules/secp256k1": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.2.tgz", - "integrity": "sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==", - "hasInstallScript": true, - "dependencies": { - "elliptic": "^6.5.2", - "node-addon-api": "^2.0.0", - "node-gyp-build": "^4.2.0" + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + }, + "dependencies": { + "@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "requires": { + "@noble/hashes": "1.8.0" }, - "engines": { - "node": ">=10.0.0" + "dependencies": { + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + } } }, - "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "bin": { - "semver": "bin/semver" - } + "@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==" }, - "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - }, - "bin": { - "sha.js": "bin.js" + "@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==" + }, + "@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "requires": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" + "@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "requires": { + "@stablelib/int": "^1.0.1" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "@stablelib/bytes": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/bytes/-/bytes-1.0.1.tgz", + "integrity": "sha512-Kre4Y4kdwuqL8BR2E9hV/R5sOrUj6NanZaZis0V6lX5yzqC3hBuVSDXUIBqQv/sCpmuWRiHLwqiT1pqqjuBXoQ==" }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + "@stablelib/constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/constant-time/-/constant-time-1.0.1.tgz", + "integrity": "sha512-tNOs3uD0vSJcK6z1fvef4Y+buN7DXhzHDPqRLSXUel1UfqMB1PWNsnnAezrKfEwTLpN0cGH2p9NNjs6IqeD0eg==" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==" }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + "@stablelib/keyagreement": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/keyagreement/-/keyagreement-1.0.1.tgz", + "integrity": "sha512-VKL6xBwgJnI6l1jKrBAfn265cspaWBPAPEc62VBQrWHLqVgNRE09gQ/AnOEyKUWrrqfD+xSQ3u42gJjLDdMDQg==", + "requires": { + "@stablelib/bytes": "^1.0.1" + } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "@stablelib/nacl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@stablelib/nacl/-/nacl-1.0.4.tgz", + "integrity": "sha512-PJ2U/MrkXSKUM8C4qFs87WeCNxri7KQwR8Cdwm9q2sweGuAtTvOJGuW0F3N+zn+ySLPJA98SYWSSpogMJ1gCmw==", + "requires": { + "@stablelib/poly1305": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/wipe": "^1.0.1", + "@stablelib/x25519": "^1.0.3", + "@stablelib/xsalsa20": "^1.0.2" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - } - }, - "dependencies": { - "@types/node": { - "version": "11.11.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", - "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" + "@stablelib/poly1305": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/poly1305/-/poly1305-1.0.1.tgz", + "integrity": "sha512-1HlG3oTSuQDOhSnLwJRKeTRSAdFNVB/1djy2ZbS35rBSJ/PFqx9cf9qatinWghC2UbfOYD8AcrtbUQl8WoxabA==", + "requires": { + "@stablelib/constant-time": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } }, - "balanced-match": { + "@stablelib/random": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "base-x": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz", - "integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==", + "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", + "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", "requires": { - "safe-buffer": "^5.0.1" + "@stablelib/binary": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" - }, - "bignumber.js": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", - "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==" - }, - "bip39": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.3.tgz", - "integrity": "sha512-P0dKrz4g0V0BjXfx7d9QNkJ/Txcz/k+hM9TnjqjUaXtuOfAvxXSw2rJw8DX0e3ZPwnK/IgDxoRqf0bvoVCqbMg==", + "@stablelib/salsa20": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/salsa20/-/salsa20-1.0.2.tgz", + "integrity": "sha512-nfjKzw0KTKrrKBasEP+j7UP4I8Xudom8lVZIBCp0kQNARXq72IlSic0oabg2FC1NU68L4RdHrNJDd8bFwrphYA==", "requires": { - "@types/node": "11.11.6", - "create-hash": "^1.1.0", - "pbkdf2": "^3.0.9", - "randombytes": "^2.0.1" + "@stablelib/binary": "^1.0.1", + "@stablelib/constant-time": "^1.0.1", + "@stablelib/wipe": "^1.0.1" } }, - "blakejs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.1.0.tgz", - "integrity": "sha1-ad+S75U6qIylGjLfarHFShVfx6U=" - }, - "bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + "@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==" }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "@stablelib/x25519": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stablelib/x25519/-/x25519-1.0.3.tgz", + "integrity": "sha512-KnTbKmUhPhHavzobclVJQG5kuivH+qDLpe84iRqX3CLrKp881cF160JvXJ+hjn1aMyCwYOKeIZefIH/P5cJoRw==", "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@stablelib/keyagreement": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/wipe": "^1.0.1" } }, - "brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" - }, - "bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=", + "@stablelib/xsalsa20": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/xsalsa20/-/xsalsa20-1.0.2.tgz", + "integrity": "sha512-7XdBGbcNgBShmuhDXv1G1WPVCkjZdkb1oPMzSidO7Fve0MHntH6TjFkj5bfLI+aRE+61weO076vYpP/jmaAYog==", "requires": { - "base-x": "^3.0.2" + "@stablelib/binary": "^1.0.1", + "@stablelib/salsa20": "^1.0.2", + "@stablelib/wipe": "^1.0.1" } }, - "bs58check": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", - "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "@taquito/core": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/core/-/core-25.0.0.tgz", + "integrity": "sha512-TlU+eMEn47mAmIjTFP2U4ReHRU05FsnyVn/c5DQnJC0psZ6LRJXimAIA1KQ94afsCDByewsXLN5fjKwn47l72A==", "requires": { - "bs58": "^4.0.0", - "create-hash": "^1.1.0", - "safe-buffer": "^5.1.2" + "json-stringify-safe": "^5.0.1" } }, - "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "@taquito/http-utils": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/http-utils/-/http-utils-25.0.0.tgz", + "integrity": "sha512-/nkwDeTXEW10lSQdyTrLgp9GIWQc2yi6wQLjEFvXveevrMjuA+lfFU8QoOOZxXH0GM42KEmU/y/VA1wcgl+gKQ==", "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "@taquito/core": "^25.0.0" } }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "@taquito/local-forging": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/local-forging/-/local-forging-25.0.0.tgz", + "integrity": "sha512-sFeObpxya59PP5xFFsPWb3zLxASe3QwQqQ1fJWTPCs/Fph7/cxk5Kj0Oco961SC7CVSkG/ayz4/WINmm+oTlPA==", + "requires": { + "@taquito/core": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "fast-text-encoding": "^1.0.6" + }, + "dependencies": { + "bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==" + } + } }, - "conseiljs": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/conseiljs/-/conseiljs-5.3.0.tgz", - "integrity": "sha512-TaP3WbMSORcX/pNHM2L0PV3vwd7aX8bdwo49frtXCPUwlJEQj5/uAfeNAIdmvZHD90cYQg/s/9nR0+YP3ADbVQ==", + "@taquito/michel-codec": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/michel-codec/-/michel-codec-25.0.0.tgz", + "integrity": "sha512-ITM3lM3tEAblPVdzQk5Rv2nLMMOalrXbnIt5b5St8OVYwqaDyfsH3fxSGIVDPxRQyyBdgJ1403aVu6768/8m4Q==", "requires": { - "big-integer": "1.6.51", - "bignumber.js": "9.0.2", - "blakejs": "1.1.0", - "bs58check": "2.1.2", - "jsonpath-plus": "6.0.1", - "moo": "0.5.0", - "nearley": "2.19.1" - } - }, - "conseiljs-softsigner": { - "version": "5.0.4-1", - "resolved": "https://registry.npmjs.org/conseiljs-softsigner/-/conseiljs-softsigner-5.0.4-1.tgz", - "integrity": "sha512-dZ6PyEcoBn6gJNO3B6+3LiW7fXBcGdAbAfjLU5hYgtLImP8W7jtADpqY80weWeJz7PRpSn9bmNK5atrCT+cBSw==", + "@taquito/core": "^25.0.0" + } + }, + "@taquito/michelson-encoder": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/michelson-encoder/-/michelson-encoder-25.0.0.tgz", + "integrity": "sha512-BWwAAwBhY3zngcMK8SkmBWDZ5aNVRnHlHvi7Xu6jnGW109yM3n9awxxCrFD8jOXPb3dly+/pulGnkv28+EvIIQ==", "requires": { - "bip39": "3.0.3", - "conseiljs": "5.3.0", - "ed25519-hd-key": "1.1.2", - "libsodium-wrappers-sumo": "0.7.8", - "secp256k1": "4.0.2" + "@taquito/core": "^25.0.0", + "@taquito/rpc": "^25.0.0", + "@taquito/signer": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "fast-json-stable-stringify": "^2.1.0" + }, + "dependencies": { + "bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==" + } } }, - "create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "@taquito/rpc": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/rpc/-/rpc-25.0.0.tgz", + "integrity": "sha512-qqV4boldGOavsM6ZzK6xky4xAEyllYDyIO5C16cvkoUzoMCCsWMpRuXa1csSrQc+0IECkkDurNLTbrMJPne9tA==", "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" + "@taquito/core": "^25.0.0", + "@taquito/http-utils": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2" + }, + "dependencies": { + "bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==" + } } }, - "create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "@taquito/signer": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/signer/-/signer-25.0.0.tgz", + "integrity": "sha512-UDHwWd3O0VwZjbEw0NDsoQoIhoQv+xHxb/XAKW7ruSjUT6OkH5whhHpgTZ+T2eJLPDBZLY1RH8IdSl+6ZFtUXg==", "requires": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.2.0", + "@scure/bip39": "^2.2.0", + "@stablelib/nacl": "^1.0.4", + "@taquito/core": "^25.0.0", + "@taquito/utils": "^25.0.0", + "@types/bn.js": "^5.1.5", + "bn.js": "^5.2.2", + "typedarray-to-buffer": "^4.0.0" + }, + "dependencies": { + "bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==" + } } }, - "discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=" + "@taquito/taquito": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/taquito/-/taquito-25.0.0.tgz", + "integrity": "sha512-MBNKXK1CrOlwBrI0IkjWbVcuwY238ZF8B6fuf2qA0uYp+UmVjukUJ4QCvRf+bayWzfiWBbkYP0upY6VGV3C1VQ==", + "requires": { + "@taquito/core": "^25.0.0", + "@taquito/http-utils": "^25.0.0", + "@taquito/local-forging": "^25.0.0", + "@taquito/michel-codec": "^25.0.0", + "@taquito/michelson-encoder": "^25.0.0", + "@taquito/rpc": "^25.0.0", + "@taquito/signer": "^25.0.0", + "@taquito/utils": "^25.0.0", + "bignumber.js": "^10.0.2", + "rxjs": "^7.8.2" + }, + "dependencies": { + "bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==" + } + } }, - "ed25519-hd-key": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/ed25519-hd-key/-/ed25519-hd-key-1.1.2.tgz", - "integrity": "sha512-/0y9y6N7vM6Kj5ASr9J9wcMVDTtygxSOvYX+PJiMD7VcxCx2G03V5bLRl8Dug9EgkLFsLhGqBtQWQRcElEeWTA==", + "@taquito/utils": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@taquito/utils/-/utils-25.0.0.tgz", + "integrity": "sha512-I/9hndZcx0yL6f1djPMhgxCpAopbh/E1ta5kYGs3AxDGrYKt0QpbWAvvUV7fwU9kgzHRQlm7fX8DP7zUbz7ntg==", "requires": { - "bip39": "3.0.2", - "create-hmac": "1.1.7", - "tweetnacl": "1.0.3" + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.2.0", + "@taquito/core": "^25.0.0", + "@types/bs58check": "^2.1.2", + "bignumber.js": "^10.0.2", + "blakejs": "^1.2.1", + "bs58check": "^3.0.1", + "buffer": "^6.0.3", + "typedarray-to-buffer": "^4.0.0" }, "dependencies": { - "bip39": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.2.tgz", - "integrity": "sha512-J4E1r2N0tUylTKt07ibXvhpT2c5pyAFgvuA5q1H9uDy6dEGpjV8jmymh3MTYJDLCNbIVClSB9FbND49I6N24MQ==", + "base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" + }, + "bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==" + }, + "blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==" + }, + "bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", "requires": { - "@types/node": "11.11.6", - "create-hash": "^1.1.0", - "pbkdf2": "^3.0.9", - "randombytes": "^2.0.1" + "base-x": "^4.0.0" + } + }, + "bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "requires": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + } } } } }, - "elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "requires": { + "@types/node": "*" + } + }, + "@types/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-xpXaQlOIY1KoXlA/ytHGHpEIU87PJt+g9SH7nC6HdCgaBwT2IEZIwBMHbjuX6BpnfbiUMlmwqurdLDwXpcdmSA==", "requires": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" + "@types/node": "*" } }, + "@types/node": { + "version": "11.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", + "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-text-encoding": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", + "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -806,34 +1046,10 @@ "path-is-absolute": "^1.0.0" } }, - "hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", - "requires": { - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" - } - }, - "hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "requires": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "requires": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "inflight": { "version": "1.0.6", @@ -849,49 +1065,16 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "jsonpath-plus": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-6.0.1.tgz", - "integrity": "sha512-EvGovdvau6FyLexFH2OeXfIITlgIbgZoAZe3usiySeaIDm5QS+A10DKNpaPBBqqRSZr2HN6HVNXxtwUAr2apEw==" - }, - "libsodium-sumo": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.9.tgz", - "integrity": "sha512-DcfJ57zlSlcmQU4s8KOX78pT0zKx5S9RLi0oyDuoIgm4K95+VNSaOidK/y9lUK4lxft14PtTPjoBy8tmLk1TDQ==" - }, - "libsodium-wrappers-sumo": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz", - "integrity": "sha512-a36GRLb59E5Zx8br6Xvwk6QsnYWodkCJFvNQxnhrvL9t9i4v6v3V/cv3h0/pxehf35ljVCWqoi0CawMQ8C1JmA==", - "requires": { - "libsodium-sumo": "^0.7.0" - } + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, "loglevel": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==" }, - "md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" - }, - "minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" - }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -900,41 +1083,6 @@ "brace-expansion": "^1.1.7" } }, - "moo": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.0.tgz", - "integrity": "sha512-AMv6iqhTEd5vT/cQlH6cammKS5ekyHhyqTRKi5zKMWl1RTyFnQ3ohPSBNSm8ySe2wlxSKwDonr9D5ZT44mdO3g==" - }, - "nearley": { - "version": "2.19.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.19.1.tgz", - "integrity": "sha512-xq47GIUGXxU9vQg7g/y1o1xuKnkO7ev4nRWqftmQrLkfnE/FjRqDaGOUakM8XHPn/6pW3bGjU2wgoJyId90rqg==", - "requires": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6", - "semver": "^5.4.1" - } - }, - "node-addon-api": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", - "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" - }, - "node-fetch": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", - "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-gyp-build": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz", - "integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q==" - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -948,129 +1096,23 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, - "pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", - "requires": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, - "railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=" - }, - "randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "requires": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - } - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" - }, - "ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "tslib": "^2.1.0" } }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "secp256k1": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.2.tgz", - "integrity": "sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==", - "requires": { - "elliptic": "^6.5.2", - "node-addon-api": "^2.0.0", - "node-gyp-build": "^4.2.0" - } - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } + "typedarray-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-4.0.0.tgz", + "integrity": "sha512-6dOYeZfS3O9RtRD1caom0sMxgK59b27+IwoNy8RDPsmslSGOyU+mpTamlaIW7aNKi90ZQZ9DFaZL3YRoiSCULQ==" }, "wrappy": { "version": "1.0.2", diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index 41e34332..66b8ce48 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -3,14 +3,17 @@ "version": "1.0.0", "main": "deploy.js", "dependencies": { - "conseiljs": "5.3.0", - "conseiljs-softsigner": "^5.0.4-1", + "@taquito/signer": "^25.0.0", + "@taquito/taquito": "^25.0.0", "glob": "7.2.3", - "loglevel": "^1.8.1", - "node-fetch": "^2.6.9" + "loglevel": "^1.8.1" }, "devDependencies": {}, "scripts": { + "check": "node -e \"require('./util').checkConnection().catch(error => { console.error('[ERROR] ' + error.message); process.exitCode = 1; })\"", + "deploy": "node deploy.js", + "deploy:e2e": "node deploy_e2e.js", + "prepare:deploy": "node prepare.js", "test": "echo \"Error: no test specified\" && exit 1" } -} \ No newline at end of file +} diff --git a/deploy/deploy_script/prepare.js b/deploy/deploy_script/prepare.js new file mode 100644 index 00000000..3e99c9ea --- /dev/null +++ b/deploy/deploy_script/prepare.js @@ -0,0 +1,9 @@ +const path = require('path'); +const { syncDeploymentOriginator } = require('./util.js'); + +const deployResultPath = path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); + +syncDeploymentOriginator(deployResultPath).catch((error) => { + console.error(`[ERROR] Deployment preparation failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index d75f6a08..53fd021c 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -1,52 +1,199 @@ const fs = require('fs'); - -var os = require("os"); +const os = require('os'); const path = require('path'); -const fetch = require('node-fetch'); -const log = require('loglevel'); -const conseiljs = require('conseiljs'); -const conseiljssoftsigner = require('conseiljs-softsigner'); const glob = require('glob'); +const { InMemorySigner } = require('@taquito/signer'); +const { TezosToolkit } = require('@taquito/taquito'); -const { registerFetch, registerLogger } = conseiljs; -const { KeyStoreUtils } = conseiljssoftsigner; +const configPath = path.join(__dirname, 'config.json'); +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +function getRequiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required. Add it to your shell environment; do not commit private keys.`); + } + return value; +} -//init conseil -const logger = log.getLogger('conseiljs'); -logger.setLevel(log.levels.ERROR, false); -registerLogger(logger); -registerFetch(fetch); +async function initAccount() { + if (process.env.TEZOS_PRIVATE_KEY) { + return InMemorySigner.fromSecretKey(process.env.TEZOS_PRIVATE_KEY.trim()); + } + const mnemonic = process.env.TEZOS_MNEMONIC; + if (!mnemonic) { + getRequiredEnv('TEZOS_PRIVATE_KEY'); + } + const fundraiserEmail = process.env.TEZOS_FUNDS_EMAIL; + const fundraiserPassword = process.env.TEZOS_FUNDS_PASSWORD; + if (fundraiserEmail || fundraiserPassword) { + if (!fundraiserEmail || !fundraiserPassword) { + throw new Error('Set both TEZOS_FUNDS_EMAIL and TEZOS_FUNDS_PASSWORD for a fundraiser account.'); + } + return InMemorySigner.fromFundraiser(fundraiserEmail, fundraiserPassword, mnemonic); + } -const config = JSON.parse(fs.readFileSync("deploy/deploy_script/config.json", 'utf8')); + return InMemorySigner.fromMnemonic({ + mnemonic, + password: process.env.TEZOS_MNEMONIC_PASSWORD || '', + derivationPath: process.env.TEZOS_DERIVATION_PATH || "44'/1729'/0'/0'", + }); +} -const tezosNode = config['tezosNode'] +function parseMichelsonFile(filePath, kind) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Could not parse ${kind} JSON at ${filePath}: ${error.message}`); + } +} -let faucetAccount = config['originator'] -let keystore; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +function isTransientRpcError(error) { + const message = String(error && error.message ? error.message : error); + return /504|502|503|408|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|Gateway Time-out|Bad Gateway|Service Unavailable/i.test( + message, + ); +} -async function initAccount() { - keystore = await KeyStoreUtils.restoreIdentityFromFundraiser(faucetAccount['mnemonic'].join(' '), - faucetAccount['email'], faucetAccount['password'], faucetAccount['pkh']); +async function rpcGetJson(pathname) { + const base = config.tezosNode.replace(/\/$/, ''); + const response = await fetch(`${base}${pathname}`); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Http error response: (${response.status}) ${body}`); + } + return response.json(); +} + +async function findOriginationAddress(opHash, { + maxAttempts = Number(process.env.TEZOS_CONFIRMATION_ATTEMPTS || 90), + pollIntervalMs = Number(process.env.TEZOS_CONFIRMATION_POLL_MS || 3000), + lookbackBlocks = Number(process.env.TEZOS_CONFIRMATION_LOOKBACK || 40), +} = {}) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const header = await rpcGetJson('/chains/main/blocks/head/header'); + for (let delta = 0; delta < lookbackBlocks; delta++) { + const level = header.level - delta; + if (level < 1) { + break; + } + const block = await rpcGetJson(`/chains/main/blocks/${level}`); + for (const passOps of block.operations || []) { + for (const op of passOps) { + if (op.hash !== opHash) { + continue; + } + for (const content of op.contents || []) { + const result = content.metadata && content.metadata.operation_result; + if (!result) { + continue; + } + if (result.status === 'applied' && result.originated_contracts?.length) { + return result.originated_contracts[0]; + } + if (result.status && result.status !== 'applied') { + throw new Error( + `Operation ${opHash} finished with status ${result.status}`, + ); + } + } + } + } + } + console.log( + `[INFO] Waiting for ${opHash} to appear in a block (attempt ${attempt}/${maxAttempts})`, + ); + } catch (error) { + if (!isTransientRpcError(error)) { + throw error; + } + console.log( + `[WARN] Transient RPC error confirming ${opHash} (attempt ${attempt}/${maxAttempts}): ${error.message}`, + ); + } + await sleep(pollIntervalMs); + } + throw new Error(`Timed out waiting for confirmation of ${opHash}`); +} + +async function deployMichelsonContract(tezos, contract, initialStorage, name) { + const feeSafetyMultiplier = Number( + process.env.TEZOS_FEE_SAFETY_MULTIPLIER || config.feeSafetyMultiplier || 1.2, + ); + if (!Number.isFinite(feeSafetyMultiplier) || feeSafetyMultiplier < 1) { + throw new Error('TEZOS_FEE_SAFETY_MULTIPLIER must be a number greater than or equal to 1.'); + } + + const params = { + balance: '0', + code: contract, + init: initialStorage, + }; + const estimate = await tezos.estimate.originate(params); + const fee = Math.ceil(estimate.suggestedFeeMutez * feeSafetyMultiplier); + console.log( + `[INFO] ${name} fee estimate: ${estimate.suggestedFeeMutez} mutez; using ${fee} mutez`, + ); + const operation = await tezos.contract.originate({ + ...params, + fee, + gasLimit: estimate.gasLimit, + storageLimit: estimate.storageLimit, + }); + console.log(`[INFO] Injected ${name}: ${operation.hash}`); + + let address; + try { + // Keep Taquito's waiter short; Previewnet RPC often 504s on long polls. + await operation.confirmation(1, 45); + address = (await operation.contract()).address; + } catch (error) { + console.log( + `[WARN] Taquito confirmation failed for ${name} (${error.message}); falling back to RPC block scan`, + ); + address = await findOriginationAddress(operation.hash); + } + + console.log(`[INFO] Originated ${name}: ${address}`); + return address; } -async function deployMichelsonContract(contract, initialStorage) { - const signer = await conseiljssoftsigner.SoftSigner.createSigner(conseiljs.TezosMessageUtils.writeKeyWithHint(keystore.secretKey, 'edsk')); - const head = await conseiljs.TezosNodeReader.getBlockHead(tezosNode) - const result = await conseiljs.TezosNodeWriter.sendContractOriginationOperation( - tezosNode, signer, keystore, 0, undefined, 100000, 60000, 400000, - contract, initialStorage, conseiljs.TezosParameterFormat.Micheline) +async function createTezosClient() { + const tezos = new TezosToolkit(config.tezosNode); + const signer = await initAccount(); + tezos.setProvider({ signer }); - const groupid = result['operationGroupID'].replace(/\"/g, '').replace(/\n/, ''); // clean up RPC output - console.log(`[Info] Injected operation group id ${groupid}`); + const chainId = await tezos.rpc.getChainId(); + if (config.chainId && config.chainId !== chainId) { + throw new Error(`Configured chain ID ${config.chainId} does not match RPC chain ID ${chainId}`); + } + const publicKeyHash = await signer.publicKeyHash(); + if (config.originator?.pkh && config.originator.pkh !== publicKeyHash) { + throw new Error(`Configured originator ${config.originator.pkh} does not match signing key ${publicKeyHash}`); + } + return { tezos, publicKeyHash, chainId }; +} - const conseilResult = await conseiljs.TezosNodeReader.awaitOperationConfirmation(tezosNode, head.header.level - 1, groupid, 6).then(res => { if (res['contents'][0]['metadata']['operation_result']['status'] === "applied") return res; else throw new Error("operation status not applied"); }).catch((error) => { console.log(error) }); - console.log(`[Info] Originated contract at ${conseilResult['contents'][0]['metadata']['operation_result']['originated_contracts'][0]}`); +async function checkConnection() { + const { tezos, publicKeyHash, chainId } = await createTezosClient(); + const balance = await tezos.tz.getBalance(publicKeyHash); + console.log(`[INFO] Connected to ${config.tezosNode} (${chainId})`); + console.log(`[INFO] Signer ${publicKeyHash} has ${balance.toString()} mutez available`); +} - return conseilResult['contents'][0]['metadata']['operation_result']['originated_contracts'][0]; +async function syncDeploymentOriginator(deployResultPath) { + const { publicKeyHash } = await createTezosClient(); + const deployResult = readDeployResult(deployResultPath); + deployResult.OriginatorAddress = publicKeyHash; + writeDeployResult(deployResultPath, JSON.stringify(deployResult, null, ' ')); + console.log(`[INFO] Set OriginatorAddress to ${publicKeyHash} in ${deployResultPath}`); } function getDirectories(path) { @@ -56,20 +203,19 @@ function getDirectories(path) { } function getMichelsonCode(directory) { - pattern = path.join(directory, "*contract.json") - return findFirstFile(pattern) + return findFirstFile(path.join(directory, "*contract.json"), 'contract') } function getMichelsonStorage(directory) { - pattern = path.join(directory, "*storage.json") - return findFirstFile(pattern) + return findFirstFile(path.join(directory, "*storage.json"), 'storage') } -function findFirstFile(pattern) { - const files = glob(pattern, { sync: true }) - filePath = files[0] - let fileRawData = fs.readFileSync(filePath, 'utf8'); - return fileRawData +function findFirstFile(pattern, kind) { + const files = glob.sync(pattern); + if (files.length !== 1) { + throw new Error(`Expected one ${kind} file matching ${pattern}, found ${files.length}`); + } + return parseMichelsonFile(files[0], kind); } function readDeployResult(jsonPath) { @@ -84,56 +230,46 @@ function writeDeployResult(jsonPath, data) { fs.writeFileSync(jsonPath, data + os.EOL) } -async function run() { - console.log(`[INFO] Log level ${log.getLevel()}`); - compiledContractsPath = path.join(__dirname, "../../TezFinBuild/compiled_contracts"); - const directories = getDirectories(compiledContractsPath); - console.log(directories); - await initAccount(); - deployResultPath = path.join(__dirname, "../../TezFinBuild/deploy_result/deploy.json") - jsonDeployResult = readDeployResult(deployResultPath); +async function runDeployment(compiledContractsPath, deployResultPath) { + const { tezos, publicKeyHash, chainId } = await createTezosClient(); + console.log(`[INFO] Deploying from ${publicKeyHash} to ${config.tezosNode} (${chainId})`); + + const directories = getDirectories(compiledContractsPath).sort(); + if (directories.length === 0) { + throw new Error(`No compiled contract directories found in ${compiledContractsPath}`); + } + const jsonDeployResult = readDeployResult(deployResultPath); for (const directoryName of directories) { - console.log(`~~ deploy ${directoryName}`); - directoryPath = path.join(compiledContractsPath, directoryName); - code = getMichelsonCode(directoryPath); - storage = getMichelsonStorage(directoryPath); + if (jsonDeployResult[directoryName]) { + console.log( + `[INFO] Skipping ${directoryName}; already in deploy.json: ${jsonDeployResult[directoryName]}`, + ); + continue; + } + console.log(`[INFO] Deploying ${directoryName}`); + const directoryPath = path.join(compiledContractsPath, directoryName); + const code = getMichelsonCode(directoryPath); + const storage = getMichelsonStorage(directoryPath); - contractAddress = await deployMichelsonContract(code, storage); + const contractAddress = await deployMichelsonContract(tezos, code, storage, directoryName); jsonDeployResult[directoryName] = contractAddress; writeDeployResult(deployResultPath, JSON.stringify(jsonDeployResult, null, ' ')); - console.log('~~~~~~~~~~~SUCCESS~~~~~~~~~~~~~~'); + console.log(`[INFO] ${directoryName} deployed successfully`); } } +async function run() { + return runDeployment( + path.join(__dirname, '../../TezFinBuild/compiled_contracts'), + path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'), + ); +} + const runE2E = async () => { - console.log(`[INFO] Log level ${log.getLevel()}`); - compiledContractsPath = path.join( - __dirname, - "../../e2e/compiled_contracts" - ); - const directories = getDirectories(compiledContractsPath); - console.log(directories); - await initAccount(); - deployResultPath = path.join( - __dirname, - "../../e2e/deploy_result/deploy.json" + return runDeployment( + path.join(__dirname, '../../e2e/compiled_contracts'), + path.join(__dirname, '../../e2e/deploy_result/deploy.json'), ); - jsonDeployResult = readDeployResult(deployResultPath); - for (const directoryName of directories) { - console.log(`~~ deploy ${directoryName}`); - directoryPath = path.join(compiledContractsPath, directoryName); - code = getMichelsonCode(directoryPath); - storage = getMichelsonStorage(directoryPath); - - contractAddress = await deployMichelsonContract(code, storage); - - jsonDeployResult[directoryName] = contractAddress; - writeDeployResult( - deployResultPath, - JSON.stringify(jsonDeployResult, null, " ") - ); - console.log("~~~~~~~~~~~SUCCESS~~~~~~~~~~~~~~"); - } }; -module.exports = {runE2E, run} +module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator } diff --git a/deploy/shell_scripts/deploy_all_contracts.sh b/deploy/shell_scripts/deploy_all_contracts.sh index 661a1ca0..4a40cce7 100755 --- a/deploy/shell_scripts/deploy_all_contracts.sh +++ b/deploy/shell_scripts/deploy_all_contracts.sh @@ -1,40 +1,21 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_all_contracts.sh ~/smartpy-cli/SmartPy.sh -($1 compile ./deploy/compile_targets/CompileTestData.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu)\ -&& echo "CompileTestData.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileTezFinOracle.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu)\ -&& echo "CompileTezFinOracle.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileGovernance.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu)\ -&& echo "CompileGovernance.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileComptroller.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileComptroller.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileIRMs.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu)\ -&& echo "CompileIRMs.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileCUSDt.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCUSDt.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileCUSDtz.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCUSDtz.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileCXTZ.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCXTZ.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js - -($1 compile ./deploy/compile_targets/CompileTzBTC.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileTzBTC.py was successfully compiled" || echo -node ./deploy/deploy_script/deploy.js \ No newline at end of file +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js + +compile_and_deploy() { + "$smartpy" compile "$1" ./TezFinBuild/compiled_contracts --purge --protocol kathmandu "${@:2}" + node ./deploy/deploy_script/deploy.js +} + +compile_and_deploy ./deploy/compile_targets/CompileTestData.py +compile_and_deploy ./deploy/compile_targets/CompileTezFinOracle.py +compile_and_deploy ./deploy/compile_targets/CompileGovernance.py +compile_and_deploy ./deploy/compile_targets/CompileComptroller.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileIRMs.py +compile_and_deploy ./deploy/compile_targets/CompileCUSDt.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileCXTZ.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileTzBTC.py --erase-comments --erase-var-annots --initial-cast diff --git a/deploy/shell_scripts/deploy_cfa12.sh b/deploy/shell_scripts/deploy_cfa12.sh index 5fc9ecae..1939220b 100755 --- a/deploy/shell_scripts/deploy_cfa12.sh +++ b/deploy/shell_scripts/deploy_cfa12.sh @@ -1,14 +1,11 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_cfa12.sh ~/smartpy-cli/SmartPy.sh - -($1 compile ./deploy/compile_targets/CompileCFA12_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol granada)\ -&& echo "CompileCFA12_IRM.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileCFA12_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu node ./deploy/deploy_script/deploy.js - - -($1 compile ./deploy/compile_targets/CompileCFA12.py ./TezFinBuild/compiled_contracts --purge --protocol granada --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCFA12.py was successfully compiled" || echo +"$smartpy" compile ./deploy/compile_targets/CompileCFA12.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_cfa2.sh b/deploy/shell_scripts/deploy_cfa2.sh index b0f09e34..aed66dda 100755 --- a/deploy/shell_scripts/deploy_cfa2.sh +++ b/deploy/shell_scripts/deploy_cfa2.sh @@ -1,14 +1,10 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_cfa2.sh ~/smartpy-cli/SmartPy.sh - - -($1 compile ./deploy/compile_targets/CompileCFA2_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol granada)\ -&& echo "CompileCFA2_IRM.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileCFA2_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu node ./deploy/deploy_script/deploy.js - - -($1 compile ./deploy/compile_targets/CompileCFA2.py ./TezFinBuild/compiled_contracts --purge --protocol granada --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCFA2.py was successfully compiled" || echo +"$smartpy" compile ./deploy/compile_targets/CompileCFA2.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_comptroller.sh b/deploy/shell_scripts/deploy_comptroller.sh index c6578ab0..82eb60ca 100644 --- a/deploy/shell_scripts/deploy_comptroller.sh +++ b/deploy/shell_scripts/deploy_comptroller.sh @@ -1,9 +1,10 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_comptroller.sh ~/smartpy-cli/SmartPy.sh -($1 compile ./deploy/compile_targets/CompileComptroller.py ./TezFinBuild/compiled_contracts --purge --protocol granada --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileComptroller.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileComptroller.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_cxtz.sh b/deploy/shell_scripts/deploy_cxtz.sh index 7a922c85..b34444ad 100755 --- a/deploy/shell_scripts/deploy_cxtz.sh +++ b/deploy/shell_scripts/deploy_cxtz.sh @@ -1,14 +1,10 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_cxtz.sh ~/smartpy-cli/SmartPy.sh - - -($1 compile ./deploy/compile_targets/CompileCXTZ_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol granada)\ -&& echo "CompileCXTZ_IRM.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileCXTZ_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu node ./deploy/deploy_script/deploy.js - - -($1 compile ./deploy/compile_targets/CompileCXTZ.py ./TezFinBuild/compiled_contracts --purge --protocol granada --erase-comments --erase-var-annots --initial-cast)\ -&& echo "CompileCXTZ.py was successfully compiled" || echo +"$smartpy" compile ./deploy/compile_targets/CompileCXTZ.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_governance.sh b/deploy/shell_scripts/deploy_governance.sh index cd345596..cb713b48 100644 --- a/deploy/shell_scripts/deploy_governance.sh +++ b/deploy/shell_scripts/deploy_governance.sh @@ -1,8 +1,9 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_governance.sh ~/smartpy-cli/SmartPy.sh -($1 compile ./deploy/compile_targets/CompileGovernance.py ./TezFinBuild/compiled_contracts --purge --protocol granada)\ -&& echo "CompileGovernance.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileGovernance.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_test_data.sh b/deploy/shell_scripts/deploy_test_data.sh index d38076dd..78703fc5 100644 --- a/deploy/shell_scripts/deploy_test_data.sh +++ b/deploy/shell_scripts/deploy_test_data.sh @@ -1,8 +1,9 @@ -#!/bin/bash -set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately +#!/usr/bin/env bash +set -euo pipefail # 1 - path to SmartPy.sh # example: ./deploy/shell_scripts/deploy_test_data.sh ~/smartpy-cli/SmartPy.sh -($1 compile ./deploy/compile_targets/CompileTestData.py ./TezFinBuild/compiled_contracts --purge --protocol granada)\ -&& echo "CompileTestData.py was successfully compiled" || echo +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" +node ./deploy/deploy_script/prepare.js +"$smartpy" compile ./deploy/compile_targets/CompileTestData.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu node ./deploy/deploy_script/deploy.js From e523c17e94e4c972850a2fd605925dee1c9e1534 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 13:53:00 +0300 Subject: [PATCH 02/19] chore: document manual admin handoff checklist for mainnet deployment --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 2ba70ac0..6e7c007d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,30 @@ npm run prepare:deploy > To deploy a specific contract run the corresponding script in [shell_scripts](deploy/shell_scripts) +## Post-Deployment Admin Handoff (Mainnet) + +After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the +deployer account (`OriginatorAddress`). Before any market is unpaused or opened to real users, the +protocol admin rights **must** be handed off to the production multisig. This is currently a manual +process: + +1. Keep all markets paused until the handoff below is fully verified. +2. On `Governance`, call `setPendingGovernance()` from the deployer account. +3. From the multisig, call `acceptGovernance()` on `Governance` to finalize the transfer. +4. On `TezFinOracle`, call `set_pending_admin()` from the deployer account, then + have the multisig call `accept_admin()` to finalize the transfer. +5. Verify on-chain (e.g. via a block explorer or a Taquito script) that the `administrator` field of + `Governance`, `TezFinOracle`, `Comptroller`, and every ꜰToken market now points to the intended + production address, and that the deployer account no longer has admin rights anywhere. +6. Only unpause markets after every check in step 5 passes. + +Note: `Comptroller` and every ꜰToken market are administered *through* `Governance` +(`Governance.setContractGovernance` / `acceptContractGovernance` act as a proxy for their +`setPendingGovernance` / `acceptGovernance` entry points). Once `Governance` itself is controlled by +the production multisig (steps 2-3), the multisig automatically controls Comptroller and every +ꜰToken through it — there is no separate handoff needed for those contracts, only the on-chain +verification in step 5. + ## Build and Run UI From bc77b3b2a757d1f6712a021e9328f2db2b3d57ad Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 14:03:43 +0300 Subject: [PATCH 03/19] chore: deployer config steps in README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6e7c007d..ee769673 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ To compile and deploy all contracts at once: ```sh export TEZOS_PRIVATE_KEY='edsk...' ``` + Before deploying to a different network or with a different deployment account, update + `originator.pkh` (and `tezosNode` / `chainId`) in `config.json` to match the target. This is an + intentional manual step: the deployer will refuse to run if the signing key does not match the + configured `originator.pkh`, so committing the wrong address is a safe failure, not a silent one. Alternatively, set `TEZOS_MNEMONIC` for a standard Tezos wallet seed phrase (and `TEZOS_MNEMONIC_PASSWORD` only if your wallet used one). The default derivation path is `44'/1729'/0'/0'`; override it with `TEZOS_DERIVATION_PATH` if needed. Legacy fundraiser accounts additionally require `TEZOS_FUNDS_EMAIL` and `TEZOS_FUNDS_PASSWORD`. The signer must match `originator.pkh` and have Previewnet XTZ. Previewnet fee estimates can change between simulation and injection; the deployer applies a 20% fee margin. Override it only when needed with `TEZOS_FEE_SAFETY_MULTIPLIER`. 3. Install deployment dependencies From 50ebb73cc74dd33016632164c1210720166a8fd9 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 14:04:55 +0300 Subject: [PATCH 04/19] chore: removed unused scripts --- deploy/shell_scripts/deploy_cfa12.sh | 11 ----------- deploy/shell_scripts/deploy_cfa2.sh | 10 ---------- 2 files changed, 21 deletions(-) delete mode 100755 deploy/shell_scripts/deploy_cfa12.sh delete mode 100755 deploy/shell_scripts/deploy_cfa2.sh diff --git a/deploy/shell_scripts/deploy_cfa12.sh b/deploy/shell_scripts/deploy_cfa12.sh deleted file mode 100755 index 1939220b..00000000 --- a/deploy/shell_scripts/deploy_cfa12.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# 1 - path to SmartPy.sh -# example: ./deploy/shell_scripts/deploy_cfa12.sh ~/smartpy-cli/SmartPy.sh - -smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" -node ./deploy/deploy_script/prepare.js -"$smartpy" compile ./deploy/compile_targets/CompileCFA12_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu -node ./deploy/deploy_script/deploy.js -"$smartpy" compile ./deploy/compile_targets/CompileCFA12.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast -node ./deploy/deploy_script/deploy.js diff --git a/deploy/shell_scripts/deploy_cfa2.sh b/deploy/shell_scripts/deploy_cfa2.sh deleted file mode 100755 index aed66dda..00000000 --- a/deploy/shell_scripts/deploy_cfa2.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# 1 - path to SmartPy.sh -# example: ./deploy/shell_scripts/deploy_cfa2.sh ~/smartpy-cli/SmartPy.sh -smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" -node ./deploy/deploy_script/prepare.js -"$smartpy" compile ./deploy/compile_targets/CompileCFA2_IRM.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu -node ./deploy/deploy_script/deploy.js -"$smartpy" compile ./deploy/compile_targets/CompileCFA2.py ./TezFinBuild/compiled_contracts --purge --protocol kathmandu --erase-comments --erase-var-annots --initial-cast -node ./deploy/deploy_script/deploy.js From a72c9acf20d3c44555c79ad275143ee54b2815d4 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 15:13:54 +0300 Subject: [PATCH 05/19] fix: verify deploy manifest before reusing contract addresses --- README.md | 19 +++ TezFinBuild/deploy_result/deploy.json | 16 --- deploy/compile_targets/Config.py | 5 +- deploy/deploy_script/prepare.js | 7 +- deploy/deploy_script/util.js | 169 ++++++++++++++++++++++++-- 5 files changed, 189 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ee769673..4128029c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,25 @@ npm run prepare:deploy > To deploy a specific contract run the corresponding script in [shell_scripts](deploy/shell_scripts) +### Deployment Manifest + +The deploy scripts track originated addresses in a manifest file +([`TezFinBuild/deploy_result/deploy.json`](TezFinBuild/deploy_result/deploy.json) by default). This +file also stores the `chainId` it was created against; the deployer refuses to reuse a manifest +whose `chainId` doesn't match the connected RPC. + +- The tracked `deploy.json` is intentionally kept **empty** (`{}`). Do not commit a populated + manifest to this path — a manifest with existing addresses will only be reused after each address + is verified on-chain (matching code and critical storage addresses), never silently. +- To keep Previewnet and mainnet deployments in fully separate files, set `DEPLOY_MANIFEST` to an + explicit path before running `npm run prepare:deploy` and the deploy shell scripts, e.g.: + ```sh + export DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.mainnet.json + ``` + Never reuse a Previewnet manifest for a mainnet run (or vice versa). +- If you need to restart a deployment from scratch on the same network, delete or rename the + manifest file first rather than editing it in place. + ## Post-Deployment Admin Handoff (Mainnet) After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the diff --git a/TezFinBuild/deploy_result/deploy.json b/TezFinBuild/deploy_result/deploy.json index 9537d2ea..2c63c085 100644 --- a/TezFinBuild/deploy_result/deploy.json +++ b/TezFinBuild/deploy_result/deploy.json @@ -1,18 +1,2 @@ { - "OriginatorAddress": "tz1RESHvJAfmQCXCAD3ubNmVtac788pnN1oL", - "Governance": "KT1NF6DKX5giazRTzPtEuNX1npkVcaoQkvK2", - "PriceOracle": "KT1B74ywpG3nX2F3ZgpYi4MnsyuoYyQtQTAn", - "USDTPriceOracle": "KT1B74ywpG3nX2F3ZgpYi4MnsyuoYyQtQTAn", - "TezFinOracle": "KT1LDtNKDnpziwSrmSfx3xCbs7nBMg7VFp4m", - "Comptroller": "KT1DiWBT6RBC97iWrvLHRzKL7AWQKorBiuRG", - "CFA12_IRM": "KT1Vzf3fXVBry4TDxWAfYzsn6ZPyMroMKUdW", - "CXTZ_IRM": "KT1B7zvU7EXmPBHazHhtajHaw5swFFxWCEfd", - "CFA2_IRM": "KT1Q2BBtfT9obGMAZ32L6esSjm8FG9NWiBb9", - "USDtz": "KT1LN4LPSqTMS7Sd2CJw4bbDGRkMv2t68Fy9", - "USDt": "KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o", - "tzBTC": "KT1PWx2mnDueood7fEmfbBDKx1D9BAnnXitn", - "CUSDt": "KT1HCRJhfqmWKRJtZXzvTkY4iisfuR4w6pkB", - "CUSDtz": "KT1WQM7wj64GHCndwV8REccQ6N4tqZ3uRNqs", - "CXTZ": "KT1MCXxbtS62tk4CUxv29BHnqTBtvsFFGzBm", - "CtzBTC": "KT19gZac3vqV3ZeMJbhMX7Xy8kcocKK4Tbz1" } diff --git a/deploy/compile_targets/Config.py b/deploy/compile_targets/Config.py index 1f729ef7..4c741e98 100644 --- a/deploy/compile_targets/Config.py +++ b/deploy/compile_targets/Config.py @@ -16,8 +16,11 @@ def Deserialize(relativePath): return json.loads(file.read(), object_hook=lambda d: SimpleNamespace(**d)) compileConfig = JsonDeserializer.Deserialize(PATH_COMPILE_CONFIG) +# Use a network-specific manifest when set, so Previewnet/mainnet deploy results are +# never read from (or written to) the same tracked file. Falls back to the legacy +# E2E override, then to the tracked Previewnet-only default. deployResult = JsonDeserializer.Deserialize( - os.getenv('E2E', PATH_DEPLOY_RESULT)) + os.getenv('DEPLOY_MANIFEST', os.getenv('E2E', PATH_DEPLOY_RESULT))) CUSDt_IRM = compileConfig.CUSDt_IRM CUSDtz_IRM = compileConfig.CUSDtz_IRM diff --git a/deploy/deploy_script/prepare.js b/deploy/deploy_script/prepare.js index 3e99c9ea..a42737af 100644 --- a/deploy/deploy_script/prepare.js +++ b/deploy/deploy_script/prepare.js @@ -1,7 +1,12 @@ const path = require('path'); const { syncDeploymentOriginator } = require('./util.js'); -const deployResultPath = path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); +// Use a separate manifest file per network/deployment run. Set DEPLOY_MANIFEST to an +// explicit path (e.g. TezFinBuild/deploy_result/deploy.mainnet.json) so Previewnet and +// mainnet runs can never share or silently overwrite each other's addresses. +const deployResultPath = process.env.DEPLOY_MANIFEST + ? path.resolve(process.env.DEPLOY_MANIFEST) + : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); syncDeploymentOriginator(deployResultPath).catch((error) => { console.error(`[ERROR] Deployment preparation failed: ${error.message}`); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index 53fd021c..0d110b81 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -189,9 +189,18 @@ async function checkConnection() { } async function syncDeploymentOriginator(deployResultPath) { - const { publicKeyHash } = await createTezosClient(); + const { publicKeyHash, chainId } = await createTezosClient(); const deployResult = readDeployResult(deployResultPath); + if (deployResult.chainId && deployResult.chainId !== chainId) { + throw new Error( + `Deploy manifest ${deployResultPath} was created for chain ${deployResult.chainId}, ` + + `but the connected RPC reports chain ${chainId}. Refusing to reuse this manifest; start a ` + + `fresh manifest (e.g. delete or rename the file) before preparing a deployment for this network.`, + ); + } deployResult.OriginatorAddress = publicKeyHash; + deployResult.chainId = chainId; + deployResult.network = config.tezosNode; writeDeployResult(deployResultPath, JSON.stringify(deployResult, null, ' ')); console.log(`[INFO] Set OriginatorAddress to ${publicKeyHash} in ${deployResultPath}`); } @@ -230,6 +239,118 @@ function writeDeployResult(jsonPath, data) { fs.writeFileSync(jsonPath, data + os.EOL) } +async function fetchOnChainScript(tezos, address) { + return tezos.rpc.getScript(address); +} + +async function fetchOnChainStorage(tezos, address) { + return tezos.rpc.getStorage(address); +} + +// Micheline JSON from the RPC and from SmartPy's compiled output can differ in key +// order (and, occasionally, annotation order) without being semantically different. +// Canonicalize both sides the same way before comparing so that a legitimate resume +// isn't falsely rejected due to formatting differences alone. +function canonicalizeMicheline(node) { + if (Array.isArray(node)) { + return node.map(canonicalizeMicheline); + } + if (node && typeof node === 'object') { + const sortedKeys = Object.keys(node).sort(); + const result = {}; + for (const key of sortedKeys) { + const value = node[key]; + result[key] = key === 'annots' && Array.isArray(value) + ? [...value].sort() + : canonicalizeMicheline(value); + } + return result; + } + return node; +} + +function micheline_equal(a, b) { + return JSON.stringify(canonicalizeMicheline(a)) === JSON.stringify(canonicalizeMicheline(b)); +} + +const TEZOS_ADDRESS_PATTERN = /^(tz1|tz2|tz3|KT1)[1-9A-HJ-NP-Za-km-z]{33}$/; + +// Best-effort "critical storage" check: SmartPy always embeds administrator, oracle, +// underlying-token, and IRM addresses as plain address strings inside storage. Two +// contracts can share identical code but be wired to different administrators/markets +// (e.g. copy-pasted CFA12 templates); comparing the *set* of addresses embedded in +// storage catches that case without needing a hand-written schema per contract type. +// It will NOT catch differences in purely numeric parameters (e.g. a different IRM +// kink/multiplier) with otherwise-identical wiring; a full per-contract schema check +// would be needed to close that gap. +function extractAddresses(node, out = new Set()) { + if (Array.isArray(node)) { + for (const item of node) { + extractAddresses(item, out); + } + return out; + } + if (node && typeof node === 'object') { + if (typeof node.string === 'string' && TEZOS_ADDRESS_PATTERN.test(node.string)) { + out.add(node.string); + } + if (typeof node.bytes === 'string') { + // addresses are sometimes packed as bytes; skip decoding for now, this is + // a best-effort check, not a full schema-aware comparison. + } + for (const value of Object.values(node)) { + extractAddresses(value, out); + } + } + return out; +} + +function diffSets(expected, actual) { + const missing = [...expected].filter((item) => !actual.has(item)); + const unexpected = [...actual].filter((item) => !expected.has(item)); + return { missing, unexpected }; +} + +// Verify that a manifest entry still points at a live, matching contract before we +// trust it enough to skip re-deploying. This prevents silently reusing a stale or +// unrelated address just because a key happens to exist in the manifest (e.g. a +// manifest copied from another network, or an address that was never confirmed). +async function verifyExistingContract(tezos, address, expectedCode, expectedStorage, directoryName) { + let script; + try { + script = await fetchOnChainScript(tezos, address); + } catch (error) { + throw new Error( + `Manifest entry "${directoryName}" points to ${address}, but no contract could be found ` + + `there on the connected chain (${error.message}). Refusing to skip deployment; remove the ` + + `stale entry from the manifest or fix the address before re-running.`, + ); + } + + if (!micheline_equal(script.code, expectedCode)) { + throw new Error( + `Manifest entry "${directoryName}" (${address}) does not match the compiled contract code ` + + `on the connected chain. Refusing to skip deployment; the on-chain contract may belong to a ` + + `different network/version than the one currently compiled.`, + ); + } + + const onChainStorage = await fetchOnChainStorage(tezos, address); + const expectedAddresses = extractAddresses(expectedStorage); + const actualAddresses = extractAddresses(onChainStorage); + const { missing, unexpected } = diffSets(expectedAddresses, actualAddresses); + if (missing.length > 0 || unexpected.length > 0) { + throw new Error( + `Manifest entry "${directoryName}" (${address}) has matching code but its on-chain storage ` + + `references different addresses (admin/oracle/underlying/IRM/etc.) than the compiled initial ` + + `storage. Expected-but-missing: [${missing.join(', ')}]; unexpected: [${unexpected.join(', ')}]. ` + + `Refusing to skip deployment; this looks like the same contract template wired to a different ` + + `configuration. Note: this check only compares embedded addresses, not numeric parameters.`, + ); + } + console.log(`[INFO] Verified ${directoryName} at ${address} matches compiled code and critical storage addresses on-chain`); +} + async function runDeployment(compiledContractsPath, deployResultPath) { const { tezos, publicKeyHash, chainId } = await createTezosClient(); console.log(`[INFO] Deploying from ${publicKeyHash} to ${config.tezosNode} (${chainId})`); @@ -239,18 +360,45 @@ async function runDeployment(compiledContractsPath, deployResultPath) { throw new Error(`No compiled contract directories found in ${compiledContractsPath}`); } const jsonDeployResult = readDeployResult(deployResultPath); + + // Bind the manifest to the chain it was created for. A manifest produced against one + // network (e.g. a stale Previewnet run) must never be silently reused to skip + // origination against a different chain (e.g. mainnet). + if (jsonDeployResult.chainId && jsonDeployResult.chainId !== chainId) { + throw new Error( + `Deploy manifest ${deployResultPath} was created for chain ${jsonDeployResult.chainId}, ` + + `but the connected RPC reports chain ${chainId}. Refusing to reuse this manifest; start a ` + + `fresh manifest for this network instead.`, + ); + } + jsonDeployResult.chainId = chainId; + jsonDeployResult.network = config.tezosNode; + writeDeployResult(deployResultPath, JSON.stringify(jsonDeployResult, null, ' ')); + + const knownKeys = new Set([...directories, 'chainId', 'network', 'OriginatorAddress']); + const staleKeys = Object.keys(jsonDeployResult).filter((key) => !knownKeys.has(key)); + if (staleKeys.length > 0) { + console.log( + `[WARN] Manifest ${deployResultPath} contains keys not produced by this compile batch: ` + + `[${staleKeys.join(', ')}]. These are not verified against the current compiled output and ` + + `may be stale (e.g. from an older/partial compile run); double-check them manually or start ` + + `from an empty manifest.`, + ); + } + for (const directoryName of directories) { - if (jsonDeployResult[directoryName]) { - console.log( - `[INFO] Skipping ${directoryName}; already in deploy.json: ${jsonDeployResult[directoryName]}`, - ); - continue; - } - console.log(`[INFO] Deploying ${directoryName}`); const directoryPath = path.join(compiledContractsPath, directoryName); const code = getMichelsonCode(directoryPath); const storage = getMichelsonStorage(directoryPath); + const existingAddress = jsonDeployResult[directoryName]; + if (existingAddress) { + await verifyExistingContract(tezos, existingAddress, code, storage, directoryName); + console.log(`[INFO] Skipping ${directoryName}; already deployed and verified at ${existingAddress}`); + continue; + } + + console.log(`[INFO] Deploying ${directoryName}`); const contractAddress = await deployMichelsonContract(tezos, code, storage, directoryName); jsonDeployResult[directoryName] = contractAddress; @@ -260,9 +408,12 @@ async function runDeployment(compiledContractsPath, deployResultPath) { } async function run() { + const deployResultPath = process.env.DEPLOY_MANIFEST + ? path.resolve(process.env.DEPLOY_MANIFEST) + : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); return runDeployment( path.join(__dirname, '../../TezFinBuild/compiled_contracts'), - path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'), + deployResultPath, ); } From 6ac3b82e54db4c1757bc65d374c6b74ab6dd75fe Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 15:36:47 +0300 Subject: [PATCH 06/19] fix: make PriceOracle configuration reproducible for deploy --- README.md | 29 ++++++++++++++++++ deploy/compile_targets/CompileTestData.py | 14 +++++---- deploy/compile_targets/CompileTezFinOracle.py | 7 +++++ deploy/compile_targets/Config.json | 12 ++++++-- deploy/compile_targets/Config.py | 1 + deploy/compile_targets/Utils.py | 10 ++++++- deploy/deploy_script/util.js | 30 ++++++++++++++++++- deploy/deploy_script/verify_oracle.js | 14 +++++++++ deploy/shell_scripts/deploy_all_contracts.sh | 6 ++++ 9 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 deploy/deploy_script/verify_oracle.js diff --git a/README.md b/README.md index 4128029c..8f067b11 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,35 @@ whose `chainId` doesn't match the connected RPC. - If you need to restart a deployment from scratch on the same network, delete or rename the manifest file first rather than editing it in place. +### PriceOracle Configuration + +`TezFinOracle` (and therefore `Comptroller`) requires a `PriceOracle` address in the manifest before +it can be compiled — `CompileTezFinOracle.py` validates this dependency and fails with a clear error +if `PriceOracle` is missing, instead of silently compiling with a stale value. `deploy_all_contracts.sh` +also runs `verify_oracle.js` right before compiling `TezFinOracle`, which checks that the configured +`PriceOracle` address actually exists on the connected chain (not just that the manifest key is +present) and fails closed if it does not. + +`TezFinOracle` ([`contracts/TezFinOracle.py`](contracts/TezFinOracle.py)) is a thin proxy: it forwards +price lookups to the address stored as `oracle` (the `PriceOracle` from the manifest) and expects that +address to behave like [Youves' Harbinger](https://harbinger.live/) oracle (`get`/price-feed +interface), with a small admin-controlled override map for assets Harbinger doesn't support (e.g. USD, +USDT). `TezFinOracle`'s own `admin` (settable via `set_pending_admin` / `accept_admin`) controls those +overrides and can repoint `oracle` to a different feed with `set_oracle`. + +- **Previewnet**: `CompileTestData.py` compiles and deploys a mock `PriceOracle` + ([`deploy/test_data/PriceOracle.py`](deploy/test_data/PriceOracle.py)) as part of + `deploy_all_contracts.sh`. This mock is for Previewnet only, is **not** Harbinger — it's a bare + stand-in that mimics the same `get` callback interface. It has **no administrator check**: its + `setPrice` entry point can be called by any address to set any price for any asset. Do not treat a + Previewnet deployment using this mock as representative of mainnet price-feed security. +- **Mainnet**: do not compile or originate the mock oracle. Instead, put the exact address of the + vetted production Harbinger (or Harbinger-compatible) oracle directly under the `PriceOracle` key in + the mainnet manifest (`DEPLOY_MANIFEST`) before running any compile target that depends on it. Never + let a mainnet run execute `CompileTestData.py`. Document, alongside the mainnet manifest, which + Harbinger instance/administrator is being used and who controls it — this project does not deploy or + administer Harbinger itself. + ## Post-Deployment Admin Handoff (Mainnet) After origination, every contract (`Governance`, `TezFinOracle`) is initially administered by the diff --git a/deploy/compile_targets/CompileTestData.py b/deploy/compile_targets/CompileTestData.py index 84aaa417..9e9cf8af 100644 --- a/deploy/compile_targets/CompileTestData.py +++ b/deploy/compile_targets/CompileTestData.py @@ -2,15 +2,17 @@ UTILS = sp.io.import_script_from_url("file:deploy/compile_targets/Utils.py") CFG = sp.io.import_script_from_url("file:deploy/compile_targets/Config.py") -# use harbinger instead of mock -# Oracle = sp.io.import_script_from_url("file:deploy/test_data/PriceOracle.py") +# Previewnet-only mock oracle. Do NOT use this for mainnet: mainnet must use the +# vetted production oracle address supplied via the mainnet manifest/profile instead +# of compiling/originating this mock. See README.md "Deployment Manifest" section. +Oracle = sp.io.import_script_from_url("file:deploy/test_data/PriceOracle.py") FA12 = sp.io.import_script_from_url("file:deploy/test_data/FA1.2.py") FA2 = sp.io.import_script_from_url("file:deploy/test_data/FA2.py") UTILS.checkDependencies(CFG.FA12) UTILS.checkDependencies(CFG.FA2) -# Oracle.compile() -FA12.compile("tzBTC",CFG.deployResult.OriginatorAddress) -FA12.compile("USDtz",CFG.deployResult.OriginatorAddress) -FA2.compile("USDt",CFG.deployResult.OriginatorAddress) \ No newline at end of file +Oracle.compile() +FA12.compile("tzBTC", CFG.deployResult.OriginatorAddress) +FA12.compile("USDtz", CFG.deployResult.OriginatorAddress) +FA2.compile("USDt", CFG.deployResult.OriginatorAddress) diff --git a/deploy/compile_targets/CompileTezFinOracle.py b/deploy/compile_targets/CompileTezFinOracle.py index 71ef4343..2d79a04c 100644 --- a/deploy/compile_targets/CompileTezFinOracle.py +++ b/deploy/compile_targets/CompileTezFinOracle.py @@ -3,6 +3,13 @@ TezFinOracle = sp.io.import_script_from_url( "file:contracts/TezFinOracle.py").TezFinOracle CFG = sp.io.import_script_from_url("file:deploy/compile_targets/Config.py") +UTILS = sp.io.import_script_from_url("file:deploy/compile_targets/Utils.py") + +# Fails early and explicitly (instead of a bare AttributeError) if PriceOracle hasn't +# been compiled/deployed yet for this network profile. See CompileTestData.py (Previewnet +# mock) and README.md ("Deployment Manifest" / PriceOracle) for how PriceOracle should be +# supplied for each network. +UTILS.checkDependencies(CFG.TezFinOracle) sp.add_compilation_target("TezFinOracle", TezFinOracle( admin=sp.address(CFG.deployResult.OriginatorAddress), diff --git a/deploy/compile_targets/Config.json b/deploy/compile_targets/Config.json index ae4ab668..2d5081fa 100644 --- a/deploy/compile_targets/Config.json +++ b/deploy/compile_targets/Config.json @@ -6,14 +6,14 @@ "jumpMultiplierPerBlock": 875200000000, "kink": 700000000000000000 }, - "CUSDt_IRM" :{ + "CUSDt_IRM": { "scale": 1000000000000000000, "multiplierPerBlock": 12970000000, "baseRatePerBlock": 0, "jumpMultiplierPerBlock": 697500000000, "kink": 880000000000000000 }, - "CUSDtz_IRM" :{ + "CUSDtz_IRM": { "scale": 1000000000000000000, "multiplierPerBlock": 12970000000, "baseRatePerBlock": 0, @@ -44,7 +44,7 @@ "liquidationIncentiveMantissa": 1050000000000000000, "dependencies": [ "Governance", - "PriceOracle" + "TezFinOracle" ] }, "CFA2": { @@ -80,5 +80,11 @@ "dependencies": [ "OriginatorAddress" ] + }, + "TezFinOracle": { + "dependencies": [ + "OriginatorAddress", + "PriceOracle" + ] } } diff --git a/deploy/compile_targets/Config.py b/deploy/compile_targets/Config.py index 4c741e98..8e472393 100644 --- a/deploy/compile_targets/Config.py +++ b/deploy/compile_targets/Config.py @@ -30,6 +30,7 @@ def Deserialize(relativePath): Governance = compileConfig.Governance Comptroller = compileConfig.Comptroller +TezFinOracle = compileConfig.TezFinOracle CFA2 = compileConfig.CFA2 CFA12 = compileConfig.CFA12 CXTZ = compileConfig.CXTZ diff --git a/deploy/compile_targets/Utils.py b/deploy/compile_targets/Utils.py index d4e8c254..e0ecbff8 100644 --- a/deploy/compile_targets/Utils.py +++ b/deploy/compile_targets/Utils.py @@ -1,4 +1,5 @@ import smartpy as sp +import os CFG = sp.io.import_script_from_url("file:deploy/compile_targets/Config.py") @@ -9,4 +10,11 @@ def checkDependencies(obj): notSpecifiedAttributes.append(attribute) if len(notSpecifiedAttributes) > 0: - raise Exception(f'Please specify {notSpecifiedAttributes} in {CFG.PATH_DEPLOY_RESULT}') + manifest = os.getenv('DEPLOY_MANIFEST', CFG.PATH_DEPLOY_RESULT) + message = f'Please specify {notSpecifiedAttributes} in the deploy manifest ({manifest}).' + if 'PriceOracle' in notSpecifiedAttributes: + message += ( + ' For Previewnet, deploy the mock PriceOracle via CompileTestData.py first. ' + 'For mainnet, put the vetted production oracle address under PriceOracle before compiling.' + ) + raise Exception(message) diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index 0d110b81..d7b0ab90 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -423,4 +423,32 @@ const runE2E = async () => { path.join(__dirname, '../../e2e/deploy_result/deploy.json'), ); }; -module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator } + +// Verify that the PriceOracle address recorded in the manifest actually exists on the +// connected chain before TezFinOracle (and therefore Comptroller) is compiled against +// it. This closes the gap where a wrong/unrelated address could be silently baked into +// TezFinOracle's storage just because a key happens to be present in the manifest. +async function verifyOracleAddress(deployResultPath) { + const { tezos, chainId } = await createTezosClient(); + const deployResult = readDeployResult(deployResultPath); + const address = deployResult.PriceOracle; + if (!address) { + throw new Error( + `No "PriceOracle" address found in ${deployResultPath}. For Previewnet, run ` + + `CompileTestData.py to deploy the mock oracle first. For mainnet, add the vetted ` + + `production oracle address to the manifest before compiling TezFinOracle.`, + ); + } + try { + await tezos.rpc.getScript(address); + } catch (error) { + throw new Error( + `PriceOracle address ${address} from ${deployResultPath} could not be found on chain ` + + `${chainId} (${error.message}). Refusing to compile TezFinOracle against a nonexistent ` + + `oracle; fix the manifest before continuing.`, + ); + } + console.log(`[INFO] Verified PriceOracle ${address} exists on chain ${chainId}`); +} + +module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator, verifyOracleAddress } diff --git a/deploy/deploy_script/verify_oracle.js b/deploy/deploy_script/verify_oracle.js new file mode 100644 index 00000000..ea04a296 --- /dev/null +++ b/deploy/deploy_script/verify_oracle.js @@ -0,0 +1,14 @@ +const path = require('path'); +const { verifyOracleAddress } = require('./util.js'); + +// Run this before compiling CompileTezFinOracle.py to make sure the PriceOracle +// address recorded in the manifest actually exists on the connected chain, instead of +// only checking that the manifest key is present. +const deployResultPath = process.env.DEPLOY_MANIFEST + ? path.resolve(process.env.DEPLOY_MANIFEST) + : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); + +verifyOracleAddress(deployResultPath).catch((error) => { + console.error(`[ERROR] PriceOracle verification failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/shell_scripts/deploy_all_contracts.sh b/deploy/shell_scripts/deploy_all_contracts.sh index 4a40cce7..215dc7bc 100755 --- a/deploy/shell_scripts/deploy_all_contracts.sh +++ b/deploy/shell_scripts/deploy_all_contracts.sh @@ -11,7 +11,13 @@ compile_and_deploy() { node ./deploy/deploy_script/deploy.js } +# WARNING: CompileTestData.py compiles/deploys a mock PriceOracle plus fake tzBTC, +# USDtz, and USDt tokens (with an admin-mint entry point). This is Previewnet-only +# test tooling. Do NOT run this script against mainnet; a mainnet deployment must +# supply the vetted production oracle and canonical token addresses directly in the +# manifest instead of originating these mocks. See README.md "Deployment Manifest". compile_and_deploy ./deploy/compile_targets/CompileTestData.py +node ./deploy/deploy_script/verify_oracle.js compile_and_deploy ./deploy/compile_targets/CompileTezFinOracle.py compile_and_deploy ./deploy/compile_targets/CompileGovernance.py compile_and_deploy ./deploy/compile_targets/CompileComptroller.py --erase-comments --erase-var-annots --initial-cast From 62569e4f6d89b73b1e09e20884cb9f3252f60ad2 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 16:41:45 +0300 Subject: [PATCH 07/19] fix: separate Previewnet and Mainnet deployment profiles --- README.md | 63 +++++++-- deploy/compile_targets/CompileTzBTC.py | 9 +- deploy/compile_targets/Config.py | 21 ++- deploy/compile_targets/Utils.py | 2 +- deploy/deploy_script/assert_network.js | 48 +++++++ deploy/deploy_script/config.json | 1 + deploy/deploy_script/mainnet_preflight.js | 121 ++++++++++++++++++ deploy/deploy_script/prepare.js | 11 +- deploy/deploy_script/util.js | 20 ++- deploy/deploy_script/verify_oracle.js | 7 +- deploy/shell_scripts/deploy_mainnet.sh | 53 ++++++++ ..._all_contracts.sh => deploy_previewnet.sh} | 12 +- 12 files changed, 331 insertions(+), 37 deletions(-) create mode 100644 deploy/deploy_script/assert_network.js create mode 100644 deploy/deploy_script/mainnet_preflight.js create mode 100755 deploy/shell_scripts/deploy_mainnet.sh rename deploy/shell_scripts/{deploy_all_contracts.sh => deploy_previewnet.sh} (72%) diff --git a/README.md b/README.md index 8f067b11..ce63ef89 100644 --- a/README.md +++ b/README.md @@ -104,13 +104,44 @@ npm install npm run check npm run prepare:deploy ``` -4. Run deployment script -```sh -./deploy/shell_scripts/deploy_all_contracts.sh ~/smartpy-cli/SmartPy.sh -``` +4. Run the deployment script for the target network: + - Previewnet: + ```sh + ./deploy/shell_scripts/deploy_previewnet.sh ~/smartpy-cli/SmartPy.sh + ``` + - Mainnet (see [Mainnet Deployment](#mainnet-deployment) below before running this): + ```sh + MAINNET_DEPLOY_CONFIRM=yes ./deploy/shell_scripts/deploy_mainnet.sh ~/smartpy-cli/SmartPy.sh + ``` > To deploy a specific contract run the corresponding script in [shell_scripts](deploy/shell_scripts) +### Mainnet Deployment + +`deploy_mainnet.sh` is a separate, stricter profile from `deploy_previewnet.sh`: + +- It never runs `CompileTestData.py` — no mock tokens or mock oracle are ever compiled or + originated on this path. +- It refuses to run unless `deploy_script/config.json` declares `networkProfile: "mainnet"` **and** + the connected RPC reports a known mainnet chain id (`assert_network.js`). It also exports + `DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.mainnet.json` at the top of the script (unless + already set) so every step — the plan/preflight check, `prepare.js`, `deploy.js`, and the SmartPy + compile targets — reads and writes the exact same manifest file. +- Before touching the manifest at all, it runs `mainnet_preflight.js`, which requires the manifest to + already contain vetted, on-chain-verified canonical addresses for `PriceOracle`, `USDt`, and `tzBTC` + (checked both against an on-chain existence check and, once configured, against a hardcoded + allowlist in `mainnet_preflight.js`), prints the full deployment plan (network, chain id, manifest, + canonical inputs, and any addresses already recorded in the manifest), and requires + `MAINNET_DEPLOY_CONFIRM=yes` to proceed past that point. Only after this passes does `prepare.js` run + and write `OriginatorAddress` to the manifest — declining confirmation leaves the manifest untouched. +- After origination it reminds you to complete the [Post-Deployment Admin + Handoff](#post-deployment-admin-handoff-mainnet) before unpausing any market. + +> `USDtz` is intentionally not required by `mainnet_preflight.js`: no compile target in either deploy +> script currently originates a `CUSDtz` market. If a `CUSDtz` market is added to the mainnet pipeline +> later, add `USDtz` back to `REQUIRED_CANONICAL_KEYS` (and to the allowlist) in +> `mainnet_preflight.js`. + ### Deployment Manifest The deploy scripts track originated addresses in a manifest file @@ -121,7 +152,12 @@ whose `chainId` doesn't match the connected RPC. - The tracked `deploy.json` is intentionally kept **empty** (`{}`). Do not commit a populated manifest to this path — a manifest with existing addresses will only be reused after each address is verified on-chain (matching code and critical storage addresses), never silently. -- To keep Previewnet and mainnet deployments in fully separate files, set `DEPLOY_MANIFEST` to an +- The manifest path resolution is centralized (`resolveDeployResultPath()` in `util.js`, mirrored by + `Config.py` for the SmartPy side) so every tool agrees on the same file: + 1. `DEPLOY_MANIFEST`, if set, always wins. + 2. Otherwise, the default is derived from `deploy_script/config.json`'s `networkProfile`: + `deploy.mainnet.json` when it's `"mainnet"`, `deploy.json` otherwise. + To keep Previewnet and mainnet deployments in fully separate files, set `DEPLOY_MANIFEST` to an explicit path before running `npm run prepare:deploy` and the deploy shell scripts, e.g.: ```sh export DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.mainnet.json @@ -134,8 +170,8 @@ whose `chainId` doesn't match the connected RPC. `TezFinOracle` (and therefore `Comptroller`) requires a `PriceOracle` address in the manifest before it can be compiled — `CompileTezFinOracle.py` validates this dependency and fails with a clear error -if `PriceOracle` is missing, instead of silently compiling with a stale value. `deploy_all_contracts.sh` -also runs `verify_oracle.js` right before compiling `TezFinOracle`, which checks that the configured +if `PriceOracle` is missing, instead of silently compiling with a stale value. Both deploy scripts +also run `verify_oracle.js` right before compiling `TezFinOracle`, which checks that the configured `PriceOracle` address actually exists on the connected chain (not just that the manifest key is present) and fails closed if it does not. @@ -148,15 +184,16 @@ overrides and can repoint `oracle` to a different feed with `set_oracle`. - **Previewnet**: `CompileTestData.py` compiles and deploys a mock `PriceOracle` ([`deploy/test_data/PriceOracle.py`](deploy/test_data/PriceOracle.py)) as part of - `deploy_all_contracts.sh`. This mock is for Previewnet only, is **not** Harbinger — it's a bare + `deploy_previewnet.sh`. This mock is for Previewnet only, is **not** Harbinger — it's a bare stand-in that mimics the same `get` callback interface. It has **no administrator check**: its `setPrice` entry point can be called by any address to set any price for any asset. Do not treat a Previewnet deployment using this mock as representative of mainnet price-feed security. -- **Mainnet**: do not compile or originate the mock oracle. Instead, put the exact address of the - vetted production Harbinger (or Harbinger-compatible) oracle directly under the `PriceOracle` key in - the mainnet manifest (`DEPLOY_MANIFEST`) before running any compile target that depends on it. Never - let a mainnet run execute `CompileTestData.py`. Document, alongside the mainnet manifest, which - Harbinger instance/administrator is being used and who controls it — this project does not deploy or +- **Mainnet**: `deploy_mainnet.sh` never compiles or originates the mock oracle (it does not run + `CompileTestData.py` at all). Put the exact address of the vetted production Harbinger (or + Harbinger-compatible) oracle directly under the `PriceOracle` key in the mainnet manifest + (`DEPLOY_MANIFEST`) before running `deploy_mainnet.sh`; `mainnet_preflight.js` verifies it exists + on-chain before anything is compiled. Document, alongside the mainnet manifest, which Harbinger + instance/administrator is being used and who controls it — this project does not deploy or administer Harbinger itself. ## Post-Deployment Admin Handoff (Mainnet) diff --git a/deploy/compile_targets/CompileTzBTC.py b/deploy/compile_targets/CompileTzBTC.py index f6ce3f9b..c6c6b0f3 100644 --- a/deploy/compile_targets/CompileTzBTC.py +++ b/deploy/compile_targets/CompileTzBTC.py @@ -1,15 +1,20 @@ import smartpy as sp import json +from types import SimpleNamespace CFG = sp.io.import_script_from_url("file:deploy/compile_targets/Config.py") CFA12 = sp.io.import_script_from_url("file:contracts/CFA12.py") UTILS = sp.io.import_script_from_url("file:deploy/compile_targets/Utils.py") -UTILS.checkDependencies(CFG.CFA12) +# tzBTC uses its own dedicated CtzBTC_IRM (see CompileCtzBTC_IRM.py / Config.json +# "CtzBTC_IRM"), not the CFA12_IRM used by CUSDtz. Check that dependency explicitly +# here instead of reusing CFG.CFA12.dependencies, which is shared with +# CompileCUSDtz.py and still correctly points at CFA12_IRM for that market. +UTILS.checkDependencies(SimpleNamespace(dependencies=["Governance", "tzBTC", "CtzBTC_IRM"])) sp.add_compilation_target("CtzBTC", CFA12.CFA12( comptroller_ = sp.address(CFG.deployResult.Comptroller), - interestRateModel_ = sp.address(CFG.deployResult.CFA12_IRM), + interestRateModel_ = sp.address(CFG.deployResult.CtzBTC_IRM), initialExchangeRateMantissa_ = sp.nat(CFG.CFA2.initialExchangeRateMantissa), administrator_ = sp.address(CFG.deployResult.Governance), # specify metadata before deployment diff --git a/deploy/compile_targets/Config.py b/deploy/compile_targets/Config.py index 8e472393..3a61dc62 100644 --- a/deploy/compile_targets/Config.py +++ b/deploy/compile_targets/Config.py @@ -3,7 +3,9 @@ from types import SimpleNamespace PATH_COMPILE_CONFIG = "deploy/compile_targets/Config.json" +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" class JsonDeserializer: # order to formulate correct path, execution must be started from root directory "TezFin" @@ -16,11 +18,22 @@ def Deserialize(relativePath): return json.loads(file.read(), object_hook=lambda d: SimpleNamespace(**d)) compileConfig = JsonDeserializer.Deserialize(PATH_COMPILE_CONFIG) -# Use a network-specific manifest when set, so Previewnet/mainnet deploy results are -# never read from (or written to) the same tracked file. Falls back to the legacy -# E2E override, then to the tracked Previewnet-only default. +deployScriptConfig = JsonDeserializer.Deserialize(PATH_DEPLOY_SCRIPT_CONFIG) + +# Manifest path resolution must match resolveDeployResultPath() in deploy_script/util.js +# exactly, otherwise prepare/deploy (JS) and compile targets (this file) can silently +# read/write different manifests for the same run: +# 1. DEPLOY_MANIFEST env var, if set (explicit override always wins). +# 2. A profile-specific default derived from deploy_script/config.json's +# networkProfile, so a mainnet config.json never defaults to the Previewnet +# manifest file (or vice versa). +_defaultDeployResultPath = ( + PATH_DEPLOY_RESULT_MAINNET + if getattr(deployScriptConfig, 'networkProfile', None) == 'mainnet' + else PATH_DEPLOY_RESULT +) deployResult = JsonDeserializer.Deserialize( - os.getenv('DEPLOY_MANIFEST', os.getenv('E2E', PATH_DEPLOY_RESULT))) + os.getenv('DEPLOY_MANIFEST', os.getenv('E2E', _defaultDeployResultPath))) CUSDt_IRM = compileConfig.CUSDt_IRM CUSDtz_IRM = compileConfig.CUSDtz_IRM diff --git a/deploy/compile_targets/Utils.py b/deploy/compile_targets/Utils.py index e0ecbff8..de4641c3 100644 --- a/deploy/compile_targets/Utils.py +++ b/deploy/compile_targets/Utils.py @@ -10,7 +10,7 @@ def checkDependencies(obj): notSpecifiedAttributes.append(attribute) if len(notSpecifiedAttributes) > 0: - manifest = os.getenv('DEPLOY_MANIFEST', CFG.PATH_DEPLOY_RESULT) + manifest = os.getenv('DEPLOY_MANIFEST', os.getenv('E2E', CFG._defaultDeployResultPath)) message = f'Please specify {notSpecifiedAttributes} in the deploy manifest ({manifest}).' if 'PriceOracle' in notSpecifiedAttributes: message += ( diff --git a/deploy/deploy_script/assert_network.js b/deploy/deploy_script/assert_network.js new file mode 100644 index 00000000..80d8a192 --- /dev/null +++ b/deploy/deploy_script/assert_network.js @@ -0,0 +1,48 @@ +const { config, createTezosClient } = require('./util.js'); + +// Known mainnet chain ids. Extend if/when the protocol targets additional networks. +const MAINNET_CHAIN_IDS = new Set(['NetXdQprcVkpaWU']); + +// Guards against running the wrong deploy script against the wrong network. Each +// deploy script declares which profile it expects ("previewnet" or "mainnet") as its +// first CLI argument; this checks both the declared config.json `networkProfile` and +// the actual connected RPC chain id agree with that expectation before anything is +// compiled or deployed. +async function assertNetwork(expectedProfile) { + if (expectedProfile !== 'previewnet' && expectedProfile !== 'mainnet') { + throw new Error(`Unknown network profile "${expectedProfile}"; expected "previewnet" or "mainnet".`); + } + + const declaredProfile = config.networkProfile; + if (declaredProfile && declaredProfile !== expectedProfile) { + throw new Error( + `This script is for "${expectedProfile}", but deploy_script/config.json declares ` + + `networkProfile "${declaredProfile}". Refusing to continue; point config.json at the ` + + `correct profile before running this script.`, + ); + } + + const { chainId } = await createTezosClient(); + const isMainnetChain = MAINNET_CHAIN_IDS.has(chainId); + if (expectedProfile === 'mainnet' && !isMainnetChain) { + throw new Error( + `Expected a mainnet chain id, but the connected RPC (${config.tezosNode}) reports chain ` + + `${chainId}, which is not a known mainnet chain id. Refusing to run the mainnet deploy ` + + `script against what looks like a non-mainnet network.`, + ); + } + if (expectedProfile === 'previewnet' && isMainnetChain) { + throw new Error( + `The connected RPC (${config.tezosNode}) reports chain ${chainId}, which is a known ` + + `mainnet chain id. Refusing to run the Previewnet deploy script against mainnet.`, + ); + } + + console.log(`[INFO] Network check passed: profile=${expectedProfile}, chainId=${chainId}, node=${config.tezosNode}`); +} + +const expectedProfile = process.argv[2]; +assertNetwork(expectedProfile).catch((error) => { + console.error(`[ERROR] Network guard failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/config.json b/deploy/deploy_script/config.json index 701efab5..c9eb087c 100644 --- a/deploy/deploy_script/config.json +++ b/deploy/deploy_script/config.json @@ -1,4 +1,5 @@ { + "networkProfile": "previewnet", "tezosNode": "https://michelson.previewnet.tezosx.nomadic-labs.com", "chainId": "NetXY2oPPzkxUW1", "feeSafetyMultiplier": 1.2, diff --git a/deploy/deploy_script/mainnet_preflight.js b/deploy/deploy_script/mainnet_preflight.js new file mode 100644 index 00000000..f2cfe724 --- /dev/null +++ b/deploy/deploy_script/mainnet_preflight.js @@ -0,0 +1,121 @@ +const { config, createTezosClient, resolveDeployResultPath } = require('./util.js'); + +const fs = require('fs'); + +function readDeployResult(jsonPath) { + if (!fs.existsSync(jsonPath)) { + return {}; + } + return JSON.parse(fs.readFileSync(jsonPath, 'utf8')); +} + +// Addresses that a mainnet deployment must never originate itself: they are external, +// canonical assets/services this protocol integrates with, not contracts this repo +// controls. They must be supplied explicitly in the mainnet manifest ahead of time. +// +// Only list keys that a currently-wired compile target actually consumes. USDtz is +// intentionally NOT required here: no compile target in deploy_mainnet.sh/ +// deploy_previewnet.sh originates a CUSDtz market (CompileCUSDtz.py is not part of +// either pipeline), so requiring it here would block deployments that never need it. +// Add it back only once a CUSDtz market is actually added to the mainnet pipeline. +const REQUIRED_CANONICAL_KEYS = ['PriceOracle', 'USDt', 'tzBTC']; + +// Vetted production addresses for canonical external assets/services. These must be +// filled in with the exact, reviewed mainnet addresses before a real mainnet +// deployment; a manifest value that doesn't match exactly is rejected rather than +// silently accepted just because *some* contract exists at that address. Deliberately +// left null/empty until vetted mainnet addresses are confirmed for this deployment. +const VETTED_MAINNET_ADDRESSES = { + // PriceOracle: null, + // USDt: null, + // tzBTC: null, +}; + +async function verifyAddressExists(tezos, key, address) { + try { + await tezos.rpc.getScript(address); + } catch (error) { + throw new Error( + `Manifest key "${key}" (${address}) could not be found on the connected chain (${error.message}).`, + ); + } +} + +function verifyAgainstAllowlist(key, address) { + const expected = VETTED_MAINNET_ADDRESSES[key]; + if (!expected) { + console.log( + `[WARN] No vetted address configured for "${key}" in mainnet_preflight.js ` + + `(VETTED_MAINNET_ADDRESSES). Falling back to "exists on-chain" only; add the exact vetted ` + + `mainnet address here before a real mainnet deployment so a wrong/unrelated KT1 can't be ` + + `silently accepted just because a contract happens to exist at that address.`, + ); + return; + } + if (address !== expected) { + throw new Error( + `Manifest key "${key}" is ${address}, but the vetted mainnet address is ${expected}. ` + + `Refusing to proceed with a mismatched canonical address.`, + ); + } +} + +async function mainnetPreflight(deployResultPath) { + const { tezos, chainId } = await createTezosClient(); + const deployResult = readDeployResult(deployResultPath); + + const missing = REQUIRED_CANONICAL_KEYS.filter((key) => !deployResult[key]); + if (missing.length > 0) { + throw new Error( + `Mainnet manifest ${deployResultPath} is missing required canonical addresses: ` + + `[${missing.join(', ')}]. A mainnet deployment must never originate test tokens or a mock ` + + `oracle (see CompileTestData.py, which this script refuses to run); populate these keys ` + + `with the vetted production addresses before continuing. See README.md "PriceOracle ` + + `Configuration".`, + ); + } + + console.log('[INFO] Verifying required canonical addresses exist on-chain and match the allowlist...'); + for (const key of REQUIRED_CANONICAL_KEYS) { + verifyAgainstAllowlist(key, deployResult[key]); + await verifyAddressExists(tezos, key, deployResult[key]); + console.log(`[INFO] ${key}: ${deployResult[key]} OK`); + } + + console.log(''); + console.log('==================== MAINNET DEPLOYMENT PLAN ===================='); + console.log(`Network: ${config.tezosNode}`); + console.log(`Chain ID: ${chainId}`); + console.log(`Manifest: ${deployResultPath}`); + console.log('Canonical inputs (not originated by this deployment):'); + for (const key of REQUIRED_CANONICAL_KEYS) { + console.log(` - ${key}: ${deployResult[key]}`); + } + console.log('Contracts already recorded in the manifest (will be verified, not re-originated):'); + const alreadyDeployedKeys = Object.keys(deployResult).filter( + (key) => !REQUIRED_CANONICAL_KEYS.includes(key) && !['chainId', 'network', 'OriginatorAddress'].includes(key), + ); + if (alreadyDeployedKeys.length === 0) { + console.log(' (none — this will be a full fresh deployment)'); + } else { + for (const key of alreadyDeployedKeys) { + console.log(` - ${key}: ${deployResult[key]}`); + } + } + console.log('==================================================================='); + console.log(''); + + if (process.env.MAINNET_DEPLOY_CONFIRM !== 'yes') { + throw new Error( + 'Refusing to proceed without explicit confirmation. Review the plan above carefully, ' + + 'then re-run with MAINNET_DEPLOY_CONFIRM=yes to continue.', + ); + } + + console.log('[INFO] MAINNET_DEPLOY_CONFIRM=yes acknowledged; proceeding.'); +} + +mainnetPreflight(resolveDeployResultPath()).catch((error) => { + console.error(`[ERROR] Mainnet preflight failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/deploy/deploy_script/prepare.js b/deploy/deploy_script/prepare.js index a42737af..51ccbe17 100644 --- a/deploy/deploy_script/prepare.js +++ b/deploy/deploy_script/prepare.js @@ -1,12 +1,11 @@ -const path = require('path'); -const { syncDeploymentOriginator } = require('./util.js'); +const { syncDeploymentOriginator, resolveDeployResultPath } = require('./util.js'); // Use a separate manifest file per network/deployment run. Set DEPLOY_MANIFEST to an // explicit path (e.g. TezFinBuild/deploy_result/deploy.mainnet.json) so Previewnet and -// mainnet runs can never share or silently overwrite each other's addresses. -const deployResultPath = process.env.DEPLOY_MANIFEST - ? path.resolve(process.env.DEPLOY_MANIFEST) - : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); +// mainnet runs can never share or silently overwrite each other's addresses. Falls back +// to a profile-specific default derived from config.json's networkProfile (see +// resolveDeployResultPath in util.js) when DEPLOY_MANIFEST isn't set. +const deployResultPath = resolveDeployResultPath(); syncDeploymentOriginator(deployResultPath).catch((error) => { console.error(`[ERROR] Deployment preparation failed: ${error.message}`); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index d7b0ab90..c76260a1 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -8,6 +8,19 @@ const { TezosToolkit } = require('@taquito/taquito'); const configPath = path.join(__dirname, 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +// Single source of truth for the manifest path so prepare/deploy/verify-oracle/preflight +// can never silently disagree about which file they're reading/writing. Resolution order: +// 1. DEPLOY_MANIFEST env var, if set (explicit override always wins). +// 2. A profile-specific default derived from config.json's networkProfile, so a +// mainnet config.json never defaults to the Previewnet manifest file (or vice versa). +function resolveDeployResultPath() { + if (process.env.DEPLOY_MANIFEST) { + return path.resolve(process.env.DEPLOY_MANIFEST); + } + const fileName = config.networkProfile === 'mainnet' ? 'deploy.mainnet.json' : 'deploy.json'; + return path.join(__dirname, '../../TezFinBuild/deploy_result', fileName); +} + function getRequiredEnv(name) { const value = process.env[name]; if (!value) { @@ -408,12 +421,9 @@ async function runDeployment(compiledContractsPath, deployResultPath) { } async function run() { - const deployResultPath = process.env.DEPLOY_MANIFEST - ? path.resolve(process.env.DEPLOY_MANIFEST) - : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); return runDeployment( path.join(__dirname, '../../TezFinBuild/compiled_contracts'), - deployResultPath, + resolveDeployResultPath(), ); } @@ -451,4 +461,4 @@ async function verifyOracleAddress(deployResultPath) { console.log(`[INFO] Verified PriceOracle ${address} exists on chain ${chainId}`); } -module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator, verifyOracleAddress } +module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator, verifyOracleAddress, config, createTezosClient, resolveDeployResultPath } diff --git a/deploy/deploy_script/verify_oracle.js b/deploy/deploy_script/verify_oracle.js index ea04a296..f0c90e26 100644 --- a/deploy/deploy_script/verify_oracle.js +++ b/deploy/deploy_script/verify_oracle.js @@ -1,12 +1,9 @@ -const path = require('path'); -const { verifyOracleAddress } = require('./util.js'); +const { verifyOracleAddress, resolveDeployResultPath } = require('./util.js'); // Run this before compiling CompileTezFinOracle.py to make sure the PriceOracle // address recorded in the manifest actually exists on the connected chain, instead of // only checking that the manifest key is present. -const deployResultPath = process.env.DEPLOY_MANIFEST - ? path.resolve(process.env.DEPLOY_MANIFEST) - : path.join(__dirname, '../../TezFinBuild/deploy_result/deploy.json'); +const deployResultPath = resolveDeployResultPath(); verifyOracleAddress(deployResultPath).catch((error) => { console.error(`[ERROR] PriceOracle verification failed: ${error.message}`); diff --git a/deploy/shell_scripts/deploy_mainnet.sh b/deploy/shell_scripts/deploy_mainnet.sh new file mode 100755 index 00000000..f2cb4553 --- /dev/null +++ b/deploy/shell_scripts/deploy_mainnet.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail +# Deploys the protocol to mainnet using only canonical production token/oracle +# addresses. This script never runs CompileTestData.py and will refuse to proceed +# unless: +# - config.json declares networkProfile "mainnet" and the connected RPC reports a +# known mainnet chain id (see assert_network.js), +# - the manifest already contains vetted PriceOracle/USDt/tzBTC addresses that match +# the allowlist in mainnet_preflight.js and exist on-chain, +# - MAINNET_DEPLOY_CONFIRM=yes is set after reviewing the printed deployment plan. +# +# Order matters: the plan/confirmation check (mainnet_preflight.js) runs BEFORE +# prepare.js, so the manifest is never touched (not even to set OriginatorAddress) if +# the operator declines to confirm. +# +# 1 - path to SmartPy.sh +# example: MAINNET_DEPLOY_CONFIRM=yes ./deploy/shell_scripts/deploy_mainnet.sh ~/smartpy-cli/SmartPy.sh + +smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" + +# Belt-and-suspenders: even though util.js/Config.py already default to +# deploy.mainnet.json when config.json declares networkProfile "mainnet", export it +# explicitly here so every child process (node and non-node alike) agrees on the same +# manifest file without relying solely on that implicit default. +export DEPLOY_MANIFEST="${DEPLOY_MANIFEST:-TezFinBuild/deploy_result/deploy.mainnet.json}" + +node ./deploy/deploy_script/assert_network.js mainnet +node ./deploy/deploy_script/mainnet_preflight.js +node ./deploy/deploy_script/prepare.js + +compile_and_deploy() { + "$smartpy" compile "$1" ./TezFinBuild/compiled_contracts --purge --protocol kathmandu "${@:2}" + node ./deploy/deploy_script/deploy.js +} + +# Intentionally does NOT run CompileTestData.py: mainnet must never originate mock +# tokens or a mock PriceOracle. PriceOracle/USDt/tzBTC are required to already be +# present (and verified against the allowlist) in the manifest by mainnet_preflight.js +# above. +node ./deploy/deploy_script/verify_oracle.js +compile_and_deploy ./deploy/compile_targets/CompileTezFinOracle.py +compile_and_deploy ./deploy/compile_targets/CompileGovernance.py +compile_and_deploy ./deploy/compile_targets/CompileComptroller.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileIRMs.py +compile_and_deploy ./deploy/compile_targets/CompileCtzBTC_IRM.py +compile_and_deploy ./deploy/compile_targets/CompileCUSDt.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileCXTZ.py --erase-comments --erase-var-annots --initial-cast +compile_and_deploy ./deploy/compile_targets/CompileTzBTC.py --erase-comments --erase-var-annots --initial-cast + +echo "" +echo "[INFO] Mainnet origination complete. Do NOT unpause markets or open the protocol to users" +echo "[INFO] until the admin handoff checklist in README.md ('Post-Deployment Admin Handoff') has" +echo "[INFO] been fully completed and verified on-chain." diff --git a/deploy/shell_scripts/deploy_all_contracts.sh b/deploy/shell_scripts/deploy_previewnet.sh similarity index 72% rename from deploy/shell_scripts/deploy_all_contracts.sh rename to deploy/shell_scripts/deploy_previewnet.sh index 215dc7bc..a48d1d3c 100755 --- a/deploy/shell_scripts/deploy_all_contracts.sh +++ b/deploy/shell_scripts/deploy_previewnet.sh @@ -1,9 +1,18 @@ #!/usr/bin/env bash set -euo pipefail +# Deploys the full protocol to Previewnet, including mock test tokens and a mock +# PriceOracle. This script must NEVER be used for a mainnet deployment; use +# deploy_mainnet.sh instead, which refuses to run CompileTestData.py. +# # 1 - path to SmartPy.sh -# example: ./deploy/shell_scripts/deploy_all_contracts.sh ~/smartpy-cli/SmartPy.sh +# example: ./deploy/shell_scripts/deploy_previewnet.sh ~/smartpy-cli/SmartPy.sh smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" + +# Guard against accidentally pointing this script at mainnet: config.json (or +# DEPLOY_MANIFEST) must not resolve to a mainnet chain id. +node ./deploy/deploy_script/assert_network.js previewnet + node ./deploy/deploy_script/prepare.js compile_and_deploy() { @@ -22,6 +31,7 @@ compile_and_deploy ./deploy/compile_targets/CompileTezFinOracle.py compile_and_deploy ./deploy/compile_targets/CompileGovernance.py compile_and_deploy ./deploy/compile_targets/CompileComptroller.py --erase-comments --erase-var-annots --initial-cast compile_and_deploy ./deploy/compile_targets/CompileIRMs.py +compile_and_deploy ./deploy/compile_targets/CompileCtzBTC_IRM.py compile_and_deploy ./deploy/compile_targets/CompileCUSDt.py --erase-comments --erase-var-annots --initial-cast compile_and_deploy ./deploy/compile_targets/CompileCXTZ.py --erase-comments --erase-var-annots --initial-cast compile_and_deploy ./deploy/compile_targets/CompileTzBTC.py --erase-comments --erase-var-annots --initial-cast From 8ca647dfe5ab2cb156f322fa6e7c37d581ead218 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 16:55:14 +0300 Subject: [PATCH 08/19] chore: assert each market compile target uses its own IRM --- .github/workflows/ci.yml | 3 + .../compile_targets/tests/test_irm_wiring.py | 74 +++++++++++++++++++ deploy/shell_scripts/deploy_mainnet.sh | 5 ++ deploy/shell_scripts/deploy_previewnet.sh | 4 + 4 files changed, 86 insertions(+) create mode 100644 deploy/compile_targets/tests/test_irm_wiring.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40314753..705cfecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: - name: "Run tests" run: | bash contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh + - name: "Check per-market IRM wiring" + run: | + python3 deploy/compile_targets/tests/test_irm_wiring.py build_and_test_typescript: runs-on: ubuntu-latest diff --git a/deploy/compile_targets/tests/test_irm_wiring.py b/deploy/compile_targets/tests/test_irm_wiring.py new file mode 100644 index 00000000..884814f2 --- /dev/null +++ b/deploy/compile_targets/tests/test_irm_wiring.py @@ -0,0 +1,74 @@ +"""Static check that each ꜰToken market's compile target is wired to its own, +asset-specific interest-rate-model config key from Config.json, instead of reusing +another market's IRM config by mistake (as tzBTC previously reused CUSDtz's CFA12_IRM +via CompileIRMs.py, even though a dedicated CtzBTC_IRM config/compile target existed). + +This test inspects only the deploy_result._IRM attribute lookups performed by +each CompileXxx.py compile target (by reading the source), and confirms that they +match this expected mapping instead of trying to execute the SmartPy compile targets +directly (which requires a populated deploy manifest and the SmartPy runtime). + +Run with: python3 deploy/compile_targets/tests/test_irm_wiring.py +""" +import os +import re +import sys + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +COMPILE_TARGETS_DIR = os.path.join(REPO_ROOT, 'deploy', 'compile_targets') + +# Expected mapping: compile target file -> (market name, expected IRM manifest key). +# Add new markets here as they are added to the deploy pipeline. +EXPECTED_MARKET_IRM = { + 'CompileCUSDt.py': ('CUSDt', 'CFA2_IRM'), + 'CompileCUSDtz.py': ('CUSDtz', 'CFA12_IRM'), + 'CompileCXTZ.py': ('CXTZ', 'CXTZ_IRM'), + 'CompileTzBTC.py': ('CtzBTC', 'CtzBTC_IRM'), +} + +IRM_ATTR_PATTERN = re.compile(r'CFG\.deployResult\.(\w*_IRM)') + + +def find_irm_reference(sourceText): + matches = set(IRM_ATTR_PATTERN.findall(sourceText)) + return matches + + +def main(): + failures = [] + + for fileName, (marketName, expectedIrmKey) in EXPECTED_MARKET_IRM.items(): + filePath = os.path.join(COMPILE_TARGETS_DIR, fileName) + if not os.path.exists(filePath): + failures.append(f'{fileName}: file not found at {filePath}') + continue + + with open(filePath) as f: + source = f.read() + + referencedIrmKeys = find_irm_reference(source) + if expectedIrmKey not in referencedIrmKeys: + failures.append( + f'{fileName} ({marketName} market): expected it to reference ' + f'CFG.deployResult.{expectedIrmKey}, but found references to ' + f'{sorted(referencedIrmKeys) or "no IRM at all"} instead.' + ) + elif len(referencedIrmKeys) > 1: + failures.append( + f'{fileName} ({marketName} market): references multiple IRM keys ' + f'{sorted(referencedIrmKeys)}; expected exactly {expectedIrmKey}.' + ) + + if failures: + print('IRM wiring check FAILED:') + for failure in failures: + print(f' - {failure}') + sys.exit(1) + + print(f'IRM wiring check passed for {len(EXPECTED_MARKET_IRM)} market(s):') + for fileName, (marketName, expectedIrmKey) in EXPECTED_MARKET_IRM.items(): + print(f' - {marketName} ({fileName}) -> {expectedIrmKey}') + + +if __name__ == '__main__': + main() diff --git a/deploy/shell_scripts/deploy_mainnet.sh b/deploy/shell_scripts/deploy_mainnet.sh index f2cb4553..93c7e288 100755 --- a/deploy/shell_scripts/deploy_mainnet.sh +++ b/deploy/shell_scripts/deploy_mainnet.sh @@ -25,6 +25,11 @@ smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" export DEPLOY_MANIFEST="${DEPLOY_MANIFEST:-TezFinBuild/deploy_result/deploy.mainnet.json}" node ./deploy/deploy_script/assert_network.js mainnet + +# Sanity check: every market compile target must reference its own asset-specific IRM +# config key (e.g. tzBTC -> CtzBTC_IRM), not another market's by mistake. +python3 ./deploy/compile_targets/tests/test_irm_wiring.py + node ./deploy/deploy_script/mainnet_preflight.js node ./deploy/deploy_script/prepare.js diff --git a/deploy/shell_scripts/deploy_previewnet.sh b/deploy/shell_scripts/deploy_previewnet.sh index a48d1d3c..a319863f 100755 --- a/deploy/shell_scripts/deploy_previewnet.sh +++ b/deploy/shell_scripts/deploy_previewnet.sh @@ -13,6 +13,10 @@ smartpy="${1:?Usage: $0 /path/to/SmartPy.sh}" # DEPLOY_MANIFEST) must not resolve to a mainnet chain id. node ./deploy/deploy_script/assert_network.js previewnet +# Sanity check: every market compile target must reference its own asset-specific IRM +# config key (e.g. tzBTC -> CtzBTC_IRM), not another market's by mistake. +python3 ./deploy/compile_targets/tests/test_irm_wiring.py + node ./deploy/deploy_script/prepare.js compile_and_deploy() { From ecab8797b35b08d7bdff685daae1a851fc59881e Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 18:01:30 +0300 Subject: [PATCH 09/19] chore: add CI coverage for deployment pipeline safety guards --- .github/workflows/ci.yml | 20 ++ README.md | 66 ++++++ .../tests/test_deploy_pipeline_wiring.py | 204 ++++++++++++++++++ .../tests/test_operation_size.py | 167 ++++++++++++++ deploy/deploy_script/assert_network.js | 34 +-- deploy/deploy_script/mainnet_preflight.js | 26 ++- deploy/deploy_script/package.json | 2 +- .../deploy_script/test/deploy_guards.test.js | 155 +++++++++++++ deploy/deploy_script/util.js | 62 ++++-- deploy/shell_scripts/deploy_mainnet.sh | 4 + deploy/shell_scripts/deploy_previewnet.sh | 4 + 11 files changed, 707 insertions(+), 37 deletions(-) create mode 100644 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py create mode 100644 deploy/compile_targets/tests/test_operation_size.py create mode 100644 deploy/deploy_script/test/deploy_guards.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 705cfecf..a957a0d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,26 @@ jobs: - name: "Check per-market IRM wiring" run: | python3 deploy/compile_targets/tests/test_irm_wiring.py + - name: "Check deploy pipeline wiring (compile targets, IRM config source, manifest path parity)" + run: | + python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py + - name: "Check contract origination sizes stay within safety threshold" + run: | + python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh + + build_and_test_deploy_scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - name: "Install deploy script dependencies" + working-directory: deploy/deploy_script + run: | + npm ci + - name: "Run deploy script guard unit tests (offline, no network access)" + working-directory: deploy/deploy_script + run: | + npm test build_and_test_typescript: runs-on: ubuntu-latest diff --git a/README.md b/README.md index ce63ef89..d332f027 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,72 @@ cd TezFin ./contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh ``` +## Required Deployment Tests + +These checks guard the deployment pipeline itself (not the contracts' business logic) +and all run offline/in CI without needing a live Tezos node: + +- **Per-market IRM wiring** (`deploy/compile_targets/tests/test_irm_wiring.py`) - static + check that each ꜰToken market compile target (`CompileCUSDt.py`, `CompileCUSDtz.py`, + `CompileCXTZ.py`, `CompileTzBTC.py`) references its own, asset-specific + `_IRM` config key instead of accidentally reusing another market's IRM. + ```sh + python3 deploy/compile_targets/tests/test_irm_wiring.py + ``` +- **Deploy pipeline wiring** (`deploy/compile_targets/tests/test_deploy_pipeline_wiring.py`) + - three cheap, static (no SmartPy, no network) checks in one script: + - every `Compile*.py` referenced from `deploy_previewnet.sh`/`deploy_mainnet.sh` + actually exists on disk (catches a typo'd/renamed/deleted compile target left + dangling in a shell script); + - `CompileCtzBTC_IRM.py` reads its parameters only from `CFG.CtzBTC_IRM` (its own, + asset-specific IRM config block), not another market's by mistake; + - `Config.py` (Python) and `util.js`'s `resolveDeployResultPath()` (JS) actually + resolve to the same default manifest file for a given `networkProfile` + ("previewnet"/"mainnet"/unset) - executed for real (each language's actual source, + not a re-implementation) rather than just asserted in a comment. + ```sh + python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py + ``` +- **Contract origination size threshold** + (`deploy/compile_targets/tests/test_operation_size.py`) - performs a **fresh SmartPy + compile** (not a read of whatever is checked into `compiled_contracts/`, which can be + stale relative to the current change) of Governance, TezFinOracle, and Comptroller + into a temporary directory, using the **same SmartPy CLI flags each target is + compiled with in `deploy_previewnet.sh`/`deploy_mainnet.sh`** (e.g. + `--erase-comments --erase-var-annots --initial-cast` for Comptroller), against a + throwaway manifest of placeholder addresses (`e2e/deploy_result/deploy.json`), then + checks each contract's compiled code + initial storage against a safety margin under + the ~32KB Tezos manager-operation limit. This is a static regression guard (e.g. + against accidentally disabling code + sharing/lazification) that runs entirely offline; it does not replace an actual + `tezos.estimate.originate` dry run against the target node before a real mainnet + deployment (that check already happens live inside `deployMichelsonContract()` in + `deploy_script/util.js`). Requires the SmartPy CLI; fails loudly (non-zero exit) if + the CLI can't be found or if every compile attempt fails, rather than silently + reporting success with nothing checked. + ```sh + python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh + ``` +- **Deploy script guards** (`deploy/deploy_script/test/deploy_guards.test.js`) - unit + tests (Node's built-in test runner, no network access) for the safety checks in + `util.js`, `assert_network.js`, and `mainnet_preflight.js`: chain-id mismatch + rejection (manifest vs. connected RPC), Micheline code/storage comparison used to + decide whether an existing manifest entry can be safely reused, manifest path + resolution (`DEPLOY_MANIFEST` env var vs. per-profile default), and mainnet preflight's + required-canonical-key / vetted-address-allowlist checks. + ```sh + cd deploy/deploy_script + npm ci + npm test + ``` + +All three are wired into CI (`.github/workflows/ci.yml`). + +Not currently automated (documented here as a manual pre-mainnet step instead): a live +dry run of `deploy_mainnet.sh` against a real mainnet-like node, and confirming the +`MAINNET_CHAIN_IDS` value in `assert_network.js` against the node you actually connect +to before relying on it to reject a misconfigured network. + ## Run Contract E2E Tests To run e2e tests use the following command, you will need latest smartpy cli installed. diff --git a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py new file mode 100644 index 00000000..f8865aea --- /dev/null +++ b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py @@ -0,0 +1,204 @@ +"""Cheap, static (no SmartPy, no network) sanity checks for the deployment pipeline +wiring itself, as opposed to the compiled contracts. These catch a class of bug where +a shell script or config references a file/attribute that doesn't actually exist, or +where the JS and Python sides of manifest-path resolution silently disagree - the kind +of mistake that's easy to introduce when editing deploy_previewnet.sh/deploy_mainnet.sh +or Config.py/util.js without re-running an actual deployment. + +Covers: + 1. Every `Compile*.py` target invoked from deploy_previewnet.sh/deploy_mainnet.sh + actually exists on disk (catches a typo'd/renamed/deleted compile target being + left dangling in a shell script). + 2. CompileCtzBTC_IRM.py reads its parameters from CFG.CtzBTC_IRM (its own, + asset-specific IRM config block), not another market's IRM config block by + mistake - mirrors the same class of bug test_irm_wiring.py catches for the + ꜰToken market compile targets themselves. + 3. Config.py's `_defaultDeployResultPath` and deploy_script/util.js's + `resolveDeployResultPath()` resolve to the same default manifest file for a given + `networkProfile` ("previewnet" vs "mainnet"), so the two languages can't silently + read/write different manifests for the same run (this parity is already asserted + in comments in both files; this test actually executes both and compares). + +Run with: python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +""" +import json +import os +import re +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +COMPILE_TARGETS_DIR = os.path.join(REPO_ROOT, 'deploy', 'compile_targets') +SHELL_SCRIPTS_DIR = os.path.join(REPO_ROOT, 'deploy', 'shell_scripts') +UTIL_JS_PATH = os.path.join(REPO_ROOT, 'deploy', 'deploy_script', 'util.js') + +SHELL_SCRIPTS_TO_CHECK = ['deploy_previewnet.sh', 'deploy_mainnet.sh'] + +COMPILE_TARGET_REFERENCE_PATTERN = re.compile( + r'compile_and_deploy\s+\./deploy/compile_targets/(\S+\.py)' +) + + +def check_shell_script_compile_targets(): + """Every Compile*.py referenced from a deploy shell script must exist on disk.""" + failures = [] + for scriptName in SHELL_SCRIPTS_TO_CHECK: + scriptPath = os.path.join(SHELL_SCRIPTS_DIR, scriptName) + if not os.path.exists(scriptPath): + failures.append(f'{scriptName}: shell script not found at {scriptPath}') + continue + + with open(scriptPath) as f: + source = f.read() + + referencedFiles = COMPILE_TARGET_REFERENCE_PATTERN.findall(source) + if not referencedFiles: + failures.append(f'{scriptName}: no "compile_and_deploy ./deploy/compile_targets/*.py" calls found at all') + continue + + for fileName in referencedFiles: + targetPath = os.path.join(COMPILE_TARGETS_DIR, fileName) + if not os.path.exists(targetPath): + failures.append( + f'{scriptName}: references {fileName}, but no such file exists at ' + f'{targetPath} (typo, renamed, or deleted compile target left dangling in the script).' + ) + return failures + + +def check_ctzbtc_irm_config_source(): + """CompileCtzBTC_IRM.py must read its parameters from CFG.CtzBTC_IRM, not another + market's IRM config block (e.g. CFG.CFA12_IRM) by mistake.""" + filePath = os.path.join(COMPILE_TARGETS_DIR, 'CompileCtzBTC_IRM.py') + if not os.path.exists(filePath): + return [f'CompileCtzBTC_IRM.py not found at {filePath}'] + + with open(filePath) as f: + source = f.read() + + referencedConfigKeys = set(re.findall(r'CFG\.(\w*_IRM)\.', source)) + if referencedConfigKeys != {'CtzBTC_IRM'}: + return [ + f'CompileCtzBTC_IRM.py: expected it to read parameters only from CFG.CtzBTC_IRM, ' + f'but found references to {sorted(referencedConfigKeys) or "no IRM config at all"} instead.' + ] + return [] + + +def resolve_python_default_manifest_path(networkProfile): + """Runs Config.py's actual _defaultDeployResultPath logic (not a re-implementation + of it), by exec'ing its source with JsonDeserializer.Deserialize monkeypatched (via + a runner script written to a temp file, to avoid any nested-quoting issues with + -c) so the deploy_script/config.json read returns a fake object with just the + networkProfile under test, without touching the real (copilot-ignored) config.json + on disk.""" + configPyPath = os.path.join(COMPILE_TARGETS_DIR, 'Config.py') + runnerSource = ( + "import sys, json\n" + "from types import SimpleNamespace\n" + f"sys.path.insert(0, {COMPILE_TARGETS_DIR!r})\n" + f"with open({configPyPath!r}) as f:\n" + " configPySource = f.read()\n" + "patched = configPySource.replace(\n" + " 'deployScriptConfig = JsonDeserializer.Deserialize(PATH_DEPLOY_SCRIPT_CONFIG)',\n" + f" 'deployScriptConfig = SimpleNamespace(networkProfile=' + repr({networkProfile!r}) + ')',\n" + ")\n" + # Only exec up to (and including) the _defaultDeployResultPath assignment: the + # lines after it try to actually open the resolved manifest file, which may not + # exist locally (e.g. deploy.mainnet.json before a real mainnet run has ever + # happened). We only care about the resolved *path*, not reading the manifest. + "marker = 'deployResult = JsonDeserializer.Deserialize('\n" + "cutoff = patched.index(marker)\n" + "truncated = patched[:cutoff]\n" + "namespace = {'__name__': 'Config'}\n" + "exec(compile(truncated, 'Config.py', 'exec'), namespace)\n" + "print(namespace['_defaultDeployResultPath'])\n" + ) + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as runnerFile: + runnerFile.write(runnerSource) + runnerPath = runnerFile.name + try: + result = subprocess.run([sys.executable, runnerPath], cwd=REPO_ROOT, capture_output=True, text=True) + finally: + os.unlink(runnerPath) + if result.returncode != 0: + raise RuntimeError(f'Failed to evaluate Config.py default path for networkProfile={networkProfile!r}: {result.stderr}') + return result.stdout.strip() + + +def resolve_js_default_manifest_path(networkProfile): + """Runs deploy_script/util.js's actual resolveDeployResultPath() (not a + re-implementation of it), with a temporary config.json declaring the given + networkProfile swapped in via a *copy* of the deploy_script directory contents + (util.js is copied, not symlinked, so Node's `path.join(__dirname, ...)` resolves + relative to the temp copy's own config.json, not the real one). node_modules is + symlinked (read-only, no config.json inside it) purely to avoid copying it.""" + realDeployScriptDir = os.path.dirname(UTIL_JS_PATH) + with tempfile.TemporaryDirectory() as tmpDir: + tmpDeployScriptDir = os.path.join(tmpDir, 'deploy_script') + os.makedirs(tmpDeployScriptDir) + with open(UTIL_JS_PATH) as src, \ + open(os.path.join(tmpDeployScriptDir, 'util.js'), 'w') as dst: + dst.write(src.read()) + os.symlink( + os.path.join(realDeployScriptDir, 'node_modules'), + os.path.join(tmpDeployScriptDir, 'node_modules'), + ) + with open(os.path.join(tmpDeployScriptDir, 'config.json'), 'w') as f: + json.dump({'networkProfile': networkProfile, 'tezosNode': 'https://example.invalid'}, f) + + script = ( + f"const {{ resolveDeployResultPath }} = require({json.dumps(os.path.join(tmpDeployScriptDir, 'util.js'))});" + "console.log(resolveDeployResultPath());" + ) + result = subprocess.run(['node', '-e', script], cwd=tmpDir, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f'Failed to evaluate util.js default path for networkProfile={networkProfile!r}: {result.stderr}') + return result.stdout.strip() + + +def check_manifest_path_resolution_parity(): + """Config.py (Python) and util.js (JS) must resolve to the same default manifest + file for a given networkProfile, otherwise compile targets and deploy scripts can + silently read/write different files for what's meant to be the same run.""" + failures = [] + for networkProfile in ['previewnet', 'mainnet', None]: + try: + pyPath = resolve_python_default_manifest_path(networkProfile) + jsPath = resolve_js_default_manifest_path(networkProfile) + except RuntimeError as error: + failures.append(str(error)) + continue + + pyFileName = os.path.basename(pyPath) + jsFileName = os.path.basename(jsPath) + if pyFileName != jsFileName: + failures.append( + f'networkProfile={networkProfile!r}: Config.py resolves to "{pyFileName}", but util.js ' + f'resolves to "{jsFileName}". These must match or the JS and Python sides of the deploy ' + f'pipeline can silently disagree about which manifest file to use.' + ) + return failures + + +def main(): + failures = [] + failures += check_shell_script_compile_targets() + failures += check_ctzbtc_irm_config_source() + failures += check_manifest_path_resolution_parity() + + if failures: + print('Deploy pipeline wiring check FAILED:') + for failure in failures: + print(f' - {failure}') + sys.exit(1) + + print('Deploy pipeline wiring check passed:') + print(' - All Compile*.py targets referenced from deploy_previewnet.sh/deploy_mainnet.sh exist.') + print(' - CompileCtzBTC_IRM.py reads parameters only from CFG.CtzBTC_IRM.') + print(' - Config.py and util.js agree on the default manifest path for previewnet/mainnet/unset profiles.') + + +if __name__ == '__main__': + main() diff --git a/deploy/compile_targets/tests/test_operation_size.py b/deploy/compile_targets/tests/test_operation_size.py new file mode 100644 index 00000000..d11e683b --- /dev/null +++ b/deploy/compile_targets/tests/test_operation_size.py @@ -0,0 +1,167 @@ +"""Guards against a contract's compiled code+storage growing past safe origination +size thresholds before it reaches mainnet. This was called out specifically in the +PR #455 review re: Comptroller's `lazify=True` removal - that change alone is benign +(SmartPy just emits a differently-shaped, non-lazified Michelson representation), but +the review recommended verifying the actual origination operation size stays +comfortably under protocol limits before a real mainnet deployment. + +Unlike a naive version of this check, this test does NOT read whatever happens to be +checked in under compiled_contracts/ (which can be stale relative to the current PR's +source changes). Instead it invokes the real SmartPy CLI to compile the relevant +targets fresh, into a temporary output directory, against a throwaway manifest of +placeholder addresses (borrowed from e2e/deploy_result/deploy.json, which already +contains a full set of syntactically-valid KT1/tz1 addresses for exactly this +purpose). This means a source change that meaningfully grows a contract (e.g. +disabling code sharing/lazification) is caught in the same PR that introduces it, +without needing a network connection. + +This does NOT call any RPC or attempt a live fee/gas estimate. That happens inside +`deployMichelsonContract()` in deploy_script/util.js (`tezos.estimate.originate`), +which does need real network access and is the authoritative check before mainnet. + +Run with: + python3 deploy/compile_targets/tests/test_operation_size.py [/path/to/SmartPy.sh] +(defaults to ~/smartpy-cli/SmartPy.sh if no argument/env var is given) +""" +import csv +import os +import shutil +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +PLACEHOLDER_MANIFEST = os.path.join(REPO_ROOT, 'e2e', 'deploy_result', 'deploy.json') + +DEFAULT_MAX_TOTAL_BYTES = 32000 + +# Compile target file -> (compiled contract directory name, extra SmartPy CLI flags). +# The extra flags MUST match exactly what deploy_previewnet.sh/deploy_mainnet.sh pass +# for the same target (--erase-comments --erase-var-annots --initial-cast for +# Comptroller), otherwise this test measures a different (larger/smaller) artifact +# than what's actually deployed, which can produce a false pass or false fail against +# the safety threshold. See deploy/shell_scripts/deploy_*.sh for the source of truth. +COMPILE_TARGETS = { + 'CompileGovernance.py': ('Governance', []), + 'CompileTezFinOracle.py': ('TezFinOracle', []), + 'CompileComptroller.py': ('Comptroller', ['--erase-comments', '--erase-var-annots', '--initial-cast']), +} + + +def find_smartpy_cli(): + if len(sys.argv) > 1: + return sys.argv[1] + envPath = os.environ.get('SMARTPY_CLI') + if envPath: + return envPath + return os.path.expanduser('~/smartpy-cli/SmartPy.sh') + + +def read_sizes_csv(csvPath): + sizes = {} + with open(csvPath, newline='') as f: + for row in csv.reader(f): + if len(row) != 2: + continue + key, value = row + try: + sizes[key.strip()] = int(value.strip()) + except ValueError: + continue + return sizes + + +def find_sizes_csv(contractDir): + for name in sorted(os.listdir(contractDir)): + if name.endswith('_sizes.csv'): + return os.path.join(contractDir, name) + return None + + +def main(): + maxTotalBytes = int(os.environ.get('MAX_CONTRACT_OPERATION_BYTES', DEFAULT_MAX_TOTAL_BYTES)) + smartpy = find_smartpy_cli() + + if not os.path.exists(smartpy): + print( + f'[ERROR] SmartPy CLI not found at "{smartpy}". Install it (see README "Run Contract ' + f'Unit Tests") or pass its path as an argument / SMARTPY_CLI env var. Refusing to skip ' + f'this check silently.' + ) + sys.exit(1) + + if not os.path.exists(PLACEHOLDER_MANIFEST): + print(f'[ERROR] Placeholder manifest not found at {PLACEHOLDER_MANIFEST}.') + sys.exit(1) + + tmpDir = tempfile.mkdtemp(prefix='tezfin_size_check_') + try: + failures = [] + checkedAny = False + for fileName, (contractName, extraFlags) in COMPILE_TARGETS.items(): + targetPath = os.path.join(REPO_ROOT, 'deploy', 'compile_targets', fileName) + result = subprocess.run( + [smartpy, 'compile', targetPath, tmpDir, '--purge', '--protocol', 'kathmandu', *extraFlags], + cwd=REPO_ROOT, + env={**os.environ, 'DEPLOY_MANIFEST': PLACEHOLDER_MANIFEST}, + capture_output=True, + text=True, + ) + if result.returncode != 0: + failures.append( + f'{contractName}: SmartPy compile of {fileName} failed (exit {result.returncode}). ' + f'stderr:\n{result.stderr.strip()[-2000:]}' + ) + continue + + contractDir = os.path.join(tmpDir, contractName) + if not os.path.isdir(contractDir): + failures.append(f'{contractName}: compile succeeded but expected output dir {contractDir} is missing') + continue + + csvPath = find_sizes_csv(contractDir) + if not csvPath: + failures.append(f'{contractName}: no *_sizes.csv found under {contractDir}') + continue + + sizes = read_sizes_csv(csvPath) + contractBytes = sizes.get('contract') + storageBytes = sizes.get('storage') + if contractBytes is None or storageBytes is None: + failures.append(f'{contractName}: {csvPath} is missing "contract" and/or "storage" rows') + continue + + checkedAny = True + total = contractBytes + storageBytes + status = 'OK' if total <= maxTotalBytes else 'TOO LARGE' + print(f'[INFO] {contractName}: code={contractBytes}B, storage={storageBytes}B, total={total}B ({status})') + if total > maxTotalBytes: + failures.append( + f'{contractName}: total origination size {total}B exceeds the safety threshold of ' + f'{maxTotalBytes}B (code={contractBytes}B, storage={storageBytes}B). Investigate whether a ' + f'recent change (e.g. disabling lazification) meaningfully increased contract size before ' + f'deploying to mainnet; consider raising MAX_CONTRACT_OPERATION_BYTES only after confirming ' + f'the actual origination still succeeds comfortably within protocol limits (see README ' + f'"Mainnet Deployment" and deployMichelsonContract()\'s fee estimate in deploy_script/util.js).' + ) + + if not checkedAny: + print( + '[ERROR] No contracts were actually checked (every compile attempt failed or produced no ' + 'output). Treating this as a failure rather than silently passing.' + ) + failures.append('No contracts were successfully compiled/measured.') + + if failures: + print('Operation size check FAILED:') + for failure in failures: + print(f' - {failure}') + sys.exit(1) + + print(f'Operation size check passed (threshold: {maxTotalBytes}B per contract).') + finally: + shutil.rmtree(tmpDir, ignore_errors=True) + + +if __name__ == '__main__': + main() diff --git a/deploy/deploy_script/assert_network.js b/deploy/deploy_script/assert_network.js index 80d8a192..87cd56c1 100644 --- a/deploy/deploy_script/assert_network.js +++ b/deploy/deploy_script/assert_network.js @@ -3,17 +3,14 @@ const { config, createTezosClient } = require('./util.js'); // Known mainnet chain ids. Extend if/when the protocol targets additional networks. const MAINNET_CHAIN_IDS = new Set(['NetXdQprcVkpaWU']); -// Guards against running the wrong deploy script against the wrong network. Each -// deploy script declares which profile it expects ("previewnet" or "mainnet") as its -// first CLI argument; this checks both the declared config.json `networkProfile` and -// the actual connected RPC chain id agree with that expectation before anything is -// compiled or deployed. -async function assertNetwork(expectedProfile) { +// Pure decision logic (no network access), extracted so it can be unit-tested +// 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".`); } - const declaredProfile = config.networkProfile; if (declaredProfile && declaredProfile !== expectedProfile) { throw new Error( `This script is for "${expectedProfile}", but deploy_script/config.json declares ` + @@ -22,27 +19,34 @@ async function assertNetwork(expectedProfile) { ); } - const { chainId } = await createTezosClient(); const isMainnetChain = MAINNET_CHAIN_IDS.has(chainId); if (expectedProfile === 'mainnet' && !isMainnetChain) { throw new Error( - `Expected a mainnet chain id, but the connected RPC (${config.tezosNode}) reports chain ` + + `Expected a mainnet chain id, but the connected RPC (${tezosNode}) reports chain ` + `${chainId}, which is not a known mainnet chain id. Refusing to run the mainnet deploy ` + `script against what looks like a non-mainnet network.`, ); } if (expectedProfile === 'previewnet' && isMainnetChain) { throw new Error( - `The connected RPC (${config.tezosNode}) reports chain ${chainId}, which is a known ` + + `The connected RPC (${tezosNode}) reports chain ${chainId}, which is a known ` + `mainnet chain id. Refusing to run the Previewnet deploy script against mainnet.`, ); } +} +async function assertNetwork(expectedProfile) { + const { chainId } = await createTezosClient(); + checkNetworkExpectation(expectedProfile, config.networkProfile, chainId, config.tezosNode); console.log(`[INFO] Network check passed: profile=${expectedProfile}, chainId=${chainId}, node=${config.tezosNode}`); } -const expectedProfile = process.argv[2]; -assertNetwork(expectedProfile).catch((error) => { - console.error(`[ERROR] Network guard failed: ${error.message}`); - process.exitCode = 1; -}); +if (require.main === module) { + const expectedProfile = process.argv[2]; + assertNetwork(expectedProfile).catch((error) => { + console.error(`[ERROR] Network guard failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { assertNetwork, checkNetworkExpectation, MAINNET_CHAIN_IDS }; diff --git a/deploy/deploy_script/mainnet_preflight.js b/deploy/deploy_script/mainnet_preflight.js index f2cfe724..04bd3714 100644 --- a/deploy/deploy_script/mainnet_preflight.js +++ b/deploy/deploy_script/mainnet_preflight.js @@ -60,11 +60,17 @@ function verifyAgainstAllowlist(key, address) { } } +// Pure check (no network access) split out from mainnetPreflight so the "missing +// required canonical key" rejection path can be unit-tested directly. +function findMissingCanonicalKeys(deployResult) { + return REQUIRED_CANONICAL_KEYS.filter((key) => !deployResult[key]); +} + async function mainnetPreflight(deployResultPath) { const { tezos, chainId } = await createTezosClient(); const deployResult = readDeployResult(deployResultPath); - const missing = REQUIRED_CANONICAL_KEYS.filter((key) => !deployResult[key]); + const missing = findMissingCanonicalKeys(deployResult); if (missing.length > 0) { throw new Error( `Mainnet manifest ${deployResultPath} is missing required canonical addresses: ` + @@ -115,7 +121,17 @@ async function mainnetPreflight(deployResultPath) { console.log('[INFO] MAINNET_DEPLOY_CONFIRM=yes acknowledged; proceeding.'); } -mainnetPreflight(resolveDeployResultPath()).catch((error) => { - console.error(`[ERROR] Mainnet preflight failed: ${error.message}`); - process.exitCode = 1; -}); +if (require.main === module) { + mainnetPreflight(resolveDeployResultPath()).catch((error) => { + console.error(`[ERROR] Mainnet preflight failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + mainnetPreflight, + findMissingCanonicalKeys, + verifyAgainstAllowlist, + REQUIRED_CANONICAL_KEYS, + VETTED_MAINNET_ADDRESSES, +}; diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index 66b8ce48..85256d38 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -14,6 +14,6 @@ "deploy": "node deploy.js", "deploy:e2e": "node deploy_e2e.js", "prepare:deploy": "node prepare.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.js" } } diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js new file mode 100644 index 00000000..f1e6dd71 --- /dev/null +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -0,0 +1,155 @@ +// Offline/unit tests for the deployment safety guards added per the PR #455 review +// ("Required Deployment Tests" checklist): chain-id rejection, manifest path +// resolution, on-chain code/storage verification comparison logic, and mainnet +// preflight canonical-address checks. These deliberately avoid any real network +// access (no Tezos RPC calls) so they run fast and deterministically in CI. +// +// Run with: node --test deploy/deploy_script/test/deploy_guards.test.js + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + checkChainIdMatch, + micheline_equal, + extractAddresses, + diffSets, + resolveDeployResultPath, +} = require('../util.js'); +const { checkNetworkExpectation, MAINNET_CHAIN_IDS } = require('../assert_network.js'); +const { findMissingCanonicalKeys, verifyAgainstAllowlist, REQUIRED_CANONICAL_KEYS, VETTED_MAINNET_ADDRESSES } = require('../mainnet_preflight.js'); + +test('checkChainIdMatch: accepts matching chain ids', () => { + assert.doesNotThrow(() => checkChainIdMatch('NetXY2oPPzkxUW1', 'NetXY2oPPzkxUW1', 'test manifest')); +}); + +test('checkChainIdMatch: accepts when no expected chain id is set (fresh manifest)', () => { + assert.doesNotThrow(() => checkChainIdMatch(undefined, 'NetXY2oPPzkxUW1', 'test manifest')); +}); + +test('checkChainIdMatch: rejects mismatched chain ids', () => { + assert.throws( + () => checkChainIdMatch('NetXdQprcVkpaWU', 'NetXY2oPPzkxUW1', 'Deploy manifest deploy.mainnet.json'), + /chain NetXdQprcVkpaWU.*chain NetXY2oPPzkxUW1/s, + ); +}); + +test('assertNetwork guard: rejects an unknown profile', () => { + assert.throws( + () => checkNetworkExpectation('staging', undefined, 'NetXY2oPPzkxUW1', 'https://node.example'), + /Unknown network profile "staging"/, + ); +}); + +test('assertNetwork guard: rejects config.json declaring a different profile than requested', () => { + assert.throws( + () => checkNetworkExpectation('mainnet', 'previewnet', 'NetXdQprcVkpaWU', 'https://node.example'), + /declares networkProfile "previewnet"/, + ); +}); + +test('assertNetwork guard: rejects running the mainnet script against a non-mainnet chain id', () => { + assert.throws( + () => checkNetworkExpectation('mainnet', 'mainnet', 'NetXY2oPPzkxUW1', 'https://node.example'), + /not a known mainnet chain id/, + ); +}); + +test('assertNetwork guard: rejects running the previewnet script against a known mainnet chain id', () => { + const [mainnetChainId] = MAINNET_CHAIN_IDS; + assert.throws( + () => checkNetworkExpectation('previewnet', 'previewnet', mainnetChainId, 'https://node.example'), + /Refusing to run the Previewnet deploy script against mainnet/, + ); +}); + +test('assertNetwork guard: accepts a correctly-matched mainnet chain id', () => { + const [mainnetChainId] = MAINNET_CHAIN_IDS; + assert.doesNotThrow(() => checkNetworkExpectation('mainnet', 'mainnet', mainnetChainId, 'https://node.example')); +}); + +test('micheline_equal: treats differently-ordered object keys as equal', () => { + const a = { prim: 'Pair', args: [{ int: '1' }, { string: 'tz1abc' }] }; + const b = { args: [{ int: '1' }, { string: 'tz1abc' }], prim: 'Pair' }; + assert.equal(micheline_equal(a, b), true); +}); + +test('micheline_equal: detects a real difference in embedded values', () => { + const a = { prim: 'Pair', args: [{ int: '1' }] }; + const b = { prim: 'Pair', args: [{ int: '2' }] }; + assert.equal(micheline_equal(a, b), false); +}); + +test('extractAddresses: pulls tz1/KT1 strings out of nested Micheline JSON', () => { + const tz1 = 'tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE'; + const kt1 = 'KT1WvzYHCNBvDSdwafTHv7nJ1dWmZ8GCYuuC'; + const storage = { + prim: 'Pair', + args: [ + { string: tz1 }, + { prim: 'Pair', args: [{ string: kt1 }, { int: '42' }] }, + ], + }; + const addresses = extractAddresses(storage); + assert.ok(addresses.has(tz1)); + assert.ok(addresses.has(kt1)); + assert.equal(addresses.size, 2); +}); + +test('diffSets: reports both missing and unexpected addresses', () => { + const expected = new Set(['tz1Admin', 'KT1Oracle']); + const actual = new Set(['tz1Admin', 'KT1DifferentOracle']); + const { missing, unexpected } = diffSets(expected, actual); + assert.deepEqual(missing, ['KT1Oracle']); + assert.deepEqual(unexpected, ['KT1DifferentOracle']); +}); + +test('diffSets: reports no differences when address sets match exactly', () => { + const expected = new Set(['tz1Admin', 'KT1Oracle']); + const actual = new Set(['tz1Admin', 'KT1Oracle']); + const { missing, unexpected } = diffSets(expected, actual); + assert.deepEqual(missing, []); + assert.deepEqual(unexpected, []); +}); + +test('resolveDeployResultPath: DEPLOY_MANIFEST env var always wins', () => { + const previous = process.env.DEPLOY_MANIFEST; + process.env.DEPLOY_MANIFEST = '/tmp/custom-manifest.json'; + try { + assert.equal(resolveDeployResultPath(), path.resolve('/tmp/custom-manifest.json')); + } finally { + if (previous === undefined) { + delete process.env.DEPLOY_MANIFEST; + } else { + process.env.DEPLOY_MANIFEST = previous; + } + } +}); + +test('mainnetPreflight: findMissingCanonicalKeys flags every missing required key', () => { + const missing = findMissingCanonicalKeys({ PriceOracle: 'KT1Oracle' }); + assert.deepEqual(missing, REQUIRED_CANONICAL_KEYS.filter((key) => key !== 'PriceOracle')); +}); + +test('mainnetPreflight: findMissingCanonicalKeys reports nothing when all keys present', () => { + const fullManifest = Object.fromEntries(REQUIRED_CANONICAL_KEYS.map((key) => [key, `KT1${key}`])); + assert.deepEqual(findMissingCanonicalKeys(fullManifest), []); +}); + +test('mainnetPreflight: verifyAgainstAllowlist warns but does not throw when no allowlist entry is configured', () => { + assert.doesNotThrow(() => verifyAgainstAllowlist('PriceOracle', 'KT1SomeAddress')); +}); + +test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not match a configured vetted entry', () => { + VETTED_MAINNET_ADDRESSES.PriceOracle = 'KT1VettedOracleAddressXXXXXXXXXXXXX'; + try { + assert.throws( + () => verifyAgainstAllowlist('PriceOracle', 'KT1DifferentOracleAddressXXXXXXXXX'), + /vetted mainnet address is KT1VettedOracleAddressXXXXXXXXXXXXX/, + ); + assert.doesNotThrow(() => verifyAgainstAllowlist('PriceOracle', 'KT1VettedOracleAddressXXXXXXXXXXXXX')); + } finally { + delete VETTED_MAINNET_ADDRESSES.PriceOracle; + } +}); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index c76260a1..9cc8d3d7 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -184,9 +184,7 @@ async function createTezosClient() { tezos.setProvider({ signer }); const chainId = await tezos.rpc.getChainId(); - if (config.chainId && config.chainId !== chainId) { - throw new Error(`Configured chain ID ${config.chainId} does not match RPC chain ID ${chainId}`); - } + checkChainIdMatch(config.chainId, chainId, 'config.json chainId'); const publicKeyHash = await signer.publicKeyHash(); if (config.originator?.pkh && config.originator.pkh !== publicKeyHash) { throw new Error(`Configured originator ${config.originator.pkh} does not match signing key ${publicKeyHash}`); @@ -204,12 +202,15 @@ async function checkConnection() { async function syncDeploymentOriginator(deployResultPath) { const { publicKeyHash, chainId } = await createTezosClient(); const deployResult = readDeployResult(deployResultPath); - if (deployResult.chainId && deployResult.chainId !== chainId) { - throw new Error( - `Deploy manifest ${deployResultPath} was created for chain ${deployResult.chainId}, ` + - `but the connected RPC reports chain ${chainId}. Refusing to reuse this manifest; start a ` + - `fresh manifest (e.g. delete or rename the file) before preparing a deployment for this network.`, - ); + if (deployResult.chainId) { + try { + checkChainIdMatch(deployResult.chainId, chainId, `Deploy manifest ${deployResultPath}`); + } catch (error) { + throw new Error( + `${error.message} Refusing to reuse this manifest; start a fresh manifest (e.g. delete ` + + `or rename the file) before preparing a deployment for this network.`, + ); + } } deployResult.OriginatorAddress = publicKeyHash; deployResult.chainId = chainId; @@ -324,6 +325,19 @@ function diffSets(expected, actual) { return { missing, unexpected }; } +// Pure guard used by createTezosClient/syncDeploymentOriginator/runDeployment to +// reject a manifest or config that was produced for a different chain than the one +// currently connected. Extracted as a standalone function (no network access) so it +// can be unit-tested directly instead of only indirectly via a live RPC connection. +function checkChainIdMatch(expectedChainId, actualChainId, context) { + if (expectedChainId && expectedChainId !== actualChainId) { + throw new Error( + `${context} was created for/configured with chain ${expectedChainId}, but the connected ` + + `RPC reports chain ${actualChainId}. Refusing to proceed.`, + ); + } +} + // Verify that a manifest entry still points at a live, matching contract before we // trust it enough to skip re-deploying. This prevents silently reusing a stale or // unrelated address just because a key happens to exist in the manifest (e.g. a @@ -377,12 +391,12 @@ async function runDeployment(compiledContractsPath, deployResultPath) { // Bind the manifest to the chain it was created for. A manifest produced against one // network (e.g. a stale Previewnet run) must never be silently reused to skip // origination against a different chain (e.g. mainnet). - if (jsonDeployResult.chainId && jsonDeployResult.chainId !== chainId) { - throw new Error( - `Deploy manifest ${deployResultPath} was created for chain ${jsonDeployResult.chainId}, ` + - `but the connected RPC reports chain ${chainId}. Refusing to reuse this manifest; start a ` + - `fresh manifest for this network instead.`, - ); + if (jsonDeployResult.chainId) { + try { + checkChainIdMatch(jsonDeployResult.chainId, chainId, `Deploy manifest ${deployResultPath}`); + } catch (error) { + throw new Error(`${error.message} Refusing to reuse this manifest; start a fresh manifest for this network instead.`); + } } jsonDeployResult.chainId = chainId; jsonDeployResult.network = config.tezosNode; @@ -461,4 +475,20 @@ async function verifyOracleAddress(deployResultPath) { console.log(`[INFO] Verified PriceOracle ${address} exists on chain ${chainId}`); } -module.exports = { checkConnection, runE2E, run, syncDeploymentOriginator, verifyOracleAddress, config, createTezosClient, resolveDeployResultPath } +module.exports = { + checkConnection, + runE2E, + run, + syncDeploymentOriginator, + verifyOracleAddress, + config, + createTezosClient, + resolveDeployResultPath, + // Exported for unit testing (pure/offline functions, no network access): + checkChainIdMatch, + canonicalizeMicheline, + micheline_equal, + extractAddresses, + diffSets, + verifyExistingContract, +} diff --git a/deploy/shell_scripts/deploy_mainnet.sh b/deploy/shell_scripts/deploy_mainnet.sh index 93c7e288..6cf13cc3 100755 --- a/deploy/shell_scripts/deploy_mainnet.sh +++ b/deploy/shell_scripts/deploy_mainnet.sh @@ -30,6 +30,10 @@ node ./deploy/deploy_script/assert_network.js mainnet # config key (e.g. tzBTC -> CtzBTC_IRM), not another market's by mistake. python3 ./deploy/compile_targets/tests/test_irm_wiring.py +# Sanity check: shell-script compile-target references exist, CtzBTC_IRM reads its own +# config block, and Config.py/util.js agree on the default manifest path per profile. +python3 ./deploy/compile_targets/tests/test_deploy_pipeline_wiring.py + node ./deploy/deploy_script/mainnet_preflight.js node ./deploy/deploy_script/prepare.js diff --git a/deploy/shell_scripts/deploy_previewnet.sh b/deploy/shell_scripts/deploy_previewnet.sh index a319863f..a6d78c36 100755 --- a/deploy/shell_scripts/deploy_previewnet.sh +++ b/deploy/shell_scripts/deploy_previewnet.sh @@ -17,6 +17,10 @@ node ./deploy/deploy_script/assert_network.js previewnet # config key (e.g. tzBTC -> CtzBTC_IRM), not another market's by mistake. python3 ./deploy/compile_targets/tests/test_irm_wiring.py +# Sanity check: shell-script compile-target references exist, CtzBTC_IRM reads its own +# config block, and Config.py/util.js agree on the default manifest path per profile. +python3 ./deploy/compile_targets/tests/test_deploy_pipeline_wiring.py + node ./deploy/deploy_script/prepare.js compile_and_deploy() { From 96715dce38bebc8e7718606caf7d5edd6df76e11 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 14 Jul 2026 18:28:40 +0300 Subject: [PATCH 10/19] fix: install deploy_script deps before running pipeline wiring test --- .github/workflows/ci.yml | 4 ++ .../tests/test_deploy_pipeline_wiring.py | 52 ++++++++++++------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a957a0d8..6ca25e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: - name: "Check per-market IRM wiring" run: | python3 deploy/compile_targets/tests/test_irm_wiring.py + - name: "Install deploy script dependencies (needed by test_deploy_pipeline_wiring.py, which requires deploy_script/util.js)" + working-directory: deploy/deploy_script + run: | + npm ci - name: "Check deploy pipeline wiring (compile targets, IRM config source, manifest path parity)" run: | python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py diff --git a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py index f8865aea..0d502ac7 100644 --- a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +++ b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py @@ -129,33 +129,49 @@ def resolve_python_default_manifest_path(networkProfile): def resolve_js_default_manifest_path(networkProfile): """Runs deploy_script/util.js's actual resolveDeployResultPath() (not a - re-implementation of it), with a temporary config.json declaring the given - networkProfile swapped in via a *copy* of the deploy_script directory contents - (util.js is copied, not symlinked, so Node's `path.join(__dirname, ...)` resolves - relative to the temp copy's own config.json, not the real one). node_modules is - symlinked (read-only, no config.json inside it) purely to avoid copying it.""" + re-implementation of it) from its real location, so Node's normal module + resolution finds deploy_script/node_modules (e.g. `glob`) without needing to copy + or symlink node_modules into a temp directory (which breaks in CI when + node_modules hasn't been installed under a symlink target, or when the temp dir + lives on a different filesystem than the repo checkout). + + To inject the networkProfile under test without permanently mutating the real + (copilot-ignored) config.json, this backs up the real file's bytes (if it exists), + overwrites it with a throwaway config declaring the given networkProfile, runs the + check, and restores the original bytes (or removes the file if it didn't exist + before) in a finally block - so a crash mid-test can't leave a stray fake + config.json behind unnoticed and the developer's real config.json is untouched by + the time this function returns either way.""" realDeployScriptDir = os.path.dirname(UTIL_JS_PATH) - with tempfile.TemporaryDirectory() as tmpDir: - tmpDeployScriptDir = os.path.join(tmpDir, 'deploy_script') - os.makedirs(tmpDeployScriptDir) - with open(UTIL_JS_PATH) as src, \ - open(os.path.join(tmpDeployScriptDir, 'util.js'), 'w') as dst: - dst.write(src.read()) - os.symlink( - os.path.join(realDeployScriptDir, 'node_modules'), - os.path.join(tmpDeployScriptDir, 'node_modules'), - ) - with open(os.path.join(tmpDeployScriptDir, 'config.json'), 'w') as f: + configJsonPath = os.path.join(realDeployScriptDir, 'config.json') + + originalConfigBytes = None + configJsonExisted = os.path.exists(configJsonPath) + if configJsonExisted: + with open(configJsonPath, 'rb') as f: + originalConfigBytes = f.read() + + try: + with open(configJsonPath, 'w') as f: json.dump({'networkProfile': networkProfile, 'tezosNode': 'https://example.invalid'}, f) script = ( - f"const {{ resolveDeployResultPath }} = require({json.dumps(os.path.join(tmpDeployScriptDir, 'util.js'))});" + f"const {{ resolveDeployResultPath }} = require({json.dumps(UTIL_JS_PATH)});" "console.log(resolveDeployResultPath());" ) - result = subprocess.run(['node', '-e', script], cwd=tmpDir, capture_output=True, text=True) + result = subprocess.run(['node', '-e', script], cwd=REPO_ROOT, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f'Failed to evaluate util.js default path for networkProfile={networkProfile!r}: {result.stderr}') return result.stdout.strip() + finally: + if configJsonExisted: + with open(configJsonPath, 'wb') as f: + f.write(originalConfigBytes) + else: + try: + os.remove(configJsonPath) + except FileNotFoundError: + pass def check_manifest_path_resolution_parity(): From 26bae20bf684ba57a2ce6473dd0df9de5a90bcc2 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Thu, 16 Jul 2026 15:06:52 +0300 Subject: [PATCH 11/19] fix: mainnet deployment verification for V3.1 --- README.md | 19 +++-- deploy/compile_targets/CompileCUSDt.py | 6 +- deploy/compile_targets/CompileCUSDtz.py | 6 +- deploy/compile_targets/CompileCXTZ.py | 6 +- deploy/compile_targets/CompileCstXTZ.py | 6 +- deploy/compile_targets/CompileTzBTC.py | 6 +- deploy/deploy_script/mainnet_preflight.js | 13 ++- .../deploy_script/test/deploy_guards.test.js | 83 ++++++++++++++----- deploy/deploy_script/util.js | 68 ++++++++------- 9 files changed, 137 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index d332f027..41adf3ea 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,8 @@ npm run prepare:deploy compile targets — reads and writes the exact same manifest file. - Before touching the manifest at all, it runs `mainnet_preflight.js`, which requires the manifest to already contain vetted, on-chain-verified canonical addresses for `PriceOracle`, `USDt`, and `tzBTC` - (checked both against an on-chain existence check and, once configured, against a hardcoded - allowlist in `mainnet_preflight.js`), prints the full deployment plan (network, chain id, manifest, + (checked both against an on-chain existence check and a required hardcoded allowlist in + `mainnet_preflight.js`; a missing allowlist entry fails deployment), prints the full deployment plan (network, chain id, manifest, canonical inputs, and any addresses already recorded in the manifest), and requires `MAINNET_DEPLOY_CONFIRM=yes` to proceed past that point. Only after this passes does `prepare.js` run and write `OriginatorAddress` to the manifest — declining confirmation leaves the manifest untouched. @@ -217,7 +217,8 @@ whose `chainId` doesn't match the connected RPC. - The tracked `deploy.json` is intentionally kept **empty** (`{}`). Do not commit a populated manifest to this path — a manifest with existing addresses will only be reused after each address - is verified on-chain (matching code and critical storage addresses), never silently. + is verified on-chain (matching code and critical storage addresses, plus all immutable IRM rate + parameters), never silently. - The manifest path resolution is centralized (`resolveDeployResultPath()` in `util.js`, mirrored by `Config.py` for the SmartPy side) so every tool agrees on the same file: 1. `DEPLOY_MANIFEST`, if set, always wins. @@ -274,9 +275,15 @@ process: 3. From the multisig, call `acceptGovernance()` on `Governance` to finalize the transfer. 4. On `TezFinOracle`, call `set_pending_admin()` from the deployer account, then have the multisig call `accept_admin()` to finalize the transfer. -5. Verify on-chain (e.g. via a block explorer or a Taquito script) that the `administrator` field of - `Governance`, `TezFinOracle`, `Comptroller`, and every ꜰToken market now points to the intended - production address, and that the deployer account no longer has admin rights anywhere. +5. Verify the exact final on-chain storage (e.g. via a block explorer or a Taquito script): + - `Governance.administrator` is the production multisig. + - `TezFinOracle.admin` is the production multisig. + - `Comptroller.administrator` is the `Governance` contract address. + - Every ꜰToken's `administrator` is the `Governance` contract address. + - `pendingAdministrator` is `None` on `Governance`, `Comptroller`, and every ꜰToken, and + `TezFinOracle.pendingAdmin` is `None`. + - The deployment wallet is absent from all administrator and pending-administrator fields and + retains no administrative authority. 6. Only unpause markets after every check in step 5 passes. Note: `Comptroller` and every ꜰToken market are administered *through* `Governance` diff --git a/deploy/compile_targets/CompileCUSDt.py b/deploy/compile_targets/CompileCUSDt.py index ff38dedc..69872102 100644 --- a/deploy/compile_targets/CompileCUSDt.py +++ b/deploy/compile_targets/CompileCUSDt.py @@ -16,16 +16,16 @@ metadata_ = sp.big_map({ "": sp.utils.bytes_of_string("tezos-storage:data"), "data": sp.utils.bytes_of_string(json.dumps({ - "name": "TezFin Interest-Bearing USD Tether", + "name": "TezFin V3.1 Interest-Bearing USD Tether", "description": "Interest-bearing token for USD Tether (USDt) supplied to the TezFin lending protocol.", - "version": "3.0", + "version": "3.1", "authors": ["Tezos Finance Protocol"], "homepage": "https://tezos.finance", "interfaces": ["TZIP-007", "TZIP-016"], })) }), token_metadata_ = { - "name": sp.utils.bytes_of_string("TezFin Interest-Bearing USD Tether"), + "name": sp.utils.bytes_of_string("TezFin V3.1 Interest-Bearing USD Tether"), "symbol": sp.utils.bytes_of_string("\ua730USDt"), "decimals": sp.utils.bytes_of_string("6"), }, diff --git a/deploy/compile_targets/CompileCUSDtz.py b/deploy/compile_targets/CompileCUSDtz.py index a348ea73..94b75951 100644 --- a/deploy/compile_targets/CompileCUSDtz.py +++ b/deploy/compile_targets/CompileCUSDtz.py @@ -16,16 +16,16 @@ metadata_ = sp.big_map({ "": sp.utils.bytes_of_string("tezos-storage:data"), "data": sp.utils.bytes_of_string(json.dumps({ - "name": "TezFin Interest-Bearing USD Tez", + "name": "TezFin V3.1 Interest-Bearing USD Tez", "description": "Interest-bearing token for USD Tez (USDtz) supplied to the TezFin lending protocol.", - "version": "3.0", + "version": "3.1", "authors": ["Tezos Finance Protocol"], "homepage": "https://tezos.finance", "interfaces": ["TZIP-007", "TZIP-016"], })) }), token_metadata_ = { - "name": sp.utils.bytes_of_string("TezFin Interest-Bearing USD Tez"), + "name": sp.utils.bytes_of_string("TezFin V3.1 Interest-Bearing USD Tez"), "symbol": sp.utils.bytes_of_string("\ua730USDtz"), "decimals": sp.utils.bytes_of_string("6"), }, diff --git a/deploy/compile_targets/CompileCXTZ.py b/deploy/compile_targets/CompileCXTZ.py index d7167602..86130b7a 100644 --- a/deploy/compile_targets/CompileCXTZ.py +++ b/deploy/compile_targets/CompileCXTZ.py @@ -15,16 +15,16 @@ metadata_ = sp.big_map({ "": sp.utils.bytes_of_string("tezos-storage:data"), "data": sp.utils.bytes_of_string(json.dumps({ - "name": "TezFin Interest-Bearing XTZ", + "name": "TezFin V3.1 Interest-Bearing XTZ", "description": "Interest-bearing token for Tez (XTZ) supplied to the TezFin lending protocol.", - "version": "3.0", + "version": "3.1", "authors": ["Tezos Finance Protocol"], "homepage": "https://tezos.finance", "interfaces": ["TZIP-007", "TZIP-016"], })) }), token_metadata_ = { - "name": sp.utils.bytes_of_string("TezFin Interest-Bearing XTZ"), + "name": sp.utils.bytes_of_string("TezFin V3.1 Interest-Bearing XTZ"), "symbol": sp.utils.bytes_of_string("\ua730XTZ"), "decimals": sp.utils.bytes_of_string("6"), } diff --git a/deploy/compile_targets/CompileCstXTZ.py b/deploy/compile_targets/CompileCstXTZ.py index 430751a1..581312fd 100644 --- a/deploy/compile_targets/CompileCstXTZ.py +++ b/deploy/compile_targets/CompileCstXTZ.py @@ -16,16 +16,16 @@ metadata_ = sp.big_map({ "": sp.utils.bytes_of_string("tezos-storage:data"), "data": sp.utils.bytes_of_string(json.dumps({ - "name": "TezFin Interest-Bearing stXTZ", + "name": "TezFin V3.1 Interest-Bearing stXTZ", "description": "Interest-bearing token for stXTZ supplied to the TezFin lending protocol", - "version": "3.0", + "version": "3.1", "authors": ["Tezos Finance Protocol"], "homepage": "https://tezos.finance", "interfaces": ["TZIP-007", "TZIP-016"], })) }), token_metadata_ = { - "name": sp.utils.bytes_of_string("TezFin Interest-Bearing stXTZ"), + "name": sp.utils.bytes_of_string("TezFin V3.1 Interest-Bearing stXTZ"), "symbol": sp.utils.bytes_of_string("\ua730stXTZ"), "decimals": sp.utils.bytes_of_string("6"), }, diff --git a/deploy/compile_targets/CompileTzBTC.py b/deploy/compile_targets/CompileTzBTC.py index c6c6b0f3..c63d2b82 100644 --- a/deploy/compile_targets/CompileTzBTC.py +++ b/deploy/compile_targets/CompileTzBTC.py @@ -21,16 +21,16 @@ metadata_ = sp.big_map({ "": sp.utils.bytes_of_string("tezos-storage:data"), "data": sp.utils.bytes_of_string(json.dumps({ - "name": "TezFin Interest-Bearing tzBTC", + "name": "TezFin V3.1 Interest-Bearing tzBTC", "description": "Interest-bearing token for tzBTC (wrapped Bitcoin on Tezos) supplied to the TezFin lending protocol.", - "version": "3.0", + "version": "3.1", "authors": ["Tezos Finance Protocol"], "homepage": "https://tezos.finance", "interfaces": ["TZIP-007", "TZIP-016"], })) }), token_metadata_ = { - "name": sp.utils.bytes_of_string("TezFin Interest-Bearing tzBTC"), + "name": sp.utils.bytes_of_string("TezFin V3.1 Interest-Bearing tzBTC"), "symbol": sp.utils.bytes_of_string("\ua730tzBTC"), "decimals": sp.utils.bytes_of_string("8"), }, diff --git a/deploy/deploy_script/mainnet_preflight.js b/deploy/deploy_script/mainnet_preflight.js index 04bd3714..3ebbfd68 100644 --- a/deploy/deploy_script/mainnet_preflight.js +++ b/deploy/deploy_script/mainnet_preflight.js @@ -24,7 +24,8 @@ const REQUIRED_CANONICAL_KEYS = ['PriceOracle', 'USDt', 'tzBTC']; // filled in with the exact, reviewed mainnet addresses before a real mainnet // deployment; a manifest value that doesn't match exactly is rejected rather than // silently accepted just because *some* contract exists at that address. Deliberately -// left null/empty until vetted mainnet addresses are confirmed for this deployment. +// left null/empty until vetted mainnet addresses are confirmed for this deployment; +// mainnet preflight fails closed while any required entry is absent. const VETTED_MAINNET_ADDRESSES = { // PriceOracle: null, // USDt: null, @@ -44,13 +45,11 @@ async function verifyAddressExists(tezos, key, address) { function verifyAgainstAllowlist(key, address) { const expected = VETTED_MAINNET_ADDRESSES[key]; if (!expected) { - console.log( - `[WARN] No vetted address configured for "${key}" in mainnet_preflight.js ` + - `(VETTED_MAINNET_ADDRESSES). Falling back to "exists on-chain" only; add the exact vetted ` + - `mainnet address here before a real mainnet deployment so a wrong/unrelated KT1 can't be ` + - `silently accepted just because a contract happens to exist at that address.`, + throw new Error( + `No vetted mainnet address is configured for required key "${key}" in ` + + `VETTED_MAINNET_ADDRESSES. Refusing to proceed until the exact reviewed production ` + + `address is added to mainnet_preflight.js.`, ); - return; } if (address !== expected) { throw new Error( diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index f1e6dd71..fe8fa5b5 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -13,9 +13,10 @@ const path = require('node:path'); const { checkChainIdMatch, micheline_equal, - extractAddresses, - diffSets, + extractAddressBindings, + diffAddressBindings, resolveDeployResultPath, + verifyExistingContract, } = require('../util.js'); const { checkNetworkExpectation, MAINNET_CHAIN_IDS } = require('../assert_network.js'); const { findMissingCanonicalKeys, verifyAgainstAllowlist, REQUIRED_CANONICAL_KEYS, VETTED_MAINNET_ADDRESSES } = require('../mainnet_preflight.js'); @@ -81,7 +82,7 @@ test('micheline_equal: detects a real difference in embedded values', () => { assert.equal(micheline_equal(a, b), false); }); -test('extractAddresses: pulls tz1/KT1 strings out of nested Micheline JSON', () => { +test('extractAddressBindings: records tz1/KT1 strings at their Micheline paths', () => { const tz1 = 'tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE'; const kt1 = 'KT1WvzYHCNBvDSdwafTHv7nJ1dWmZ8GCYuuC'; const storage = { @@ -91,26 +92,65 @@ test('extractAddresses: pulls tz1/KT1 strings out of nested Micheline JSON', () { prim: 'Pair', args: [{ string: kt1 }, { int: '42' }] }, ], }; - const addresses = extractAddresses(storage); - assert.ok(addresses.has(tz1)); - assert.ok(addresses.has(kt1)); + const addresses = extractAddressBindings(storage); + assert.equal(addresses.get('$.args[0].string'), tz1); + assert.equal(addresses.get('$.args[1].args[0].string'), kt1); assert.equal(addresses.size, 2); }); -test('diffSets: reports both missing and unexpected addresses', () => { - const expected = new Set(['tz1Admin', 'KT1Oracle']); - const actual = new Set(['tz1Admin', 'KT1DifferentOracle']); - const { missing, unexpected } = diffSets(expected, actual); - assert.deepEqual(missing, ['KT1Oracle']); - assert.deepEqual(unexpected, ['KT1DifferentOracle']); +test('diffAddressBindings: reports addresses assigned to different roles', () => { + const expected = new Map([['$.admin', 'tz1Admin'], ['$.oracle', 'KT1Oracle']]); + const actual = new Map([['$.admin', 'KT1Oracle'], ['$.oracle', 'tz1Admin']]); + assert.deepEqual(diffAddressBindings(expected, actual), [ + { path: '$.admin', expected: 'tz1Admin', actual: 'KT1Oracle' }, + { path: '$.oracle', expected: 'KT1Oracle', actual: 'tz1Admin' }, + ]); }); -test('diffSets: reports no differences when address sets match exactly', () => { - const expected = new Set(['tz1Admin', 'KT1Oracle']); - const actual = new Set(['tz1Admin', 'KT1Oracle']); - const { missing, unexpected } = diffSets(expected, actual); - assert.deepEqual(missing, []); - assert.deepEqual(unexpected, []); +test('verifyExistingContract: rejects addresses swapped between immutable roles', async () => { + const expectedCode = [{ prim: 'parameter', args: [{ prim: 'unit' }] }]; + const administrator = 'tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE'; + const comptroller = 'KT1WvzYHCNBvDSdwafTHv7nJ1dWmZ8GCYuuC'; + const expectedStorage = { + prim: 'Pair', + args: [{ string: administrator }, { string: comptroller }], + }; + const tezos = { + rpc: { + getScript: async () => ({ code: expectedCode }), + getStorage: async () => ({ + prim: 'Pair', + args: [{ string: comptroller }, { string: administrator }], + }), + }, + }; + + await assert.rejects( + verifyExistingContract(tezos, 'KT1ExistingMarketAddress', expectedCode, expectedStorage, 'CUSDt'), + /different role-aware address bindings/, + ); +}); + +test('verifyExistingContract: rejects an IRM with mismatched immutable rate parameters', async () => { + const expectedCode = [{ prim: 'parameter', args: [{ prim: 'unit' }] }]; + const expectedStorage = { + prim: 'Pair', + args: [{ int: '100' }, { prim: 'Pair', args: [{ int: '200' }, { int: '300' }] }], + }; + const tezos = { + rpc: { + getScript: async () => ({ code: expectedCode }), + getStorage: async () => ({ + prim: 'Pair', + args: [{ int: '100' }, { prim: 'Pair', args: [{ int: '201' }, { int: '300' }] }], + }), + }, + }; + + await assert.rejects( + verifyExistingContract(tezos, 'KT1ExistingIrmAddress', expectedCode, expectedStorage, 'CFA2_IRM'), + /immutable IRM parameters do not match/, + ); }); test('resolveDeployResultPath: DEPLOY_MANIFEST env var always wins', () => { @@ -137,8 +177,11 @@ test('mainnetPreflight: findMissingCanonicalKeys reports nothing when all keys p assert.deepEqual(findMissingCanonicalKeys(fullManifest), []); }); -test('mainnetPreflight: verifyAgainstAllowlist warns but does not throw when no allowlist entry is configured', () => { - assert.doesNotThrow(() => verifyAgainstAllowlist('PriceOracle', 'KT1SomeAddress')); +test('mainnetPreflight: verifyAgainstAllowlist rejects a missing required allowlist entry', () => { + assert.throws( + () => verifyAgainstAllowlist('PriceOracle', 'KT1SomeAddress'), + /No vetted mainnet address is configured for required key "PriceOracle"/, + ); }); test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not match a configured vetted entry', () => { diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index 9cc8d3d7..20965aba 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -289,40 +289,43 @@ function micheline_equal(a, b) { const TEZOS_ADDRESS_PATTERN = /^(tz1|tz2|tz3|KT1)[1-9A-HJ-NP-Za-km-z]{33}$/; -// Best-effort "critical storage" check: SmartPy always embeds administrator, oracle, -// underlying-token, and IRM addresses as plain address strings inside storage. Two -// contracts can share identical code but be wired to different administrators/markets -// (e.g. copy-pasted CFA12 templates); comparing the *set* of addresses embedded in -// storage catches that case without needing a hand-written schema per contract type. -// It will NOT catch differences in purely numeric parameters (e.g. a different IRM -// kink/multiplier) with otherwise-identical wiring; a full per-contract schema check -// would be needed to close that gap. -function extractAddresses(node, out = new Set()) { +// Record each embedded address at its exact Micheline path. Paths preserve the role of +// administrator, oracle, underlying token, comptroller, and IRM addresses, so swapping +// two addresses cannot pass merely because the unordered set of values is unchanged. +// Mutable non-address storage is intentionally ignored; immutable IRMs are compared in full. +function extractAddressBindings(node, path = '$', out = new Map()) { if (Array.isArray(node)) { - for (const item of node) { - extractAddresses(item, out); + for (const [index, item] of node.entries()) { + extractAddressBindings(item, `${path}[${index}]`, out); } return out; } if (node && typeof node === 'object') { if (typeof node.string === 'string' && TEZOS_ADDRESS_PATTERN.test(node.string)) { - out.add(node.string); + out.set(`${path}.string`, node.string); } if (typeof node.bytes === 'string') { // addresses are sometimes packed as bytes; skip decoding for now, this is // a best-effort check, not a full schema-aware comparison. } - for (const value of Object.values(node)) { - extractAddresses(value, out); + for (const [key, value] of Object.entries(node)) { + extractAddressBindings(value, `${path}.${key}`, out); } } return out; } -function diffSets(expected, actual) { - const missing = [...expected].filter((item) => !actual.has(item)); - const unexpected = [...actual].filter((item) => !expected.has(item)); - return { missing, unexpected }; +function diffAddressBindings(expected, actual) { + const mismatches = []; + const paths = new Set([...expected.keys(), ...actual.keys()]); + for (const path of paths) { + const expectedAddress = expected.get(path); + const actualAddress = actual.get(path); + if (expectedAddress !== actualAddress) { + mismatches.push({ path, expected: expectedAddress, actual: actualAddress }); + } + } + return mismatches; } // Pure guard used by createTezosClient/syncDeploymentOriginator/runDeployment to @@ -363,16 +366,25 @@ async function verifyExistingContract(tezos, address, expectedCode, expectedStor } const onChainStorage = await fetchOnChainStorage(tezos, address); - const expectedAddresses = extractAddresses(expectedStorage); - const actualAddresses = extractAddresses(onChainStorage); - const { missing, unexpected } = diffSets(expectedAddresses, actualAddresses); - if (missing.length > 0 || unexpected.length > 0) { + if (directoryName.endsWith('_IRM') && !micheline_equal(onChainStorage, expectedStorage)) { + throw new Error( + `Manifest entry "${directoryName}" (${address}) has matching code but its immutable IRM ` + + `parameters do not match the compiled initial storage. Refusing to skip deployment; ` + + `verify the base rate, multiplier, jump multiplier, kink, and scale.`, + ); + } + const expectedAddresses = extractAddressBindings(expectedStorage); + const actualAddresses = extractAddressBindings(onChainStorage); + const addressMismatches = diffAddressBindings(expectedAddresses, actualAddresses); + if (addressMismatches.length > 0) { + const details = addressMismatches.map(({ path, expected, actual }) => + `${path}: expected ${expected || '(none)'}, actual ${actual || '(none)'}`, + ); throw new Error( `Manifest entry "${directoryName}" (${address}) has matching code but its on-chain storage ` + - `references different addresses (admin/oracle/underlying/IRM/etc.) than the compiled initial ` + - `storage. Expected-but-missing: [${missing.join(', ')}]; unexpected: [${unexpected.join(', ')}]. ` + - `Refusing to skip deployment; this looks like the same contract template wired to a different ` + - `configuration. Note: this check only compares embedded addresses, not numeric parameters.`, + `has different role-aware address bindings (admin/oracle/underlying/IRM/etc.) than the ` + + `compiled initial storage. Mismatches: [${details.join('; ')}]. Refusing to skip deployment; ` + + `this looks like the same contract template wired to a different configuration.`, ); } console.log(`[INFO] Verified ${directoryName} at ${address} matches compiled code and critical storage addresses on-chain`); @@ -488,7 +500,7 @@ module.exports = { checkChainIdMatch, canonicalizeMicheline, micheline_equal, - extractAddresses, - diffSets, + extractAddressBindings, + diffAddressBindings, verifyExistingContract, } From 9191bdd2250489978befe8f2e8cc8fb5522c47fc Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Thu, 16 Jul 2026 16:26:31 +0300 Subject: [PATCH 12/19] fix: restore Previewnet deployment compatibility --- TezFinBuild/deploy_result/deploy.json | 17 +++++++++++++++++ .../tests/test_deploy_pipeline_wiring.py | 10 +++++++++- deploy/deploy_script/config.json | 2 +- deploy/test_data/PriceOracle.py | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/TezFinBuild/deploy_result/deploy.json b/TezFinBuild/deploy_result/deploy.json index 2c63c085..e9c9357f 100644 --- a/TezFinBuild/deploy_result/deploy.json +++ b/TezFinBuild/deploy_result/deploy.json @@ -1,2 +1,19 @@ { + "OriginatorAddress": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA", + "chainId": "NetXY2oPPzkxUW1", + "network": "https://michelson.previewnet.tezosx.nomadic-labs.com", + "PriceOracle": "KT1SyeZTavgHjmk1zBBJZ1aVx5F5NH27shJG", + "USDt": "KT1WWKLMc2aAanNxvYR3GcLpArTnYG6sbzzp", + "USDtz": "KT1NyMkX6ddVdZJmQgM826EApDLQanvuJ4LG", + "tzBTC": "KT1N8UrMt8bwJTphb5NSpFhB7GDFiYbzJFLz", + "TezFinOracle": "KT1V4ArruDLGTjGYC5Xgd9habQ2SpDtrmkDF", + "Governance": "KT1Wq7uJeiXXociunW4LqQZzdNvM7QYbtVEN", + "Comptroller": "KT1N8XU2pe51z22aMbRQya9YhPmCytyX2Met", + "CFA12_IRM": "KT1BntKHTG4So6RCT4CHHSXP1xNGLVj6krXE", + "CFA2_IRM": "KT1QT1P8rHnoirQquBV1cmTaWivgGXFxhYax", + "CXTZ_IRM": "KT1LEGHhjvLyun3E5zwBNsJRmTtCzCjVpUk6", + "CtzBTC_IRM": "KT1XkH9xQQnjGheHJ5eHzzVBTj4A119iR8yC", + "CUSDt": "KT1Cugpw5mGTQydYt2dCw2CUxsPsnGSdt7R9", + "CXTZ": "KT1DKYqH3v6AnQ9YTnCyJ44CnCvSG2pUogHH", + "CtzBTC": "KT1A45Vx1fwxwjBBfxzXBWmTkqEvnB2udq7J" } diff --git a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py index 0d502ac7..d28e7a2e 100644 --- a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +++ b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py @@ -159,7 +159,15 @@ def resolve_js_default_manifest_path(networkProfile): f"const {{ resolveDeployResultPath }} = require({json.dumps(UTIL_JS_PATH)});" "console.log(resolveDeployResultPath());" ) - result = subprocess.run(['node', '-e', script], cwd=REPO_ROOT, capture_output=True, text=True) + env = os.environ.copy() + env.pop('DEPLOY_MANIFEST', None) + result = subprocess.run( + ['node', '-e', script], + cwd=REPO_ROOT, + capture_output=True, + text=True, + env=env, + ) if result.returncode != 0: raise RuntimeError(f'Failed to evaluate util.js default path for networkProfile={networkProfile!r}: {result.stderr}') return result.stdout.strip() diff --git a/deploy/deploy_script/config.json b/deploy/deploy_script/config.json index c9eb087c..49950879 100644 --- a/deploy/deploy_script/config.json +++ b/deploy/deploy_script/config.json @@ -4,6 +4,6 @@ "chainId": "NetXY2oPPzkxUW1", "feeSafetyMultiplier": 1.2, "originator": { - "pkh": "tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE" + "pkh": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA" } } diff --git a/deploy/test_data/PriceOracle.py b/deploy/test_data/PriceOracle.py index 137033d9..bb0e62fd 100644 --- a/deploy/test_data/PriceOracle.py +++ b/deploy/test_data/PriceOracle.py @@ -10,6 +10,22 @@ def __init__(self, **extra_storage): def setPrice(self, params): sp.set_type(params, sp.TRecord(asset = sp.TString, price = sp.TNat)) self.data.prices[params.asset] = params.price + + @sp.onchain_view() + def get_price_with_timestamp(self, requestedAsset): + sp.set_type(requestedAsset, sp.TString) + price = sp.local('price', sp.nat(0)) + sp.if self.data.prices.contains(requestedAsset): + price.value = self.data.prices[requestedAsset] + sp.result((price.value, sp.timestamp(0))) + + @sp.onchain_view() + def getPrice(self, requestedAsset): + sp.set_type(requestedAsset, sp.TString) + price = sp.local('price', sp.nat(0)) + sp.if self.data.prices.contains(requestedAsset): + price.value = self.data.prices[requestedAsset] + sp.result((sp.timestamp(0), price.value)) @sp.entry_point def get(self, requestPair): From 6b098d5b308f51adfbdd508922148b536da2cfd0 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Fri, 17 Jul 2026 16:07:09 +0300 Subject: [PATCH 13/19] chore: decode Micheline address bytes for safe resume --- README.md | 10 +- deploy/deploy_script/package-lock.json | 1 + deploy/deploy_script/package.json | 1 + .../deploy_script/test/deploy_guards.test.js | 162 +++++++++++++++++- deploy/deploy_script/util.js | 41 ++++- 5 files changed, 204 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 41adf3ea..c5242fec 100644 --- a/README.md +++ b/README.md @@ -215,10 +215,12 @@ The deploy scripts track originated addresses in a manifest file file also stores the `chainId` it was created against; the deployer refuses to reuse a manifest whose `chainId` doesn't match the connected RPC. -- The tracked `deploy.json` is intentionally kept **empty** (`{}`). Do not commit a populated - manifest to this path — a manifest with existing addresses will only be reused after each address - is verified on-chain (matching code and critical storage addresses, plus all immutable IRM rate - parameters), never silently. +- The tracked `deploy.json` is the populated **Previewnet deployment record** and may be committed so + an interrupted Previewnet deployment can resume. Do not edit or replace its addresses manually: + existing entries are reused only after each address is verified on-chain (matching code and + critical storage addresses, plus all immutable IRM rate parameters), never silently. Mainnet + deployment records must use `deploy.mainnet.json` (or another explicit `DEPLOY_MANIFEST` path) and + must never be written to `deploy.json`. - The manifest path resolution is centralized (`resolveDeployResultPath()` in `util.js`, mirrored by `Config.py` for the SmartPy side) so every tool agrees on the same file: 1. `DEPLOY_MANIFEST`, if set, always wins. diff --git a/deploy/deploy_script/package-lock.json b/deploy/deploy_script/package-lock.json index 51c4e810..3e7c7c16 100644 --- a/deploy/deploy_script/package-lock.json +++ b/deploy/deploy_script/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", + "@taquito/utils": "^25.0.0", "glob": "7.2.3", "loglevel": "^1.8.1" }, diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index 85256d38..a32f1b32 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -5,6 +5,7 @@ "dependencies": { "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", + "@taquito/utils": "^25.0.0", "glob": "7.2.3", "loglevel": "^1.8.1" }, diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index fe8fa5b5..4e10045b 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -13,6 +13,8 @@ const path = require('node:path'); const { checkChainIdMatch, micheline_equal, + michelineScriptEqual, + decodeTezosAddressBytes, extractAddressBindings, diffAddressBindings, resolveDeployResultPath, @@ -21,6 +23,50 @@ const { const { checkNetworkExpectation, MAINNET_CHAIN_IDS } = require('../assert_network.js'); const { findMissingCanonicalKeys, verifyAgainstAllowlist, REQUIRED_CANONICAL_KEYS, VETTED_MAINNET_ADDRESSES } = require('../mainnet_preflight.js'); +const CUSDT_COMPILED_ADDRESSES = [ + { string: 'KT1Wq7uJeiXXociunW4LqQZzdNvM7QYbtVEN' }, + { string: 'KT1N8XU2pe51z22aMbRQya9YhPmCytyX2Met' }, + { string: 'KT1WWKLMc2aAanNxvYR3GcLpArTnYG6sbzzp' }, + { string: 'KT1QT1P8rHnoirQquBV1cmTaWivgGXFxhYax' }, +]; +const CUSDT_RPC_ADDRESSES = [ + { bytes: '01f409912cd853b330c9fbe60315b2e7301ef9194b00' }, + { bytes: '01949b2d456ed3104815e3c281a570c77e063b8eba00' }, + { bytes: '01f07b34ea1003b885b8155095a2573a7f6395a66500' }, + { bytes: '01ae0a37ee2338f34330c1d46c35ceea4aa7c1fcc100' }, +]; + +function createCUSDtStorage([administrator, comptroller, underlying, irm]) { + return { + prim: 'Pair', + args: [{ + prim: 'Pair', + args: [{ + prim: 'Pair', + args: [ + { + prim: 'Pair', + args: [ + { prim: 'Pair', args: [{ int: '0' }, { prim: 'Pair', args: [[], administrator] }] }, + { int: '1000000000000000000' }, + ], + }, + { + prim: 'Pair', + args: [ + { prim: 'Pair', args: [{ int: '1075' }, { prim: 'Pair', args: [comptroller, { int: '0' }] }] }, + { prim: 'Pair', args: [underlying, { int: '1000000000000000000' }] }, + ], + }, + ], + }, { + prim: 'Pair', + args: [{ prim: 'Pair', args: [irm, []] }, { int: '0' }], + }], + }, { int: '0' }], + }; +} + test('checkChainIdMatch: accepts matching chain ids', () => { assert.doesNotThrow(() => checkChainIdMatch('NetXY2oPPzkxUW1', 'NetXY2oPPzkxUW1', 'test manifest')); }); @@ -82,6 +128,43 @@ test('micheline_equal: detects a real difference in embedded values', () => { assert.equal(micheline_equal(a, b), false); }); +test('michelineScriptEqual: ignores top-level Michelson section order only', () => { + const parameter = { prim: 'parameter', args: [{ prim: 'unit' }] }; + const storage = { prim: 'storage', args: [{ prim: 'unit' }] }; + const code = { prim: 'code', args: [[{ prim: 'CAR' }, { prim: 'NIL', args: [{ prim: 'operation' }] }]] }; + const view = { prim: 'view', args: [{ string: 'get' }, { prim: 'unit' }, { prim: 'unit' }, []] }; + + assert.equal(michelineScriptEqual([storage, parameter, code, view], [view, parameter, storage, code]), true); + assert.equal( + michelineScriptEqual( + [storage, parameter, code, view], + [view, parameter, storage, { ...code, args: [[{ prim: 'NIL', args: [{ prim: 'operation' }] }, { prim: 'CAR' }]] }], + ), + false, + ); +}); + +test('decodeTezosAddressBytes: decodes optimized tz1/tz2/tz3/tz4 and KT1 encodings', () => { + const vectors = [ + ['00001111111111111111111111111111111111111111', 'tz1MCGdC9qYbSjtWEbup9i17WkohvzwCm2HV'], + ['00011111111111111111111111111111111111111111', 'tz29sUbQkQxxNVXNWmxepLyN4L4iStKf9x8Y'], + ['00021111111111111111111111111111111111111111', 'tz3MtHYjeH6Vm7yfw32upJRjsgxEDiVdgA85'], + ['00031111111111111111111111111111111111111111', 'tz4AZVWxErWrgscYDD5kUwPzRGDEjbwhTeLz'], + ['01222222222222222222222222222222222222222200', 'KT1BhFRuvKL9E8ggxycsHDf8qS42HLvCrXYr'], + ]; + + for (const [encoded, address] of vectors) { + assert.equal(decodeTezosAddressBytes(encoded), address); + } +}); + +test('decodeTezosAddressBytes: ignores malformed and unsupported encodings', () => { + assert.equal(decodeTezosAddressBytes('0004' + '11'.repeat(20)), undefined); + assert.equal(decodeTezosAddressBytes('01' + '22'.repeat(20) + '01'), undefined); + assert.equal(decodeTezosAddressBytes('00'), undefined); + assert.equal(decodeTezosAddressBytes('not-hex'), undefined); +}); + test('extractAddressBindings: records tz1/KT1 strings at their Micheline paths', () => { const tz1 = 'tz1VLnrVYrMtLHRUfLV594uvzSthZ5w7wXqE'; const kt1 = 'KT1WvzYHCNBvDSdwafTHv7nJ1dWmZ8GCYuuC'; @@ -93,11 +176,37 @@ test('extractAddressBindings: records tz1/KT1 strings at their Micheline paths', ], }; const addresses = extractAddressBindings(storage); - assert.equal(addresses.get('$.args[0].string'), tz1); - assert.equal(addresses.get('$.args[1].args[0].string'), kt1); + assert.equal(addresses.get('$.args[0]'), tz1); + assert.equal(addresses.get('$.args[1].args[0]'), kt1); assert.equal(addresses.size, 2); }); +test('extractAddressBindings: normalizes string and bytes addresses to identical paths', () => { + const stringStorage = { + prim: 'Pair', + args: [ + { string: 'tz1MCGdC9qYbSjtWEbup9i17WkohvzwCm2HV' }, + { string: 'KT1BhFRuvKL9E8ggxycsHDf8qS42HLvCrXYr' }, + ], + }; + const bytesStorage = { + prim: 'Pair', + args: [ + { bytes: '00001111111111111111111111111111111111111111' }, + { bytes: '01222222222222222222222222222222222222222200' }, + ], + }; + + assert.deepEqual(extractAddressBindings(bytesStorage), extractAddressBindings(stringStorage)); +}); + +test('extractAddressBindings: matches compiled CUSDt strings to Previewnet RPC bytes', () => { + const compiledStorage = createCUSDtStorage(CUSDT_COMPILED_ADDRESSES); + const rpcStorage = createCUSDtStorage(CUSDT_RPC_ADDRESSES); + + assert.deepEqual(extractAddressBindings(rpcStorage), extractAddressBindings(compiledStorage)); +}); + test('diffAddressBindings: reports addresses assigned to different roles', () => { const expected = new Map([['$.admin', 'tz1Admin'], ['$.oracle', 'KT1Oracle']]); const actual = new Map([['$.admin', 'KT1Oracle'], ['$.oracle', 'tz1Admin']]); @@ -131,6 +240,55 @@ test('verifyExistingContract: rejects addresses swapped between immutable roles' ); }); +test('verifyExistingContract: accepts matching CUSDt string/bytes wiring', async () => { + const expectedCode = [{ prim: 'parameter', args: [{ prim: 'unit' }] }]; + const tezos = { + rpc: { + getScript: async () => ({ code: expectedCode }), + getStorage: async () => createCUSDtStorage(CUSDT_RPC_ADDRESSES), + }, + }; + + await assert.doesNotReject( + verifyExistingContract( + tezos, + 'KT1Cugpw5mGTQydYt2dCw2CUxsPsnGSdt7R9', + expectedCode, + createCUSDtStorage(CUSDT_COMPILED_ADDRESSES), + 'CUSDt', + ), + ); +}); + +test('verifyExistingContract: rejects every administrator/comptroller/underlying/IRM swap', async () => { + const expectedCode = [{ prim: 'parameter', args: [{ prim: 'unit' }] }]; + const roles = ['administrator', 'comptroller', 'underlying', 'IRM']; + + for (let left = 0; left < roles.length; left++) { + for (let right = left + 1; right < roles.length; right++) { + const swapped = [...CUSDT_RPC_ADDRESSES]; + [swapped[left], swapped[right]] = [swapped[right], swapped[left]]; + const tezos = { + rpc: { + getScript: async () => ({ code: expectedCode }), + getStorage: async () => createCUSDtStorage(swapped), + }, + }; + + await assert.rejects( + verifyExistingContract( + tezos, + 'KT1Cugpw5mGTQydYt2dCw2CUxsPsnGSdt7R9', + expectedCode, + createCUSDtStorage(CUSDT_COMPILED_ADDRESSES), + 'CUSDt', + ), + /different role-aware address bindings/, + ); + } + } +}); + test('verifyExistingContract: rejects an IRM with mismatched immutable rate parameters', async () => { const expectedCode = [{ prim: 'parameter', args: [{ prim: 'unit' }] }]; const expectedStorage = { diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index 20965aba..b45b00c6 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -4,6 +4,7 @@ const path = require('path'); const glob = require('glob'); const { InMemorySigner } = require('@taquito/signer'); const { TezosToolkit } = require('@taquito/taquito'); +const { encodeAddress, encodeKeyHash } = require('@taquito/utils'); const configPath = path.join(__dirname, 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); @@ -287,7 +288,33 @@ function micheline_equal(a, b) { return JSON.stringify(canonicalizeMicheline(a)) === JSON.stringify(canonicalizeMicheline(b)); } -const TEZOS_ADDRESS_PATTERN = /^(tz1|tz2|tz3|KT1)[1-9A-HJ-NP-Za-km-z]{33}$/; +function michelineScriptEqual(a, b) { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + const sectionKey = (section) => + `${section.prim}:${section.prim === 'view' ? section.args?.[0]?.string || '' : ''}`; + const sortSections = (sections) => + [...sections].sort((left, right) => sectionKey(left).localeCompare(sectionKey(right))); + return micheline_equal(sortSections(a), sortSections(b)); +} + +const TEZOS_ADDRESS_PATTERN = /^(tz1|tz2|tz3|tz4|KT1)[1-9A-HJ-NP-Za-km-z]{33}$/; + +function decodeTezosAddressBytes(value) { + if (typeof value !== 'string' || !/^[0-9a-fA-F]{44}$/.test(value)) { + return undefined; + } + + const bytes = value.toLowerCase(); + if (/^00(00|01|02|03)[0-9a-f]{40}$/.test(bytes)) { + return encodeKeyHash(bytes.slice(2)); + } + if (/^01[0-9a-f]{40}00$/.test(bytes)) { + return encodeAddress(bytes); + } + return undefined; +} // Record each embedded address at its exact Micheline path. Paths preserve the role of // administrator, oracle, underlying token, comptroller, and IRM addresses, so swapping @@ -302,11 +329,13 @@ function extractAddressBindings(node, path = '$', out = new Map()) { } if (node && typeof node === 'object') { if (typeof node.string === 'string' && TEZOS_ADDRESS_PATTERN.test(node.string)) { - out.set(`${path}.string`, node.string); + out.set(path, node.string); } if (typeof node.bytes === 'string') { - // addresses are sometimes packed as bytes; skip decoding for now, this is - // a best-effort check, not a full schema-aware comparison. + const address = decodeTezosAddressBytes(node.bytes); + if (address) { + out.set(path, address); + } } for (const [key, value] of Object.entries(node)) { extractAddressBindings(value, `${path}.${key}`, out); @@ -357,7 +386,7 @@ async function verifyExistingContract(tezos, address, expectedCode, expectedStor ); } - if (!micheline_equal(script.code, expectedCode)) { + if (!michelineScriptEqual(script.code, expectedCode)) { throw new Error( `Manifest entry "${directoryName}" (${address}) does not match the compiled contract code ` + `on the connected chain. Refusing to skip deployment; the on-chain contract may belong to a ` + @@ -500,6 +529,8 @@ module.exports = { checkChainIdMatch, canonicalizeMicheline, micheline_equal, + michelineScriptEqual, + decodeTezosAddressBytes, extractAddressBindings, diffAddressBindings, verifyExistingContract, From bb984a0dbb36d1078d4a2cbf25b20ed2843dbe0d Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Mon, 20 Jul 2026 13:31:28 +0300 Subject: [PATCH 14/19] feat: mainnet preflight and oracle validation --- README.md | 16 +- TezFinBuild/deploy_result/deploy.mainnet.json | 8 + contracts/Comptroller.py | 7 +- contracts/tests/ComptrollerTest.py | 6 + contracts/tests/mock/OracleMock.py | 11 +- deploy/deploy_script/config.json | 6 +- deploy/deploy_script/estimate_origination.js | 75 ++++++ deploy/deploy_script/mainnet_preflight.js | 8 +- deploy/deploy_script/package-lock.json | 219 ++++++++---------- deploy/deploy_script/package.json | 4 +- .../deploy_script/test/deploy_guards.test.js | 40 +++- deploy/deploy_script/verify_mainnet_oracle.js | 89 +++++++ deploy/shell_scripts/deploy_mainnet.sh | 1 + docs/MainnetGovernancePayloads.json | 139 +++++++++++ 14 files changed, 480 insertions(+), 149 deletions(-) create mode 100644 TezFinBuild/deploy_result/deploy.mainnet.json create mode 100644 deploy/deploy_script/estimate_origination.js create mode 100644 deploy/deploy_script/verify_mainnet_oracle.js create mode 100644 docs/MainnetGovernancePayloads.json diff --git a/README.md b/README.md index c5242fec..d00df143 100644 --- a/README.md +++ b/README.md @@ -246,10 +246,11 @@ present) and fails closed if it does not. `TezFinOracle` ([`contracts/TezFinOracle.py`](contracts/TezFinOracle.py)) is a thin proxy: it forwards price lookups to the address stored as `oracle` (the `PriceOracle` from the manifest) and expects that -address to behave like [Youves' Harbinger](https://harbinger.live/) oracle (`get`/price-feed -interface), with a small admin-controlled override map for assets Harbinger doesn't support (e.g. USD, -USDT). `TezFinOracle`'s own `admin` (settable via `set_pending_admin` / `accept_admin`) controls those -overrides and can repoint `oracle` to a different feed with `set_oracle`. +address to expose the on-chain view `get_price_with_timestamp(string) -> pair(nat, timestamp)` for +symbols such as `XTZUSDT` and `BTCUSDT`. It also has a small admin-controlled override map for assets +the upstream feed does not support (e.g. USD and USDT). `TezFinOracle`'s own `admin` (settable via +`set_pending_admin` / `accept_admin`) controls those overrides and can repoint `oracle` to a different +feed with `set_oracle`. - **Previewnet**: `CompileTestData.py` compiles and deploys a mock `PriceOracle` ([`deploy/test_data/PriceOracle.py`](deploy/test_data/PriceOracle.py)) as part of @@ -261,9 +262,10 @@ overrides and can repoint `oracle` to a different feed with `set_oracle`. `CompileTestData.py` at all). Put the exact address of the vetted production Harbinger (or Harbinger-compatible) oracle directly under the `PriceOracle` key in the mainnet manifest (`DEPLOY_MANIFEST`) before running `deploy_mainnet.sh`; `mainnet_preflight.js` verifies it exists - on-chain before anything is compiled. Document, alongside the mainnet manifest, which Harbinger - instance/administrator is being used and who controls it — this project does not deploy or - administer Harbinger itself. + on-chain before anything is compiled. `verify_mainnet_oracle.js` also executes the exact XTZ and + BTC views before confirmation and rejects zero, stale, or future/millisecond timestamps. Document, + alongside the mainnet manifest, which oracle instance/administrator is being used and who controls + it — this project does not deploy or administer that upstream feed itself. ## Post-Deployment Admin Handoff (Mainnet) diff --git a/TezFinBuild/deploy_result/deploy.mainnet.json b/TezFinBuild/deploy_result/deploy.mainnet.json new file mode 100644 index 00000000..47e293f7 --- /dev/null +++ b/TezFinBuild/deploy_result/deploy.mainnet.json @@ -0,0 +1,8 @@ +{ + "OriginatorAddress": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA", + "chainId": "NetXdQprcVkpaWU", + "network": "https://rpc.tzkt.io/mainnet", + "PriceOracle": null, + "USDt": "KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o", + "tzBTC": "KT1PWx2mnDueood7fEmfbBDKx1D9BAnnXitn" +} diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index bacd3467..8f5587be 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -369,11 +369,12 @@ def updateAllAssetPrices(self): sp.view("getPrice", self.data.oracleAddress, self.data.markets[asset].name + "-USD", t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") ) - sp.if self.data.markets[asset].priceTimestamp!= sp.timestamp(0): - sp.verify(sp.now - sp.fst(pricePair.value) <= self.data.maxPriceTimeDifference, "STALE_ASSET_PRICE") + priceTimestamp = sp.fst(pricePair.value) + sp.verify(priceTimestamp <= sp.now, "FUTURE_ASSET_PRICE") + sp.verify(sp.now - priceTimestamp <= self.data.maxPriceTimeDifference, "STALE_ASSET_PRICE") self.data.markets[asset].price = self.makeExp( sp.snd(pricePair.value)*self.data.markets[asset].priceExp) - self.data.markets[asset].priceTimestamp = sp.fst(pricePair.value) + self.data.markets[asset].priceTimestamp = priceTimestamp self.data.markets[asset].updateLevel = sp.level def getAssetPrice(self, asset): diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 2a73755a..432bf647 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -365,6 +365,12 @@ def test(): oracle.setPrice(1) scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.current()) scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(2e18))) + scenario.h3("Reject a price timestamp from the future") + oracle.setTimestamp(sp.timestamp(101)) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender = bob, level = bLevel.next(), now = sp.timestamp(100), + valid = False, exception = "FUTURE_ASSET_PRICE") + oracle.setTimestamp(sp.timestamp(0)) scenario.h2("Test account liquidity") cmpt.enterMarkets(sp.list([cTokenMock.address])).run(sender = bob, level = bLevel.next()) diff --git a/contracts/tests/mock/OracleMock.py b/contracts/tests/mock/OracleMock.py index 76d99b5c..140508bf 100644 --- a/contracts/tests/mock/OracleMock.py +++ b/contracts/tests/mock/OracleMock.py @@ -4,12 +4,17 @@ class OracleMock(OracleInterface.OracleInterface): def __init__(self): - self.init(price = sp.nat(0)) + self.init(price = sp.nat(0), timestamp = sp.timestamp(0)) @sp.entry_point def setPrice(self, price): sp.set_type(price, sp.TNat) self.data.price = price + + @sp.entry_point + def setTimestamp(self, timestamp): + sp.set_type(timestamp, sp.TTimestamp) + self.data.timestamp = timestamp @sp.entry_point def get(self, requestPair): @@ -19,10 +24,10 @@ def get(self, requestPair): requestedAsset = sp.compute(sp.fst(requestPair)) callback = sp.compute(sp.snd(requestPair)) - callbackParam = (requestedAsset, (sp.timestamp(0), self.data.price)) + callbackParam = (requestedAsset, (self.data.timestamp, self.data.price)) sp.transfer(callbackParam, sp.mutez(0), callback) @sp.onchain_view() def getPrice(self, assetCode): sp.set_type(assetCode, sp.TString) - sp.result((sp.timestamp(0), self.data.price)) + sp.result((self.data.timestamp, self.data.price)) diff --git a/deploy/deploy_script/config.json b/deploy/deploy_script/config.json index 49950879..ff02189a 100644 --- a/deploy/deploy_script/config.json +++ b/deploy/deploy_script/config.json @@ -1,7 +1,7 @@ { - "networkProfile": "previewnet", - "tezosNode": "https://michelson.previewnet.tezosx.nomadic-labs.com", - "chainId": "NetXY2oPPzkxUW1", + "networkProfile": "mainnet", + "tezosNode": "https://rpc.tzkt.io/mainnet", + "chainId": "NetXdQprcVkpaWU", "feeSafetyMultiplier": 1.2, "originator": { "pkh": "tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA" diff --git a/deploy/deploy_script/estimate_origination.js b/deploy/deploy_script/estimate_origination.js new file mode 100644 index 00000000..fa0bbde3 --- /dev/null +++ b/deploy/deploy_script/estimate_origination.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const path = require('path'); + +const { TezosToolkit } = require('@taquito/taquito'); + +const config = require('./config.json'); + +class PublicKeySigner { + constructor(publicKeyHash, publicKey) { + this.publicKeyHashValue = publicKeyHash; + this.publicKeyValue = publicKey; + } + + async publicKeyHash() { + return this.publicKeyHashValue; + } + + async publicKey() { + return this.publicKeyValue; + } + + async secretKey() { + throw new Error('Read-only estimator has no secret key.'); + } + + async sign() { + throw new Error('Read-only estimator cannot sign or inject operations.'); + } +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +async function main() { + const [contractPath, storagePath] = process.argv.slice(2); + if (!contractPath || !storagePath) { + throw new Error('Usage: node estimate_origination.js CONTRACT.json STORAGE.json'); + } + + const rpc = process.env.TEZOS_RPC || config.tezosNode; + const publicKeyHash = process.env.TEZOS_PUBLIC_KEY_HASH || config.originator?.pkh; + const publicKey = process.env.TEZOS_PUBLIC_KEY; + if (!rpc || !publicKeyHash || !publicKey) { + throw new Error('TEZOS_RPC, TEZOS_PUBLIC_KEY_HASH, and TEZOS_PUBLIC_KEY are required.'); + } + + const tezos = new TezosToolkit(rpc); + tezos.setProvider({ signer: new PublicKeySigner(publicKeyHash, publicKey) }); + + const chainId = await tezos.rpc.getChainId(); + if (config.chainId && chainId !== config.chainId && !process.env.TEZOS_RPC) { + throw new Error(`Configured chain id ${config.chainId} does not match RPC chain id ${chainId}.`); + } + + const estimate = await tezos.estimate.originate({ + balance: '0', + code: readJson(path.resolve(contractPath)), + init: readJson(path.resolve(storagePath)), + }); + console.log(JSON.stringify({ + rpc, + chainId, + source: publicKeyHash, + suggestedFeeMutez: estimate.suggestedFeeMutez, + gasLimit: estimate.gasLimit, + storageLimit: estimate.storageLimit, + operationSize: estimate.opSize, + }, null, 2)); +} + +main().catch((error) => { + console.error(`[ERROR] Origination estimate failed: ${error.message}`); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/deploy/deploy_script/mainnet_preflight.js b/deploy/deploy_script/mainnet_preflight.js index 3ebbfd68..ebf62ab9 100644 --- a/deploy/deploy_script/mainnet_preflight.js +++ b/deploy/deploy_script/mainnet_preflight.js @@ -27,9 +27,11 @@ const REQUIRED_CANONICAL_KEYS = ['PriceOracle', 'USDt', 'tzBTC']; // left null/empty until vetted mainnet addresses are confirmed for this deployment; // mainnet preflight fails closed while any required entry is absent. const VETTED_MAINNET_ADDRESSES = { - // PriceOracle: null, - // USDt: null, - // tzBTC: null, + // Blocked after the 2026-07-15 Kolibri incident. KT1B74... proxies the + // affected KT1Exb... feed; add an address only after independent review. + PriceOracle: null, + USDt: 'KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o', + tzBTC: 'KT1PWx2mnDueood7fEmfbBDKx1D9BAnnXitn', }; async function verifyAddressExists(tezos, key, address) { diff --git a/deploy/deploy_script/package-lock.json b/deploy/deploy_script/package-lock.json index 3e7c7c16..252f1c7e 100644 --- a/deploy/deploy_script/package-lock.json +++ b/deploy/deploy_script/package-lock.json @@ -11,7 +11,7 @@ "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", "@taquito/utils": "^25.0.0", - "glob": "7.2.3", + "glob": "^13.0.6", "loglevel": "^1.8.1" }, "devDependencies": {} @@ -432,9 +432,13 @@ "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -457,12 +461,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/buffer": { @@ -489,11 +496,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -506,25 +508,18 @@ "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==", "license": "Apache-2.0" }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -550,20 +545,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -582,31 +563,53 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dependencies": { - "wrappy": "1" + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rxjs": { @@ -643,11 +646,6 @@ } ], "license": "MIT" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" } }, "dependencies": { @@ -987,9 +985,9 @@ "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" }, "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" }, "base64-js": { "version": "1.5.1", @@ -997,12 +995,11 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" } }, "buffer": { @@ -1014,11 +1011,6 @@ "ieee754": "^1.2.1" } }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1029,22 +1021,14 @@ "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" } }, "ieee754": { @@ -1052,20 +1036,6 @@ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -1076,27 +1046,33 @@ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==" }, + "lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==" + }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "requires": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" } }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "requires": { - "wrappy": "1" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" } }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" - }, "rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1114,11 +1090,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-4.0.0.tgz", "integrity": "sha512-6dOYeZfS3O9RtRD1caom0sMxgK59b27+IwoNy8RDPsmslSGOyU+mpTamlaIW7aNKi90ZQZ9DFaZL3YRoiSCULQ==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" } } } diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index a32f1b32..12852e4c 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -6,7 +6,7 @@ "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", "@taquito/utils": "^25.0.0", - "glob": "7.2.3", + "glob": "^13.0.6", "loglevel": "^1.8.1" }, "devDependencies": {}, @@ -14,7 +14,9 @@ "check": "node -e \"require('./util').checkConnection().catch(error => { console.error('[ERROR] ' + error.message); process.exitCode = 1; })\"", "deploy": "node deploy.js", "deploy:e2e": "node deploy_e2e.js", + "estimate:origination": "node estimate_origination.js", "prepare:deploy": "node prepare.js", + "verify:mainnet-oracle": "node verify_mainnet_oracle.js", "test": "node --test test/*.test.js" } } diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index 4e10045b..14f91d61 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -22,6 +22,7 @@ const { } = require('../util.js'); const { checkNetworkExpectation, MAINNET_CHAIN_IDS } = require('../assert_network.js'); const { findMissingCanonicalKeys, verifyAgainstAllowlist, REQUIRED_CANONICAL_KEYS, VETTED_MAINNET_ADDRESSES } = require('../mainnet_preflight.js'); +const { parsePriceResult } = require('../verify_mainnet_oracle.js'); const CUSDT_COMPILED_ADDRESSES = [ { string: 'KT1Wq7uJeiXXociunW4LqQZzdNvM7QYbtVEN' }, @@ -336,13 +337,20 @@ test('mainnetPreflight: findMissingCanonicalKeys reports nothing when all keys p }); test('mainnetPreflight: verifyAgainstAllowlist rejects a missing required allowlist entry', () => { - assert.throws( - () => verifyAgainstAllowlist('PriceOracle', 'KT1SomeAddress'), - /No vetted mainnet address is configured for required key "PriceOracle"/, - ); + const vettedOracle = VETTED_MAINNET_ADDRESSES.PriceOracle; + delete VETTED_MAINNET_ADDRESSES.PriceOracle; + try { + assert.throws( + () => verifyAgainstAllowlist('PriceOracle', 'KT1SomeAddress'), + /No vetted mainnet address is configured for required key "PriceOracle"/, + ); + } finally { + VETTED_MAINNET_ADDRESSES.PriceOracle = vettedOracle; + } }); test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not match a configured vetted entry', () => { + const vettedOracle = VETTED_MAINNET_ADDRESSES.PriceOracle; VETTED_MAINNET_ADDRESSES.PriceOracle = 'KT1VettedOracleAddressXXXXXXXXXXXXX'; try { assert.throws( @@ -351,6 +359,28 @@ test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not ); assert.doesNotThrow(() => verifyAgainstAllowlist('PriceOracle', 'KT1VettedOracleAddressXXXXXXXXXXXXX')); } finally { - delete VETTED_MAINNET_ADDRESSES.PriceOracle; + VETTED_MAINNET_ADDRESSES.PriceOracle = vettedOracle; } }); + +test('mainnet oracle guard: accepts a current nonzero price', () => { + const response = { data: { args: [{ int: '226300' }, { int: '1784510000' }] } }; + assert.deepEqual(parsePriceResult('XTZUSDT', response, 1784510030, 86400), { + asset: 'XTZUSDT', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000, ageSeconds: 30, + }); +}); + +test('mainnet oracle guard: accepts millisecond timestamps and rejects stale or zero prices', () => { + assert.deepEqual( + parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510000000' }] } }, 1784510030, 86400), + { asset: 'XTZUSDT', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000000, ageSeconds: 30 }, + ); + assert.throws( + () => parsePriceResult('BTCUSDT', { data: { args: [{ int: '0' }, { int: '1784510000' }] } }, 1784510030, 86400), + /invalid or zero price/, + ); + assert.throws( + () => parsePriceResult('BTCUSDT', { data: { args: [{ int: '1' }, { int: '1784400000' }] } }, 1784510030, 86400), + /price is stale/, + ); +}); diff --git a/deploy/deploy_script/verify_mainnet_oracle.js b/deploy/deploy_script/verify_mainnet_oracle.js new file mode 100644 index 00000000..b9e35939 --- /dev/null +++ b/deploy/deploy_script/verify_mainnet_oracle.js @@ -0,0 +1,89 @@ +const fs = require('fs'); + +const { config, resolveDeployResultPath } = require('./util.js'); + +const ASSETS = ['XTZUSDT', 'BTCUSDT']; +const DEFAULT_MAX_AGE_SECONDS = 86400; +const MAX_FUTURE_SKEW_SECONDS = 300; + +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(); +} + +function parsePriceResult(asset, response, headTimestamp, maxAgeSeconds) { + const args = response?.data?.args; + const price = Number(args?.[0]?.int); + const rawTimestamp = Number(args?.[1]?.int); + if (!Number.isSafeInteger(price) || price <= 0) { + throw new Error(`${asset} returned an invalid or zero price: ${args?.[0]?.int}`); + } + if (!Number.isSafeInteger(rawTimestamp) || rawTimestamp <= 0) { + throw new Error(`${asset} returned an invalid timestamp: ${args?.[1]?.int}`); + } + + // The Youves/Acurast feed encodes Unix milliseconds in its timestamp field. + // Native Tezos feeds normally encode seconds, so accept either representation. + const timestamp = rawTimestamp > 100000000000 ? Math.floor(rawTimestamp / 1000) : rawTimestamp; + const ageSeconds = headTimestamp - timestamp; + if (ageSeconds < -MAX_FUTURE_SKEW_SECONDS) { + throw new Error( + `${asset} timestamp ${rawTimestamp} is ${Math.abs(ageSeconds)} seconds ahead of the mainnet head.`, + ); + } + if (ageSeconds > maxAgeSeconds) { + throw new Error(`${asset} price is stale (${ageSeconds}s old; maximum ${maxAgeSeconds}s).`); + } + return { asset, price, timestamp, rawTimestamp, ageSeconds }; +} + +async function main() { + const manifest = JSON.parse(fs.readFileSync(resolveDeployResultPath(), 'utf8')); + const oracle = manifest.PriceOracle; + if (!oracle) { + throw new Error('Mainnet manifest is missing PriceOracle.'); + } + + const rpc = process.env.TEZOS_RPC || config.tezosNode; + const source = config.originator?.pkh; + const maxAgeSeconds = Number(process.env.ORACLE_MAX_AGE_SECONDS || DEFAULT_MAX_AGE_SECONDS); + if (!source || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0) { + throw new Error('A public originator pkh and a positive integer ORACLE_MAX_AGE_SECONDS are required.'); + } + + 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); + for (const asset of ASSETS) { + const response = await rpcJson(rpc, '/chains/main/blocks/head/helpers/scripts/run_script_view', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contract: oracle, + view: 'get_price_with_timestamp', + input: { string: asset }, + chain_id: chainId, + source, + payer: source, + gas: '1040000', + unparsing_mode: 'Readable', + }), + }); + const result = parsePriceResult(asset, response, headTimestamp, maxAgeSeconds); + console.log(`[INFO] ${asset}: price=${result.price}, timestamp=${result.timestamp}, age=${result.ageSeconds}s`); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(`[ERROR] Mainnet oracle verification failed: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { parsePriceResult }; \ No newline at end of file diff --git a/deploy/shell_scripts/deploy_mainnet.sh b/deploy/shell_scripts/deploy_mainnet.sh index 6cf13cc3..d8f6bee5 100755 --- a/deploy/shell_scripts/deploy_mainnet.sh +++ b/deploy/shell_scripts/deploy_mainnet.sh @@ -34,6 +34,7 @@ python3 ./deploy/compile_targets/tests/test_irm_wiring.py # config block, and Config.py/util.js agree on the default manifest path per profile. python3 ./deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +node ./deploy/deploy_script/verify_mainnet_oracle.js node ./deploy/deploy_script/mainnet_preflight.js node ./deploy/deploy_script/prepare.js diff --git a/docs/MainnetGovernancePayloads.json b/docs/MainnetGovernancePayloads.json new file mode 100644 index 00000000..496af9ba --- /dev/null +++ b/docs/MainnetGovernancePayloads.json @@ -0,0 +1,139 @@ +{ + "status": "INITIAL_DRAFT", + "requiredInputs": { + "productionMultisig": "KT1Mc9rBcVmoLATY1FQDBvzSMHmUh6rgwjDR", + "maxPriceTimeDifferenceSeconds": 86400, + "collateralFactorMantissa": { + "XTZ": "500000000000000000", + "USDT": "500000000000000000", + "TZBTC": "500000000000000000" + }, + "openAfterHandoff": { + "XTZ": "TODO_BOOLEAN", + "USDT": "TODO_BOOLEAN", + "TZBTC": "TODO_BOOLEAN", + "transfers": "TODO_BOOLEAN" + } + }, + "addressSources": { + "Governance": "TezFinBuild/deploy_result/deploy.mainnet.json#Governance", + "Comptroller": "TezFinBuild/deploy_result/deploy.mainnet.json#Comptroller", + "TezFinOracle": "TezFinBuild/deploy_result/deploy.mainnet.json#TezFinOracle", + "XTZ": "TezFinBuild/deploy_result/deploy.mainnet.json#CXTZ", + "USDT": "TezFinBuild/deploy_result/deploy.mainnet.json#CUSDt", + "TZBTC": "TezFinBuild/deploy_result/deploy.mainnet.json#CtzBTC" + }, + "phases": [ + { + "name": "deployer_stages_admin_handoff", + "operations": [ + { + "destination": "Governance", + "entrypoint": "setPendingGovernance", + "value": "productionMultisig" + }, + { + "destination": "TezFinOracle", + "entrypoint": "set_pending_admin", + "value": "productionMultisig" + } + ] + }, + { + "name": "multisig_accepts_admin_handoff", + "operations": [ + { + "destination": "Governance", + "entrypoint": "acceptGovernance", + "value": "Unit" + }, + { + "destination": "TezFinOracle", + "entrypoint": "accept_admin", + "value": "Unit" + } + ] + }, + { + "name": "multisig_configures_closed_markets", + "operations": [ + { + "destination": "Governance", + "entrypoint": "setPriceOracleAndTimeDiff", + "value": { + "comptroller": "Comptroller", + "priceOracle": "TezFinOracle", + "timeDiff": "maxPriceTimeDifferenceSeconds" + } + }, + { + "destination": "Governance", + "entrypoint": "supportMarket", + "value": { + "comptroller": "Comptroller", + "market": { "cToken": "XTZ", "name": "XTZ", "priceExp": 1000000000000 } + } + }, + { + "destination": "Governance", + "entrypoint": "supportMarket", + "value": { + "comptroller": "Comptroller", + "market": { "cToken": "USDT", "name": "USDT", "priceExp": 1000000000000 } + } + }, + { + "destination": "Governance", + "entrypoint": "supportMarket", + "value": { + "comptroller": "Comptroller", + "market": { "cToken": "TZBTC", "name": "TZBTC", "priceExp": 10000000000 } + } + }, + { + "destination": "Governance", + "entrypoint": "setCollateralFactor", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "collateralFactor": { + "cToken": "$market", + "newCollateralFactor": "collateralFactorMantissa.$market" + } + } + } + ] + }, + { + "name": "multisig_opens_approved_markets", + "preconditions": [ + "Admin handoff storage has been verified on-chain", + "The production oracle guard passes for XTZUSDT and BTCUSDT", + "Exact-transfer review is complete for the canonical USDt and tzBTC contracts", + "Every collateral factor and pause state has formal risk approval" + ], + "operations": [ + { + "destination": "Governance", + "repeatEntrypoints": ["setMintPaused", "setBorrowPaused", "setRedeemPaused"], + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "tokenState": { + "cToken": "$market", + "state": "!openAfterHandoff.$market" + } + } + }, + { + "destination": "Governance", + "entrypoint": "setTransferPaused", + "value": { + "comptroller": "Comptroller", + "state": "!openAfterHandoff.transfers" + } + } + ] + } + ] +} \ No newline at end of file From c0c270247c4b1c1679181a4e488f915fee5c2a59 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Tue, 21 Jul 2026 13:13:19 +0300 Subject: [PATCH 15/19] feat: harden oracle, market controls, and release builds Add price bounds, timestamp validation, market caps, liquidation pause, and emergency guardian controls. Remove fixed oracle pegs, enforce mainnet deployment guards, correct tzBTC configuration, and pin reproducible SmartPy 0.16.0 builds. --- .github/workflows/ci.yml | 56 +++++-- README.md | 29 +++- contracts/CToken.py | 7 + contracts/Comptroller.py | 83 +++++++++-- contracts/Governance.py | 32 ++++ contracts/TezFinOracle.py | 72 ++++++++- contracts/errors/ComptrollerErrors.py | 3 + contracts/interfaces/ComptrollerInterface.py | 12 ++ contracts/interfaces/GovernanceInterface.py | 12 ++ contracts/interfaces/OracleInterface.py | 12 ++ contracts/tests/ComptrollerTest.py | 124 +++++++++++++--- contracts/tests/GovernanceTest.py | 22 ++- contracts/tests/TezFinOracleTest.py | 57 +++++++- contracts/tests/mock/CTokenMock.py | 15 +- contracts/tests/mock/OracleMock.py | 71 ++++++++- deploy/compile_targets/CompileTzBTC.py | 2 +- .../compile_targets/tests/test_irm_wiring.py | 6 + .../tests/test_operation_size.py | 2 +- .../tests/test_reproducible_build.py | 138 ++++++++++++++++++ .../deploy_script/test/deploy_guards.test.js | 36 ++++- deploy/deploy_script/util.js | 13 ++ deploy/deploy_script/verify_mainnet_oracle.js | 14 +- tools/install-smartpy.sh | 36 +++++ tools/smartpy/package-lock.json | 108 ++++++++++++++ tools/smartpy/package.json | 12 ++ 25 files changed, 889 insertions(+), 85 deletions(-) create mode 100644 deploy/compile_targets/tests/test_reproducible_build.py create mode 100755 tools/install-smartpy.sh create mode 100644 tools/smartpy/package-lock.json create mode 100644 tools/smartpy/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ca25e72..f279e337 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,19 +4,31 @@ on: pull_request: branches: ['*'] +permissions: + contents: read + +env: + NODE_VERSION: '20.19.5' + PYTHON_VERSION: '3.11.11' + jobs: build_and_test_smart_contracts: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - - uses: actions/setup-python@v2 - - name: "Install SmartPy" - run: | - bash <(curl -s https://legacy.smartpy.io/cli/install.sh) --yes + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: "Install verified SmartPy 0.16.0" + run: tools/install-smartpy.sh + - name: "Audit SmartPy runtime dependencies" + run: npm audit --omit=dev --prefix tools/smartpy - name: "Version Check" run: | - ~/smartpy-cli/SmartPy.sh --version + test "$(~/smartpy-cli/SmartPy.sh --version)" = "SmartPy Version: 0.16.0" - name: "Run tests" run: | bash contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh @@ -33,12 +45,26 @@ jobs: - name: "Check contract origination sizes stay within safety threshold" run: | python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh + - name: "Verify reproducible contract compilation" + env: + COMPILED_HASHES_OUTPUT: compiled-contract-hashes.json + run: | + python3 deploy/compile_targets/tests/test_reproducible_build.py ~/smartpy-cli/SmartPy.sh + - name: "Publish compiled contract hashes" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: compiled-contract-hashes + path: compiled-contract-hashes.json + if-no-files-found: error + retention-days: 30 build_and_test_deploy_scripts: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} - name: "Install deploy script dependencies" working-directory: deploy/deploy_script run: | @@ -49,10 +75,12 @@ jobs: npm test build_and_test_typescript: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} - name: "Build and test utility package" working-directory: src/util run: | diff --git a/README.md b/README.md index d00df143..41fbea7a 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,24 @@ For the detailed description please refer to the [wiki pages](https://github.com ## Project Structure -SmartPy CLI is required to interact with contracts. For installation please refer to https://smartpy.io/cli/ +SmartPy Legacy CLI **0.16.0** is the only supported compiler version for this +codebase. The contracts use the legacy SmartPy syntax and cannot be compiled with +the current SmartPy language without a reviewed migration. -Install legacy version: `bash <(curl -s https://legacy.smartpy.io/cli/install.sh) --yes` +Install the pinned compiler and runtime dependencies with: + +```sh +tools/install-smartpy.sh +``` + +The installer fails closed unless the downloaded archive matches the repository's +SHA-256 pin, installs npm dependencies with `npm ci` from +`tools/smartpy/package-lock.json`, and verifies that the compiler reports exactly +`SmartPy Version: 0.16.0`. Do not use the upstream `curl | bash` installer for +release builds. + +CI additionally pins Ubuntu 22.04, Node.js 20.19.5, Python 3.11.11, and GitHub +Actions to immutable commit SHAs. Code is organized in the following structure @@ -118,6 +133,16 @@ and all run offline/in CI without needing a live Tezos node: ```sh python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh ``` +- **Reproducible contract compilation** + (`deploy/compile_targets/tests/test_reproducible_build.py`) - compiles every contract + originated by `deploy_mainnet.sh` twice in clean temporary directories with the + same production flags, then compares SHA-256 hashes of canonical contract and + storage JSON. CI publishes the resulting + `compiled-contract-hashes.json` artifact for deployment and multisig review. + ```sh + COMPILED_HASHES_OUTPUT=compiled-contract-hashes.json \ + python3 deploy/compile_targets/tests/test_reproducible_build.py ~/smartpy-cli/SmartPy.sh + ``` - **Deploy script guards** (`deploy/deploy_script/test/deploy_guards.test.js`) - unit tests (Node's built-in test runner, no network access) for the safety checks in `util.js`, `assert_network.js`, and `mainnet_preflight.js`: chain-id mismatch diff --git a/contracts/CToken.py b/contracts/CToken.py index a2b26df5..a5eeedb2 100644 --- a/contracts/CToken.py +++ b/contracts/CToken.py @@ -546,6 +546,13 @@ def getTotalSupply(self, params): sp.set_type(params, sp.TUnit) sp.result(self.data.totalSupply) + @sp.onchain_view() + def marketTotals(self, params): + sp.set_type(params, sp.TUnit) + suppliedUnderlying = self.mulScalarTruncate( + self.makeExp(self.exchangeRateStoredImpl()), self.data.totalSupply) + sp.result(sp.pair(suppliedUnderlying, self.data.totalBorrows)) + """ Get the current allowance from `owner` for `spender` diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index 8f5587be..64ac390b 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -14,9 +14,6 @@ "file:contracts/utils/Exponential.py") OP = sp.io.import_script_from_url("file:contracts/utils/OperationProtector.py") -SweepTokens = sp.io.import_script_from_url( - "file:contracts/utils/SweepTokens.py") - TMarket = sp.TRecord(isListed=sp.TBool, # Whether or not this market is listed # Multiplier representing the most one can borrow against their collateral in this market. collateralFactor=Exponential.TExp, @@ -25,6 +22,9 @@ mintPaused=sp.TBool, borrowPaused=sp.TBool, redeemPaused=sp.TBool, + liquidatePaused=sp.TBool, + supplyCap=sp.TNat, + borrowCap=sp.TNat, name=sp.TString, # Asset name for price oracle price=Exponential.TExp, # The price of the asset priceExp=sp.TNat, # exponent needed to normalize the token prices to 10^18 @@ -41,7 +41,7 @@ DEFAULT_COLLATERAL_FACTOR = int(5e17) # 50 % -class Comptroller(CMPTInterface.ComptrollerInterface, Exponential.Exponential, SweepTokens.SweepTokens, OP.OperationProtector): +class Comptroller(CMPTInterface.ComptrollerInterface, Exponential.Exponential, OP.OperationProtector): def __init__(self, administrator_, oracleAddress_, closeFactorMantissa_, liquidationIncentiveMantissa_, maxAssetsPerUser_=15, **extra_storage): Exponential.Exponential.__init__( self, @@ -66,7 +66,6 @@ def __init__(self, administrator_, oracleAddress_, closeFactorMantissa_, liquida activeOperations=sp.set(t=sp.TNat), closeFactorMantissa=closeFactorMantissa_, liquidationIncentiveMantissa=liquidationIncentiveMantissa_, - maxPriceTimeDifference = sp.int(86400), maxAssetsPerUser=maxAssetsPerUser_, **extra_storage ) @@ -160,6 +159,12 @@ def mintAllowed(self, params): sp.verify( ~ self.data.markets[params.cToken].mintPaused, EC.CMPT_MINT_PAUSED) self.verifyMarketListed(params.cToken) + marketTotals = sp.view("marketTotals", params.cToken, sp.unit, + t=sp.TPair(sp.TNat, sp.TNat)).open_some( + "INVALID MARKET TOTALS VIEW") + sp.verify(sp.fst(marketTotals) + params.mintAmount <= + self.data.markets[params.cToken].supplyCap, + EC.CMPT_SUPPLY_CAP_EXCEEDED) self.invalidateLiquidity(params.minter) """ @@ -256,6 +261,12 @@ def borrowAllowed(self, params): sp.verify( ~ self.data.markets[params.cToken].borrowPaused, EC.CMPT_BORROW_PAUSED) self.verifyMarketListed(params.cToken) + marketTotals = sp.view("marketTotals", params.cToken, sp.unit, + t=sp.TPair(sp.TNat, sp.TNat)).open_some( + "INVALID MARKET TOTALS VIEW") + sp.verify(sp.snd(marketTotals) + params.borrowAmount <= + self.data.markets[params.cToken].borrowCap, + EC.CMPT_BORROW_CAP_EXCEEDED) sp.if self.isNewAssetForUser(params.borrower, params.cToken): uniqueAssetsCount = self.getUserUniqueAssetsCount(params.borrower) sp.verify(uniqueAssetsCount < self.data.maxAssetsPerUser, @@ -364,16 +375,21 @@ def updateAllAssetPricesWithView(self): def updateAllAssetPrices(self): sp.for asset in self.data.marketNameToAddress.values(): - sp.if self.data.markets[asset].updateLevel < sp.level: - pricePair = sp.local("pricePair", - sp.view("getPrice", self.data.oracleAddress, self.data.markets[asset].name + "-USD", + sp.if self.data.markets[asset].isListed & (self.data.markets[asset].updateLevel < sp.level): + previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp + pricePair = sp.local("pricePair", + sp.view("getValidatedPrice", self.data.oracleAddress, + sp.record(comptroller=sp.self_address, + cToken=asset, + requestedAsset=self.data.markets[asset].name + "-USD", + previousPrice=previousRawPrice, + previousTimestamp=self.data.markets[asset].priceTimestamp), t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") ) priceTimestamp = sp.fst(pricePair.value) - sp.verify(priceTimestamp <= sp.now, "FUTURE_ASSET_PRICE") - sp.verify(sp.now - priceTimestamp <= self.data.maxPriceTimeDifference, "STALE_ASSET_PRICE") + rawPrice = sp.snd(pricePair.value) self.data.markets[asset].price = self.makeExp( - sp.snd(pricePair.value)*self.data.markets[asset].priceExp) + rawPrice*self.data.markets[asset].priceExp) self.data.markets[asset].priceTimestamp = priceTimestamp self.data.markets[asset].updateLevel = sp.level @@ -418,8 +434,9 @@ def setAccountLiquidityWithView(self, account): def accrueAllAssetInterests(self): sp.for asset in self.data.marketNameToAddress.values(): - sp.transfer(sp.unit, sp.mutez(0), sp.contract( - sp.TUnit, asset, entry_point="accrueInterest").open_some()) + sp.if self.data.markets[asset].isListed: + sp.transfer(sp.unit, sp.mutez(0), sp.contract( + sp.TUnit, asset, entry_point="accrueInterest").open_some()) """ Determine what the account liquidity would be if the given amounts were redeemed/borrowed, @@ -508,6 +525,10 @@ def liquidateBorrowAllowed(self, params): self.verifyMarketListed(params.cTokenBorrowed) self.verifyMarketListed(params.cTokenCollateral) + sp.verify(~self.data.markets[params.cTokenBorrowed].liquidatePaused, + EC.CMPT_LIQUIDATE_PAUSED) + sp.verify(~self.data.markets[params.cTokenCollateral].liquidatePaused, + EC.CMPT_LIQUIDATE_PAUSED) liquidity = sp.local( "liquidtiy", self.getAccountLiquidityInternal(params.borrower)) @@ -705,6 +726,24 @@ def setRedeemPaused(self, params): self.verifyAdministrator() self.data.markets[params.cToken].redeemPaused = params.state + @sp.entry_point + def setLiquidatePaused(self, params): + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") + sp.set_type(params, sp.TRecord(cToken=sp.TAddress, state=sp.TBool)) + self.verifyMarketListed(params.cToken) + self.verifyAdministrator() + self.data.markets[params.cToken].liquidatePaused = params.state + + @sp.entry_point + def setMarketCaps(self, params): + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") + sp.set_type(params, sp.TRecord(cToken=sp.TAddress, supplyCap=sp.TNat, + borrowCap=sp.TNat)) + self.verifyMarketListed(params.cToken) + self.verifyAdministrator() + self.data.markets[params.cToken].supplyCap = params.supplyCap + self.data.markets[params.cToken].borrowCap = params.borrowCap + """ Pause or activate the transfer of CTokens @@ -735,8 +774,21 @@ def setPriceOracleAndTimeDiff(self, params): 0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(priceOracle=sp.TAddress, timeDiff=sp.TInt)) self.verifyAdministrator() - self.data.maxPriceTimeDifference = params.timeDiff self.data.oracleAddress = params.priceOracle + destination = sp.contract(sp.TInt, params.priceOracle, + "configureMaxPriceAge").open_some() + sp.transfer(params.timeDiff, sp.mutez(0), destination) + + @sp.entry_point + def setPriceBounds(self, params): + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") + sp.set_type(params, sp.TRecord(cToken=sp.TAddress, minPrice=sp.TNat, + maxPrice=sp.TNat, maxChangeBps=sp.TNat)) + self.verifyAdministrator() + destination = sp.contract(OracleInterface.TPriceBounds, + self.data.oracleAddress, + "configurePriceBounds").open_some() + sp.transfer(params, sp.mutez(0), destination) """ Sets the closeFactor used when liquidating borrows @@ -829,6 +881,9 @@ def supportMarket(self, params): borrowPaused=sp.bool( True), redeemPaused=sp.bool(True), + liquidatePaused=sp.bool(True), + supplyCap=sp.nat(0), + borrowCap=sp.nat(0), name=params.name, price=self.makeExp( sp.nat(0)), diff --git a/contracts/Governance.py b/contracts/Governance.py index e2a3d5bb..19fd2eee 100644 --- a/contracts/Governance.py +++ b/contracts/Governance.py @@ -220,6 +220,18 @@ def setPriceOracleAndTimeDiff(self, params): sp.TRecord(priceOracle=sp.TAddress, timeDiff=sp.TInt), params.comptroller, "setPriceOracleAndTimeDiff").open_some() sp.transfer(sp.record(priceOracle=params.priceOracle,timeDiff=params.timeDiff), sp.mutez(0), contract) + @sp.entry_point + def setPriceBounds(self, params): + self.verifyAdministrator() + sp.set_type(params, sp.TRecord(comptroller=sp.TAddress, + bounds=sp.TRecord(cToken=sp.TAddress, minPrice=sp.TNat, + maxPrice=sp.TNat, maxChangeBps=sp.TNat))) + contract = sp.contract(sp.TRecord(cToken=sp.TAddress, + minPrice=sp.TNat, maxPrice=sp.TNat, + maxChangeBps=sp.TNat), params.comptroller, + "setPriceBounds").open_some() + sp.transfer(params.bounds, sp.mutez(0), contract) + """ Sets the closeFactor used when liquidating borrows @@ -371,6 +383,26 @@ def setRedeemPaused(self, params): "setRedeemPaused").open_some() sp.transfer(params.tokenState, sp.mutez(0), contract) + @sp.entry_point + def setLiquidatePaused(self, params): + self.verifyAdministrator() + sp.set_type(params, sp.TRecord(comptroller=sp.TAddress, + tokenState=sp.TRecord(cToken=sp.TAddress, state=sp.TBool))) + contract = sp.contract(sp.TRecord(cToken=sp.TAddress, state=sp.TBool), + params.comptroller, "setLiquidatePaused").open_some() + sp.transfer(params.tokenState, sp.mutez(0), contract) + + @sp.entry_point + def setMarketCaps(self, params): + self.verifyAdministrator() + sp.set_type(params, sp.TRecord(comptroller=sp.TAddress, + caps=sp.TRecord(cToken=sp.TAddress, supplyCap=sp.TNat, + borrowCap=sp.TNat))) + contract = sp.contract(sp.TRecord(cToken=sp.TAddress, + supplyCap=sp.TNat, borrowCap=sp.TNat), + params.comptroller, "setMarketCaps").open_some() + sp.transfer(params.caps, sp.mutez(0), contract) + """ Pause or activate the transfer of CTokens diff --git a/contracts/TezFinOracle.py b/contracts/TezFinOracle.py index f31b64d8..b11a88cb 100644 --- a/contracts/TezFinOracle.py +++ b/contracts/TezFinOracle.py @@ -1,5 +1,4 @@ import smartpy as sp -import time OracleInterface = sp.io.import_script_from_url( "file:contracts/interfaces/OracleInterface.py") @@ -12,10 +11,13 @@ class TezFinOracle(OracleInterface.OracleInterface): def __init__(self, admin, oracle): self.init( - overrides=sp.big_map(l={"USD-USD": (sp.timestamp(int(time.time())), sp.as_nat( - 1000000)),"USDT-USD": (sp.timestamp(int(time.time())), sp.as_nat( - 1000000))}, tkey=sp.TString, tvalue=sp.TPair(sp.TTimestamp, sp.TNat)), - alias=sp.big_map(l={"OXTZ-USD": "XTZ-USD", "WTZ-USD": "XTZ-USD", "TZBTC-USD":"BTC-USD", "STXTZ-USD": "XTZ-USD"}, + overrides=sp.big_map(l={}, tkey=sp.TString, + tvalue=sp.TPair(sp.TTimestamp, sp.TNat)), + priceBounds=sp.big_map( + l={}, tkey=sp.TPair(sp.TAddress, sp.TAddress), + tvalue=OracleInterface.TPriceBounds), + maxPriceAge=sp.big_map(l={}, tkey=sp.TAddress, tvalue=sp.TInt), + alias=sp.big_map(l={"OXTZ-USD": "XTZ-USD", "WTZ-USD": "XTZ-USD", "STXTZ-USD": "XTZ-USD"}, tkey=sp.TString, tvalue=sp.TString), oracle=oracle, admin=admin, @@ -85,6 +87,22 @@ def removeAlias(self, asset): sp.verify(self.is_admin(sp.sender), message="NOT_ADMIN") del self.data.alias[asset] + @sp.entry_point + def configurePriceBounds(self, params): + sp.set_type(params, OracleInterface.TPriceBounds) + sp.verify((params.minPrice > 0) & + (params.minPrice <= params.maxPrice) & + (params.maxChangeBps <= 10000), + "INVALID_PRICE_BOUNDS") + self.data.priceBounds[sp.pair(sp.sender, params.cToken)] = params + + @sp.entry_point + def configureMaxPriceAge(self, maxPriceAge): + sp.set_type(maxPriceAge, sp.TInt) + sp.verify((maxPriceAge > 0) & (maxPriceAge <= 3600), + "INVALID_MAX_PRICE_TIME_DIFFERENCE") + self.data.maxPriceAge[sp.sender] = maxPriceAge + @sp.onchain_view() def get_price_with_timestamp(self, requestedAsset): """ @@ -92,7 +110,8 @@ def get_price_with_timestamp(self, requestedAsset): """ sp.set_type(requestedAsset, sp.TString) sp.if self.data.overrides.contains(requestedAsset): - sp.result((sp.snd(self.data.overrides[requestedAsset]), sp.now)) + sp.result((sp.snd(self.data.overrides[requestedAsset]), + sp.fst(self.data.overrides[requestedAsset]))) sp.else: asset = sp.local("asset", requestedAsset) sp.if self.data.alias.contains(requestedAsset): @@ -109,7 +128,7 @@ def getPrice(self, requestedAsset): """ sp.set_type(requestedAsset, sp.TString) sp.if self.data.overrides.contains(requestedAsset): - sp.result((sp.now, sp.snd(self.data.overrides[requestedAsset]))) + sp.result(self.data.overrides[requestedAsset]) sp.else: asset = sp.local("asset", requestedAsset) sp.if self.data.alias.contains(requestedAsset): @@ -117,4 +136,41 @@ def getPrice(self, 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))) \ No newline at end of file + sp.result((sp.snd(oracle_data), sp.fst(oracle_data))) + + @sp.onchain_view() + def getValidatedPrice(self, params): + sp.set_type(params, OracleInterface.TValidatedPriceRequest) + configKey = sp.pair(params.comptroller, params.cToken) + sp.verify(self.data.priceBounds.contains(configKey), + "PRICE_BOUNDS_NOT_CONFIGURED") + sp.verify(self.data.maxPriceAge.contains(params.comptroller), + "MAX_PRICE_AGE_NOT_CONFIGURED") + pricePair = sp.view("getPrice", sp.self_address, params.requestedAsset, + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some( + "invalid oracle price view") + priceTimestamp = sp.fst(pricePair) + rawPrice = sp.snd(pricePair) + bounds = self.data.priceBounds[configKey] + sp.verify(priceTimestamp > sp.timestamp(0), + "INVALID_ASSET_PRICE_TIMESTAMP") + sp.verify(priceTimestamp <= sp.now, "FUTURE_ASSET_PRICE") + sp.verify(sp.now - priceTimestamp <= + self.data.maxPriceAge[params.comptroller], + "STALE_ASSET_PRICE") + sp.verify((params.previousTimestamp == sp.timestamp(0)) | + (priceTimestamp >= params.previousTimestamp), + "ASSET_PRICE_TIMESTAMP_ROLLBACK") + sp.verify((rawPrice >= bounds.minPrice) & + (rawPrice <= bounds.maxPrice), + "ASSET_PRICE_OUT_OF_BOUNDS") + sp.if params.previousTimestamp != sp.timestamp(0): + priceChange = sp.local("priceChange", sp.nat(0)) + sp.if rawPrice >= params.previousPrice: + priceChange.value = sp.as_nat(rawPrice - params.previousPrice) + sp.else: + priceChange.value = sp.as_nat(params.previousPrice - rawPrice) + sp.verify(priceChange.value * 10000 <= + params.previousPrice * bounds.maxChangeBps, + "ASSET_PRICE_CHANGE_TOO_LARGE") + sp.result(pricePair) \ No newline at end of file diff --git a/contracts/errors/ComptrollerErrors.py b/contracts/errors/ComptrollerErrors.py index 555159e1..1843c2b3 100644 --- a/contracts/errors/ComptrollerErrors.py +++ b/contracts/errors/ComptrollerErrors.py @@ -7,6 +7,9 @@ class ErrorCodes: CMPT_MINT_PAUSED = "CMPT_MINT_PAUSED" # Borrow is paused CMPT_BORROW_PAUSED = "CMPT_BORROW_PAUSED" + CMPT_LIQUIDATE_PAUSED = "CMPT_LIQUIDATE_PAUSED" + CMPT_SUPPLY_CAP_EXCEEDED = "CMPT_SUPPLY_CAP_EXCEEDED" + CMPT_BORROW_CAP_EXCEEDED = "CMPT_BORROW_CAP_EXCEEDED" # Redemption is paused CMPT_REDEEM_PAUSED = "CMPT_REDEEM_PAUSED" # Transfer is paused diff --git a/contracts/interfaces/ComptrollerInterface.py b/contracts/interfaces/ComptrollerInterface.py index a2b8a1ef..37471cef 100644 --- a/contracts/interfaces/ComptrollerInterface.py +++ b/contracts/interfaces/ComptrollerInterface.py @@ -187,6 +187,10 @@ def acceptGovernance(self, unusedArg): def setPriceOracleAndTimeDiff(self, params): pass + @sp.entry_point + def setPriceBounds(self, params): + pass + """ Sets the closeFactor used when liquidating borrows @@ -292,6 +296,14 @@ def setBorrowPaused(self, params): def setRedeemPaused(self, params): pass + @sp.entry_point + def setLiquidatePaused(self, params): + pass + + @sp.entry_point + def setMarketCaps(self, params): + pass + """ Pause or activate the transfer of CTokens diff --git a/contracts/interfaces/GovernanceInterface.py b/contracts/interfaces/GovernanceInterface.py index 84df12eb..98412726 100644 --- a/contracts/interfaces/GovernanceInterface.py +++ b/contracts/interfaces/GovernanceInterface.py @@ -113,6 +113,10 @@ def updateMetadata(self, params): def setPriceOracleAndTimeDiff(self, params): pass + @sp.entry_point + def setPriceBounds(self, params): + pass + """ Sets the closeFactor used when liquidating borrows @@ -206,6 +210,14 @@ def setBorrowPaused(self, params): def setRedeemPaused(self, params): pass + @sp.entry_point + def setLiquidatePaused(self, params): + pass + + @sp.entry_point + def setMarketCaps(self, params): + pass + """ Pause or activate the transfer of CTokens diff --git a/contracts/interfaces/OracleInterface.py b/contracts/interfaces/OracleInterface.py index aada233e..fe84d335 100644 --- a/contracts/interfaces/OracleInterface.py +++ b/contracts/interfaces/OracleInterface.py @@ -6,9 +6,21 @@ TSetPriceParam = sp.TPair(sp.TString, sp.TPair(sp.TTimestamp, sp.TNat)) TGetPriceParam = sp.TPair(sp.TString, sp.TContract(TSetPriceParam)) +TPriceBounds = sp.TRecord(cToken=sp.TAddress, minPrice=sp.TNat, + maxPrice=sp.TNat, maxChangeBps=sp.TNat) +TValidatedPriceRequest = sp.TRecord( + comptroller=sp.TAddress, + cToken=sp.TAddress, + requestedAsset=sp.TString, + previousPrice=sp.TNat, + previousTimestamp=sp.TTimestamp) class OracleInterface(sp.Contract): @sp.onchain_view() def getPrice(self, requestedAsset): pass + + @sp.onchain_view() + def getValidatedPrice(self, params): + pass diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 432bf647..8df56b2f 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -112,6 +112,9 @@ def test(): mintPaused = sp.bool(True), borrowPaused = sp.bool(True), redeemPaused = sp.bool(False), + liquidatePaused = sp.bool(False), + supplyCap = sp.nat(10**50), + borrowCap = sp.nat(10**50), name = sp.string("m1"), price = sp.record(mantissa=sp.nat(0)), priceExp = 1000000000000000000, @@ -124,6 +127,9 @@ def test(): mintPaused = sp.bool(True), borrowPaused = sp.bool(True), redeemPaused = sp.bool(False), + liquidatePaused = sp.bool(False), + supplyCap = sp.nat(10**50), + borrowCap = sp.nat(10**50), name = sp.string("m4"), price = sp.record(mantissa=sp.nat(0)), priceExp = 1000000000000000000, @@ -131,6 +137,15 @@ def test(): priceTimestamp= sp.timestamp(0))), ] initMarkets(scenario, bLevel, markets, cmpt) + scenario += cmpt.setPriceOracleAndTimeDiff(sp.record( + priceOracle=oracle.address, timeDiff=sp.int(300))).run( + sender=admin, level=bLevel.next()) + scenario += cmpt.setPriceBounds(sp.record( + cToken=listedMarket, minPrice=sp.nat(1), maxPrice=sp.nat(10**50), + maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.setPriceBounds(sp.record( + cToken=cTokenMock.address, minPrice=sp.nat(1), maxPrice=sp.nat(10**50), + maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) marketsList = [listedMarket, notListedMarket, listedMarketWithoutAccountMembership, cTokenMock.address] scenario.h4("Add Alice and admin to markets") @@ -174,6 +189,10 @@ def test(): TestAdminFunctionality.checkAdminRequirementH4(scenario, "set transfer paused False", bLevel, admin, alice, cmpt.setTransferPaused, sp.bool(False)) scenario.verify(cmpt.data.transferPaused == sp.bool(False)) + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.setBorrowPaused(sp.record(cToken=listedMarket, state=sp.bool(False))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.setTransferPaused(sp.bool(False)).run(sender=admin, level=bLevel.next()) + scenario.h2("Test allowed functionality") scenario.h3("Mint allowed") minterArgLambda = lambda market : sp.record(cToken=market, minter=alice.address, mintAmount=sp.nat(100)) @@ -187,6 +206,12 @@ def test(): scenario.h4("mint is not paused") scenario += cmpt.setMintPaused(sp.record(cToken = listedMarket, state = sp.bool(False))).run(sender = admin, level = bLevel.next()) scenario += cmpt.mintAllowed(minterArgLambda(listedMarket)).run(sender = alice, level = bLevel.next()) + scenario.h4("supply cap cannot be exceeded") + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(99), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.mintAllowed(minterArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next(), valid=False, + exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) scenario.h3("Redeem allowed") cmpt.addToLoansExternal(sp.pair(alice.address, sp.set([cTokenMock.address]))) @@ -208,61 +233,67 @@ def test(): scenario += cmpt.redeemAllowed(redeemArgLambda(notListedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("with insufficient liquidity") cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(0), borrowBalance = sp.nat(100), exchangeRateMantissa = exchRate)).run(level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario.show(cmpt.data.collaterals) scenario.show(alice.address) scenario += cmpt.redeemAllowed(redeemArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("without insufficient liquidity") cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(100*1000000000000000000), borrowBalance = sp.nat(0), exchangeRateMantissa = exchRate)).run(level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.redeemAllowed(redeemArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("invalid after price was not updated for 5 blocks") scenario += cmpt.redeemAllowed(redeemArgLambda(listedMarket)).run(sender = alice, level = bLevel.add(5), valid = False) scenario.h3("Borrow allowed") borrowArgLambda = lambda market : sp.record(cToken=market, borrower=alice.address, borrowAmount=sp.nat(100*1000000000000000000)) + scenario.h4("borrow cap cannot be exceeded") + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(99))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next(), valid=False, + exception=CMPT.EC.CMPT_BORROW_CAP_EXCEEDED) + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) scenario.h4("on the listed market, without updated price") scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.next(), valid = False) scenario.h4("on the listed market, with updated price, without updated liquidity") updateAssetsPrices(scenario, cmpt, bLevel, marketsList) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.next(), valid = False) scenario.h4("on the listed market, with updated price and updated liquidity") - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = True) scenario.h4("borrowing uses the full debt value when the market collateral factor is zero") scenario += cmpt.setCollateralFactor(sp.record(cToken = listedMarket, newCollateralFactor = sp.nat(0))).run(sender = admin, level = bLevel.next()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(sp.record(cToken = listedMarket, borrower = alice.address, borrowAmount = sp.nat(100*1000000000000000000 + 1))).run( sender = alice, level = bLevel.current(), valid = False) scenario.h4("on the not listed market") scenario += cmpt.borrowAllowed(borrowArgLambda(notListedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("with insufficient liquidity") cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(0), borrowBalance = sp.nat(100), exchangeRateMantissa = exchRate)).run(level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("without insufficient liquidity") cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(100*1000000000000000000), borrowBalance = sp.nat(0), exchangeRateMantissa = exchRate)).run(level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("with price errors") oracle.setPrice(0) - updateAssetsPrices(scenario, cmpt, bLevel, marketsList) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), valid = False) - scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) + scenario += cmpt.updateAllAssetPricesWithView().run( + level=bLevel.next(), now=sp.timestamp(100), valid=False, + exception="ASSET_PRICE_OUT_OF_BOUNDS") scenario.h4("without price errors") oracle.setPrice(1) updateAssetsPrices(scenario, cmpt, bLevel, marketsList) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("borrow is paused") scenario += cmpt.setBorrowPaused(sp.record(cToken = listedMarket, state = sp.bool(True))).run(sender = admin, level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("borrow is not paused") scenario += cmpt.setBorrowPaused(sp.record(cToken = listedMarket, state = sp.bool(False))).run(sender = admin, level = bLevel.current()) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("alice calls borrowAllowed if borrower not in market") - scenario += cmpt.updateAccountLiquidityWithView(notMember.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(notMember.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.borrowAllowed(sp.record(cToken=listedMarket, borrower=notMember.address, borrowAmount=sp.nat(0))).run( sender = alice, level = bLevel.current(), valid = False) scenario.h4("cToken calls borrowAllowed if borrower not in market") @@ -278,6 +309,19 @@ def test(): scenario.h4("on the not listed market") scenario += cmpt.repayBorrowAllowed(repayBorrowArgLambda(notListedMarket)).run(sender = alice, level = bLevel.next(), valid = False) + scenario.h3("Liquidation pause does not block repayment") + liquidateArg = sp.record(cTokenBorrowed=listedMarket, + cTokenCollateral=cTokenMock.address, + borrower=alice.address, liquidator=bob.address, + repayAmount=sp.nat(1)) + scenario += cmpt.setLiquidatePaused(sp.record(cToken=listedMarket, state=sp.bool(True))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.liquidateBorrowAllowed(liquidateArg).run( + sender=listedMarket, level=bLevel.next(), valid=False, + exception=CMPT.EC.CMPT_LIQUIDATE_PAUSED) + scenario += cmpt.repayBorrowAllowed(repayBorrowArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next()) + scenario += cmpt.setLiquidatePaused(sp.record(cToken=listedMarket, state=sp.bool(False))).run(sender=admin, level=bLevel.next()) + scenario.h3("Incident mode keeps repayment available") scenario += cmpt.setMintPaused(sp.record(cToken = listedMarket, state = sp.bool(True))).run(sender = admin, level = bLevel.next()) scenario += cmpt.setBorrowPaused(sp.record(cToken = listedMarket, state = sp.bool(True))).run(sender = admin, level = bLevel.current()) @@ -304,13 +348,13 @@ def test(): updateAssetsPrices(scenario, cmpt, bLevel, marketsList) scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.next(), valid = False) scenario.h4("redeem is allowed, with updated price and updated liquidity") - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("redeem is not allowed") scenario += cmpt.transferAllowed(transferArgLambda(notListedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("transfer is paused") scenario += cmpt.setTransferPaused(sp.bool(True)).run(sender = admin, level = bLevel.current()) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("transfer is not paused") scenario += cmpt.setTransferPaused(sp.bool(False)).run(sender = admin, level = bLevel.current()) @@ -329,12 +373,12 @@ def test(): scenario += cTokenMock1.setSnapshotAvailable(sp.bool(True)).run(level = bLevel.next()) scenario += cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(100), borrowBalance = sp.nat(0), exchangeRateMantissa = exchRate)).run(level = bLevel.current()) updateAssetsPrices(scenario, cmpt, bLevel, marketsList) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.current()) scenario.h4("collateralized borrowers cannot transfer into a one-unit shortfall") scenario += cTokenMock.setAccountSnapshot(sp.record(account = alice.address, cTokenBalance = sp.nat(99), borrowBalance = sp.nat(0), exchangeRateMantissa = exchRate)).run(level = bLevel.next()) updateAssetsPrices(scenario, cmpt, bLevel, marketsList) - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.current(), valid = False) scenario.h4("invalid after price was not updated for 5 blocks") scenario += cmpt.transferAllowed(transferArgLambda(listedMarket)).run(sender = alice, level = bLevel.add(5), valid = False) @@ -347,7 +391,7 @@ def test(): updateAssetsPrices(scenario, cmpt, bLevel, marketsList) cmpt.exitMarket(cTokenMock.address).run(sender = alice, level = bLevel.next(), valid = False) scenario.h4("The sender hasn't borrow balance, asset price was updated and updated liquidity") - scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next()) + scenario += cmpt.updateAccountLiquidityWithView(alice.address).run(sender = alice, level = bLevel.next(), now=sp.timestamp(100)) scenario.verify(cmpt.data.collaterals[alice.address].contains(cTokenMock.address)) # account membership should exist before cmpt.exitMarket(cTokenMock.address).run(sender = alice, level = bLevel.current()) scenario.verify( (~ cmpt.data.collaterals[alice.address].contains(cTokenMock.address))) # account membership must be removed @@ -358,19 +402,48 @@ def test(): scenario.h2("Test updateAssetPrice") scenario.h3("Update price") oracle.setPrice(2) - scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.next()) + scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.next(), now=sp.timestamp(100)) scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(2e18))) scenario.verify_equal(cmpt.data.markets[listedMarket].updateLevel, bLevel.current()) scenario.h3("Try to update price at the same level") oracle.setPrice(1) - scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.current()) + scenario += cmpt.updateAllAssetPricesWithView().run(sender = bob, level = bLevel.current(), now=sp.timestamp(100)) scenario.verify_equal(cmpt.data.markets[listedMarket].price.mantissa, sp.nat(int(2e18))) scenario.h3("Reject a price timestamp from the future") oracle.setTimestamp(sp.timestamp(101)) scenario += cmpt.updateAllAssetPricesWithView().run( sender = bob, level = bLevel.next(), now = sp.timestamp(100), valid = False, exception = "FUTURE_ASSET_PRICE") + scenario.h3("Reject a zero price timestamp") oracle.setTimestamp(sp.timestamp(0)) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender = bob, level = bLevel.next(), now = sp.timestamp(100), + valid = False, exception = "INVALID_ASSET_PRICE_TIMESTAMP") + scenario.h3("Reject a price timestamp rollback") + oracle.setTimestamp(sp.timestamp(100)) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender = bob, level = bLevel.next(), now = sp.timestamp(100)) + oracle.setTimestamp(sp.timestamp(99)) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender = bob, level = bLevel.next(), now = sp.timestamp(100), + valid = False, exception = "ASSET_PRICE_TIMESTAMP_ROLLBACK") + oracle.clearTimestamp() + scenario.h3("Reject extreme prices outside configured bounds") + scenario += cmpt.setPriceBounds(sp.record(cToken=listedMarket, + minPrice=sp.nat(100000), maxPrice=sp.nat(10000000), + maxChangeBps=sp.nat(2000))).run(sender=admin, level=bLevel.next()) + oracle.setPrice(1) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, + exception="ASSET_PRICE_OUT_OF_BOUNDS") + oracle.setPrice(9000000000000000) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, + exception="ASSET_PRICE_OUT_OF_BOUNDS") + scenario += cmpt.setPriceBounds(sp.record(cToken=listedMarket, + minPrice=sp.nat(1), maxPrice=sp.nat(10**50), + maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) + oracle.setPrice(1) scenario.h2("Test account liquidity") cmpt.enterMarkets(sp.list([cTokenMock.address])).run(sender = bob, level = bLevel.next()) @@ -395,7 +468,7 @@ def test(): scenario.h2("Test admin functionality") scenario.h3("Set price oracle") TestAdminFunctionality.checkAdminRequirementH4(scenario, "set price oracle", bLevel, admin, alice, cmpt.setPriceOracleAndTimeDiff, - sp.record(priceOracle=priceOracle, timeDiff=86400)) + sp.record(priceOracle=priceOracle, timeDiff=300)) scenario.verify(cmpt.data.oracleAddress == priceOracle) scenario.h3("Set close factor") @@ -483,6 +556,9 @@ def test(): mintPaused = sp.bool(False), borrowPaused = sp.bool(False), redeemPaused = sp.bool(False), + liquidatePaused = sp.bool(False), + supplyCap = sp.nat(10**50), + borrowCap = sp.nat(10**50), name = sp.string("extra1"), price = sp.record(mantissa=sp.nat(int(1e18))), priceExp = sp.nat(int(1e18)), @@ -494,6 +570,9 @@ def test(): mintPaused = sp.bool(False), borrowPaused = sp.bool(False), redeemPaused = sp.bool(False), + liquidatePaused = sp.bool(False), + supplyCap = sp.nat(10**50), + borrowCap = sp.nat(10**50), name = sp.string("extra2"), price = sp.record(mantissa=sp.nat(int(1e18))), priceExp = sp.nat(int(1e18)), @@ -554,7 +633,7 @@ def testPauseFunctionsOnMarkets(scenario, actionText, bLevel, sender, callableOb def updateAssetsPrices(scenario, cmpt, bLevel, markets): bLevel.next() - cmpt.updateAllAssetPricesWithView().run(level = bLevel.current()) + cmpt.updateAllAssetPricesWithView().run(level = bLevel.current(), now=sp.timestamp(100)) @sp.add_test(name = "Comptroller_Collateral_Boundaries") @@ -589,6 +668,9 @@ def collateral_boundary_matrix(): mintPaused=sp.bool(False), borrowPaused=sp.bool(False), redeemPaused=sp.bool(False), + liquidatePaused=sp.bool(False), + supplyCap=sp.nat(10**50), + borrowCap=sp.nat(10**50), name=sp.string("boundary"), price=sp.record(mantissa=sp.nat(price)), priceExp=sp.nat(exchange_scale), diff --git a/contracts/tests/GovernanceTest.py b/contracts/tests/GovernanceTest.py index 9dd21bb8..62697529 100644 --- a/contracts/tests/GovernanceTest.py +++ b/contracts/tests/GovernanceTest.py @@ -210,7 +210,7 @@ def testComptroller(scenario, ctoken, bLevel, alice, admin, governor, cmpt, orac scenario.verify(cmpt.data.administrator == governor.address) scenario.h3("Set price oracle") - arg = sp.record(comptroller = cmpt.address, priceOracle = oracle.address, timeDiff=84600) + arg = sp.record(comptroller = cmpt.address, priceOracle = oracle.address, timeDiff=300) TestAdminFunctionality.checkAdminRequirementH4(scenario, "set price oracle", bLevel, admin, alice, governor.setPriceOracleAndTimeDiff, arg) scenario.verify(cmpt.data.oracleAddress == arg.priceOracle) @@ -229,6 +229,15 @@ def testComptroller(scenario, ctoken, bLevel, alice, admin, governor, cmpt, orac TestAdminFunctionality.checkAdminRequirementH4(scenario, "support market", bLevel, admin, alice, governor.supportMarket, arg) scenario.verify(cmpt.data.markets.contains(arg.market.cToken) & cmpt.data.markets[arg.market.cToken].isListed) + scenario.h3("Set price bounds") + boundsArg = sp.record(comptroller=cmpt.address, bounds=sp.record( + cToken=ctoken.address, minPrice=sp.nat(100000), + maxPrice=sp.nat(10000000), maxChangeBps=sp.nat(2000))) + TestAdminFunctionality.checkAdminRequirementH4(scenario, "set price bounds", bLevel, admin, alice, governor.setPriceBounds, boundsArg) + boundsKey = sp.pair(cmpt.address, ctoken.address) + scenario.verify(oracle.data.priceBounds[boundsKey].minPrice == boundsArg.bounds.minPrice) + scenario.verify(oracle.data.priceBounds[boundsKey].maxPrice == boundsArg.bounds.maxPrice) + scenario.h3("Set collateral factor") arg = sp.record(comptroller = cmpt.address, collateralFactor = sp.record(cToken = ctoken.address, newCollateralFactor = sp.nat(2))) TestAdminFunctionality.checkAdminRequirementH4(scenario, "set collateral factor", bLevel, admin, alice, governor.setCollateralFactor, arg) @@ -247,6 +256,17 @@ def testComptroller(scenario, ctoken, bLevel, alice, admin, governor, cmpt, orac TestAdminFunctionality.checkAdminRequirementH4(scenario, "set redeem paused", bLevel, admin, alice, governor.setRedeemPaused, arg) scenario.verify(cmpt.data.markets[arg.tokenState.cToken].redeemPaused == arg.tokenState.state) + scenario.h3("Set liquidation paused") + TestAdminFunctionality.checkAdminRequirementH4(scenario, "set liquidation paused", bLevel, admin, alice, governor.setLiquidatePaused, arg) + scenario.verify(cmpt.data.markets[arg.tokenState.cToken].liquidatePaused == arg.tokenState.state) + + scenario.h3("Set market caps") + capsArg = sp.record(comptroller=cmpt.address, caps=sp.record( + cToken=ctoken.address, supplyCap=sp.nat(1000), borrowCap=sp.nat(500))) + TestAdminFunctionality.checkAdminRequirementH4(scenario, "set market caps", bLevel, admin, alice, governor.setMarketCaps, capsArg) + scenario.verify(cmpt.data.markets[ctoken.address].supplyCap == capsArg.caps.supplyCap) + scenario.verify(cmpt.data.markets[ctoken.address].borrowCap == capsArg.caps.borrowCap) + scenario.h3("Set transfer paused") arg = sp.record(comptroller = cmpt.address, state = sp.bool(False)) TestAdminFunctionality.checkAdminRequirementH4(scenario, "set transfer paused", bLevel, admin, alice, governor.setTransferPaused, arg) diff --git a/contracts/tests/TezFinOracleTest.py b/contracts/tests/TezFinOracleTest.py index cd6e4927..32925a8a 100644 --- a/contracts/tests/TezFinOracleTest.py +++ b/contracts/tests/TezFinOracleTest.py @@ -26,6 +26,33 @@ def getPrice(self, asset, resp): price = sp.compute(sp.snd(oracle_data)) sp.verify(resp == price, "PRICE_MISTMATCH") + @sp.entry_point + def verifyPrice(self, params): + sp.set_type(params, sp.TRecord(asset=sp.TString, price=sp.TNat, + timestamp=sp.TTimestamp)) + oracle_data = sp.view("getPrice", self.contract, params.asset, + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some( + "invalid oracle view call") + sp.verify(sp.fst(oracle_data) == params.timestamp, "TIMESTAMP_MISMATCH") + sp.verify(sp.snd(oracle_data) == params.price, "PRICE_MISTMATCH") + + @sp.entry_point + def verifyValidatedPrice(self, params): + sp.set_type(params, sp.TRecord( + cToken=sp.TAddress, asset=sp.TString, + previousPrice=sp.TNat, previousTimestamp=sp.TTimestamp, + expectedPrice=sp.TNat)) + oracle_data = sp.view( + "getValidatedPrice", self.contract, + sp.record(comptroller=sp.self_address, cToken=params.cToken, + requestedAsset=params.asset, + previousPrice=params.previousPrice, + previousTimestamp=params.previousTimestamp), + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some( + "invalid validated oracle view call") + sp.verify(sp.snd(oracle_data) == params.expectedPrice, + "PRICE_MISTMATCH") + @sp.add_test(name="tezfin_oracle") def test(): @@ -59,6 +86,11 @@ def test(): scenario.h2("Consumer Contract") consumer = View_consumer(tezfinOracle.address) scenario += consumer + market = sp.address("KT10") + tezfinOracle.configureMaxPriceAge(sp.int(300)).run(sender=consumer.address) + tezfinOracle.configurePriceBounds(sp.record( + cToken=market, minPrice=sp.nat(10000), maxPrice=sp.nat(20000), + maxChangeBps=sp.nat(2000))).run(sender=consumer.address) scenario.h3("Verify Price") consumer.getPrice(asset="ETH", resp=13425) consumer.getPrice(asset="BTC", resp=2342354345) @@ -66,12 +98,29 @@ def test(): consumer.getPrice(asset="WTZ", resp=203434) consumer.getPrice(asset="OXTZ", resp=203434) consumer.getPrice(asset="RRXTZ", resp=203434) - consumer.getPrice(asset="USD", resp=1000000) - consumer.getPrice(asset="USD", resp=43000000).run(valid=False) + consumer.verifyPrice(asset="FINUSDT", price=1000000, + 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( + now=sp.timestamp(16534534)) + consumer.verifyValidatedPrice( + cToken=market, asset="ETH-USD", previousPrice=sp.nat(10000), + previousTimestamp=sp.timestamp(16534534), + expectedPrice=sp.nat(13425)).run( + now=sp.timestamp(16534534), valid=False, + exception="ASSET_PRICE_CHANGE_TOO_LARGE") + consumer.verifyValidatedPrice( + cToken=sp.address("KT11"), asset="ETH-USD", + previousPrice=sp.nat(0), previousTimestamp=sp.timestamp(0), + 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) - consumer.getPrice(asset="USD", resp=43000000).run(valid=False) + consumer.getPrice(asset="USD", resp=1000000).run(valid=False) consumer.getPrice(asset="XTZ", resp=43000000).run(valid=False) diff --git a/contracts/tests/mock/CTokenMock.py b/contracts/tests/mock/CTokenMock.py index b858fbc7..b27bef32 100644 --- a/contracts/tests/mock/CTokenMock.py +++ b/contracts/tests/mock/CTokenMock.py @@ -6,7 +6,8 @@ class CTokenMock(sp.Contract): def __init__(self, test_account_snapshot_): self.init(test_account_snapshot = test_account_snapshot_, - snapshot_available=sp.bool(True), comptroller=sp.address("KT10")) + snapshot_available=sp.bool(True), comptroller=sp.address("KT10"), + totalSupplyUnderlying=sp.nat(0), totalBorrows=sp.nat(0)) @sp.entry_point def setAccountSnapshot(self, params): @@ -18,6 +19,12 @@ def setSnapshotAvailable(self, params): sp.set_type(params, sp.TBool) self.data.snapshot_available = params + @sp.entry_point + def setMarketTotals(self, params): + sp.set_type(params, sp.TRecord(supply=sp.TNat, borrows=sp.TNat)) + self.data.totalSupplyUnderlying = params.supply + self.data.totalBorrows = params.borrows + @sp.utils.view(CTI.TAccountSnapshot) def getAccountSnapshot(self, account): sp.set_type(account, sp.TAddress) @@ -35,6 +42,12 @@ def getAccountSnapshotView(self, account): def accrueInterest(self, params): sp.set_type(params, sp.TUnit) + @sp.onchain_view() + def marketTotals(self, params): + sp.set_type(params, sp.TUnit) + sp.result(sp.pair(self.data.totalSupplyUnderlying, + self.data.totalBorrows)) + @sp.entry_point def setComptroller(self, params): sp.set_type(params, sp.TAddress) diff --git a/contracts/tests/mock/OracleMock.py b/contracts/tests/mock/OracleMock.py index 140508bf..88e06608 100644 --- a/contracts/tests/mock/OracleMock.py +++ b/contracts/tests/mock/OracleMock.py @@ -4,7 +4,13 @@ class OracleMock(OracleInterface.OracleInterface): def __init__(self): - self.init(price = sp.nat(0), timestamp = sp.timestamp(0)) + self.init( + price=sp.nat(0), + timestamp=sp.none, + priceBounds=sp.big_map( + l={}, tkey=sp.TPair(sp.TAddress, sp.TAddress), + tvalue=OracleInterface.TPriceBounds), + maxPriceAge=sp.big_map(l={}, tkey=sp.TAddress, tvalue=sp.TInt)) @sp.entry_point def setPrice(self, price): @@ -14,7 +20,33 @@ def setPrice(self, price): @sp.entry_point def setTimestamp(self, timestamp): sp.set_type(timestamp, sp.TTimestamp) - self.data.timestamp = timestamp + self.data.timestamp = sp.some(timestamp) + + @sp.entry_point + def clearTimestamp(self): + self.data.timestamp = sp.none + + @sp.entry_point + def configurePriceBounds(self, params): + sp.set_type(params, OracleInterface.TPriceBounds) + sp.verify((params.minPrice > 0) & + (params.minPrice <= params.maxPrice) & + (params.maxChangeBps <= 10000), + "INVALID_PRICE_BOUNDS") + self.data.priceBounds[sp.pair(sp.sender, params.cToken)] = params + + @sp.entry_point + def configureMaxPriceAge(self, maxPriceAge): + sp.set_type(maxPriceAge, sp.TInt) + sp.verify((maxPriceAge > 0) & (maxPriceAge <= 3600), + "INVALID_MAX_PRICE_TIME_DIFFERENCE") + self.data.maxPriceAge[sp.sender] = maxPriceAge + + def getTimestamp(self): + timestamp = sp.local("timestamp", sp.now) + sp.if self.data.timestamp.is_some(): + timestamp.value = self.data.timestamp.open_some() + return timestamp.value @sp.entry_point def get(self, requestPair): @@ -24,10 +56,41 @@ def get(self, requestPair): requestedAsset = sp.compute(sp.fst(requestPair)) callback = sp.compute(sp.snd(requestPair)) - callbackParam = (requestedAsset, (self.data.timestamp, self.data.price)) + callbackParam = (requestedAsset, (self.getTimestamp(), self.data.price)) sp.transfer(callbackParam, sp.mutez(0), callback) @sp.onchain_view() def getPrice(self, assetCode): sp.set_type(assetCode, sp.TString) - sp.result((self.data.timestamp, self.data.price)) + sp.result((self.getTimestamp(), self.data.price)) + + @sp.onchain_view() + def getValidatedPrice(self, params): + sp.set_type(params, OracleInterface.TValidatedPriceRequest) + configKey = sp.pair(params.comptroller, params.cToken) + sp.verify(self.data.priceBounds.contains(configKey), + "PRICE_BOUNDS_NOT_CONFIGURED") + sp.verify(self.data.maxPriceAge.contains(params.comptroller), + "MAX_PRICE_AGE_NOT_CONFIGURED") + timestamp = self.getTimestamp() + bounds = self.data.priceBounds[configKey] + sp.verify(timestamp > sp.timestamp(0), "INVALID_ASSET_PRICE_TIMESTAMP") + sp.verify(timestamp <= sp.now, "FUTURE_ASSET_PRICE") + sp.verify(sp.now - timestamp <= self.data.maxPriceAge[params.comptroller], + "STALE_ASSET_PRICE") + sp.verify((params.previousTimestamp == sp.timestamp(0)) | + (timestamp >= params.previousTimestamp), + "ASSET_PRICE_TIMESTAMP_ROLLBACK") + sp.verify((self.data.price >= bounds.minPrice) & + (self.data.price <= bounds.maxPrice), + "ASSET_PRICE_OUT_OF_BOUNDS") + sp.if params.previousTimestamp != sp.timestamp(0): + priceChange = sp.local("priceChange", sp.nat(0)) + sp.if self.data.price >= params.previousPrice: + priceChange.value = sp.as_nat(self.data.price - params.previousPrice) + sp.else: + priceChange.value = sp.as_nat(params.previousPrice - self.data.price) + sp.verify(priceChange.value * 10000 <= + params.previousPrice * bounds.maxChangeBps, + "ASSET_PRICE_CHANGE_TOO_LARGE") + sp.result((timestamp, self.data.price)) diff --git a/deploy/compile_targets/CompileTzBTC.py b/deploy/compile_targets/CompileTzBTC.py index c63d2b82..f579c564 100644 --- a/deploy/compile_targets/CompileTzBTC.py +++ b/deploy/compile_targets/CompileTzBTC.py @@ -15,7 +15,7 @@ sp.add_compilation_target("CtzBTC", CFA12.CFA12( comptroller_ = sp.address(CFG.deployResult.Comptroller), interestRateModel_ = sp.address(CFG.deployResult.CtzBTC_IRM), - initialExchangeRateMantissa_ = sp.nat(CFG.CFA2.initialExchangeRateMantissa), + initialExchangeRateMantissa_ = sp.nat(CFG.CFA12.initialExchangeRateMantissa), administrator_ = sp.address(CFG.deployResult.Governance), # specify metadata before deployment metadata_ = sp.big_map({ diff --git a/deploy/compile_targets/tests/test_irm_wiring.py b/deploy/compile_targets/tests/test_irm_wiring.py index 884814f2..21fb0147 100644 --- a/deploy/compile_targets/tests/test_irm_wiring.py +++ b/deploy/compile_targets/tests/test_irm_wiring.py @@ -59,6 +59,12 @@ def main(): f'{sorted(referencedIrmKeys)}; expected exactly {expectedIrmKey}.' ) + if fileName == 'CompileTzBTC.py' and 'CFG.CFA12.initialExchangeRateMantissa' not in source: + failures.append( + 'CompileTzBTC.py (CtzBTC market): expected the FA1.2 initial ' + 'exchange rate from CFG.CFA12.' + ) + if failures: print('IRM wiring check FAILED:') for failure in failures: diff --git a/deploy/compile_targets/tests/test_operation_size.py b/deploy/compile_targets/tests/test_operation_size.py index d11e683b..cd255d08 100644 --- a/deploy/compile_targets/tests/test_operation_size.py +++ b/deploy/compile_targets/tests/test_operation_size.py @@ -33,7 +33,7 @@ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) PLACEHOLDER_MANIFEST = os.path.join(REPO_ROOT, 'e2e', 'deploy_result', 'deploy.json') -DEFAULT_MAX_TOTAL_BYTES = 32000 +DEFAULT_MAX_TOTAL_BYTES = 32768 # Compile target file -> (compiled contract directory name, extra SmartPy CLI flags). # The extra flags MUST match exactly what deploy_previewnet.sh/deploy_mainnet.sh pass diff --git a/deploy/compile_targets/tests/test_reproducible_build.py b/deploy/compile_targets/tests/test_reproducible_build.py new file mode 100644 index 00000000..eb6b609f --- /dev/null +++ b/deploy/compile_targets/tests/test_reproducible_build.py @@ -0,0 +1,138 @@ +"""Compile production-critical contracts twice and compare canonical artifacts.""" + +import hashlib +import json +import os +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +BASE_PLACEHOLDER_MANIFEST = os.path.join(REPO_ROOT, 'e2e', 'deploy_result', 'deploy.json') +COMPILE_TARGETS = { + 'CompileGovernance.py': (['Governance'], []), + 'CompileTezFinOracle.py': (['TezFinOracle'], []), + 'CompileComptroller.py': ( + ['Comptroller'], + ['--erase-comments', '--erase-var-annots', '--initial-cast'], + ), + 'CompileIRMs.py': (['CFA12_IRM', 'CFA2_IRM', 'CXTZ_IRM'], []), + 'CompileCtzBTC_IRM.py': (['CtzBTC_IRM'], []), + 'CompileCUSDt.py': ( + ['CUSDt'], + ['--erase-comments', '--erase-var-annots', '--initial-cast'], + ), + 'CompileCXTZ.py': ( + ['CXTZ'], + ['--erase-comments', '--erase-var-annots', '--initial-cast'], + ), + 'CompileTzBTC.py': ( + ['CtzBTC'], + ['--erase-comments', '--erase-var-annots', '--initial-cast'], + ), +} +PLACEHOLDER_ADDRESS = 'KT1ENe4jbDE1QVG1euryp23GsAeWuEwJutQX' + + +def find_smartpy_cli(): + if len(sys.argv) > 1: + return os.path.abspath(os.path.expanduser(sys.argv[1])) + return os.path.abspath(os.path.expanduser(os.environ.get('SMARTPY_CLI', '~/smartpy-cli/SmartPy.sh'))) + + +def find_single_file(directory, suffix): + matches = sorted(name for name in os.listdir(directory) if name.endswith(suffix)) + if len(matches) != 1: + raise RuntimeError(f'Expected one *{suffix} under {directory}, found {len(matches)}') + return os.path.join(directory, matches[0]) + + +def canonical_json_hash(file_path): + with open(file_path, encoding='utf-8') as source: + value = json.load(source) + encoded = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode() + return hashlib.sha256(encoded).hexdigest() + + +def compile_once(smartpy, output_dir, manifest_path): + hashes = {} + for file_name, (contract_names, extra_flags) in COMPILE_TARGETS.items(): + target_path = os.path.join(REPO_ROOT, 'deploy', 'compile_targets', file_name) + result = subprocess.run( + [smartpy, 'compile', target_path, output_dir, '--purge', + '--protocol', 'kathmandu', *extra_flags], + cwd=REPO_ROOT, + env={**os.environ, 'DEPLOY_MANIFEST': manifest_path}, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f'{file_name} compilation failed with exit {result.returncode}:\n' + f'{result.stderr.strip()[-4000:]}' + ) + + for contract_name in contract_names: + contract_dir = os.path.join(output_dir, contract_name) + hashes[contract_name] = { + 'contractSha256': canonical_json_hash( + find_single_file(contract_dir, '_contract.json') + ), + 'storageSha256': canonical_json_hash( + find_single_file(contract_dir, '_storage.json') + ), + } + return hashes + + +def write_placeholder_manifest(path): + with open(BASE_PLACEHOLDER_MANIFEST, encoding='utf-8') as source: + manifest = json.load(source) + for key in ('USDt', 'tzBTC', 'CtzBTC_IRM'): + manifest.setdefault(key, PLACEHOLDER_ADDRESS) + with open(path, 'w', encoding='utf-8') as destination: + json.dump(manifest, destination, sort_keys=True) + + +def main(): + smartpy = find_smartpy_cli() + if not os.path.isfile(smartpy): + raise RuntimeError(f'SmartPy CLI not found at {smartpy}') + if not os.path.isfile(BASE_PLACEHOLDER_MANIFEST): + raise RuntimeError(f'Placeholder manifest not found at {BASE_PLACEHOLDER_MANIFEST}') + + with tempfile.TemporaryDirectory(prefix='tezfin_repro_a_') as first_dir, \ + tempfile.TemporaryDirectory(prefix='tezfin_repro_b_') as second_dir, \ + tempfile.NamedTemporaryFile(mode='w', suffix='.json') as manifest_file: + write_placeholder_manifest(manifest_file.name) + first_hashes = compile_once(smartpy, first_dir, manifest_file.name) + second_hashes = compile_once(smartpy, second_dir, manifest_file.name) + + if first_hashes != second_hashes: + print('[ERROR] SmartPy builds are not reproducible:', file=sys.stderr) + print(json.dumps({'first': first_hashes, 'second': second_hashes}, indent=2), + file=sys.stderr) + return 1 + + output = { + 'smartpyVersion': '0.16.0', + 'protocol': 'kathmandu', + 'contracts': first_hashes, + } + output_path = os.environ.get('COMPILED_HASHES_OUTPUT') + if output_path: + with open(output_path, 'w', encoding='utf-8') as destination: + json.dump(output, destination, indent=2, sort_keys=True) + destination.write('\n') + + print(json.dumps(output, indent=2, sort_keys=True)) + print('[INFO] Two clean SmartPy builds produced identical canonical hashes.') + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f'[ERROR] {error}', file=sys.stderr) + sys.exit(1) diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index 14f91d61..b0071877 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -19,6 +19,7 @@ const { diffAddressBindings, resolveDeployResultPath, verifyExistingContract, + enforceDeploymentPreflight, } = require('../util.js'); const { checkNetworkExpectation, MAINNET_CHAIN_IDS } = require('../assert_network.js'); const { findMissingCanonicalKeys, verifyAgainstAllowlist, REQUIRED_CANONICAL_KEYS, VETTED_MAINNET_ADDRESSES } = require('../mainnet_preflight.js'); @@ -331,6 +332,23 @@ test('mainnetPreflight: findMissingCanonicalKeys flags every missing required ke assert.deepEqual(missing, REQUIRED_CANONICAL_KEYS.filter((key) => key !== 'PriceOracle')); }); +test('raw deployment path: mainnet requires preflight', async () => { + let receivedPath; + await enforceDeploymentPreflight('/tmp/deploy.mainnet.json', 'mainnet', 'NetXdQprcVkpaWU', async (manifestPath) => { + receivedPath = manifestPath; + }); + assert.equal(receivedPath, '/tmp/deploy.mainnet.json'); +}); + +test('raw deployment path: mainnet preflight failure blocks deployment', async () => { + await assert.rejects( + enforceDeploymentPreflight('/tmp/deploy.mainnet.json', 'previewnet', 'NetXdQprcVkpaWU', async () => { + throw new Error('MAINNET_DEPLOY_CONFIRM is required'); + }), + /MAINNET_DEPLOY_CONFIRM is required/, + ); +}); + test('mainnetPreflight: findMissingCanonicalKeys reports nothing when all keys present', () => { const fullManifest = Object.fromEntries(REQUIRED_CANONICAL_KEYS.map((key) => [key, `KT1${key}`])); assert.deepEqual(findMissingCanonicalKeys(fullManifest), []); @@ -365,22 +383,26 @@ test('mainnetPreflight: verifyAgainstAllowlist rejects an address that does not test('mainnet oracle guard: accepts a current nonzero price', () => { const response = { data: { args: [{ int: '226300' }, { int: '1784510000' }] } }; - assert.deepEqual(parsePriceResult('XTZUSDT', response, 1784510030, 86400), { + assert.deepEqual(parsePriceResult('XTZUSDT', response, 1784510030, 300), { asset: 'XTZUSDT', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000, ageSeconds: 30, }); }); -test('mainnet oracle guard: accepts millisecond timestamps and rejects stale or zero prices', () => { - assert.deepEqual( - parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510000000' }] } }, 1784510030, 86400), - { asset: 'XTZUSDT', price: 226300, timestamp: 1784510000, rawTimestamp: 1784510000000, ageSeconds: 30 }, +test('mainnet oracle guard: rejects milliseconds, future timestamps, stale prices, and zero prices', () => { + assert.throws( + () => parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510000000' }] } }, 1784510030, 300), + /Unix seconds, not milliseconds/, + ); + assert.throws( + () => parsePriceResult('XTZUSDT', { data: { args: [{ int: '226300' }, { int: '1784510031' }] } }, 1784510030, 300), + /ahead of the mainnet head/, ); assert.throws( - () => parsePriceResult('BTCUSDT', { data: { args: [{ int: '0' }, { int: '1784510000' }] } }, 1784510030, 86400), + () => parsePriceResult('TZBTCUSDT', { data: { args: [{ int: '0' }, { int: '1784510000' }] } }, 1784510030, 300), /invalid or zero price/, ); assert.throws( - () => parsePriceResult('BTCUSDT', { data: { args: [{ int: '1' }, { int: '1784400000' }] } }, 1784510030, 86400), + () => parsePriceResult('TZBTCUSDT', { data: { args: [{ int: '1' }, { int: '1784400000' }] } }, 1784510030, 300), /price is stale/, ); }); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index b45b00c6..f55517ca 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -370,6 +370,15 @@ function checkChainIdMatch(expectedChainId, actualChainId, context) { } } +async function enforceDeploymentPreflight(deployResultPath, networkProfile, chainId, preflight) { + const isMainnet = networkProfile === 'mainnet' || chainId === 'NetXdQprcVkpaWU'; + if (!isMainnet) { + return; + } + const runPreflight = preflight || require('./mainnet_preflight.js').mainnetPreflight; + await runPreflight(deployResultPath); +} + // Verify that a manifest entry still points at a live, matching contract before we // trust it enough to skip re-deploying. This prevents silently reusing a stale or // unrelated address just because a key happens to exist in the manifest (e.g. a @@ -421,6 +430,9 @@ async function verifyExistingContract(tezos, address, expectedCode, expectedStor async function runDeployment(compiledContractsPath, deployResultPath) { const { tezos, publicKeyHash, chainId } = await createTezosClient(); + await enforceDeploymentPreflight( + deployResultPath, config.networkProfile, chainId, + ); console.log(`[INFO] Deploying from ${publicKeyHash} to ${config.tezosNode} (${chainId})`); const directories = getDirectories(compiledContractsPath).sort(); @@ -534,4 +546,5 @@ module.exports = { extractAddressBindings, diffAddressBindings, verifyExistingContract, + enforceDeploymentPreflight, } diff --git a/deploy/deploy_script/verify_mainnet_oracle.js b/deploy/deploy_script/verify_mainnet_oracle.js index b9e35939..7865de23 100644 --- a/deploy/deploy_script/verify_mainnet_oracle.js +++ b/deploy/deploy_script/verify_mainnet_oracle.js @@ -2,9 +2,8 @@ const fs = require('fs'); const { config, resolveDeployResultPath } = require('./util.js'); -const ASSETS = ['XTZUSDT', 'BTCUSDT']; -const DEFAULT_MAX_AGE_SECONDS = 86400; -const MAX_FUTURE_SKEW_SECONDS = 300; +const ASSETS = ['XTZUSDT', 'USDTUSDT', 'TZBTCUSDT']; +const DEFAULT_MAX_AGE_SECONDS = 300; async function rpcJson(rpc, pathname, options = {}) { const response = await fetch(`${rpc.replace(/\/$/, '')}${pathname}`, options); @@ -25,11 +24,12 @@ function parsePriceResult(asset, response, headTimestamp, maxAgeSeconds) { throw new Error(`${asset} returned an invalid timestamp: ${args?.[1]?.int}`); } - // The Youves/Acurast feed encodes Unix milliseconds in its timestamp field. - // Native Tezos feeds normally encode seconds, so accept either representation. - const timestamp = rawTimestamp > 100000000000 ? Math.floor(rawTimestamp / 1000) : rawTimestamp; + if (rawTimestamp > 100000000000) { + throw new Error(`${asset} timestamp must use Unix seconds, not milliseconds: ${rawTimestamp}`); + } + const timestamp = rawTimestamp; const ageSeconds = headTimestamp - timestamp; - if (ageSeconds < -MAX_FUTURE_SKEW_SECONDS) { + if (ageSeconds < 0) { throw new Error( `${asset} timestamp ${rawTimestamp} is ${Math.abs(ageSeconds)} seconds ahead of the mainnet head.`, ); diff --git a/tools/install-smartpy.sh b/tools/install-smartpy.sh new file mode 100755 index 00000000..5901fbbd --- /dev/null +++ b/tools/install-smartpy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SMARTPY_VERSION="0.16.0" +readonly SMARTPY_ARCHIVE_URL="https://legacy.smartpy.io/cli/smartpy-cli.tar.gz" +readonly SMARTPY_ARCHIVE_SHA256="615e60659f3550d9d50623ab8e6390ba75a0e4bbd4ebb00bed1aab41743bc392" +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly RUNTIME_DIR="${SCRIPT_DIR}/smartpy" + +prefix="${1:-${HOME}/smartpy-cli}" +archive="$(mktemp)" +trap 'rm -f "${archive}"' EXIT + +curl --fail --silent --show-error --location "${SMARTPY_ARCHIVE_URL}" --output "${archive}" +if command -v shasum >/dev/null; then + printf '%s %s\n' "${SMARTPY_ARCHIVE_SHA256}" "${archive}" | shasum -a 256 --check --status +else + printf '%s %s\n' "${SMARTPY_ARCHIVE_SHA256}" "${archive}" | sha256sum --check --status +fi + +rm -rf "${prefix}" +mkdir -p "${prefix}" +tar xzf "${archive}" -C "${prefix}" +rm -f "${prefix}/smartpyc" + +cp "${RUNTIME_DIR}/package.json" "${RUNTIME_DIR}/package-lock.json" "${prefix}/" +npm --prefix "${prefix}" ci --ignore-scripts --no-audit --no-fund + +actual_version="$("${prefix}/SmartPy.sh" --version)" +if [[ "${actual_version}" != "SmartPy Version: ${SMARTPY_VERSION}" ]]; then + printf 'Expected SmartPy %s, got: %s\n' "${SMARTPY_VERSION}" "${actual_version}" >&2 + exit 1 +fi + +printf 'Installed verified SmartPy %s at %s\n' "${SMARTPY_VERSION}" "${prefix}" diff --git a/tools/smartpy/package-lock.json b/tools/smartpy/package-lock.json new file mode 100644 index 00000000..a43eece8 --- /dev/null +++ b/tools/smartpy/package-lock.json @@ -0,0 +1,108 @@ +{ + "name": "tezfin-smartpy-runtime", + "version": "0.16.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tezfin-smartpy-runtime", + "version": "0.16.0", + "dependencies": { + "@smartpy/timelock": "0.0.9", + "bs58check": "4.0.0", + "js-sha3": "0.9.3", + "libsodium-wrappers-sumo": "0.8.4", + "tezos-bls12-381": "1.1.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@smartpy/hacl-wasm": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@smartpy/hacl-wasm/-/hacl-wasm-0.0.3.tgz", + "integrity": "sha512-qKDOmdxHYyvEYbhMRMrPeehggM9iYPIukpOQl5M0kdYVKY3pH8YftonYxRJx5sYZ+CFqjNKj2pCk31pzTZsA1Q==", + "license": "Apache-2.0" + }, + "node_modules/@smartpy/timelock": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@smartpy/timelock/-/timelock-0.0.9.tgz", + "integrity": "sha512-opX43ZPK7gXCkrcUV/efykHx1efsAgw5ItZQ6PwHBS+SVl25CYc6Bz/r3z/gPELgxNh9KKKadYxAEWNekWJxMQ==", + "license": "MIT", + "dependencies": { + "@smartpy/hacl-wasm": "^0.0.3" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, + "node_modules/js-sha3": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", + "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==", + "license": "MIT" + }, + "node_modules/libsodium-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz", + "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz", + "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" + } + }, + "node_modules/noble-bls12-381": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/noble-bls12-381/-/noble-bls12-381-0.11.1.tgz", + "integrity": "sha512-72jd9c1VwGQz4y/OKgdNNKDDGNDkAInka6iiq7hVHCiqPWH4DTlW8KwDfvpsVnbK8t+5Sg6mG7r0CRqiUWwP7g==", + "deprecated": "Switch to \"@noble/curves\" for security updates", + "license": "MIT" + }, + "node_modules/tezos-bls12-381": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tezos-bls12-381/-/tezos-bls12-381-1.1.1.tgz", + "integrity": "sha512-MFaxhyRON/T/SbQWZnFcTZo8kSxhdP0IY9zfeMxHWUT+StgoEzJltAEYxiiSlQQqyLJyd67YsCGmGBqW+4mJSA==", + "license": "MIT", + "dependencies": { + "noble-bls12-381": "0.11.1" + } + } + } +} diff --git a/tools/smartpy/package.json b/tools/smartpy/package.json new file mode 100644 index 00000000..c491385e --- /dev/null +++ b/tools/smartpy/package.json @@ -0,0 +1,12 @@ +{ + "name": "tezfin-smartpy-runtime", + "version": "0.16.0", + "private": true, + "dependencies": { + "@smartpy/timelock": "0.0.9", + "bs58check": "4.0.0", + "js-sha3": "0.9.3", + "libsodium-wrappers-sumo": "0.8.4", + "tezos-bls12-381": "1.1.1" + } +} From a189f33424291e64d75fcbbe90703c712e761b70 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Thu, 23 Jul 2026 13:09:59 +0300 Subject: [PATCH 16/19] feat: harden market caps, oracle updates, and mainnet deployment --- .github/workflows/ci.yml | 3 + README.md | 21 ++- contracts/Comptroller.py | 79 +++++---- contracts/tests/CapPostStateTest.py | 158 ++++++++++++++++++ contracts/tests/ComptrollerTest.py | 42 ++++- .../tests/test_mainnet_governance_payload.py | 98 +++++++++++ .../deploy_script/test/deploy_guards.test.js | 35 +++- deploy/deploy_script/util.js | 4 +- deploy/deploy_script/verify_mainnet_oracle.js | 8 +- deploy/shell_scripts/deploy_mainnet.sh | 1 - docs/MainnetGovernancePayloads.json | 109 ++++++++++-- 11 files changed, 509 insertions(+), 49 deletions(-) create mode 100644 contracts/tests/CapPostStateTest.py create mode 100644 deploy/compile_targets/tests/test_mainnet_governance_payload.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f279e337..8feb6dba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - name: "Check deploy pipeline wiring (compile targets, IRM config source, manifest path parity)" run: | python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py + - name: "Check mainnet governance payload" + run: | + python3 deploy/compile_targets/tests/test_mainnet_governance_payload.py - name: "Check contract origination sizes stay within safety threshold" run: | python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh diff --git a/README.md b/README.md index 41fbea7a..215e9070 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ cd TezFin ./contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.sh ``` +The contract suite includes `CapPostStateTest.py`, which exercises supply and +borrow caps through real ꜰToken `mint` and `borrow` entrypoints at below-cap, +exact-cap, and cap-plus-one boundaries, including exchange-rate rounding. + ## Required Deployment Tests These checks guard the deployment pipeline itself (not the contracts' business logic) @@ -113,6 +117,15 @@ and all run offline/in CI without needing a live Tezos node: ```sh python3 deploy/compile_targets/tests/test_deploy_pipeline_wiring.py ``` +- **Mainnet governance payload** + (`deploy/compile_targets/tests/test_mainnet_governance_payload.py`) - validates + that the checked-in manifest uses an oracle max age accepted by the contract, + configures price bounds and market caps before activation, keeps unapproved + markets fail-closed, and controls mint, borrow, redeem, and liquidation + independently. + ```sh + python3 deploy/compile_targets/tests/test_mainnet_governance_payload.py + ``` - **Contract origination size threshold** (`deploy/compile_targets/tests/test_operation_size.py`) - performs a **fresh SmartPy compile** (not a read of whatever is checked into `compiled_contracts/`, which can be @@ -225,6 +238,9 @@ npm run prepare:deploy canonical inputs, and any addresses already recorded in the manifest), and requires `MAINNET_DEPLOY_CONFIRM=yes` to proceed past that point. Only after this passes does `prepare.js` run and write `OriginatorAddress` to the manifest — declining confirmation leaves the manifest untouched. +- Before every mainnet origination, the programmatic preflight in `util.js` also runs + `verify_mainnet_oracle.js`. This applies to both the shell script and raw `node deploy.js` usage and + rejects missing views, zero prices, stale or future timestamps, and millisecond timestamps. - After origination it reminds you to complete the [Post-Deployment Admin Handoff](#post-deployment-admin-handoff-mainnet) before unpausing any market. @@ -287,8 +303,9 @@ feed with `set_oracle`. `CompileTestData.py` at all). Put the exact address of the vetted production Harbinger (or Harbinger-compatible) oracle directly under the `PriceOracle` key in the mainnet manifest (`DEPLOY_MANIFEST`) before running `deploy_mainnet.sh`; `mainnet_preflight.js` verifies it exists - on-chain before anything is compiled. `verify_mainnet_oracle.js` also executes the exact XTZ and - BTC views before confirmation and rejects zero, stale, or future/millisecond timestamps. Document, + on-chain before anything is compiled. The mandatory programmatic deployment preflight executes the + exact XTZ, USDT, and tzBTC views before origination and rejects zero, stale, or + future/millisecond timestamps. Document, alongside the mainnet manifest, which oracle instance/administrator is being used and who controls it — this project does not deploy or administer that upstream feed itself. diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index 64ac390b..3871e14c 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -162,7 +162,7 @@ def mintAllowed(self, params): marketTotals = sp.view("marketTotals", params.cToken, sp.unit, t=sp.TPair(sp.TNat, sp.TNat)).open_some( "INVALID MARKET TOTALS VIEW") - sp.verify(sp.fst(marketTotals) + params.mintAmount <= + sp.verify(sp.fst(marketTotals) <= self.data.markets[params.cToken].supplyCap, EC.CMPT_SUPPLY_CAP_EXCEEDED) self.invalidateLiquidity(params.minter) @@ -264,7 +264,7 @@ def borrowAllowed(self, params): marketTotals = sp.view("marketTotals", params.cToken, sp.unit, t=sp.TPair(sp.TNat, sp.TNat)).open_some( "INVALID MARKET TOTALS VIEW") - sp.verify(sp.snd(marketTotals) + params.borrowAmount <= + sp.verify(sp.snd(marketTotals) <= self.data.markets[params.cToken].borrowCap, EC.CMPT_BORROW_CAP_EXCEEDED) sp.if self.isNewAssetForUser(params.borrower, params.cToken): @@ -277,6 +277,15 @@ def borrowAllowed(self, params): EC.CMPT_INVALID_BORROW_SENDER) sp.if sp.sender == params.cToken: self.addToLoans(params.cToken, params.borrower) + sp.transfer(params.cToken, sp.mutez(0), sp.self_entry_point( + "updateAssetPriceWithView")) + sp.transfer(params, sp.mutez(0), sp.self_entry_point( + "completeBorrowAllowed")) + + @sp.entry_point + def completeBorrowAllowed(self, params): + sp.set_type(params, CMPTInterface.TBorrowAllowedParams) + sp.verify(sp.sender == sp.self_address, EC.CMPT_INVALID_BORROW_SENDER) self.checkInsuffLiquidityInternal( params.cToken, params.borrower, params.borrowAmount) self.invalidateLiquidity(params.borrower) @@ -375,23 +384,32 @@ def updateAllAssetPricesWithView(self): def updateAllAssetPrices(self): sp.for asset in self.data.marketNameToAddress.values(): - sp.if self.data.markets[asset].isListed & (self.data.markets[asset].updateLevel < sp.level): - previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp - pricePair = sp.local("pricePair", - sp.view("getValidatedPrice", self.data.oracleAddress, - sp.record(comptroller=sp.self_address, - cToken=asset, - requestedAsset=self.data.markets[asset].name + "-USD", - previousPrice=previousRawPrice, - previousTimestamp=self.data.markets[asset].priceTimestamp), - t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") - ) - priceTimestamp = sp.fst(pricePair.value) - rawPrice = sp.snd(pricePair.value) - self.data.markets[asset].price = self.makeExp( - rawPrice*self.data.markets[asset].priceExp) - self.data.markets[asset].priceTimestamp = priceTimestamp - self.data.markets[asset].updateLevel = sp.level + sp.if self.data.markets[asset].isListed: + sp.transfer(asset, sp.mutez(0), sp.self_entry_point( + "updateAssetPriceWithView")) + + @sp.entry_point + def updateAssetPriceWithView(self, asset): + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") + sp.set_type(asset, sp.TAddress) + self.verifyMarketListed(asset) + sp.if self.data.markets[asset].updateLevel < sp.level: + previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp + pricePair = sp.local("pricePair", + sp.view("getValidatedPrice", self.data.oracleAddress, + sp.record(comptroller=sp.self_address, + cToken=asset, + requestedAsset=self.data.markets[asset].name + "-USD", + previousPrice=previousRawPrice, + previousTimestamp=self.data.markets[asset].priceTimestamp), + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") + ) + priceTimestamp = sp.fst(pricePair.value) + rawPrice = sp.snd(pricePair.value) + self.data.markets[asset].price = self.makeExp( + rawPrice*self.data.markets[asset].priceExp) + self.data.markets[asset].priceTimestamp = priceTimestamp + self.data.markets[asset].updateLevel = sp.level def getAssetPrice(self, asset): sp.verify(sp.level == self.data.markets[asset].updateLevel, EC.CMPT_UPDATE_PRICE) @@ -407,8 +425,19 @@ def updateAccountLiquidityWithView(self, account): sp.verify(sp.amount == sp.utils.nat_to_mutez( 0), "TEZ_TRANSFERED") sp.set_type(account, sp.TAddress) - self.updateAllAssetPrices() - self.accrueAllAssetInterests() + accountAssets = sp.local("accountAssets", sp.set(t=sp.TAddress)) + sp.if self.data.collaterals.contains(account): + sp.for asset in self.data.collaterals[account].elements(): + accountAssets.value.add(asset) + sp.if self.data.loans.contains(account): + sp.for asset in self.data.loans[account].elements(): + accountAssets.value.add(asset) + sp.for asset in accountAssets.value.elements(): + sp.transfer(asset, sp.mutez(0), sp.self_entry_point( + "updateAssetPriceWithView")) + sp.for asset in accountAssets.value.elements(): + sp.transfer(sp.unit, sp.mutez(0), sp.contract( + sp.TUnit, asset, entry_point="accrueInterest").open_some()) self.activateOp(OP.ComptrollerOperations.SET_LIQUIDITY) sp.transfer(account, sp.mutez(0), sp.self_entry_point( "setAccountLiquidityWithView")) @@ -432,12 +461,6 @@ def setAccountLiquidityWithView(self, account): valid=True ) - def accrueAllAssetInterests(self): - sp.for asset in self.data.marketNameToAddress.values(): - sp.if self.data.markets[asset].isListed: - sp.transfer(sp.unit, sp.mutez(0), sp.contract( - sp.TUnit, asset, entry_point="accrueInterest").open_some()) - """ Determine what the account liquidity would be if the given amounts were redeemed/borrowed, updates asset prices and accrues interests if stale @@ -448,7 +471,6 @@ def accrueAllAssetInterests(self): # def getHypoAccountLiquidity(self, params): # sp.set_type(params, CMPTInterface.TGetAccountLiquidityParams) # self.updateAllAssetPrices() - # self.accrueAllAssetInterests() # sp.transfer((params.data, params.callback), sp.mutez( # 0), sp.self_entry_point("returnHypoAccountLiquidity")) @@ -874,6 +896,7 @@ def supportMarket(self, params): name=sp.TString, priceExp=sp.TNat)) self.verifyAdministrator() self.verifyMarketNotListed(params.cToken) + sp.verify(params.priceExp > 0, "INVALID_PRICE_EXP") self.data.markets[params.cToken] = sp.record(isListed=sp.bool(True), collateralFactor=self.makeExp( sp.nat(DEFAULT_COLLATERAL_FACTOR)), diff --git a/contracts/tests/CapPostStateTest.py b/contracts/tests/CapPostStateTest.py new file mode 100644 index 00000000..68425485 --- /dev/null +++ b/contracts/tests/CapPostStateTest.py @@ -0,0 +1,158 @@ +import json + +import smartpy as sp + +CMPT = sp.io.import_script_from_url("file:contracts/Comptroller.py") +CToken = sp.io.import_script_from_url("file:contracts/CToken.py") +IRM = sp.io.import_script_from_url( + "file:contracts/tests/mock/InterestRateModelMock.py") + + +class CapTestComptroller(CMPT.Comptroller): + def __init__(self, administrator_): + CMPT.Comptroller.__init__( + self, + administrator_=administrator_, + oracleAddress_=sp.address("KT10"), + closeFactorMantissa_=sp.nat(0), + liquidationIncentiveMantissa_=sp.nat(0)) + + @sp.entry_point + def setMarketPriceForTest(self, cToken): + sp.set_type(cToken, sp.TAddress) + self.data.markets[cToken].price.mantissa = sp.nat(int(1e18)) + self.data.markets[cToken].updateLevel = sp.level + + @sp.entry_point + def setLiquidityForTest(self, params): + sp.set_type(params, sp.TRecord(account=sp.TAddress, liquidity=sp.TInt)) + self.data.account_liquidity[params.account] = sp.record( + liquidity=params.liquidity, + updateLevel=sp.level, + valid=sp.bool(True)) + + +class CapTestCToken(CToken.CToken): + def __init__(self, comptroller_, interestRateModel_, administrator_, + initialExchangeRateMantissa_): + CToken.CToken.__init__( + self, + comptroller_, + interestRateModel_, + initialExchangeRateMantissa_, + administrator_, + sp.big_map({ + "": sp.utils.bytes_of_string("tezos-storage:data"), + "data": sp.utils.bytes_of_string(json.dumps({"name": "cap test"})) + }), + { + "name": sp.utils.bytes_of_string("Cap test token"), + "symbol": sp.utils.bytes_of_string("CAP"), + "decimals": sp.utils.bytes_of_string("18") + }, + cash=sp.nat(0)) + + def getCashImpl(self): + return self.data.cash + + def doTransferIn(self, from_, amount): + self.data.cash += amount + + def doTransferOut(self, to_, amount, isContract=False): + self.data.cash = sp.as_nat(self.data.cash - amount) + + +@sp.add_test(name="Cap_Post_State_Boundaries") +def test(): + scenario = sp.test_scenario() + scenario.add_flag("protocol", "lima") + + admin = sp.test_account("cap admin") + supplier = sp.test_account("cap supplier") + borrower = sp.test_account("cap borrower") + + irm = IRM.InterestRateModelMock( + borrowRate_=sp.nat(0), supplyRate_=sp.nat(0)) + scenario += irm + comptroller = CapTestComptroller(administrator_=admin.address) + scenario += comptroller + cToken = CapTestCToken( + comptroller_=comptroller.address, + interestRateModel_=irm.address, + administrator_=admin.address, + initialExchangeRateMantissa_=sp.nat(int(1e18))) + scenario += cToken + + scenario += comptroller.supportMarket(sp.record( + cToken=cToken.address, name="CAP", priceExp=sp.nat(1))).run( + sender=admin, level=1) + scenario += comptroller.setMintPaused(sp.record( + cToken=cToken.address, state=False)).run(sender=admin, level=1) + scenario += comptroller.setBorrowPaused(sp.record( + cToken=cToken.address, state=False)).run(sender=admin, level=1) + scenario += comptroller.setMarketCaps(sp.record( + cToken=cToken.address, supplyCap=sp.nat(200), + borrowCap=sp.nat(100))).run(sender=admin, level=1) + scenario += cToken.accrueInterest().run(sender=supplier, level=1) + + scenario.h2("Supply cap uses the post-mint total exactly once") + scenario += cToken.mint(sp.nat(80)).run(sender=supplier, level=1) + scenario += cToken.mint(sp.nat(120)).run(sender=supplier, level=1) + scenario.verify(cToken.data.totalSupply == sp.nat(200)) + scenario += cToken.mint(sp.nat(1)).run( + sender=supplier, level=1, valid=False, + exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) + scenario.verify(cToken.data.totalSupply == sp.nat(200)) + + scenario.h2("Borrow cap uses the post-borrow total exactly once") + scenario += comptroller.setMarketPriceForTest(cToken.address).run(level=1) + scenario += comptroller.setLiquidityForTest(sp.record( + account=borrower.address, liquidity=sp.int(10**30))).run(level=1) + scenario += cToken.borrow(sp.nat(40)).run(sender=borrower, level=1) + scenario += comptroller.setLiquidityForTest(sp.record( + account=borrower.address, liquidity=sp.int(10**30))).run(level=1) + scenario += cToken.borrow(sp.nat(60)).run(sender=borrower, level=1) + scenario.verify(cToken.data.totalBorrows == sp.nat(100)) + scenario += comptroller.setLiquidityForTest(sp.record( + account=borrower.address, liquidity=sp.int(10**30))).run(level=1) + scenario += cToken.borrow(sp.nat(1)).run( + sender=borrower, level=1, valid=False, + exception=CMPT.EC.CMPT_BORROW_CAP_EXCEEDED) + scenario.verify(cToken.data.totalBorrows == sp.nat(100)) + + +@sp.add_test(name="Supply_Cap_Exchange_Rate_Rounding") +def exchange_rate_rounding_test(): + scenario = sp.test_scenario() + scenario.add_flag("protocol", "lima") + + admin = sp.test_account("rounding admin") + supplier = sp.test_account("rounding supplier") + irm = IRM.InterestRateModelMock( + borrowRate_=sp.nat(0), supplyRate_=sp.nat(0)) + scenario += irm + comptroller = CapTestComptroller(administrator_=admin.address) + scenario += comptroller + cToken = CapTestCToken( + comptroller_=comptroller.address, + interestRateModel_=irm.address, + administrator_=admin.address, + initialExchangeRateMantissa_=sp.nat(1500000000000000000)) + scenario += cToken + + scenario += comptroller.supportMarket(sp.record( + cToken=cToken.address, name="ROUND", priceExp=sp.nat(1))).run( + sender=admin, level=1) + scenario += comptroller.setMintPaused(sp.record( + cToken=cToken.address, state=False)).run(sender=admin, level=1) + scenario += comptroller.setMarketCaps(sp.record( + cToken=cToken.address, supplyCap=sp.nat(2), + borrowCap=sp.nat(0))).run(sender=admin, level=1) + scenario += cToken.accrueInterest().run(sender=supplier, level=1) + + scenario += cToken.mint(sp.nat(2)).run(sender=supplier, level=1) + scenario.verify(cToken.data.totalSupply == sp.nat(1)) + scenario.verify(cToken.data.cash == sp.nat(2)) + scenario += cToken.mint(sp.nat(2)).run( + sender=supplier, level=1, valid=False, + exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 8df56b2f..43afda31 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -207,10 +207,16 @@ def test(): scenario += cmpt.setMintPaused(sp.record(cToken = listedMarket, state = sp.bool(False))).run(sender = admin, level = bLevel.next()) scenario += cmpt.mintAllowed(minterArgLambda(listedMarket)).run(sender = alice, level = bLevel.next()) scenario.h4("supply cap cannot be exceeded") + scenario += cTokenMock1.setMarketTotals(sp.record(supply=sp.nat(100), borrows=sp.nat(0))) scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(99), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) scenario += cmpt.mintAllowed(minterArgLambda(listedMarket)).run( sender=alice, level=bLevel.next(), valid=False, exception=CMPT.EC.CMPT_SUPPLY_CAP_EXCEEDED) + scenario.h4("supply cap can be reached exactly") + scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(100), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.mintAllowed(minterArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next()) + scenario += cTokenMock1.setMarketTotals(sp.record(supply=sp.nat(0), borrows=sp.nat(0))) scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) scenario.h3("Redeem allowed") @@ -247,11 +253,17 @@ def test(): scenario.h3("Borrow allowed") borrowArgLambda = lambda market : sp.record(cToken=market, borrower=alice.address, borrowAmount=sp.nat(100*1000000000000000000)) scenario.h4("borrow cap cannot be exceeded") + scenario += cTokenMock1.setMarketTotals(sp.record(supply=sp.nat(0), borrows=sp.nat(100))) scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(99))).run(sender=admin, level=bLevel.next()) scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run( sender=alice, level=bLevel.next(), valid=False, exception=CMPT.EC.CMPT_BORROW_CAP_EXCEEDED) + scenario += cTokenMock1.setMarketTotals(sp.record(supply=sp.nat(0), borrows=sp.nat(0))) scenario += cmpt.setMarketCaps(sp.record(cToken=listedMarket, supplyCap=sp.nat(10**50), borrowCap=sp.nat(10**50))).run(sender=admin, level=bLevel.next()) + scenario.h4("borrow completion is internal only") + scenario += cmpt.completeBorrowAllowed(borrowArgLambda(listedMarket)).run( + sender=alice, level=bLevel.next(), valid=False, + exception=CMPT.EC.CMPT_INVALID_BORROW_SENDER) scenario.h4("on the listed market, without updated price") scenario += cmpt.borrowAllowed(borrowArgLambda(listedMarket)).run(sender = alice, level = bLevel.next(), valid = False) scenario.h4("on the listed market, with updated price, without updated liquidity") @@ -433,17 +445,36 @@ def test(): minPrice=sp.nat(100000), maxPrice=sp.nat(10000000), maxChangeBps=sp.nat(2000))).run(sender=admin, level=bLevel.next()) oracle.setPrice(1) - scenario += cmpt.updateAllAssetPricesWithView().run( + scenario += cmpt.updateAssetPriceWithView(listedMarket).run( sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, exception="ASSET_PRICE_OUT_OF_BOUNDS") oracle.setPrice(9000000000000000) - scenario += cmpt.updateAllAssetPricesWithView().run( + scenario += cmpt.updateAssetPriceWithView(listedMarket).run( sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, exception="ASSET_PRICE_OUT_OF_BOUNDS") scenario += cmpt.setPriceBounds(sp.record(cToken=listedMarket, minPrice=sp.nat(1), maxPrice=sp.nat(10**50), maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) oracle.setPrice(1) + scenario.h3("Unrelated unhealthy market does not block account price updates") + healthyMarketAccount = sp.test_account("healthy market account") + scenario += cmpt.addToLoansExternal(sp.pair( + healthyMarketAccount.address, sp.set([listedMarket]))).run( + level=bLevel.next()) + scenario += cmpt.setPriceBounds(sp.record( + cToken=cTokenMock.address, minPrice=sp.nat(2), maxPrice=sp.nat(3), + maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) + scenario += cmpt.updateAllAssetPricesWithView().run( + sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, + exception="ASSET_PRICE_OUT_OF_BOUNDS") + scenario += cmpt.updateAccountLiquidityWithView( + healthyMarketAccount.address).run( + sender=bob, level=bLevel.next(), now=sp.timestamp(100)) + scenario.verify(cmpt.data.account_liquidity[ + healthyMarketAccount.address].valid) + scenario += cmpt.setPriceBounds(sp.record( + cToken=cTokenMock.address, minPrice=sp.nat(1), maxPrice=sp.nat(10**50), + maxChangeBps=sp.nat(10000))).run(sender=admin, level=bLevel.next()) scenario.h2("Test account liquidity") cmpt.enterMarkets(sp.list([cTokenMock.address])).run(sender = bob, level = bLevel.next()) @@ -503,6 +534,13 @@ def test(): supportMarketParams) scenario.verify(cmpt.data.markets.contains(newMarket) & cmpt.data.markets[newMarket].isListed) scenario.verify(cmpt.data.marketNameToAddress.contains("market-USD")) + scenario.h4("Zero price exponent") + zeroPriceExpMarket = sp.test_account("[supportMarket] zero price exponent").address + scenario += cmpt.supportMarket(sp.record( + cToken=zeroPriceExpMarket, name=sp.string("zero-exp"), + priceExp=sp.nat(0))).run( + sender=admin, level=bLevel.next(), valid=False, + exception="INVALID_PRICE_EXP") scenario.h4("Already listed market") cmpt.supportMarket(supportMarketParams).run(sender = admin, level = bLevel.next(), valid = False) diff --git a/deploy/compile_targets/tests/test_mainnet_governance_payload.py b/deploy/compile_targets/tests/test_mainnet_governance_payload.py new file mode 100644 index 00000000..140f08a0 --- /dev/null +++ b/deploy/compile_targets/tests/test_mainnet_governance_payload.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +import json +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +PAYLOAD_PATH = ROOT / "docs" / "MainnetGovernancePayloads.json" +MARKETS = ("XTZ", "USDT", "TZBTC") +MARKET_ACTIONS = ("mint", "borrow", "redeem", "liquidate") + + +class MainnetGovernancePayloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.payload = json.loads(PAYLOAD_PATH.read_text(encoding="utf-8")) + cls.phases = {phase["name"]: phase for phase in cls.payload["phases"]} + + def test_oracle_max_price_age_is_accepted_by_contract(self): + max_price_age = self.payload["requiredInputs"][ + "maxPriceTimeDifferenceSeconds"] + self.assertGreater(max_price_age, 0) + self.assertLessEqual(max_price_age, 3600) + + def test_closed_market_configuration_is_complete_and_ordered(self): + operations = self.phases[ + "multisig_configures_closed_markets"]["operations"] + entrypoints = [operation["entrypoint"] for operation in operations] + + self.assertLess(entrypoints.index("setPriceOracleAndTimeDiff"), + entrypoints.index("supportMarket")) + self.assertLess(entrypoints.index("supportMarket"), + entrypoints.index("setPriceBounds")) + self.assertLess(entrypoints.index("setPriceBounds"), + entrypoints.index("setMarketCaps")) + self.assertLess(entrypoints.index("setMarketCaps"), + entrypoints.index("setCollateralFactor")) + + required_inputs = self.payload["requiredInputs"] + self.assertEqual(set(required_inputs["priceBounds"]), set(MARKETS)) + self.assertEqual(set(required_inputs["marketCaps"]), set(MARKETS)) + + def test_unapproved_manifest_remains_blocked_and_fail_closed(self): + self.assertEqual(self.payload["status"], "INITIAL_DRAFT") + + required_inputs = self.payload["requiredInputs"] + risk_values = [] + for market in MARKETS: + risk_values.extend(required_inputs["priceBounds"][market].values()) + risk_values.extend(required_inputs["marketCaps"][market].values()) + self.assertTrue(risk_values) + self.assertTrue(all(value == "TODO_VALUE" for value in risk_values)) + + activation = required_inputs["activateAfterVerification"] + for market in MARKETS: + for action in MARKET_ACTIONS: + self.assertFalse(activation[market][action]) + self.assertFalse(activation["transfers"]) + + blocking_conditions = self.phases[ + "multisig_verifies_closed_market_configuration"][ + "blockingConditions"] + self.assertIn("No TODO_VALUE values remain", blocking_conditions) + + def test_activation_uses_independent_pause_entrypoints(self): + operations = self.phases[ + "multisig_opens_approved_markets"]["operations"] + self.assertTrue(all("repeatEntrypoints" not in operation + for operation in operations)) + + entrypoints = {operation["entrypoint"] for operation in operations} + self.assertTrue({ + "setMintPaused", + "setBorrowPaused", + "setRedeemPaused", + "setLiquidatePaused", + "setTransferPaused", + }.issubset(entrypoints)) + + def test_activation_is_preceded_by_storage_verification(self): + phase_names = [phase["name"] for phase in self.payload["phases"]] + verification_index = phase_names.index( + "multisig_verifies_closed_market_configuration") + activation_index = phase_names.index( + "multisig_opens_approved_markets") + self.assertLess(verification_index, activation_index) + + checks = self.phases[ + "multisig_verifies_closed_market_configuration"][ + "requiredStorageChecks"] + self.assertTrue(any("priceBounds" in check for check in checks)) + self.assertTrue(any("supplyCap" in check and "borrowCap" in check + for check in checks)) + self.assertTrue(any("liquidation" in check for check in checks)) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/deploy_script/test/deploy_guards.test.js b/deploy/deploy_script/test/deploy_guards.test.js index b0071877..04a0d387 100644 --- a/deploy/deploy_script/test/deploy_guards.test.js +++ b/deploy/deploy_script/test/deploy_guards.test.js @@ -334,21 +334,54 @@ test('mainnetPreflight: findMissingCanonicalKeys flags every missing required ke test('raw deployment path: mainnet requires preflight', async () => { let receivedPath; + let oracleManifestPath; await enforceDeploymentPreflight('/tmp/deploy.mainnet.json', 'mainnet', 'NetXdQprcVkpaWU', async (manifestPath) => { receivedPath = manifestPath; + }, async (manifestPath) => { + oracleManifestPath = manifestPath; }); assert.equal(receivedPath, '/tmp/deploy.mainnet.json'); + assert.equal(oracleManifestPath, '/tmp/deploy.mainnet.json'); }); test('raw deployment path: mainnet preflight failure blocks deployment', async () => { await assert.rejects( enforceDeploymentPreflight('/tmp/deploy.mainnet.json', 'previewnet', 'NetXdQprcVkpaWU', async () => { throw new Error('MAINNET_DEPLOY_CONFIRM is required'); - }), + }, async () => {}), /MAINNET_DEPLOY_CONFIRM is required/, ); }); +test('raw deployment path: oracle verification failure blocks deployment', async () => { + await assert.rejects( + enforceDeploymentPreflight( + '/tmp/deploy.mainnet.json', + 'mainnet', + 'NetXdQprcVkpaWU', + async () => {}, + async () => { + throw new Error('XTZUSDT price is stale'); + }, + ), + /XTZUSDT price is stale/, + ); +}); + +test('raw deployment path: previewnet does not run mainnet preflights', async () => { + await enforceDeploymentPreflight( + '/tmp/deploy.previewnet.json', + 'previewnet', + 'NetXnHfVqm9iesp', + async () => { + throw new Error('mainnet preflight should not run'); + }, + async () => { + throw new Error('mainnet oracle verification should not run'); + }, + ); +}); + test('mainnetPreflight: findMissingCanonicalKeys reports nothing when all keys present', () => { const fullManifest = Object.fromEntries(REQUIRED_CANONICAL_KEYS.map((key) => [key, `KT1${key}`])); assert.deepEqual(findMissingCanonicalKeys(fullManifest), []); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index f55517ca..9093e34e 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -370,13 +370,15 @@ function checkChainIdMatch(expectedChainId, actualChainId, context) { } } -async function enforceDeploymentPreflight(deployResultPath, networkProfile, chainId, preflight) { +async function enforceDeploymentPreflight(deployResultPath, networkProfile, chainId, preflight, oracleVerification) { const isMainnet = networkProfile === 'mainnet' || chainId === 'NetXdQprcVkpaWU'; if (!isMainnet) { return; } const runPreflight = preflight || require('./mainnet_preflight.js').mainnetPreflight; await runPreflight(deployResultPath); + const verifyOracle = oracleVerification || require('./verify_mainnet_oracle.js').verifyMainnetOracle; + await verifyOracle(deployResultPath); } // Verify that a manifest entry still points at a live, matching contract before we diff --git a/deploy/deploy_script/verify_mainnet_oracle.js b/deploy/deploy_script/verify_mainnet_oracle.js index 7865de23..80e99bfe 100644 --- a/deploy/deploy_script/verify_mainnet_oracle.js +++ b/deploy/deploy_script/verify_mainnet_oracle.js @@ -40,8 +40,8 @@ function parsePriceResult(asset, response, headTimestamp, maxAgeSeconds) { return { asset, price, timestamp, rawTimestamp, ageSeconds }; } -async function main() { - const manifest = JSON.parse(fs.readFileSync(resolveDeployResultPath(), 'utf8')); +async function verifyMainnetOracle(deployResultPath = resolveDeployResultPath()) { + const manifest = JSON.parse(fs.readFileSync(deployResultPath, 'utf8')); const oracle = manifest.PriceOracle; if (!oracle) { throw new Error('Mainnet manifest is missing PriceOracle.'); @@ -80,10 +80,10 @@ async function main() { } if (require.main === module) { - main().catch((error) => { + verifyMainnetOracle().catch((error) => { console.error(`[ERROR] Mainnet oracle verification failed: ${error.message}`); process.exitCode = 1; }); } -module.exports = { parsePriceResult }; \ No newline at end of file +module.exports = { parsePriceResult, verifyMainnetOracle }; \ No newline at end of file diff --git a/deploy/shell_scripts/deploy_mainnet.sh b/deploy/shell_scripts/deploy_mainnet.sh index d8f6bee5..6cf13cc3 100755 --- a/deploy/shell_scripts/deploy_mainnet.sh +++ b/deploy/shell_scripts/deploy_mainnet.sh @@ -34,7 +34,6 @@ python3 ./deploy/compile_targets/tests/test_irm_wiring.py # config block, and Config.py/util.js agree on the default manifest path per profile. python3 ./deploy/compile_targets/tests/test_deploy_pipeline_wiring.py -node ./deploy/deploy_script/verify_mainnet_oracle.js node ./deploy/deploy_script/mainnet_preflight.js node ./deploy/deploy_script/prepare.js diff --git a/docs/MainnetGovernancePayloads.json b/docs/MainnetGovernancePayloads.json index 496af9ba..600e026f 100644 --- a/docs/MainnetGovernancePayloads.json +++ b/docs/MainnetGovernancePayloads.json @@ -1,18 +1,28 @@ { "status": "INITIAL_DRAFT", - "requiredInputs": { + "requiredInputs": { "productionMultisig": "KT1Mc9rBcVmoLATY1FQDBvzSMHmUh6rgwjDR", - "maxPriceTimeDifferenceSeconds": 86400, + "maxPriceTimeDifferenceSeconds": 3600, + "priceBounds": { + "XTZ": { "minPrice": "TODO_VALUE", "maxPrice": "TODO_VALUE", "maxChangeBps": "TODO_VALUE" }, + "USDT": { "minPrice": "TODO_VALUE", "maxPrice": "TODO_VALUE", "maxChangeBps": "TODO_VALUE" }, + "TZBTC": { "minPrice": "TODO_VALUE", "maxPrice": "TODO_VALUE", "maxChangeBps": "TODO_VALUE" } + }, + "marketCaps": { + "XTZ": { "supplyCap": "TODO_VALUE", "borrowCap": "TODO_VALUE" }, + "USDT": { "supplyCap": "TODO_VALUE", "borrowCap": "TODO_VALUE" }, + "TZBTC": { "supplyCap": "TODO_VALUE", "borrowCap": "TODO_VALUE" } + }, "collateralFactorMantissa": { "XTZ": "500000000000000000", "USDT": "500000000000000000", "TZBTC": "500000000000000000" }, - "openAfterHandoff": { - "XTZ": "TODO_BOOLEAN", - "USDT": "TODO_BOOLEAN", - "TZBTC": "TODO_BOOLEAN", - "transfers": "TODO_BOOLEAN" + "activateAfterVerification": { + "XTZ": { "mint": false, "borrow": false, "redeem": false, "liquidate": false }, + "USDT": { "mint": false, "borrow": false, "redeem": false, "liquidate": false }, + "TZBTC": { "mint": false, "borrow": false, "redeem": false, "liquidate": false }, + "transfers": false } }, "addressSources": { @@ -90,6 +100,33 @@ "market": { "cToken": "TZBTC", "name": "TZBTC", "priceExp": 10000000000 } } }, + { + "destination": "Governance", + "entrypoint": "setPriceBounds", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "bounds": { + "cToken": "$market", + "minPrice": "priceBounds.$market.minPrice", + "maxPrice": "priceBounds.$market.maxPrice", + "maxChangeBps": "priceBounds.$market.maxChangeBps" + } + } + }, + { + "destination": "Governance", + "entrypoint": "setMarketCaps", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "caps": { + "cToken": "$market", + "supplyCap": "marketCaps.$market.supplyCap", + "borrowCap": "marketCaps.$market.borrowCap" + } + } + }, { "destination": "Governance", "entrypoint": "setCollateralFactor", @@ -104,6 +141,22 @@ } ] }, + { + "name": "multisig_verifies_closed_market_configuration", + "requiredStorageChecks": [ + "TezFinOracle.maxPriceAge[Comptroller] equals maxPriceTimeDifferenceSeconds", + "TezFinOracle.priceBounds[(Comptroller, $market)] equals priceBounds.$market for every market", + "Comptroller.markets[$market].supplyCap and borrowCap equal marketCaps.$market for every market", + "Comptroller.markets[$market].collateralFactor equals collateralFactorMantissa.$market for every market", + "Mint, borrow, redeem, and liquidation remain paused for every market", + "Transfers remain paused" + ], + "blockingConditions": [ + "No TODO_VALUE values remain", + "Every risk parameter has formal approval", + "Every required storage check has passed against the target deployment" + ] + }, { "name": "multisig_opens_approved_markets", "preconditions": [ @@ -115,13 +168,49 @@ "operations": [ { "destination": "Governance", - "repeatEntrypoints": ["setMintPaused", "setBorrowPaused", "setRedeemPaused"], + "entrypoint": "setMintPaused", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "tokenState": { + "cToken": "$market", + "state": "!activateAfterVerification.$market.mint" + } + } + }, + { + "destination": "Governance", + "entrypoint": "setBorrowPaused", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "tokenState": { + "cToken": "$market", + "state": "!activateAfterVerification.$market.borrow" + } + } + }, + { + "destination": "Governance", + "entrypoint": "setRedeemPaused", + "repeatFor": ["XTZ", "USDT", "TZBTC"], + "value": { + "comptroller": "Comptroller", + "tokenState": { + "cToken": "$market", + "state": "!activateAfterVerification.$market.redeem" + } + } + }, + { + "destination": "Governance", + "entrypoint": "setLiquidatePaused", "repeatFor": ["XTZ", "USDT", "TZBTC"], "value": { "comptroller": "Comptroller", "tokenState": { "cToken": "$market", - "state": "!openAfterHandoff.$market" + "state": "!activateAfterVerification.$market.liquidate" } } }, @@ -130,7 +219,7 @@ "entrypoint": "setTransferPaused", "value": { "comptroller": "Comptroller", - "state": "!openAfterHandoff.transfers" + "state": "!activateAfterVerification.transfers" } } ] From 70f120160fdabb267db3fa99ce5d8c1264241520 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Thu, 23 Jul 2026 13:56:43 +0300 Subject: [PATCH 17/19] feat: pin Node.js 22.16.0 across environments --- .github/workflows/ci.yml | 2 +- .node-version | 1 + .nvmrc | 1 + README.md | 7 ++++++- deploy/deploy_script/.npmrc | 1 + deploy/deploy_script/package-lock.json | 5 ++++- deploy/deploy_script/package.json | 3 +++ 7 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc create mode 100644 deploy/deploy_script/.npmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8feb6dba..98ddcbe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ permissions: contents: read env: - NODE_VERSION: '20.19.5' + NODE_VERSION: '22.16.0' PYTHON_VERSION: '3.11.11' jobs: diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..5b540673 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.16.0 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5b540673 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.16.0 diff --git a/README.md b/README.md index 215e9070..41afeb9a 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,14 @@ SHA-256 pin, installs npm dependencies with `npm ci` from `SmartPy Version: 0.16.0`. Do not use the upstream `curl | bash` installer for release builds. -CI additionally pins Ubuntu 22.04, Node.js 20.19.5, Python 3.11.11, and GitHub +CI additionally pins Ubuntu 22.04, Node.js 22.16.0, Python 3.11.11, and GitHub Actions to immutable commit SHAs. +Node.js 22.16.0 is also the required local and production deployment runtime. +Use `nvm use` (or a version manager that reads `.node-version`) before running +`npm ci`; the deployment package rejects other Node.js versions because Taquito +25 requires Node.js 22 or newer. + Code is organized in the following structure - [contracts](contracts) - contains SmartPy code of smart contracts diff --git a/deploy/deploy_script/.npmrc b/deploy/deploy_script/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/deploy/deploy_script/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/deploy/deploy_script/package-lock.json b/deploy/deploy_script/package-lock.json index 252f1c7e..453a6c04 100644 --- a/deploy/deploy_script/package-lock.json +++ b/deploy/deploy_script/package-lock.json @@ -14,7 +14,10 @@ "glob": "^13.0.6", "loglevel": "^1.8.1" }, - "devDependencies": {} + "devDependencies": {}, + "engines": { + "node": "22.16.0" + } }, "node_modules/@noble/curves": { "version": "1.9.7", diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index 12852e4c..56d05322 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -2,6 +2,9 @@ "name": "TezFinDeploy", "version": "1.0.0", "main": "deploy.js", + "engines": { + "node": "22.16.0" + }, "dependencies": { "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", From d91db5fafc459cff3bf2c01011cf43be08c25a89 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Fri, 24 Jul 2026 10:43:13 +0300 Subject: [PATCH 18/19] chore: batch comptroller price updates into a single self-call --- contracts/Comptroller.py | 56 ++++++++++++++++-------------- contracts/tests/ComptrollerTest.py | 4 +-- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index 3871e14c..199de502 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -277,8 +277,8 @@ def borrowAllowed(self, params): EC.CMPT_INVALID_BORROW_SENDER) sp.if sp.sender == params.cToken: self.addToLoans(params.cToken, params.borrower) - sp.transfer(params.cToken, sp.mutez(0), sp.self_entry_point( - "updateAssetPriceWithView")) + sp.transfer(sp.set([params.cToken]), sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) sp.transfer(params, sp.mutez(0), sp.self_entry_point( "completeBorrowAllowed")) @@ -383,33 +383,36 @@ def updateAllAssetPricesWithView(self): self.updateAllAssetPrices() def updateAllAssetPrices(self): + assets = sp.local("assets", sp.set(t=sp.TAddress)) sp.for asset in self.data.marketNameToAddress.values(): sp.if self.data.markets[asset].isListed: - sp.transfer(asset, sp.mutez(0), sp.self_entry_point( - "updateAssetPriceWithView")) + assets.value.add(asset) + sp.transfer(assets.value, sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) @sp.entry_point - def updateAssetPriceWithView(self, asset): + def updateAssetPricesWithView(self, assets): sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") - sp.set_type(asset, sp.TAddress) - self.verifyMarketListed(asset) - sp.if self.data.markets[asset].updateLevel < sp.level: - previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp - pricePair = sp.local("pricePair", - sp.view("getValidatedPrice", self.data.oracleAddress, - sp.record(comptroller=sp.self_address, - cToken=asset, - requestedAsset=self.data.markets[asset].name + "-USD", - previousPrice=previousRawPrice, - previousTimestamp=self.data.markets[asset].priceTimestamp), - t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") - ) - priceTimestamp = sp.fst(pricePair.value) - rawPrice = sp.snd(pricePair.value) - self.data.markets[asset].price = self.makeExp( - rawPrice*self.data.markets[asset].priceExp) - self.data.markets[asset].priceTimestamp = priceTimestamp - self.data.markets[asset].updateLevel = sp.level + sp.set_type(assets, sp.TSet(sp.TAddress)) + sp.for asset in assets.elements(): + self.verifyMarketListed(asset) + sp.if self.data.markets[asset].updateLevel < sp.level: + previousRawPrice = self.data.markets[asset].price.mantissa // self.data.markets[asset].priceExp + pricePair = sp.local("pricePair", + sp.view("getValidatedPrice", self.data.oracleAddress, + sp.record(comptroller=sp.self_address, + cToken=asset, + requestedAsset=self.data.markets[asset].name + "-USD", + previousPrice=previousRawPrice, + previousTimestamp=self.data.markets[asset].priceTimestamp), + t=sp.TPair(sp.TTimestamp, sp.TNat)).open_some("invalid oracle view call") + ) + priceTimestamp = sp.fst(pricePair.value) + rawPrice = sp.snd(pricePair.value) + self.data.markets[asset].price = self.makeExp( + rawPrice*self.data.markets[asset].priceExp) + self.data.markets[asset].priceTimestamp = priceTimestamp + self.data.markets[asset].updateLevel = sp.level def getAssetPrice(self, asset): sp.verify(sp.level == self.data.markets[asset].updateLevel, EC.CMPT_UPDATE_PRICE) @@ -432,9 +435,8 @@ def updateAccountLiquidityWithView(self, account): sp.if self.data.loans.contains(account): sp.for asset in self.data.loans[account].elements(): accountAssets.value.add(asset) - sp.for asset in accountAssets.value.elements(): - sp.transfer(asset, sp.mutez(0), sp.self_entry_point( - "updateAssetPriceWithView")) + sp.transfer(accountAssets.value, sp.mutez(0), sp.self_entry_point( + "updateAssetPricesWithView")) sp.for asset in accountAssets.value.elements(): sp.transfer(sp.unit, sp.mutez(0), sp.contract( sp.TUnit, asset, entry_point="accrueInterest").open_some()) diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 43afda31..75cb1149 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -445,11 +445,11 @@ def test(): minPrice=sp.nat(100000), maxPrice=sp.nat(10000000), maxChangeBps=sp.nat(2000))).run(sender=admin, level=bLevel.next()) oracle.setPrice(1) - scenario += cmpt.updateAssetPriceWithView(listedMarket).run( + scenario += cmpt.updateAssetPricesWithView(sp.set([listedMarket])).run( sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, exception="ASSET_PRICE_OUT_OF_BOUNDS") oracle.setPrice(9000000000000000) - scenario += cmpt.updateAssetPriceWithView(listedMarket).run( + scenario += cmpt.updateAssetPricesWithView(sp.set([listedMarket])).run( sender=bob, level=bLevel.next(), now=sp.timestamp(100), valid=False, exception="ASSET_PRICE_OUT_OF_BOUNDS") scenario += cmpt.setPriceBounds(sp.record(cToken=listedMarket, From 404d72e8a58e31fb3fefe56f54abd6e839523735 Mon Sep 17 00:00:00 2001 From: Anton Karuna Date: Thu, 30 Jul 2026 14:23:52 +0300 Subject: [PATCH 19/19] fix: shrink Comptroller opSize and gate CI on forged origination envelope --- .github/workflows/ci.yml | 2 +- README.md | 36 +++- contracts/Comptroller.py | 96 ++++------- contracts/tests/ComptrollerTest.py | 17 ++ .../tests/test_deploy_pipeline_wiring.py | 2 +- .../tests/test_operation_size.py | 106 +++++++++--- .../deploy_script/measure_origination_size.js | 160 ++++++++++++++++++ deploy/deploy_script/package-lock.json | 1 + deploy/deploy_script/package.json | 2 + .../test/origination_size.test.js | 111 ++++++++++++ deploy/deploy_script/util.js | 31 +++- deploy/test_data/PriceOracle.py | 13 +- docs/MainnetGovernancePayloads.json | 2 +- 13 files changed, 484 insertions(+), 95 deletions(-) create mode 100644 deploy/deploy_script/measure_origination_size.js create mode 100644 deploy/deploy_script/test/origination_size.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98ddcbe2..022d684a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: "Check per-market IRM wiring" run: | python3 deploy/compile_targets/tests/test_irm_wiring.py - - name: "Install deploy script dependencies (needed by test_deploy_pipeline_wiring.py, which requires deploy_script/util.js)" + - name: "Install deploy script dependencies (needed by test_deploy_pipeline_wiring.py, which requires deploy_script/util.js, and by test_operation_size.py, which forges originations with @taquito/local-forging)" working-directory: deploy/deploy_script run: | npm ci diff --git a/README.md b/README.md index 41afeb9a..cf9af9c5 100644 --- a/README.md +++ b/README.md @@ -139,18 +139,36 @@ and all run offline/in CI without needing a live Tezos node: compiled with in `deploy_previewnet.sh`/`deploy_mainnet.sh`** (e.g. `--erase-comments --erase-var-annots --initial-cast` for Comptroller), against a throwaway manifest of placeholder addresses (`e2e/deploy_result/deploy.json`), then - checks each contract's compiled code + initial storage against a safety margin under - the ~32KB Tezos manager-operation limit. This is a static regression guard (e.g. - against accidentally disabling code - sharing/lazification) that runs entirely offline; it does not replace an actual - `tezos.estimate.originate` dry run against the target node before a real mainnet - deployment (that check already happens live inside `deployMichelsonContract()` in - `deploy_script/util.js`). Requires the SmartPy CLI; fails loudly (non-zero exit) if - the CLI can't be found or if every compile attempt fails, rather than silently - reporting success with nothing checked. + measures the **complete origination operation** for each and checks it against the + 32768-byte Tezos manager-operation limit. + + The measured quantity matters here. SmartPy's `*_sizes.csv` reports only the packed + Micheline size of the contract code and the initial storage, but the protocol's + `max_operation_data_length` applies to the whole signed operation, which also carries + the branch, source, fee/counter/gas/storage limits and a 64-byte signature - about 140 + bytes more. This check therefore forges a real origination operation offline with + `@taquito/local-forging` (see `deploy_script/measure_origination_size.js`) and gates on + that, which is the same number Taquito reports as `estimate.originate().opSize`. + Because the forging is local it needs no RPC, no funded account and no secret key, so + the authoritative figure is available on every commit rather than only at deploy time. + The code+storage numbers are still printed alongside it for continuity. + + It also warns when the remaining margin is under ~162 bytes, since Taquito batches a + reveal into the same operation group when the deployer's public key has not yet been + revealed, and that reveal shares the same 32768-byte budget. + + Requires the SmartPy CLI and `npm ci` in `deploy/deploy_script`; fails loudly + (non-zero exit) if the CLI can't be found, if the forger is unavailable, or if every + compile attempt fails, rather than silently reporting success with nothing checked. ```sh python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.sh ``` + + To measure a single already-compiled contract directly: + ```sh + cd deploy/deploy_script + npm run measure:origination-size -- ../../TezFinBuild/compiled_contracts/Comptroller --json + ``` - **Reproducible contract compilation** (`deploy/compile_targets/tests/test_reproducible_build.py`) - compiles every contract originated by `deploy_mainnet.sh` twice in clean temporary directories with the diff --git a/contracts/Comptroller.py b/contracts/Comptroller.py index 199de502..8da6529a 100644 --- a/contracts/Comptroller.py +++ b/contracts/Comptroller.py @@ -77,8 +77,7 @@ def __init__(self, administrator_, oracleAddress_, closeFactorMantissa_, liquida """ @sp.entry_point def enterMarkets(self, cTokens): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(cTokens, sp.TList(sp.TAddress)) currentAssetCount = sp.local("currentAssetCount", self.getUserUniqueAssetsCount(sp.sender)) sp.for token in cTokens: @@ -109,8 +108,7 @@ def addToCollaterals(self, cToken, lender): """ @sp.entry_point def exitMarket(self, cToken): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(cToken, sp.TAddress) self.activateOp(OP.ComptrollerOperations.EXIT_MARKET) @@ -126,8 +124,7 @@ def exitMarket(self, cToken): """ @sp.entry_point def setAccountSnapAndExitMarket(self, accountSnapshot): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(accountSnapshot, CTI.TAccountSnapshot) self.verifyAndFinishActiveOp(OP.ComptrollerOperations.EXIT_MARKET) sp.verify(accountSnapshot.borrowBalance == @@ -153,8 +150,7 @@ def setAccountSnapAndExitMarket(self, accountSnapshot): """ @sp.entry_point def mintAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TMintAllowedParams) sp.verify( ~ self.data.markets[params.cToken].mintPaused, EC.CMPT_MINT_PAUSED) @@ -182,8 +178,7 @@ def mintAllowed(self, params): """ @sp.entry_point def redeemAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TRedeemAllowedParams) self.verifyMarketListed(params.cToken) sp.verify( @@ -255,8 +250,7 @@ def checkCollateralReductionInternal(self, cToken, account, exchangeRateMantissa """ @sp.entry_point def borrowAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TBorrowAllowedParams) sp.verify( ~ self.data.markets[params.cToken].borrowPaused, EC.CMPT_BORROW_PAUSED) @@ -303,8 +297,7 @@ def addToLoans(self, cToken, borrower): @sp.entry_point def removeFromLoans(self, borrower): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") self.verifyMarketListed(sp.sender) sp.if self.data.loans.contains(borrower) & self.data.loans[borrower].contains(sp.sender): self.data.loans[borrower].remove(sp.sender) @@ -320,8 +313,7 @@ def removeFromLoans(self, borrower): """ @sp.entry_point def repayBorrowAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TRepayBorrowAllowedParams) self.verifyMarketListed(params.cToken) self.invalidateLiquidity(params.borrower) @@ -342,8 +334,7 @@ def repayBorrowAllowed(self, params): """ @sp.entry_point def transferAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TTransferAllowedParams) sp.verify(~ self.data.transferPaused, EC.CMPT_TRANSFER_PAUSED) self.verifyMarketListed(params.cToken) @@ -378,8 +369,7 @@ def transferAllowed(self, params): """ @sp.entry_point def updateAllAssetPricesWithView(self): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") self.updateAllAssetPrices() def updateAllAssetPrices(self): @@ -425,8 +415,7 @@ def getAssetPrice(self, asset): """ @sp.entry_point def updateAccountLiquidityWithView(self, account): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(account, sp.TAddress) accountAssets = sp.local("accountAssets", sp.set(t=sp.TAddress)) sp.if self.data.collaterals.contains(account): @@ -451,8 +440,7 @@ def updateAccountLiquidityWithView(self, account): """ @sp.entry_point def setAccountLiquidityWithView(self, account): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(account, sp.TAddress) liquidity = sp.local( 'liquidity', self.calculateCurrentAccountLiquidityWithView(account)).value @@ -543,8 +531,7 @@ def liquidateCalculateSeizeTokens(self, params): """ @sp.entry_point def liquidateBorrowAllowed(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, CMPTInterface.TLiquidateBorrowAllowed) self.verifyMarketListed(params.cTokenBorrowed) @@ -617,16 +604,16 @@ def calculateAccountLiquidityWithView(self, params): calculation.sumBorrowPlusEffects += temp.sumBorrowPlusEffects sp.if self.data.loans.contains(params.account): sp.for asset in self.data.loans[params.account].elements(): - # only get liquidity for assets that aren't in collaterals to avoid double counting + # only get liquidity for assets that aren't in collaterals to avoid double counting. + # Deciding that up front, rather than calling the helper from both branches of the + # collaterals check, keeps a single inlined copy of the (large) per-asset view call. + alreadyCounted = sp.local("alreadyCounted", False) sp.if self.data.collaterals.contains(params.account): - sp.if ~self.data.collaterals[params.account].contains(asset): - # cToken.accrueInterest() for the given asset should be executed within the same block prior to this call - # updateAssetPrice() should be executed within the same block prior to this call - temp = self.calculateAccountAssetLiquidityView( - asset, params.account) - calculation.sumCollateral += temp.sumCollateral - calculation.sumBorrowPlusEffects += temp.sumBorrowPlusEffects - sp.else: + sp.if self.data.collaterals[params.account].contains(asset): + alreadyCounted.value = True + sp.if ~alreadyCounted.value: + # cToken.accrueInterest() for the given asset should be executed within the same block prior to this call + # updateAssetPrice() should be executed within the same block prior to this call temp = self.calculateAccountAssetLiquidityView( asset, params.account) calculation.sumCollateral += temp.sumCollateral @@ -679,8 +666,7 @@ def getMaxAssetsPerUser(self): """ @sp.entry_point def setPendingGovernance(self, pendingAdminAddress): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(pendingAdminAddress, sp.TAddress) self.verifyAdministrator() self.data.pendingAdministrator = sp.some(pendingAdminAddress) @@ -694,8 +680,7 @@ def setPendingGovernance(self, pendingAdminAddress): """ @sp.entry_point def acceptGovernance(self, unusedArg): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(unusedArg, sp.TUnit) sp.verify(sp.sender == self.data.pendingAdministrator.open_some( EC.CMPT_NOT_SET_PENDING_ADMIN), EC.CMPT_NOT_PENDING_ADMIN) @@ -713,8 +698,7 @@ def acceptGovernance(self, unusedArg): """ @sp.entry_point def setMintPaused(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(cToken=sp.TAddress, state=sp.TBool)) self.verifyMarketListed(params.cToken) self.verifyAdministrator() @@ -731,8 +715,7 @@ def setMintPaused(self, params): """ @sp.entry_point def setBorrowPaused(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(cToken=sp.TAddress, state=sp.TBool)) self.verifyMarketListed(params.cToken) self.verifyAdministrator() @@ -743,8 +726,7 @@ def setBorrowPaused(self, params): """ @sp.entry_point def setRedeemPaused(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(cToken=sp.TAddress, state=sp.TBool)) self.verifyMarketListed(params.cToken) self.verifyAdministrator() @@ -777,8 +759,7 @@ def setMarketCaps(self, params): """ @sp.entry_point def setTransferPaused(self, state): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(state, sp.TBool) self.verifyAdministrator() self.data.transferPaused = state @@ -794,8 +775,7 @@ def setTransferPaused(self, state): """ @sp.entry_point def setPriceOracleAndTimeDiff(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(priceOracle=sp.TAddress, timeDiff=sp.TInt)) self.verifyAdministrator() self.data.oracleAddress = params.priceOracle @@ -823,8 +803,7 @@ def setPriceBounds(self, params): """ @sp.entry_point def setCloseFactor(self, closeFactorMantissa): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(closeFactorMantissa, sp.TNat) self.verifyAdministrator() self.data.closeFactorMantissa = closeFactorMantissa @@ -838,8 +817,7 @@ def setCloseFactor(self, closeFactorMantissa): """ @sp.entry_point def setMaxAssetsPerUser(self, newLimit): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(newLimit, sp.TNat) self.verifyAdministrator() self.data.maxAssetsPerUser = newLimit @@ -855,8 +833,7 @@ def setMaxAssetsPerUser(self, newLimit): """ @sp.entry_point def setCollateralFactor(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord( cToken=sp.TAddress, newCollateralFactor=sp.TNat)) self.verifyAdministrator() @@ -874,8 +851,7 @@ def setCollateralFactor(self, params): """ @sp.entry_point def setLiquidationIncentive(self, liquidationIncentiveMantissa): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(liquidationIncentiveMantissa, sp.TNat) self.verifyAdministrator() self.data.liquidationIncentiveMantissa = liquidationIncentiveMantissa @@ -892,8 +868,7 @@ def setLiquidationIncentive(self, liquidationIncentiveMantissa): """ @sp.entry_point def supportMarket(self, params): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(params, sp.TRecord(cToken=sp.TAddress, name=sp.TString, priceExp=sp.TNat)) self.verifyAdministrator() @@ -926,8 +901,7 @@ def supportMarket(self, params): """ @sp.entry_point def disableMarket(self, cToken): - sp.verify(sp.amount == sp.utils.nat_to_mutez( - 0), "TEZ_TRANSFERED") + sp.verify(sp.amount == sp.mutez(0), "TEZ_TRANSFERED") sp.set_type(cToken, sp.TAddress) self.verifyAdministrator() self.verifyMarketListed(cToken) diff --git a/contracts/tests/ComptrollerTest.py b/contracts/tests/ComptrollerTest.py index 75cb1149..22d8d573 100644 --- a/contracts/tests/ComptrollerTest.py +++ b/contracts/tests/ComptrollerTest.py @@ -496,6 +496,23 @@ def test(): result = sp.view("calculateAccountLiquidityExposed", cmpt.address, liquidityParams, t=sp.TRecord(sumBorrowPlusEffects = sp.TNat,sumCollateral = sp.TNat)).open_some() scenario.verify_equal((result.sumCollateral-result.sumBorrowPlusEffects), -90) + scenario.h3("A borrow-only market is counted once, alongside a collateralised market") + # Covers the loans loop for an account that has collateral: the market that is also + # collateral must not be counted twice, and the borrow-only market must still be + # counted. A per-asset flag that failed to reset between iterations would silently + # drop the borrow-only market's debt and overstate liquidity. + mixedProbe = sp.test_account("Collateral plus borrow-only probe") + cmpt.enterMarkets(sp.list([cTokenMock.address])).run(sender = mixedProbe, level = bLevel.next()) + cmpt.addToLoansExternal(sp.pair(mixedProbe.address, sp.set([cTokenMock.address, listedMarket]))).run(level = bLevel.next()) + cTokenMock.setAccountSnapshot(sp.record(account = mixedProbe.address, cTokenBalance = sp.nat(10), borrowBalance = sp.nat(100), exchangeRateMantissa = exchRate)) + cTokenMock1.setAccountSnapshot(sp.record(account = mixedProbe.address, cTokenBalance = sp.nat(0), borrowBalance = sp.nat(50), exchangeRateMantissa = exchRate)) + updateAssetsPrices(scenario, cmpt, bLevel, marketsList) + result = sp.view("calculateAccountLiquidityExposed", cmpt.address, sp.record(account=mixedProbe.address), t=sp.TRecord(sumBorrowPlusEffects = sp.TNat,sumCollateral = sp.TNat)).open_some() + # Only the collateralised market contributes collateral, and it is counted once. + scenario.verify_equal(result.sumCollateral, 10) + # Both debts count: 100 on the collateralised market plus 50 on the borrow-only market. + scenario.verify_equal(result.sumBorrowPlusEffects, 150) + scenario.h2("Test admin functionality") scenario.h3("Set price oracle") TestAdminFunctionality.checkAdminRequirementH4(scenario, "set price oracle", bLevel, admin, alice, cmpt.setPriceOracleAndTimeDiff, diff --git a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py index d28e7a2e..09f5e0f6 100644 --- a/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py +++ b/deploy/compile_targets/tests/test_deploy_pipeline_wiring.py @@ -134,7 +134,7 @@ def resolve_js_default_manifest_path(networkProfile): or symlink node_modules into a temp directory (which breaks in CI when node_modules hasn't been installed under a symlink target, or when the temp dir lives on a different filesystem than the repo checkout). - + To inject the networkProfile under test without permanently mutating the real (copilot-ignored) config.json, this backs up the real file's bytes (if it exists), overwrites it with a throwaway config declaring the given networkProfile, runs the diff --git a/deploy/compile_targets/tests/test_operation_size.py b/deploy/compile_targets/tests/test_operation_size.py index cd255d08..a9218f7d 100644 --- a/deploy/compile_targets/tests/test_operation_size.py +++ b/deploy/compile_targets/tests/test_operation_size.py @@ -1,9 +1,19 @@ -"""Guards against a contract's compiled code+storage growing past safe origination -size thresholds before it reaches mainnet. This was called out specifically in the -PR #455 review re: Comptroller's `lazify=True` removal - that change alone is benign -(SmartPy just emits a differently-shaped, non-lazified Michelson representation), but -the review recommended verifying the actual origination operation size stays -comfortably under protocol limits before a real mainnet deployment. +"""Guards against a contract growing past the origination operation size limit before it +reaches mainnet. This was called out specifically in the PR #455 review re: Comptroller's +`lazify=True` removal - that change alone is benign (SmartPy just emits a +differently-shaped, non-lazified Michelson representation), but the review recommended +verifying the actual origination operation size stays comfortably under protocol limits +before a real mainnet deployment. + +The number that has to fit under `max_operation_data_length` (32768) is the size of the +whole signed manager operation, NOT just the contract code and initial storage. SmartPy's +`*_sizes.csv` only reports the latter, so gating on code+storage under-reports the real +figure by ~140 bytes of operation framing plus a 64-byte signature. For the Comptroller +that difference was the whole apparent safety margin, so this check measures the forged +operation instead, via deploy_script/measure_origination_size.js. That produces the same +quantity Taquito reports as `estimate.originate().opSize`, but it forges locally, so it +needs no RPC, no funded account and no secret key and can run on every commit. The +code+storage figures are still printed for continuity with previous CI output. Unlike a naive version of this check, this test does NOT read whatever happens to be checked in under compiled_contracts/ (which can be stale relative to the current PR's @@ -15,15 +25,15 @@ disabling code sharing/lazification) is caught in the same PR that introduces it, without needing a network connection. -This does NOT call any RPC or attempt a live fee/gas estimate. That happens inside -`deployMichelsonContract()` in deploy_script/util.js (`tezos.estimate.originate`), -which does need real network access and is the authoritative check before mainnet. +Requires deploy/deploy_script/node_modules (`npm ci` in that directory) for the Taquito +forger; CI installs it before this step. Run with: python3 deploy/compile_targets/tests/test_operation_size.py [/path/to/SmartPy.sh] (defaults to ~/smartpy-cli/SmartPy.sh if no argument/env var is given) """ import csv +import json import os import shutil import subprocess @@ -32,9 +42,18 @@ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) PLACEHOLDER_MANIFEST = os.path.join(REPO_ROOT, 'e2e', 'deploy_result', 'deploy.json') +DEPLOY_SCRIPT_DIR = os.path.join(REPO_ROOT, 'deploy', 'deploy_script') +MEASURE_SCRIPT = os.path.join(DEPLOY_SCRIPT_DIR, 'measure_origination_size.js') DEFAULT_MAX_TOTAL_BYTES = 32768 +# Taquito prepends a reveal to the same operation group when the deployer account has not +# yet revealed its public key, and that reveal shares the 32768-byte budget. A contract +# that only fits without the reveal cannot be originated as the deployer's first +# operation, so a margin thinner than this is reported as a warning. Value from Taquito's +# own REVEAL_LENGTH (324 hex chars, i.e. 162 bytes). +REVEAL_BYTES = 162 + # Compile target file -> (compiled contract directory name, extra SmartPy CLI flags). # The extra flags MUST match exactly what deploy_previewnet.sh/deploy_mainnet.sh pass # for the same target (--erase-comments --erase-var-annots --initial-cast for @@ -78,6 +97,30 @@ def find_sizes_csv(contractDir): return None +def measure_origination_operation(contractDir): + """Returns the forged origination operation size for a compiled contract directory, + as {'forgedBytes': int, 'signatureBytes': int, 'opSize': int}. Raises RuntimeError if + the measurement could not be taken, so a broken forger fails the check instead of + silently falling back to the narrower code+storage number.""" + result = subprocess.run( + ['node', MEASURE_SCRIPT, contractDir, '--json', '--max', str(DEFAULT_MAX_TOTAL_BYTES)], + cwd=DEPLOY_SCRIPT_DIR, + capture_output=True, + text=True, + ) + # The script exits non-zero when the contract is over the limit, and that is a + # measurement we still want to report, so only treat unparseable output as an error. + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + raise RuntimeError( + f'could not measure the origination operation via {MEASURE_SCRIPT} ' + f'(exit {result.returncode}). Ensure `npm ci` has run in deploy/deploy_script so ' + f'@taquito/local-forging is available.\nstdout:\n{result.stdout.strip()[-1000:]}\n' + f'stderr:\n{result.stderr.strip()[-1000:]}' + ) + + def main(): maxTotalBytes = int(os.environ.get('MAX_CONTRACT_OPERATION_BYTES', DEFAULT_MAX_TOTAL_BYTES)) smartpy = find_smartpy_cli() @@ -131,18 +174,38 @@ def main(): failures.append(f'{contractName}: {csvPath} is missing "contract" and/or "storage" rows') continue + try: + measured = measure_origination_operation(contractDir) + except RuntimeError as error: + failures.append(f'{contractName}: {error}') + continue + checkedAny = True - total = contractBytes + storageBytes - status = 'OK' if total <= maxTotalBytes else 'TOO LARGE' - print(f'[INFO] {contractName}: code={contractBytes}B, storage={storageBytes}B, total={total}B ({status})') - if total > maxTotalBytes: + codeAndStorage = contractBytes + storageBytes + opSize = measured['opSize'] + margin = maxTotalBytes - opSize + status = 'OK' if opSize <= maxTotalBytes else 'TOO LARGE' + print( + f'[INFO] {contractName}: code={contractBytes}B, storage={storageBytes}B ' + f'(code+storage={codeAndStorage}B); forged origination operation=' + f'{measured["forgedBytes"]}B + signature={measured["signatureBytes"]}B = ' + f'{opSize}B, margin={margin}B ({status})' + ) + if opSize > maxTotalBytes: failures.append( - f'{contractName}: total origination size {total}B exceeds the safety threshold of ' - f'{maxTotalBytes}B (code={contractBytes}B, storage={storageBytes}B). Investigate whether a ' - f'recent change (e.g. disabling lazification) meaningfully increased contract size before ' - f'deploying to mainnet; consider raising MAX_CONTRACT_OPERATION_BYTES only after confirming ' - f'the actual origination still succeeds comfortably within protocol limits (see README ' - f'"Mainnet Deployment" and deployMichelsonContract()\'s fee estimate in deploy_script/util.js).' + f'{contractName}: the origination operation is {opSize}B, which exceeds the ' + f'{maxTotalBytes}B protocol limit by {-margin}B, so it cannot be injected at all. ' + f'Note this is the size of the whole operation, not just code+storage ' + f'({codeAndStorage}B). Make the contract smaller; raising ' + f'MAX_CONTRACT_OPERATION_BYTES cannot make an over-limit operation injectable.' + ) + elif margin < REVEAL_BYTES: + print( + f'[WARN] {contractName}: only {margin}B of margin remains, which is less than the ' + f'~{REVEAL_BYTES}B a reveal operation adds. This contract can only be originated ' + f'from an account whose public key is already revealed, because Taquito batches the ' + f'reveal into the same operation group and the 32768B limit applies to the group. ' + f'Reduce the contract size before deploying from a fresh key.' ) if not checkedAny: @@ -158,7 +221,10 @@ def main(): print(f' - {failure}') sys.exit(1) - print(f'Operation size check passed (threshold: {maxTotalBytes}B per contract).') + print( + f'Operation size check passed (forged origination operation must stay within ' + f'{maxTotalBytes}B per contract).' + ) finally: shutil.rmtree(tmpDir, ignore_errors=True) diff --git a/deploy/deploy_script/measure_origination_size.js b/deploy/deploy_script/measure_origination_size.js new file mode 100644 index 00000000..d04cbec0 --- /dev/null +++ b/deploy/deploy_script/measure_origination_size.js @@ -0,0 +1,160 @@ +// Measures the size of the *complete* origination operation for a compiled contract, +// offline. This exists because SmartPy's `*_sizes.csv` only reports the packed +// Micheline size of the contract code and the initial storage value. The protocol's +// `max_operation_data_length` (32768) applies to the whole signed manager operation, so +// code+storage systematically under-reports the number that actually has to fit. +// +// The value printed here is the same quantity Taquito reports as `estimate.opSize`: +// the forged operation bytes plus the signature that gets appended at injection +// (see @taquito/taquito rpc-estimate-provider.js, which computes +// `opbytes.length / 2 + payloadLength[Ed25519Signature]`). Forging locally with +// @taquito/local-forging means this needs no RPC, no funded account, and no secret key, +// so it can run in CI on every commit instead of only at deploy time. +// +// Envelope fields (fee/counter/gas_limit/storage_limit) are zarith-encoded, so their +// byte length depends on their magnitude. We deliberately forge with values at or above +// anything a real origination would use, which makes the reported size an upper bound: +// if this passes, the real origination is no larger. +const fs = require('fs'); +const path = require('path'); + +const { LocalForger } = require('@taquito/local-forging'); +const { PrefixV2, b58Encode, payloadLength } = require('@taquito/utils'); + +const DEFAULT_MAX_TOTAL_BYTES = 32768; + +// Ed25519 (tz1) signature payload length, read from Taquito rather than hardcoded so this +// stays in step with the same table its estimator uses. +const SIGNATURE_BYTES = payloadLength[PrefixV2.Ed25519Signature]; + +// Deliberately pessimistic envelope values. Each is >= what a real origination needs, +// so the zarith encodings are at least as long as the ones that get injected: +// fee - far above any observed origination fee +// counter - 9 digits, beyond current mainnet counters +// gas_limit - the per-operation hard gas cap +// storage_limit - well above a 32KB contract's storage cost +// balance is '0' to match deployMichelsonContract() in util.js, which originates with +// no initial balance. +const WORST_CASE_ENVELOPE = { + fee: '1000000', + counter: '999999999', + gas_limit: '1040000', + storage_limit: '999999', + balance: '0', +}; + +// Any well-formed values work for forging; only their encoded length matters, and both of +// these are fixed-width. The branch is a block hash (32 bytes) and the source is an +// implicit account (21 bytes), regardless of which block or account is used. +const PLACEHOLDER_BRANCH = b58Encode(new Uint8Array(32).fill(0x11), PrefixV2.BlockHash); +const PLACEHOLDER_SOURCE = 'tz1XTWbfhyWK9xmPAa6TyQUSv437JFgZDzgA'; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function resolveArtifactPaths(contractDir) { + const entries = fs.readdirSync(contractDir); + const contractFile = entries.find((name) => /_contract\.json$/.test(name)); + const storageFile = entries.find((name) => /_storage\.json$/.test(name)); + if (!contractFile || !storageFile) { + throw new Error( + `${contractDir} does not look like a SmartPy output directory: expected a ` + + `*_contract.json and a *_storage.json (found: ${entries.join(', ') || 'nothing'}).`, + ); + } + return { + contractPath: path.join(contractDir, contractFile), + storagePath: path.join(contractDir, storageFile), + }; +} + +async function measureOriginationSize({ contractPath, storagePath }) { + const forger = new LocalForger(); + const opBytes = await forger.forge({ + branch: PLACEHOLDER_BRANCH, + contents: [{ + kind: 'origination', + source: PLACEHOLDER_SOURCE, + ...WORST_CASE_ENVELOPE, + script: { + code: readJson(contractPath), + storage: readJson(storagePath), + }, + }], + }); + + const forgedBytes = opBytes.length / 2; + return { + forgedBytes, + signatureBytes: SIGNATURE_BYTES, + opSize: forgedBytes + SIGNATURE_BYTES, + }; +} + +function parseArgs(argv) { + const args = { contractDir: null, maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES, json: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') { + args.json = true; + } else if (arg === '--max') { + args.maxTotalBytes = Number(argv[i + 1]); + i += 1; + } else if (!args.contractDir) { + args.contractDir = arg; + } else { + throw new Error(`Unexpected argument "${arg}".`); + } + } + if (!args.contractDir) { + throw new Error( + 'Usage: node measure_origination_size.js [--max BYTES] [--json]\n' + + 'e.g. node measure_origination_size.js ../../TezFinBuild/compiled_contracts/Comptroller', + ); + } + if (!Number.isFinite(args.maxTotalBytes) || args.maxTotalBytes <= 0) { + throw new Error('--max must be a positive number of bytes.'); + } + return args; +} + +async function main() { + const { contractDir, maxTotalBytes, json } = parseArgs(process.argv.slice(2)); + const measurement = await measureOriginationSize(resolveArtifactPaths(contractDir)); + const margin = maxTotalBytes - measurement.opSize; + const withinLimit = measurement.opSize <= maxTotalBytes; + + if (json) { + console.log(JSON.stringify({ + contractDir, ...measurement, maxTotalBytes, margin, withinLimit, + }, null, 2)); + } else { + console.log(`[INFO] ${path.basename(contractDir)}: forged=${measurement.forgedBytes}B + ` + + `signature=${measurement.signatureBytes}B = opSize ${measurement.opSize}B ` + + `(limit ${maxTotalBytes}B, margin ${margin}B)`); + } + + if (!withinLimit) { + console.error( + `[FAIL] ${path.basename(contractDir)} origination operation is ${measurement.opSize}B, ` + + `which exceeds the ${maxTotalBytes}B limit by ${-margin}B. This operation cannot be ` + + 'injected; the contract must be made smaller.', + ); + process.exitCode = 1; + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { + measureOriginationSize, + resolveArtifactPaths, + DEFAULT_MAX_TOTAL_BYTES, + SIGNATURE_BYTES, +}; diff --git a/deploy/deploy_script/package-lock.json b/deploy/deploy_script/package-lock.json index 453a6c04..ba1b8ea9 100644 --- a/deploy/deploy_script/package-lock.json +++ b/deploy/deploy_script/package-lock.json @@ -8,6 +8,7 @@ "name": "TezFinDeploy", "version": "1.0.0", "dependencies": { + "@taquito/local-forging": "^25.0.0", "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", "@taquito/utils": "^25.0.0", diff --git a/deploy/deploy_script/package.json b/deploy/deploy_script/package.json index 56d05322..ab4177b6 100644 --- a/deploy/deploy_script/package.json +++ b/deploy/deploy_script/package.json @@ -6,6 +6,7 @@ "node": "22.16.0" }, "dependencies": { + "@taquito/local-forging": "^25.0.0", "@taquito/signer": "^25.0.0", "@taquito/taquito": "^25.0.0", "@taquito/utils": "^25.0.0", @@ -18,6 +19,7 @@ "deploy": "node deploy.js", "deploy:e2e": "node deploy_e2e.js", "estimate:origination": "node estimate_origination.js", + "measure:origination-size": "node measure_origination_size.js", "prepare:deploy": "node prepare.js", "verify:mainnet-oracle": "node verify_mainnet_oracle.js", "test": "node --test test/*.test.js" diff --git a/deploy/deploy_script/test/origination_size.test.js b/deploy/deploy_script/test/origination_size.test.js new file mode 100644 index 00000000..8b89ef0c --- /dev/null +++ b/deploy/deploy_script/test/origination_size.test.js @@ -0,0 +1,111 @@ +// Offline tests for measure_origination_size.js, which is what the CI size gate +// (deploy/compile_targets/tests/test_operation_size.py) uses to decide whether a contract +// can actually be originated. These pin the two properties that make the measurement +// trustworthy: it accounts for the whole operation rather than just code+storage, and it +// tracks contract size monotonically. No network access. +// +// Run with: node --test deploy/deploy_script/test/origination_size.test.js + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + measureOriginationSize, + resolveArtifactPaths, + SIGNATURE_BYTES, +} = require('../measure_origination_size.js'); + +// A minimal but real contract: `parameter nat; storage nat; code { CDR; NIL operation; PAIR }`. +function buildContract(extraStorageEntries) { + return [ + { prim: 'parameter', args: [{ prim: 'nat' }] }, + { prim: 'storage', args: [{ prim: 'nat' }] }, + { + prim: 'code', + args: [[{ prim: 'CDR' }, { prim: 'NIL', args: [{ prim: 'operation' }] }, { prim: 'PAIR' }]], + }, + ...extraStorageEntries, + ]; +} + +function writeArtifacts(dir, contract, storage) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'step_000_cont_0_contract.json'), JSON.stringify(contract)); + fs.writeFileSync(path.join(dir, 'step_000_cont_0_storage.json'), JSON.stringify(storage)); + return dir; +} + +// Awaits run() before cleaning up, so an async body cannot have the directory deleted out +// from under it at its first suspension point. +async function withTempDir(run) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'origination_size_test_')); + try { + return await run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('reported opSize is the forged operation plus the signature', async () => { + await withTempDir(async (dir) => { + const artifacts = writeArtifacts(dir, buildContract([]), { int: '0' }); + const measured = await measureOriginationSize(resolveArtifactPaths(artifacts)); + + assert.equal(measured.signatureBytes, SIGNATURE_BYTES); + assert.equal(measured.opSize, measured.forgedBytes + SIGNATURE_BYTES); + }); +}); + +test('opSize accounts for the operation envelope, not just the script', async () => { + // This is the whole point of the check: gating on code+storage alone under-reports what + // has to fit under the protocol's 32768-byte limit. A tiny contract's script is a + // handful of bytes, yet the operation carrying it must also encode the branch (32B), + // source (21B), the four zarith limits, and the signature (64B). + await withTempDir(async (dir) => { + const artifacts = writeArtifacts(dir, buildContract([]), { int: '0' }); + const measured = await measureOriginationSize(resolveArtifactPaths(artifacts)); + + const branchAndSourceBytes = 32 + 21; + assert.ok( + measured.forgedBytes > branchAndSourceBytes, + `forged operation (${measured.forgedBytes}B) must exceed branch+source framing alone`, + ); + assert.ok(measured.opSize > measured.forgedBytes, 'opSize must add the signature'); + }); +}); + +test('a larger contract measures larger', async () => { + await withTempDir(async (dir) => { + const small = writeArtifacts(path.join(dir, 'small'), buildContract([]), { int: '0' }); + // Views are part of the contract script, so adding one must increase the measurement. + const big = writeArtifacts( + path.join(dir, 'big'), + buildContract([{ + prim: 'view', + args: [ + { string: 'someView' }, + { prim: 'unit' }, + { prim: 'nat' }, + [{ prim: 'CDR' }], + ], + }]), + { int: '0' }, + ); + + const smallMeasured = await measureOriginationSize(resolveArtifactPaths(small)); + const bigMeasured = await measureOriginationSize(resolveArtifactPaths(big)); + assert.ok( + bigMeasured.opSize > smallMeasured.opSize, + `expected the contract with an extra view to measure larger, got ${bigMeasured.opSize} <= ${smallMeasured.opSize}`, + ); + }); +}); + +test('resolveArtifactPaths rejects a directory without SmartPy output', async () => { + await withTempDir((dir) => { + assert.throws(() => resolveArtifactPaths(dir), /does not look like a SmartPy output directory/); + }); +}); diff --git a/deploy/deploy_script/util.js b/deploy/deploy_script/util.js index 9093e34e..bd3bd680 100644 --- a/deploy/deploy_script/util.js +++ b/deploy/deploy_script/util.js @@ -57,11 +57,40 @@ async function initAccount() { } function parseMichelsonFile(filePath, kind) { + let value; try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + value = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (error) { throw new Error(`Could not parse ${kind} JSON at ${filePath}: ${error.message}`); } + if (kind === 'contract') { + assertValidMichelsonCode(value, filePath); + } + return value; +} + +// SmartPy can still write a *.json artifact after a compile-time type error, with +// `{ "prim": "ERROR", ... }` nodes in place of real Michelson. The node then rejects +// origination with an opaque "unexpected string value ERROR" parse failure. Catch that +// here so the deploy fails before fee estimation / injection. +function assertValidMichelsonCode(node, filePath) { + const stack = [node]; + while (stack.length) { + const current = stack.pop(); + if (Array.isArray(current)) { + for (const child of current) stack.push(child); + continue; + } + if (!current || typeof current !== 'object') continue; + if (current.prim === 'ERROR') { + throw new Error( + `${filePath} contains invalid SmartPy output (prim "ERROR"). Recompile the ` + + `source and fix any SmartPy warnings such as "Missing type" / "unknown type ` + + `variable" before deploying.`, + ); + } + if (current.args) stack.push(current.args); + } } function sleep(ms) { diff --git a/deploy/test_data/PriceOracle.py b/deploy/test_data/PriceOracle.py index bb0e62fd..c91ee4a4 100644 --- a/deploy/test_data/PriceOracle.py +++ b/deploy/test_data/PriceOracle.py @@ -26,7 +26,18 @@ def getPrice(self, requestedAsset): sp.if self.data.prices.contains(requestedAsset): price.value = self.data.prices[requestedAsset] sp.result((sp.timestamp(0), price.value)) - + + # OracleInterface declares getValidatedPrice; leaving it as `pass` makes SmartPy emit + # invalid Michelson (`prim: "ERROR"` / unknown type variable) that Previewnet rejects + # at origination. The mock does not enforce bounds — TezFinOracle does that for real. + @sp.onchain_view() + def getValidatedPrice(self, params): + sp.set_type(params, OracleInterface.TValidatedPriceRequest) + price = sp.local('price', sp.nat(0)) + sp.if self.data.prices.contains(params.requestedAsset): + price.value = self.data.prices[params.requestedAsset] + sp.result((sp.timestamp(0), price.value)) + @sp.entry_point def get(self, requestPair): sp.set_type(requestPair, OracleInterface.TGetPriceParam) diff --git a/docs/MainnetGovernancePayloads.json b/docs/MainnetGovernancePayloads.json index 600e026f..ae3b92c4 100644 --- a/docs/MainnetGovernancePayloads.json +++ b/docs/MainnetGovernancePayloads.json @@ -161,7 +161,7 @@ "name": "multisig_opens_approved_markets", "preconditions": [ "Admin handoff storage has been verified on-chain", - "The production oracle guard passes for XTZUSDT and BTCUSDT", + "The production oracle guard passes for XTZUSDT, USDTUSDT, and TZBTCUSDT", "Exact-transfer review is complete for the canonical USDt and tzBTC contracts", "Every collateral factor and pause state has formal risk approval" ],