Skip to main content

TUNDRA Lending for Developers

This guide covers everything needed to integrate with TUNDRA from outside the SUBFROST app: reading state, building transactions, and liquidating. Read the protocol page first for the model. Contract ids are in the contract reference.

Contracts you will touch​

contractid
Factory (registry)4:881050
DIESEL / frUSD pool2:98445, market 969751733f39479fa2c4291126ec4df55b8670e9e3a901c14a2d095532a5f573
DIESEL / frBTC pool2:98619, market 22fd73d6dd54b57378d4ce1c565406053933739f6fd4a2d3643556e3bd470d5a
Adaptive curve IRM4:674184
Oracle adapters4:727967 (frUSD market), 4:727968 (frBTC market)
TUNDRACLE4:9010
Liquidation callee4:999999

frUSD (4:1776), frBTC (32:0) and DIESEL (2:0) all use 8 decimals. Every amount on the wire is in base units. No alkane declares its decimals, so a scale mistake is silent.

Reading state​

All reads are alkanes_simulate calls against a contract's view opcodes on the SUBFROST JSON-RPC endpoint. Use your API key (see Authentication):

https://mainnet.subfrost.io/v4/YOUR_API_KEY

One call​

GetMarket (opcode 103) on the DIESEL / frUSD pool:

curl -s https://mainnet.subfrost.io/v4/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"alkanes_simulate","params":[{
"target":"2:98445","inputs":["103"],"alkanes":[],
"transaction":"0x","block":"0x","height":"1","txindex":0,"vout":0}]}'
{
"jsonrpc": "2.0",
"result": {
"execution": {
"alkanes": [],
"data": "0xb9054113000000000000000000000000402092e9ca25010000000000000000000000000000000000000000000000000000000000000000000000000000000000000007c80e0000000000000000000000000000008a5d784563010000000000000000",
"error": null,
"storage": []
},
"gasUsed": 54062,
"status": 0
},
"id": 1
}

target is the contract, and inputs is the opcode followed by its arguments, each a decimal string. execution.data is the return value. If the call reverts, execution.error carries the message and data is empty: always check it.

Every TUNDRA return value is a sequence of little-endian u128 words, 16 bytes each. The response above is six words:

wordfieldvalue
0total supply assets0x134105b9 = 323,028,409 (3.23028409 frUSD)
1total supply shares323,028,409,000,000
2total borrow assets0
3total borrow shares0
4last update (block height)968,711
5fee (1e18 = 100%)1e17 = 10%

The totals are as of the last update. Interest since then is added the next time anyone supplies, withdraws, borrows, repays or liquidates, or calls AccrueInterest (opcode 18).

Factory views​

opnameargumentsreturns
103ComputeIdthe 10 market parameter words32-byte market id
104IsIrmEnabledirm_block, irm_tx1 or 0
105IsLltvEnabledlltv (1e18 scale)1 or 0
106GetOwnerthe governance token id, 4:881050
107GetFeeRecipientthe recipient as an encoded owner (see below)
108GetPoolid_a, id_bthe pool serving that market id
109GetDefaultFeefee for new markets, 1e18 scale

A market id goes into GetPool as two words: id_a is bytes 0-15 of the id read little-endian, id_b is bytes 16-31.

Pool views​

opnameargumentsreturns
99GetNameUTF-8 string, Tundra Pool <8 hex>
100GetSymbolTUN-P
101GetTotalSupplycirculating pool units
102GetMarketParams10 words, 160 bytes
103GetMarket6 words, 96 bytes
104GetPositionkey_a, key_bposition, see below. Reverts no such position if none
105GetPositionKeyAtnthe n-th position key ever created, 32 bytes
109GetPositionCounthow many entries GetPositionKeyAt has
110GetFeeSharesfee units accrued but not yet claimed
998GetFactorythe factory that created this pool
999GetMarketId32-byte market id

GetMarketParams returns, in order: loan token (2 words), collateral token (2), oracle (2), IRM (2), LLTV (1e18 scale), minimum borrow (base units). For the frUSD market that is 4, 1776, 2, 0, 4, 727967, 4, 674184, 400000000000000000, 300000000000.

GetTotalSupply can lag total supply shares in GetMarket by the fee units not yet claimed.

Discovery: always round-trip through the factory​

Anyone can clone the pool template with invented parameters, and a clone can answer views however it likes. Before trusting a pool id from anywhere:

