Ink! contracts for a collateralized TUSDT system with vault borrowing, a lending/borrowing pool, liquidation auctions, and on-chain governance plus a treasury that books protocol fees.
The contracts split into three layers:
- Protocol —
tusdt-erc20(token, multi-minter),tusdt-vault-alpha(CDP borrowing/liquidation backed by subnet alpha),tusdt-lending-pool(TAO/TUSDT lending with alpha collateral, lToken receipt tokens, utilization-based interest rates),tusdt-auction(ascending-bid liquidation auctions for the vault),tusdt-oracle(collateral pricing). The vault owns the token/auction/oracle instances it creates; the lending pool spawns its own lToken children. - Governance & treasury —
tusdt-governance(token-holder proposals plus a maintainer/council authority that steers the protocol contracts) andtusdt-treasury(per-fund accounting for fees, released only by governance).
- Rust 1.89 (pinned via
rust-toolchain.toml— rustup picks it up automatically) wasm32-unknown-unknowntargetcargo-contract5.x- Node.js + Yarn (for the isolated contract tooling under
tools/) - A Contracts-enabled Substrate node (local or remote)
rustup target add wasm32-unknown-unknown
cargo install --locked cargo-contractOne-command gates mirror CI (requires just):
| Command | What it does |
|---|---|
just fmt |
cargo fmt --all |
just check |
cargo check --workspace |
just test |
cargo test --workspace |
just clippy |
cargo clippy --workspace --all-targets -- -D warnings |
just lint |
fmt → clippy-fix → clippy |
just build <name> |
Build one contract's artifacts (e.g. just build tusdt-lending-pool) |
just build-all |
Build all 8 contracts' artifacts |
Build all crates:
cargo checkBuild contract artifacts (.contract, .wasm, metadata):
cargo contract build --manifest-path contracts/tusdt-erc20/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-auction/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-oracle/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-vault-alpha/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-lending-pool/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-treasury/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-governance/Cargo.toml --release
cargo contract build --manifest-path contracts/tusdt-election/Cargo.toml --releaseArtifacts are produced in target/ink/.
# Format all Rust source files
cargo fmt -- --check
# Lint with clippy (deny warnings)
cargo clippy --all-targets --all-features -- -D warnings
# Auto-fix formatting
cargo fmt
# Run all tests across the workspace
cargo test --workspace
# Run a single contract's tests
cargo test -p tusdt-vault-alpha
# Run a single named test with output
cargo test -p tusdt-vault-alpha <test_name> -- --nocaptureContracts are panic-free by construction — the clippy lints that are denied workspace-wide in the root Cargo.toml:
arithmetic-side-effects = "deny"
unwrap-used = "deny"
expect-used = "deny"
indexing-slicing = "deny"
Contract code must use .ok_or(...)/checked_*/saturating_*; a panic can never revert a
message or brick a query. Rare provably-unreachable exceptions carry a narrowly-scoped #[allow]
with a justification comment. Test modules opt out via the #![allow(...)] header at the top of
each tests.rs (test ergonomics only — production code stays strict).
CI gate: cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
⚠️ cargo contract buildisolates each contract into a temp dir, so contracts define their[lints.clippy]deny block inline in their ownCargo.toml—lints.workspace = trueinheritance is used only by the non-contract crates (env,primitives,voting,test-support).
The off-chain chain-extension mock (MockExtension — function ids 0/15/36/25/2|5|6 plus
register_mock*/set_caller helpers) used to be copy-pasted into every contract's tests.rs.
It now lives once in the test-support workspace crate and is wired as a dev-dependency of all 8
contracts. When the chain extension gains a function id, update the mock once.
Every Error variant of all 8 contracts is documented in docs/errors/ — one page per contract
with the enum's doc comment, the messages that return each variant, and client guidance (input
error? authorization? retry?). docs/errors/index.md links them all. Add/rename a variant →
update its page in the same commit.
Shared deployment scripts and on-chain tests live in an isolated TypeScript subproject under tools/.
The current iteration exposes upload support for erc20, auction, oracle, and vault-alpha, plus a single vault-alpha deployment entrypoint. The treasury, governance, and election upload scripts are also available.
Setup:
cd tools
yarn install
cp .env.example .envDefault .env values target a local dev node:
WS_URL=ws://127.0.0.1:9944
Scripts and tests use the standard local dev accounts (//Alice, //Bob, //Charlie, //Dave, //Eve, //Ferdie) from the shared dev-account helper.
When needed, you can override the selected account via SURI environment variables such as CONTRACT_UPLOADER=//Alice, CONTRACT_DEPLOYER=//Alice.
Useful commands:
cd tools
yarn build:erc20-artifacts
yarn build:auction-artifacts
yarn build:oracle-artifacts
yarn build:vault-artifacts
yarn erc20:upload
yarn auction:upload
yarn oracle:upload
yarn vault:upload
yarn vault:deploy --token-code-hash <TOKEN_CODE_HASH> --auction-code-hash <AUCTION_CODE_HASH> --oracle-code-hash <ORACLE_CODE_HASH> --treasury-address <SS58> --oracle-netuid <NETUID> --hotkey <SS58>
yarn test:oracletusdt-vault-alpha::new takes a treasury address, code hashes for token/auction/oracle,
oracle_netuid (subnet for oracle reporters), and hotkey (staking hotkey for alpha collateral).
The vault instantiates the token, auction, and oracle internally. Because the vault creates the
TUSDT token, the token address is not known until the vault exists — deploy the treasury after the
vault and wire it in via update_treasury.
-
Upload ERC20 code (
tusdt-erc20) and capture code hash. -
Upload Auction code (
tusdt-auction) and capture code hash. -
Upload Oracle code (
tusdt-oracle) and capture code hash. -
Instantiate Alpha Vault (
tusdt-vault-alpha::new) with:treasury— a placeholder address for now (e.g. the deployer); reassigned in step 7.token_code_hashauction_code_hashoracle_code_hashoracle_netuid— subnet whose registered neurons may submit oracle priceshotkey— staking hotkey for alpha collateral deposits
The deployer becomes the initial governance of the vault, auction, and oracle.
-
Read the token / auction / oracle addresses from the vault (
get_token_address,get_auction_address,get_oracle_address). -
Instantiate Treasury (
tusdt-treasury::new) with the vault's TUSDT token address. -
Wire the vault's fee recipient:
tusdt-vault-alpha::update_treasury(treasury). -
Add vault as minter on ERC20:
tusdt-erc20::add_minter(vault_address). -
Upload Election code (
tusdt-election) and capture code hash. -
Instantiate Governance (
tusdt-governance::new) with thetreasury,vault,auction,oracleaddresses, initialmaintainer, andelection_code_hash. -
Hand control to the governance contract:
tusdt-treasury::set_governance(governance)tusdt-vault-alpha::update_governance(governance)— propagates to auction and oracle too.
-
Seat the council:
tusdt-governance::set_council([c1..c5]). -
Approve target subnets:
tusdt-governance::vault_set_approved_netuid(N, true)for each subnet. -
Configure per-netuid params:
tusdt-governance::vault_set_contract_params(N, params)for each subnet (24h timelock, then anyone callstusdt-vault-alpha::execute_contract_params_update(N)). -
(Optional) Adjust global params:
tusdt-governance::vault_set_global_params(config)(24h timelock, then anyone callstusdt-vault-alpha::execute_global_params_update()).
After step 11 the protocol contracts are steered exclusively by the governance contract, and within governance the maintainer/council split (see Governance & Treasury) applies.
Example CLI for the protocol layer (adjust URL/account):
cargo contract upload \
--manifest-path contracts/tusdt-erc20/Cargo.toml \
--suri //Alice --url ws://127.0.0.1:9944
cargo contract upload \
--manifest-path contracts/tusdt-auction/Cargo.toml \
--suri //Alice --url ws://127.0.0.1:9944
cargo contract upload \
--manifest-path contracts/tusdt-oracle/Cargo.toml \
--suri //Alice --url ws://127.0.0.1:9944
cargo contract instantiate \
--manifest-path contracts/tusdt-vault-alpha/Cargo.toml \
--constructor new \
--args <TREASURY_OR_PLACEHOLDER> <ERC20_CODE_HASH> <AUCTION_CODE_HASH> <ORACLE_CODE_HASH> <ORACLE_NETUID> <HOTKEY> <ALPHA_PRICE_NETUID> \
--suri //Alice --url ws://127.0.0.1:9944Then deploy the governance layer and wire the roles:
cargo contract instantiate \
--manifest-path contracts/tusdt-treasury/Cargo.toml \
--constructor new \
--args <TUSDT_TOKEN_ADDRESS> \
--suri //Alice --url ws://127.0.0.1:9944
cargo contract upload \
--manifest-path contracts/tusdt-election/Cargo.toml \
--suri //Alice --url ws://127.0.0.1:9944
cargo contract instantiate \
--manifest-path contracts/tusdt-governance/Cargo.toml \
--constructor new \
--args <TREASURY_ADDRESS> <VAULT_ADDRESS> <AUCTION_ADDRESS> <ORACLE_ADDRESS> <MAINTAINER> \
<ELECTION_CODE_HASH> \
--suri //Alice --url ws://127.0.0.1:9944Prefer the tools/ workflow above instead of using cargo contract for upload/deploy operations
where a TS script already exists. The current e2e test suite is intentionally oracle-only, the only
deployment script entrypoint is vault:deploy, and the treasury/governance/election contracts have
no TS scripts yet — deploy and wire them with cargo contract as shown.
The lending pool (tusdt-lending-pool) is a standalone protocol contract that enables:
- Supply TAO/TUSDT to earn variable yield (receive lTAO/lTUSDT receipt tokens)
- Supply Alpha collateral (one market per approved subnet) to gain borrowing power
- Borrow TAO/TUSDT against Alpha collateral with health factor checks
- Direct liquidation when health factor drops below 1.0 (close factor 50%, configurable bonus)
Source layout: the contract module is split into lib.rs (storage, events, messages, queries) plus
params.rs (interest/alpha/global param structs + validation), rates.rs (interest accrual,
borrow-rate curve, cash accounting), and risk.rs (oracle pricing, health factor, borrow capacity,
liquidation math).
- Upload the lending pool code and capture the code hash:
cargo contract upload \ --manifest-path contracts/tusdt-lending-pool/Cargo.toml \ --suri //Alice --url ws://127.0.0.1:9944
- Instantiate the pool with the existing treasury, TUSDT token, oracle, lToken code hash (reuses
tusdt-erc20code), and a staking hotkey:The pool spawns two child lToken instances (lTAO for market 0, lTUSDT for market 1).cargo contract instantiate \ --manifest-path contracts/tusdt-lending-pool/Cargo.toml \ --constructor new \ --args <TREASURY_ADDRESS> <TUSDT_TOKEN_ADDRESS> <ORACLE_ADDRESS> \ <LTOKEN_CODE_HASH> <POOL_HOTKEY> \ --suri //Alice --url ws://127.0.0.1:9944
- Read the spawned lToken addresses:
pool.get_ltoken_address(0)→ lTAOpool.get_ltoken_address(1)→ lTUSDT
- Approve alpha markets:
pool.add_alpha_market(netuid, params)for each target subnet. - (Optional) Wire governance:
pool.update_governance(governance_address), then callgovernance.update_pool_address(pool_address)to record the pool in the governance contract. - (Optional) Adjust interest rate params, alpha params, or global params via timelocked updates.
- Supply: Users deposit TAO (payable) or TUSDT (
transfer_from) → receive lTAO/lTUSDT at the current exchange rate. Exchange rate starts at 1.0 and grows as interest accrues. - Supply Alpha collateral: Users call
deposit_alpha(netuid, amount)→ atomically pulls the caller's alpha stake into the pool's coldkey via chain extension func 25 (same mechanism as the vault). One market per approved subnet. - Borrow: Users with alpha collateral can borrow TAO or TUSDT. Borrowing power is:
collateral_value × collateral_factor − existing_debt. Health factor must stay ≥ 1.0. - Repay: Repay TAO (payable) or TUSDT (
transfer_from). Repaid assets stay as pool liquidity — no burn. Interest accrues continuously while borrowed. - Withdraw collateral: Only allowed when the account remains healthy after withdrawal. Uses
chain extension func 6 (
transfer_stake) to return stake to the user's coldkey. - Liquidate: Permissionless. When
health_factor < 1.0, any account can repay a portion of the borrower's debt (up to 50% close factor) and receive discounted alpha collateral (configurable bonus, default 5%).
- Utilization-based 2-zone curve:
U = total_debt / (total_debt + cash).- Zone 1 (U ≤ optimal):
base_rate + slope1 × U / optimal - Zone 2 (U > optimal):
base_rate + slope1 + slope2 × (U − optimal) / (1 − optimal)
- Zone 1 (U ≤ optimal):
- Discrete hourly compounding via
checked_pow— same primitives as the vault's former interest model. - Reserve factor (default 20%): share of borrower interest sent to the protocol treasury.
supplier_rate = borrow_rate × U × (1 − reserve_factor). - lToken exchange rate:
underlying = ltoken_balance × exchange_rate. Exchange rate starts at 1.0 and grows monotonically as supplier interest accrues. Non-rebasing (Compound-style).
Alpha collateral continues earning native staking yield while supplied. A permissionless
claim_alpha_yield(netuid) function:
- Computes excess = actual available stake − booked collateral (accounting for yield index).
- Splits 25% → treasury (unstaked to TAO via
remove_stake, transferred as native TAO). - Credits 75% → per-netuid yield index, proportionally increasing all borrowers' effective collateral.
| Asset | base_rate | slope1 | slope2 | optimal_util | reserve_factor |
|---|---|---|---|---|---|
| TAO | 0% | 4% | 96% | 80% | 20% |
| TUSDT | 0% | 3% | 97% | 80% | 20% |
| Alpha param | Default |
|---|---|
| Collateral factor | 50% |
| Liquidation threshold | 60% |
| Liquidation bonus | 5% |
| Global param | Default |
|---|---|
| Close factor | 50% |
| Performance fee | 25% |
| Max oracle age | 30 min |
get_market_state(market_id)→MarketState(total_supplied, total_debt, borrow_index, exchange_rate, reserve_accrued)get_position(market_id, user)→Position(ltoken_balance, scaled_debt, alpha_principal)get_exchange_rate(market_id),get_borrow_index(market_id),get_utilization(market_id)get_borrow_rate(market_id),get_supply_rate(market_id)— current annualized ratesget_underlying_balance(market_id, user)→ underlying value of lToken positionget_user_debt(market_id, user)→ current debt in underlying unitsget_user_debt_details(market_id, user)→(debt, principal)— debt includes accrued interest;interest = debt − principal. Principal is tracked in a dedicated mapping updated on borrow/repay/liquidate; positions created before principal tracking fall back to an estimate.get_collateral_value_tusdt(user),get_debt_value_tusdt(user),get_health_factor(user)get_available_borrow_tusdt(user)→ remaining borrowing capacity in TUSDTget_alpha_markets()→Vec<(netuid, AlphaMarketParams)>— list all approved alpha marketsget_user_alpha_position(user, netuid)→ alpha principal for a specific subnetget_alpha_yield_index(netuid),get_netuid_total_collateral(netuid)paused(),governance(),treasury(),platform(),get_pool_hotkey(),get_oracle_address()- Paginated:
get_positions(user, page),get_all_positions(page)(10 per page)
After wiring (pool.update_governance(governance)), the governance contract can steer the pool:
| Forwarder | Gated by | Purpose |
|---|---|---|
pool_set_approved_netuid(netuid, approved) |
maintainer | Add/remove alpha collateral markets |
pool_set_market_params(market, config) / cancel |
maintainer | Schedule timelocked interest rate changes |
pool_set_alpha_params(netuid, config) / cancel |
maintainer | Schedule timelocked alpha param changes |
pool_set_global_params(config) / cancel |
maintainer | Schedule timelocked global param changes |
pool_pause |
council | Emergency halt (single member) |
pool_unpause |
maintainer | Resume operations |
pool_update_pool_hotkey(new_hotkey, netuids) |
maintainer | Migrate alpha stake to new hotkey |
All param changes follow the 24h timelock: schedule (governance) → execute (permissionless, time-gated)
→ cancel (governance). Params can be read at any time via get_pending_*_params_update.
- User stakes alpha under the vault's hotkey (if not already staked there) — the pull keeps the hotkey.
- User creates vault:
create_alpha_vault(amount, netuid)— the contract atomically pullsamountof the caller's alpha into its own coldkey via the caller-forwardedcaller_transfer_stakechain extension (function 25) and opens the CDP in the same message. Deposits are always attributed to the caller; no separate intent ortransfer_stakeextrinsic is needed. Requires the subnet'sTransferToggleto be on and the amount to exceed the chain's minimum stake (0.002 TAO equivalent); failures revert cleanly withStakeTransferFailed. - User borrows token:
borrow_token(vault_id, amount). - User repays token:
repay_token(vault_id, amount). - Anyone can trigger debt accrual:
accrue_interest(owner, vault_id). - User adds more alpha collateral:
add_alpha_collateral(vault_id, amount)— pulls exactlyamountfrom the caller, same mechanism as vault creation. - User releases alpha collateral:
release_alpha_collateral(vault_id, amount, dest_coldkey)— returns stake via chain extension.
Deposit messages are EOA-facing: a contract calling the vault would pull its own stake, since the chain extension forwards the immediate caller's origin.
- Accrual is hour-based.
- The configured
interest_rateis an APR-style annual rate, not a simple yearly charge. - Growth model uses discrete hourly compounding from that annual rate.
- At the default
5%APR, the effective annualized cost is approximately5.13%APY under hourly compounding. - Implementation compounds by elapsed full hours and advances
last_interest_accrued_atto the last fully accrued hour.
- Anyone can call
trigger_liquidation_auction(owner, vault_id)when vault exceeds liquidation threshold. - Auction contract creates an auction tied to that vault.
- Bidders approve token allowance to auction contract, then call
place_bid. - After end time, call
finalize_auctionon auction contract. - Vault settlement:
settle_liquidation_auction(owner, vault_id).
Privileged protocol actions are gated by each contract's governance role. Before hand-off this is
the deployer; after wiring (deployment steps 7 & 9) it is the governance contract, and you drive
them through governance's forwarders rather than calling the protocol contracts directly. See
Governance & Treasury for who may invoke what.
Risk params split into two scopes, both applied behind a 24h timelock:
- Per-netuid — governance schedules
set_contract_params(netuid, params)per subnet. Params:collateral_ratio,liquidation_ratio,interest_rate,liquidation_fee(all basis points). Falls back to defaults for unconfigured netuids. - Global (all netuids) — governance schedules
set_global_params(config). Params:transaction_fee(basis points),auction_duration_ms,max_oracle_age_ms.
Governance also controls which subnets are accepted via set_approved_netuid(netuid, approved)
(exposed after hand-off through the vault_set_approved_netuid forwarder).
Oracle reporter access (set_reporter) is managed by the oracle's validator; the validator and the
max price deviation are governance-set. The active round is committed by the validator via
commit_round; governance can also commit an emergency override price (see below).
Default per-netuid params:
- Collateral ratio:
150% - Liquidation ratio:
120% - Interest rate:
10% APR(approximately10.52% APYunder hourly compounding) - Liquidation fee:
11%
Default global params:
- Transaction fee:
0.3%(30 bps) - Auction duration:
3_600_000milliseconds (1 hour) - Max oracle age:
1_800_000milliseconds (30 minutes)
tusdt-governance carries two roles in addition to token-holder voting:
- Maintainer — the top authority (the elected subnet owner). Set initially at construction and thereafter replaced only by the election contract via
elect_maintainer. The maintainer seats the council (set_council), updates governance parameters (update_params), and drives the protocol config forwarders below. The election contract — instantiated by governance's constructor — runs the election and installs the winner as maintainer. - Council — a fixed committee of exactly 5 members set by the maintainer. The council performs
operational duties: committing voting snapshots (
submit_snapshot) and the emergency vault halt (vault_pause). Any single council member can act on these.
Funding and signal proposals are decided by token-weighted voting against a committed Merkle
snapshot (voting power is sqrt(snapshot balance) × time-staked multiplier, so flash-staking can't
inflate it). Flow: submit_proposal → vote → finalize → execute. Submission is gated on the
proposer's subnet alpha stake and a monthly submission window; passing requires both quorum (a
fraction of circulating supply) and an approval threshold. A passed Funding proposal calls
treasury.release(...) on execution; NonFunding proposals are signal-only.
After the role hand-off, the governance contract holds the governance role on the vault, auction,
and oracle. It exposes thin forwarders that perform the cross-contract call; the protocol contracts
see the governance contract as their governance caller. Authorization is decided inside
governance:
| Forwarder | Gated by | Target |
|---|---|---|
vault_set_contract_params(netuid, params), vault_cancel_contract_params_update(netuid) |
maintainer | vault-alpha (per-netuid timelocked params) |
vault_set_global_params(config), vault_cancel_global_params_update() |
maintainer | vault-alpha (global timelocked params: fee, auction duration, oracle age) |
vault_set_approved_netuid(netuid, approved) |
maintainer | vault-alpha (accepted collateral subnets) |
vault_update_treasury, vault_update_platform, vault_unpause |
maintainer | vault-alpha |
vault_pause |
council (fast emergency halt) | vault-alpha |
oracle_set_validator, oracle_set_max_price_deviation |
maintainer | oracle |
oracle_commit_round |
maintainer (emergency price — drives liquidations) | oracle |
auction_set_admin |
maintainer | auction |
update_governance on the protocol contracts is intentionally not forwarded — the vault's role
is fixed after wiring (and it propagates role changes to the auction/oracle itself).
tusdt-treasury books protocol fees into named funds — Emergency, Operation, Insurance,
Dividend, Buyback, Voting — in both Tusdt and Native denominations. distribute() splits
incoming balance across the funds; release(fund, token_kind, amount, recipient) pays out and is
callable only by governance (i.e. via an executed Funding proposal). The deployer is the initial
governance until set_governance hands control to the governance contract.
- Vault:
get_vault,get_total_debt,get_contract_params(netuid),get_global_params(),is_approved_netuid(netuid),get_oracle_address,get_vaults,get_all_vaults - Oracle:
get_latest_price,get_current_round_summary,is_reporter - Chain extension:
get_alpha_price(netuid)— on-chain subnet alpha/TAO price (RAO-scaled by 1e9) - Oracle:
get_latest_price,get_current_round_summary,is_reporter - Auction:
get_auction,get_active_vault_auction,get_bid,get_all_auctions,get_active_auctions - Token:
balance_of,allowance,total_supply - Governance:
maintainer,election,netuid,council,is_council,params,current_epoch,get_snapshot,quorum,proposal_count,get_proposal,has_voted - Treasury:
governance,token,fund_balance_tusdt,fund_balance_native
tusdt-vault-alphaowns the token and auction instances it creates;tusdt-lending-poolspawns its own lToken children (lTAO, lTUSDT) reusing thetusdt-erc20code hash.- Alpha collateral is verified via chain extension (
get_stake_info). Both the vault and lending pool act as coldkeys for staked alpha, using the caller-forwardedcaller_transfer_stake(func 25) for atomic pull deposits. - Pricing:
TUSDT_per_alpha = oracle_TUSDT_per_TAO * (get_alpha_price(netuid) / 1_000_000_000). - Borrowing from the vault mints TUSDT to borrower; the lending pool transfers existing TUSDT (no mint on borrow — pool liquidity comes from suppliers).
- Vault repayment and settlement burn TUSDT; lending pool repayment keeps TUSDT as pool cash.
- Protocol fees accrue to
tusdt-treasury; onlytusdt-governancecan release them. - After wiring, the vault/auction/oracle/pool are governed by
tusdt-governance; the maintainer and council act through its forwarders rather than calling those contracts directly. - The lending pool uses direct bonus-based liquidation (not auctions). The existing
tusdt-auctioncontract serves the vault's liquidation path only.