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
| contract | id |
|---|---|
| Factory (registry) | 4:881050 |
| DIESEL / frUSD pool | 2:98445, market 969751733f39479fa2c4291126ec4df55b8670e9e3a901c14a2d095532a5f573 |
| DIESEL / frBTC pool | 2:98619, market 22fd73d6dd54b57378d4ce1c565406053933739f6fd4a2d3643556e3bd470d5a |
| Adaptive curve IRM | 4:674184 |
| Oracle adapters | 4:727967 (frUSD market), 4:727968 (frBTC market) |
| TUNDRACLE | 4:9010 |
| Liquidation callee | 4: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:
| word | field | value |
|---|---|---|
| 0 | total supply assets | 0x134105b9 = 323,028,409 (3.23028409 frUSD) |
| 1 | total supply shares | 323,028,409,000,000 |
| 2 | total borrow assets | 0 |
| 3 | total borrow shares | 0 |
| 4 | last update (block height) | 968,711 |
| 5 | fee (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
| op | name | arguments | returns |
|---|---|---|---|
| 103 | ComputeId | the 10 market parameter words | 32-byte market id |
| 104 | IsIrmEnabled | irm_block, irm_tx | 1 or 0 |
| 105 | IsLltvEnabled | lltv (1e18 scale) | 1 or 0 |
| 106 | GetOwner | the governance token id, 4:881050 | |
| 107 | GetFeeRecipient | the recipient as an encoded owner (see below) | |
| 108 | GetPool | id_a, id_b | the pool serving that market id |
| 109 | GetDefaultFee | fee 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
| op | name | arguments | returns |
|---|---|---|---|
| 99 | GetName | UTF-8 string, Tundra Pool <8 hex> | |
| 100 | GetSymbol | TUN-P | |
| 101 | GetTotalSupply | circulating pool units | |
| 102 | GetMarketParams | 10 words, 160 bytes | |
| 103 | GetMarket | 6 words, 96 bytes | |
| 104 | GetPosition | key_a, key_b | position, see below. Reverts no such position if none |
| 105 | GetPositionKeyAt | n | the n-th position key ever created, 32 bytes |
| 109 | GetPositionCount | how many entries GetPositionKeyAt has | |
| 110 | GetFeeShares | fee units accrued but not yet claimed | |
| 998 | GetFactory | the factory that created this pool | |
| 999 | GetMarketId | 32-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:
| call | returns |
|---|---|
adapter GetConfig, opcode 106 | TUNDRACLE market index, multiplier, divisor |
adapter GetSource, opcode 105 | the TUNDRACLE id, 4:9010 |
TUNDRACLE GetRatio(market), opcode 34 | ratio (1e8) ‖ computed_at ‖ revision ‖ fresh |
TUNDRACLE GetFeed(key), opcode 30 | height ‖ seq ‖ price ‖ applied ‖ updates |
TUNDRACLE GetFeedConfig(key), opcode 31 | registered ‖ 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
- 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). - 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,
v0is your own address (--output self), the call's pointer and refund arev0, and a borrow namesowner_output = 0. - 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
| op | name | arguments | notes |
|---|---|---|---|
| 16 | Liquidate | key_a, key_b, seized_assets, repaid_shares, callee_block, callee_tx, callee_op | see below |
| 17 | FlashLoan | token_block, token_tx, assets, callee_block, callee_tx, callee_op | free; lends only what this pool holds; refuses the pool's own units |
| 18 | AccrueInterest | permissionless bookkeeping | |
| 19 | ClaimFee | fee recipient only; mints the accrued fee units | |
| 33 | SetFee | fee | governance 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:
-
Resolve and read. Round-trip the market id through the factory, then read
GetMarketParamsandGetMarket. -
Price it. Call
price()on the market's oracle. If it reverts, the market cannot be liquidated right now; skip it. -
Enumerate positions.
GetPositionCount, thenGetPositionKeyAt(n)andGetPositionfor each key. -
Check health. A position is liquidatable when
debt > collateral × price / 1e27 × lltv / 1e18with
debtrounded up from its borrow shares as shown earlier. -
Quote. Choose
repaid_shares(orseized_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.
-
Decide. Your profit is the value of
seized_assetsminusrepaid_assets, minus the Bitcoin fee. A liquidation transaction is roughly 350 to 450 vB. -
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
| message | cause |
|---|---|
no such position | wrong key, or the position was closed |
tundracle ratio is not fresh; refusing to price collateral | stale oracle; borrows and liquidations are paused |
position would be unhealthy | the borrow exceeds the LLTV |
borrow below the market's floor | position total under the minimum borrow |
an add may not degrade the position's ratio | a second borrow without enough collateral in proportion |
a re-draw must stand on its own attached collateral | borrowing into a fully repaid, unclosed position needs fresh collateral for the new loan |
an spk-owned position takes no salt | script owners must pass salt 0 |
auth: an authorised message may not carry edicts | move tokens in an earlier protostone |
auth: pointer must target a real output, not a downstream protostone | the call's pointer must be an output |
auth: an spk-owned position can only be acted on from a top-level message | a contract tried to act for a script owner |
repay the debt before closing | debt is not zero |
position has no debt to defend | AddCollateral on a position with no debt |
insufficient liquidity | withdrawal or borrow larger than the unborrowed supply |
position is healthy | liquidation attempted on a healthy position |
a liquidation may not leave a sub-floor remainder | partial liquidation below the minimum borrow; clear more |
liquidator paid X of the Y owed | not enough loan token attached or returned by the callee |
pass exactly one of assets and shares | both 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.