factory.GetPool(market_id) == pool
pool.GetFactory() == 4:881050
pool.GetMarketId() == market_id

The factory keeps no list of markets, because creation is open to anyone. Keep a list of market ids you have checked (the two above are the SUBFROST-listed markets), or watch for CreateMarket calls (factory opcode 2) and verify each new id.

Positions​

A person's position is keyed by their output script with salt 0:

key = sha256(0x01 || len(script) || script || 0x00 × 16)
key_a = key[0..16] read little-endian, key_b = key[16..32] read little-endian

For a taproot address the script is 5120 followed by the 32-byte output key, which any Bitcoin library will give you (scriptPubKey in bitcoin-cli getaddressinfo). A contract-owned position uses 0x00 || block (16 LE) || tx (16 LE) instead of the script part, and its own salt.

GetPosition returns borrow_shares (16) || collateral (16) || owner, where the owner is 0x00 + 32 bytes of alkane id, or 0x01 + a length byte + the script.

Convert borrow shares to debt with the market totals, rounding up as the pool does:

debt = ceil(borrow_shares × (total_borrow_assets + 1) / (total_borrow_shares + 1,000,000))

To list every position in a pool, read GetPositionCount, then GetPositionKeyAt(n) for each n. A key appears again if a position was closed and later reopened, and a closed one reads back as no such position, so deduplicate and skip those.

A complete reader​

import hashlib, json, urllib.request

RPC = "https://mainnet.subfrost.io/v4/YOUR_API_KEY"
FACTORY = "4:881050"

def simulate(target, inputs):
body = {"target": target, "inputs": [str(i) for i in inputs], "alkanes": [],
"transaction": "0x", "block": "0x", "height": "1", "txindex": 0, "vout": 0}
req = urllib.request.Request(RPC, headers={"Content-Type": "application/json", "User-Agent": "tundra-reader"},
data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "alkanes_simulate",
"params": [body]}).encode())
ex = json.load(urllib.request.urlopen(req))["result"]["execution"]
if ex["error"]:
raise RuntimeError(f"{target} op {inputs[0]}: {ex['error']}")
return bytes.fromhex(ex["data"][2:])

def words(b):
return [int.from_bytes(b[i:i + 16], "little") for i in range(0, len(b), 16)]

def alkane_id(b, off=0):
w = words(b[off:off + 32])
return f"{w[0]}:{w[1]}"

def resolve_pool(market_id_hex):
m = bytes.fromhex(market_id_hex)
a, b = int.from_bytes(m[:16], "little"), int.from_bytes(m[16:], "little")
pool = alkane_id(simulate(FACTORY, [108, a, b])) # factory.GetPool(id)
if alkane_id(simulate(pool, [998])) != FACTORY: # pool.GetFactory()
raise RuntimeError("pool does not name the factory")
if simulate(pool, [999]).hex() != market_id_hex: # pool.GetMarketId()
raise RuntimeError("pool serves a different market")
return pool

def market_params(pool):
w = words(simulate(pool, [102])) # 160 bytes
return {"loan": f"{w[0]}:{w[1]}", "collateral": f"{w[2]}:{w[3]}",
"oracle": f"{w[4]}:{w[5]}", "irm": f"{w[6]}:{w[7]}",
"lltv": w[8] / 1e18, "min_borrow": w[9]}

def market(pool):
w = words(simulate(pool, [103])) # 96 bytes
return dict(zip(["total_supply_assets", "total_supply_shares", "total_borrow_assets",
"total_borrow_shares", "last_update", "fee"], w))

def position_key(script_hex, salt=0):
script = bytes.fromhex(script_hex)
owner = bytes([1, len(script)]) + script # tag 1 = script owner
key = hashlib.sha256(owner + salt.to_bytes(16, "little")).digest()
return int.from_bytes(key[:16], "little"), int.from_bytes(key[16:], "little")

def position(pool, key_a, key_b):
b = simulate(pool, [104, key_a, key_b]) # reverts if there is none
shares, collateral = words(b[:32])
owner = ("alkane", alkane_id(b, 33)) if b[32] == 0 else ("script", b[34:34 + b[33]].hex())
return {"borrow_shares": shares, "collateral": collateral, "owner": owner}

for mid in ["969751733f39479fa2c4291126ec4df55b8670e9e3a901c14a2d095532a5f573",
"22fd73d6dd54b57378d4ce1c565406053933739f6fd4a2d3643556e3bd470d5a"]:
pool = resolve_pool(mid)
print(pool, market_params(pool), market(pool))

