This project is the implementation of Compound Protocol on Tezos chain using SmartPy language.
Node.js 22 is required for the JavaScript and TypeScript packages. Run nvm use from the repository root before installing dependencies.
The diagram demonstrates relations between contracts

The platform consists of the following contracts
- Governance - admin contract that has the ability to change parameters in Comptroller and ꜰTokens. The first version of Tezfin has a centralized Governance that works as an admin proxy
- Comptroller - the risk management layer. It determines how much collateral a user is required to maintain, and whether (and by how much) a user can be liquidated. Each time a user interacts with a ꜰToken, the Comptroller is asked to approve or deny the transaction
- Price oracle - third-party contract that provides price data. Used by Comptroller for liquidity calculation
- ꜰToken - Tezfin market for the underlying token
- Interest Rate Model - specifies rules of acquiring interest rate and borrow rate
- Underlying token - the contract of the actual asset. Tezfin supports both FA1.2 and FA2 tokens
For the detailed description please refer to the wiki pages.
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 the pinned compiler and runtime dependencies with:
tools/install-smartpy.shThe 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 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 - contains SmartPy code of smart contracts
- interfaces - smart contracts interfaces with description of external entry points
- utils - smart contracts extensions with utility functions
- tests - unit tests
- mock - mock contracts for test purposes
- utils - unit tests utility functions
- CTokenTest.py - unit tests for generic ꜰToken code
- CFA12Test.py - unit tests for FA1.2 ꜰToken template
- CFA2Test.py - unit tests for FA2 ꜰToken template
- CXTZTest.py - unit tests for XTZ ꜰToken implementation
- InterestRateModelTest.py - unit tests for interest rate model
- ComptrollerTest.py - unit tests for Comptroller
- GovernanceTest.py - unit tests for Governance
- InterestRateModel.py - interest rate model, calculates supply and borrow rate for ꜰToken instance
- CToken.py - ꜰToken generic code
- CFA12.py - FA1.2 ꜰToken template
- CFA2.py - FA2 ꜰToken template
- CXTZ.py - XTZ ꜰToken implementation
- Comptroller.py - The risk model contract
- Governance.py - Performs control over the protocol
- docs - materials for documentation
- deploy - contains scipts for compilation and deployment
- test_data - additional contracts for deploment on testnet
- compile_targets - contains description of compilation targets
- Config.json - contracts compilation configuration
- deploy_script
- config.json - deploy configuration with secret data
- deploy.js - conseiljs deployment script
- shell_scripts - shell scripts to compile and deploy contracts in one command
To run tests use SmartPy CLI from the core project directory. Create a folder for test outputs. Example:
mkdir TezFinTest
cd TezFin
~/smartpy-cli/SmartPy.sh test contracts/tests/CTokenTest.py ../TezFinTest/ --htmlAfter executing the previous command, an HTML report will be generated in "../TezFinTest/CToken_Tests/log.html"
The following script does the same as above, but for all test files at once.
cd TezFin
./contracts/tests/run_tests.sh ~/smartpy-cli/SmartPy.shThe 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.
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<Market>_IRMconfig key instead of accidentally reusing another market's IRM.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*.pyreferenced fromdeploy_previewnet.sh/deploy_mainnet.shactually exists on disk (catches a typo'd/renamed/deleted compile target left dangling in a shell script); CompileCtzBTC_IRM.pyreads its parameters only fromCFG.CtzBTC_IRM(its own, asset-specific IRM config block), not another market's by mistake;Config.py(Python) andutil.js'sresolveDeployResultPath()(JS) actually resolve to the same default manifest file for a givennetworkProfile("previewnet"/"mainnet"/unset) - executed for real (each language's actual source, not a re-implementation) rather than just asserted in a comment.
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.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 intocompiled_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 indeploy_previewnet.sh/deploy_mainnet.sh(e.g.--erase-comments --erase-var-annots --initial-castfor Comptroller), against a throwaway manifest of placeholder addresses (e2e/deploy_result/deploy.json), then 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.csvreports only the packed Micheline size of the contract code and the initial storage, but the protocol'smax_operation_data_lengthapplies 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(seedeploy_script/measure_origination_size.js) and gates on that, which is the same number Taquito reports asestimate.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 ciindeploy/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.python3 deploy/compile_targets/tests/test_operation_size.py ~/smartpy-cli/SmartPy.shTo measure a single already-compiled contract directly:
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 bydeploy_mainnet.shtwice in clean temporary directories with the same production flags, then compares SHA-256 hashes of canonical contract and storage JSON. CI publishes the resultingcompiled-contract-hashes.jsonartifact for deployment and multisig review.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 inutil.js,assert_network.js, andmainnet_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_MANIFESTenv var vs. per-profile default), and mainnet preflight's required-canonical-key / vetted-address-allowlist checks.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.
To run e2e tests use the following command, you will need latest smartpy cli installed.
cd TezFin
./e2e/shell_scripts/deploy_all_contracts.sh ~/smartpy-cli/SmartPy.shBefore listing an underlying, complete the market listing checklist. In particular, this cash-accounting design supports only exact-transfer underlyings.
To compile and deploy all contracts at once:
- Configure parameters for contracts compilation in Config.json. Reffer to Compilation arguments
- The deployer is configured for Tezos X Previewnet in config.json. Keep only the public deployment address there; provide the matching private key from your shell (never commit it):
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
cd deploy/deploy_script
npm install
npm run check
npm run prepare:deploy- Run the deployment script for the target network:
- Previewnet:
./deploy/shell_scripts/deploy_previewnet.sh ~/smartpy-cli/SmartPy.sh - Mainnet (see Mainnet Deployment below before running this):
MAINNET_DEPLOY_CONFIRM=yes ./deploy/shell_scripts/deploy_mainnet.sh ~/smartpy-cli/SmartPy.sh
- Previewnet:
To deploy a specific contract run the corresponding script in shell_scripts
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.jsondeclaresnetworkProfile: "mainnet"and the connected RPC reports a known mainnet chain id (assert_network.js). It also exportsDEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.mainnet.jsonat 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 forPriceOracle,USDt, andtzBTC(checked both against an on-chain existence check and a required hardcoded allowlist inmainnet_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 requiresMAINNET_DEPLOY_CONFIRM=yesto proceed past that point. Only after this passes doesprepare.jsrun and writeOriginatorAddressto the manifest — declining confirmation leaves the manifest untouched. - Before every mainnet origination, the programmatic preflight in
util.jsalso runsverify_mainnet_oracle.js. This applies to both the shell script and rawnode deploy.jsusage 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 before unpausing any market.
USDtzis intentionally not required bymainnet_preflight.js: no compile target in either deploy script currently originates aCUSDtzmarket. If aCUSDtzmarket is added to the mainnet pipeline later, addUSDtzback toREQUIRED_CANONICAL_KEYS(and to the allowlist) inmainnet_preflight.js.
The deploy scripts track originated addresses in a manifest file
(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.jsonis 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 usedeploy.mainnet.json(or another explicitDEPLOY_MANIFESTpath) and must never be written todeploy.json. - The manifest path resolution is centralized (
resolveDeployResultPath()inutil.js, mirrored byConfig.pyfor the SmartPy side) so every tool agrees on the same file:DEPLOY_MANIFEST, if set, always wins.- Otherwise, the default is derived from
deploy_script/config.json'snetworkProfile:deploy.mainnet.jsonwhen it's"mainnet",deploy.jsonotherwise. To keep Previewnet and mainnet deployments in fully separate files, setDEPLOY_MANIFESTto an explicit path before runningnpm run prepare:deployand the deploy shell scripts, e.g.:
Never reuse a Previewnet manifest for a mainnet run (or vice versa).export DEPLOY_MANIFEST=TezFinBuild/deploy_result/deploy.mainnet.json - 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.
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. 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.
TezFinOracle (contracts/TezFinOracle.py) is a thin proxy: it forwards
price lookups to the address stored as oracle (the PriceOracle from the manifest) and expects that
address to expose the on-chain view get_price_with_timestamp(string) -> pair(nat, timestamp) for
symbols such as XTZUSDT and BTCUSDT. It also has a small admin-controlled override map for assets
the upstream feed does not support (e.g. USD and USDT). TezFinOracle's own admin (settable via
set_pending_admin / accept_admin) controls those overrides and can repoint oracle to a different
feed with set_oracle.
- Previewnet:
CompileTestData.pycompiles and deploys a mockPriceOracle(deploy/test_data/PriceOracle.py) as part ofdeploy_previewnet.sh. This mock is for Previewnet only, is not Harbinger — it's a bare stand-in that mimics the samegetcallback interface. It has no administrator check: itssetPriceentry point can be called by any address to set any price for any asset. Do not treat a Previewnet deployment using this mock as representative of mainnet price-feed security. - Mainnet:
deploy_mainnet.shnever compiles or originates the mock oracle (it does not runCompileTestData.pyat all). Put the exact address of the vetted production Harbinger (or Harbinger-compatible) oracle directly under thePriceOraclekey in the mainnet manifest (DEPLOY_MANIFEST) before runningdeploy_mainnet.sh;mainnet_preflight.jsverifies it exists on-chain before anything is compiled. The mandatory programmatic deployment preflight executes the exact XTZ, USDT, and tzBTC views before origination and rejects zero, stale, or 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.
TezFinOracle's Etherlink/Pyth upstream lookup
is fail-closed by design: pythCore, pythMaxAgeWord, and feedIds are not populated in the
constructor (only a placeholder 60-second pythMaxAgeWord is), so getPrice/getValidatedPrice
reject every non-override asset until an admin finishes configuring them. The following order is
mandatory and must be reproduced by the deployment runner and any governance payload:
originate TezFinOracle
-> setPythCore(pythCoreEvmAddress)
-> setPythMaxAge(maxAgeWord)
-> setFeedIds([{asset, feedId, targetDecimals, maxConfidenceBps}, ...])
-> configurePriceBounds(...) (per Comptroller/cToken)
-> configureMaxPriceAge(...) (per Comptroller)
-> enable market (supportMarket / unpause)
If a step is skipped, getPrice/getValidatedPrice fails closed with a specific error instead of
silently returning stale or zero data:
| Skipped step | getPrice / getValidatedPrice error |
|---|---|
setFeedIds for the asset |
UNSUPPORTED_PYTH_ASSET |
setPythCore |
PYTH_CORE_NOT_CONFIGURED |
configurePriceBounds |
PRICE_BOUNDS_NOT_CONFIGURED |
configureMaxPriceAge |
MAX_PRICE_AGE_NOT_CONFIGURED |
This order and every error in the table above are covered by
contracts/tests/TezFinOracleTest.py.
Each Pyth feed carries its own mandatory confidence limit (maxConfidenceBps), stored as
part of that feed's setFeedIds entry. There is no shared/implicit fallback limit: a feed
cannot be pinned at all without an explicit basis-points value in (0, 10000]
(INVALID_PYTH_CONFIDENCE_LIMIT otherwise -- 0 is rejected too, since it would make the
feed permanently unusable rather than "unconfigured"), and getPrice rejects a quote as
EXCESSIVE_PYTH_CONFIDENCE whenever conf * 10000 > rawPrice * maxConfidenceBps for that
feed.
The limits below are proposed TezFin starting policy values, not Pyth-prescribed defaults, and are not a substitute for measured confidence/price ratios:
| Feed | Limit | Basis points |
|---|---|---|
| BTC/USD | 0.25% | 25 |
| XTZ/USD | 0.50% | 50 |
| USDT/USD | 0.10% | 10 |
No feed/market may be activated on mainnet using these starting values alone; final
production approval requires empirical per-feed confidence/price ratio measurements
and explicit governance sign-off. tzBTC-USD inherits BTC/USD's limit and
USDtz-USD/USDt-USD inherit USDT/USD's limit, since they resolve to the same underlying
feed (see proxy policy below).
deploy/deploy_script/configure_pyth_oracle.js enforces this at the deployment-tooling
level: it reads maxConfidenceBps from the manifest's PythConfidenceLimitsBps (never
hard-coded in the script) and refuses to run setFeedIds unless the manifest also sets
PythConfidenceLimitsApproved: true. On the mainnet network profile there is no override.
On any other profile, a one-off smoke test may bypass the gate with
ALLOW_UNAPPROVED_CONFIDENCE_LIMITS=1, which prints a loud warning and must never be
treated as governance approval.
The L2 asset mappings below are explicit proxies, not independent price feeds:
tzBTC-USDuses the BTC/USD Pyth feed. This does not detect a tzBTC/BTC depeg.USDtz-USDandUSDt-USDuse the USDT/USD Pyth feed. This does not prove or detect a USDtz/USDT or USDt/USDT peg failure.
These proxy mappings must be treated as a governance and risk-policy decision; they are not evidence that the wrapped asset maintains its intended peg.
TezFinOracle fails closed on stale, malformed, future, non-positive, out-of-range,
or excessive-confidence Pyth quotes. The resulting behavior is operation-specific:
| Operation/path | Behavior when a required quote is rejected |
|---|---|
| Mint | Fails during the price/liquidity refresh path; no market action is authorized. |
| Borrow | Fails during the required price/liquidity refresh path; no borrow is authorized. |
| Redeem | Fails when the required account snapshot/liquidity path cannot be refreshed. |
| Repay | Remains available as a recovery operation; it does not require a new collateral price. |
| Liquidation | Fails when the borrower liquidity snapshot cannot be refreshed or is invalid. |
| Transfer | Fails when the transfer requires a collateral/liquidity check that cannot be refreshed. |
Repayment remaining available is intentional: it lets users reduce debt during an oracle incident. It must not be interpreted as proof that collateral valuation is available. A liquidation cannot use a stale or invalid liquidity snapshot, and must wait for a successful price/liquidity refresh.
The tzBTC -> BTC/USD and USDtz/USDt -> USDT/USD mappings do not authorize those proxy
markets for production. Before activation, each proxy market requires a separate governance
approval recording:
- the approved underlying feed and confidence limit;
- price bounds and maximum-change policy;
- an independent depeg monitor for
tzBTC/BTC,USDtz/USDT, orUSDt/USDT; - alert and emergency actions, including pausing new mint/borrow and any additional price-dependent actions required by governance;
- the accountable owner and approval record.
Until those conditions are approved and verified, proxy-priced markets remain disabled even if the underlying Pyth feed is fresh and within its confidence limit.
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:
- Keep all markets paused until the handoff below is fully verified.
- On
Governance, callsetPendingGovernance(<multisig address>)from the deployer account. - From the multisig, call
acceptGovernance()onGovernanceto finalize the transfer. - On
TezFinOracle, callset_pending_admin(<multisig address>)from the deployer account, then have the multisig callaccept_admin()to finalize the transfer. - Verify the exact final on-chain storage (e.g. via a block explorer or a Taquito script):
Governance.administratoris the production multisig.TezFinOracle.adminis the production multisig.Comptroller.administratoris theGovernancecontract address.- Every ꜰToken's
administratoris theGovernancecontract address. pendingAdministratorisNoneonGovernance,Comptroller, and every ꜰToken, andTezFinOracle.pendingAdminisNone.- The deployment wallet is absent from all administrator and pending-administrator fields and retains no administrative authority.
- 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 Util
cd src/util
npm install- Build UI
cd src/ui
npm install
npm run build- Run
cd src/ui
npm start