Rates​

The IRM can quote the borrow rate a pool would be charged without changing anything. BorrowRateView is opcode 4 on 4:674184:

[4, pool_block, pool_tx, total_supply_assets, total_borrow_assets, elapsed_blocks]

It returns the per-block borrow rate at 1e18 scale. Multiply by 52,560 for the annual rate. With elapsed_blocks = 0 you get the instantaneous rate at those totals. Then:

utilization   = total_borrow_assets / total_supply_assets
supplier rate = borrow rate × utilization × (1 − fee / 1e18)

GetRateAtTarget (opcode 105, pool_block, pool_tx) returns the pool's stored rate at target, per block.

Prices and freshness​

The pool's price is price(), opcode 2, on the market's oracle adapter. It returns loan base units per collateral base unit, times 1e27, or reverts. It reverts with tundracle ratio is not fresh; refusing to price collateral when the feed is stale.

alkanes_simulate  target 4:727967  inputs ["2"]    -> 1 word: frUSD per DIESEL × 1e27
alkanes_simulate target 4:727968 inputs ["2"] -> 1 word: frBTC per DIESEL × 1e27

To see the freshness signal itself, read the adapter's configuration and ask TUNDRACLE:

callreturns
adapter GetConfig, opcode 106TUNDRACLE market index, multiplier, divisor
adapter GetSource, opcode 105the TUNDRACLE id, 4:9010
TUNDRACLE GetRatio(market), opcode 34ratio (1e8) ‖ computed_at ‖ revision ‖ fresh
TUNDRACLE GetFeed(key), opcode 30height ‖ seq ‖ price ‖ applied ‖ updates
TUNDRACLE GetFeedConfig(key), opcode 31registered ‖ max_age ‖ threshold_bps ‖ rejected

4:727967 reads TUNDRACLE market 4 (DIESEL in USD) and 4:727968 reads market 5 (DIESEL / frBTC, feeds 3 and 4). fresh is 1 only when every feed in the market was updated within max_age (144) blocks. Read the TUNDRACLE proxy 4:9010, never its implementation contract.

Building transactions​

Use subfrost-cli tx, which builds a plan from a spec, simulates the whole transaction server side, and only then signs. The protostone grammar is the alkanes-cli one: [numbers] is a cellpack, [block:tx:amount:target] is an edict, and the bare vN/pN that follow are the pointer and the refund. See Trading Pairs for more subfrost-cli tx examples.

Three rules​

  1. Put the call in its own protostone, with no edicts. The pool refuses an authorized message (borrow, close) that carries an edict. Move tokens with a first, edict-only protostone (p0) that targets the call (p1).
  2. Point the call at your own output. For a script-owned position, the pointer of the call must be a real output paying the owner's script. In every example below, v0 is your own address (--output self), the call's pointer and refund are v0, and a borrow names owner_output = 0.
  3. Use the same address every time. The position is filed under the script the loan was paid to. Closing from a different address cannot work.

Each example uses the DIESEL / frUSD pool 2:98445. For the frBTC market use 2:98619 and frBTC amounts. Wallet flags (--keystore FILE --password-file FILE, or --phrase-file FILE) go before tx on build, sign and run.

Supply​

Supply 100 frUSD (10,000,000,000 base units). The pool units come back on v0:

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--spend 4:1776:10000000000 \
--protostones '[4:1776:10000000000:p1]:v0:v0,[2,98445,10]:v0:v0' \
--fee-rate 2 --out supply.json
subfrost-cli tx simulate supply.json --expect 'p1 returns 2:98445 > 0'
subfrost-cli --keystore wallet.json --password-file pw.txt tx sign supply.json
subfrost-cli tx broadcast supply.json

Supply (opcode 10) takes no arguments: everything of the loan token that arrives is supplied. It returns assets, shares.

Withdraw​

Send pool units back; the loan token comes out. all spends the wallet's whole balance:

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--spend 2:98445:all \
--protostones '[2:98445:all:p1]:v0:v0,[2,98445,11]:v0:v0' \
--fee-rate 2 --out withdraw.json

Withdraw (opcode 11) burns every pool unit that arrives and fails with insufficient liquidity if the pool does not have that much unborrowed.

Borrow​

OpenBorrow is opcode 12: [2, 98445, 12, borrow_assets, owner_kind, owner_output, salt]. For a person, owner_kind = 0 (a script), owner_output is the output index of your address, and salt = 0.

Borrow 3,000 frUSD (300,000,000,000) against 300 DIESEL (30,000,000,000):

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--spend 2:0:30000000000 \
--protostones '[2:0:30000000000:p1]:v0:v0,[2,98445,12,300000000000,0,0,0]:v0:v0' \
--fee-rate 2 --out borrow.json
subfrost-cli tx simulate borrow.json --expect 'p1 returns 4:1776 == 300000000000'

It returns key_a, key_b, borrow_assets, borrow_shares. Keep the key, or derive it from your script as shown above. A second borrow from the same address adds to the same position.

OpenBorrow reads the oracle, so it fails while the price is stale. It also fails if the position would be unhealthy, if the position total is below the minimum borrow, or if an addition would raise the position's debt ratio.

Add collateral​

AddCollateral (opcode 14, key_a, key_b) is permissionless. It refuses a position with no debt:

  --spend 2:0:5000000000 \
--protostones '[2:0:5000000000:p1]:v0:v0,[2,98445,14,<key_a>,<key_b>]:v0:v0'

Repay​

Repay (opcode 13, key_a, key_b) is permissionless. Overpaying is safe: anything above the debt is refunded to the pointer. To clear a loan in full, attach a little more than the debt, because interest accrues until the transaction is mined.

  --spend 4:1776:300100000000 \
--protostones '[4:1776:300100000000:p1]:v0:v0,[2,98445,13,<key_a>,<key_b>]:v0:v0'

A partial repay that would leave less than the minimum borrow is capped at the minimum, and the rest is refunded. It returns assets, shares repaid.

Close​

ClosePosition (opcode 15, key_a, key_b) needs the debt at zero and releases all the collateral to the owner. Nothing rides in, so there is one protostone:

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--protostones '[2,98445,15,<key_a>,<key_b>]:v0:v0' \
--fee-rate 2 --out close.json

v0 must be the owner's address. Anyone may submit a close, but the collateral can only land on the owner.

Other pool opcodes​

opnameargumentsnotes
16Liquidatekey_a, key_b, seized_assets, repaid_shares, callee_block, callee_tx, callee_opsee below
17FlashLoantoken_block, token_tx, assets, callee_block, callee_tx, callee_opfree; lends only what this pool holds; refuses the pool's own units
18AccrueInterestpermissionless bookkeeping
19ClaimFeefee recipient only; mints the accrued fee units
33SetFeefeegovernance token must ride in; at most 25%

Creating a market​

CreateMarket is factory opcode 2 with the ten parameter words, and is open to anyone:

[4, 881050, 2, loan_block, loan_tx, coll_block, coll_tx, oracle_block, oracle_tx,
irm_block, irm_tx, lltv, min_borrow]

It returns the 32-byte market id and the new pool's id. The IRM must pass IsIrmEnabled and the LLTV must pass IsLltvEnabled (today: IRM 4:674184, LLTV 350000000000000000 or 400000000000000000). Use ComputeId (opcode 103) to see the id first. The oracle must answer price() (opcode 2) in the 1e27 scale described above; for a new collateral that usually means deploying an adapter over a TUNDRACLE market. A new market starts at the default fee and appears in the SUBFROST app only once SUBFROST lists it.

Running a liquidator​

Liquidation is permissionless, and more liquidators make every market safer. SUBFROST runs one; anyone else can too.

The loop​

Every block, for each market you watch:

  1. Resolve and read. Round-trip the market id through the factory, then read GetMarketParams and GetMarket.

  2. Price it. Call price() on the market's oracle. If it reverts, the market cannot be liquidated right now; skip it.

  3. Enumerate positions. GetPositionCount, then GetPositionKeyAt(n) and GetPosition for each key.

  4. Check health. A position is liquidatable when

    debt > collateral × price / 1e27 × lltv / 1e18

    with debt rounded up from its borrow shares as shown earlier.

  5. Quote. Choose repaid_shares (or seized_assets; pass exactly one, the other as 0):

    LIF            = min(1.15, 1 / (1 − 0.5 × (1 − lltv)))          (1.15 for both live markets)
    repaid_assets = floor(repaid_shares × (total_borrow_assets + 1) / (total_borrow_shares + 1e6))
    seized_assets = floor(repaid_assets × LIF × 1e27 / price)

    If the position has a minimum borrow, a partial liquidation must not leave less than it. Clearing all the shares (or seizing all the collateral) is always allowed.

  6. Decide. Your profit is the value of seized_assets minus repaid_assets, minus the Bitcoin fee. A liquidation transaction is roughly 350 to 450 vB.

  7. Send, and do not resend the same position until the first attempt confirms or drops.

The pool accrues interest before it quotes, so the debt at execution can be slightly higher than your read. Attach a small margin when repaying from inventory; the change is returned.

With your own loan tokens​

Attach the repayment and call Liquidate with no callee:

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--spend 4:1776:<repaid_assets_plus_margin> \
--protostones '[4:1776:<repaid_assets_plus_margin>:p1]:v0:v0,[2,98445,16,<key_a>,<key_b>,0,<repaid_shares>,0,0,0]:v0:v0' \
--fee-rate <next block feerate> --out liquidate.json
subfrost-cli tx simulate liquidate.json --expect 'p1 ok'

The seized DIESEL and any change land on v0. It returns seized_assets, repaid_assets, bad_debt_assets.

With no inventory: the callback path​

With a callee, the pool first settles the liquidation, hands the seized collateral to the callee as [callee_op, repaid_assets, seized_assets], and takes the repayment out of the loan tokens the callee returns. Everything else the callee returns goes to the liquidator. If the callee does not return enough, the whole liquidation reverts.

4:999999 is a callee (its callback opcode is 11) that sells the seized collateral for the loan token through the AMM at 4:65522. It reads the market from the calling pool, so it needs no configuration, and it reverts if the sale would not cover the debt. Nothing rides in; the wallet only pays the miner fee:

subfrost-cli --keystore wallet.json --password-file pw.txt tx build \
--output self \
--protostones '[2,98619,16,<key_a>,<key_b>,0,<repaid_shares>,4,999999,11]:v0:v0' \
--fee-rate <next block feerate> --out liquidate.json

The callee needs a direct pool for the market's collateral and loan token on that AMM, deep enough to cover the debt. DIESEL / frBTC has one (2:77087). The DIESEL / frUSD direct pool is shallow, so liquidations in the frUSD market are better funded from frUSD inventory. Always simulate first: a revert still confirms on chain and still costs its fee.

You can also write your own callee (for example one that routes through several pools). It must accept the collateral, return at least repaid_assets of the loan token to the pool, and return anything else it wants the liquidator to receive.

Practical notes​

  • Fees. Price each attempt at the next block's feerate. The bonus is 15% of the repayment, so small positions only pay at low fees; the minimum borrow is sized so that every position pays at crash-level fees.
  • Stale prices. While the oracle is stale, nobody can liquidate. Expect a burst of liquidatable positions when a fresh price lands after a big move.
  • Exit price. The oracle is a one-day median. The price you can actually sell the collateral for may be lower; check the pool you will sell into.
  • One sender. Two bot instances with separate views of what is in flight will pay for the same liquidation twice.

Error messages​

messagecause
no such positionwrong key, or the position was closed
tundracle ratio is not fresh; refusing to price collateralstale oracle; borrows and liquidations are paused
position would be unhealthythe borrow exceeds the LLTV
borrow below the market's floorposition total under the minimum borrow
an add may not degrade the position's ratioa second borrow without enough collateral in proportion
a re-draw must stand on its own attached collateralborrowing into a fully repaid, unclosed position needs fresh collateral for the new loan
an spk-owned position takes no saltscript owners must pass salt 0
auth: an authorised message may not carry edictsmove tokens in an earlier protostone
auth: pointer must target a real output, not a downstream protostonethe call's pointer must be an output
auth: an spk-owned position can only be acted on from a top-level messagea contract tried to act for a script owner
repay the debt before closingdebt is not zero
position has no debt to defendAddCollateral on a position with no debt
insufficient liquiditywithdrawal or borrow larger than the unborrowed supply
position is healthyliquidation attempted on a healthy position
a liquidation may not leave a sub-floor remainderpartial liquidation below the minimum borrow; clear more
liquidator paid X of the Y owednot enough loan token attached or returned by the callee
pass exactly one of assets and sharesboth or neither were non-zero

A failed call is still a valid Bitcoin transaction: it confirms, the tokens are refunded to the refund pointer, and nothing changes in the pool. Check the trace, not just the confirmation.