跳到主要内容

TUNDRA 借贷开发者指南

本指南介绍在 SUBFROST 应用之外与 TUNDRA 集成所需的一切:读取状态、构建交易、执行清算。请先阅读协议页面了解整体模型。合约 ID 见合约参考。

会用到的合约​

合约ID
工厂合约(登记表)4:881050
DIESEL / frUSD 资金池2:98445,市场 ID 969751733f39479fa2c4291126ec4df55b8670e9e3a901c14a2d095532a5f573
DIESEL / frBTC 资金池2:98619,市场 ID 22fd73d6dd54b57378d4ce1c565406053933739f6fd4a2d3643556e3bd470d5a
自适应曲线利率模型4:674184
预言机适配合约4:727967(frUSD 市场)、4:727968(frBTC 市场)
TUNDRACLE4:9010
清算回调合约4:999999

frUSD(4:1776)、frBTC(32:0)和 DIESEL(2:0)都是 8 位小数。链上传递的所有金额都以最小单位表示。 alkane 不会声明自己的小数位数,所以单位搞错不会报错。

读取状态​

所有读取都是对合约查询操作码的 alkanes_simulate 调用,发往 SUBFROST 的 JSON-RPC 端点。请使用你的 API key(见身份验证):

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

一次调用​

对 DIESEL / frUSD 资金池调用 GetMarket(操作码 103):

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 是目标合约,inputs 是操作码加参数,每一项都是十进制字符串。execution.data 是返回值。如果调用回滚,execution.error 中会有错误信息,data 为空,所以务必检查它。

TUNDRA 的所有返回值都是一串小端序 u128 字,每个 16 字节。 上面的返回值是六个字:

字字段数值
0存款总额0x134105b9 = 323,028,409(3.23028409 frUSD)
1存款份额总数323,028,409,000,000
2借款总额0
3借款份额总数0
4最近更新(区块高度)968,711
5费率(1e18 = 100%)1e17 = 10%

这些总额是最近一次更新时的数值。之后产生的利息,要等下一次有人存款、取款、借款、还款或清算,或者有人调用 AccrueInterest(操作码 18)时才会计入。

工厂合约查询​

操作码名称参数返回
103ComputeId10 个市场参数字32 字节市场 ID
104IsIrmEnabledirm_block, irm_tx1 或 0
105IsLltvEnabledlltv(1e18 精度)1 或 0
106GetOwner治理代币 ID,即 4:881050
107GetFeeRecipient编码后的费用接收方(格式见下文)
108GetPoolid_a, id_b服务该市场 ID 的资金池
109GetDefaultFee新市场的费率,1e18 精度

市场 ID 以两个字的形式传给 GetPool:id_a 是 ID 的第 0-15 字节按小端序读出的数,id_b 是第 16-31 字节。

资金池查询​

操作码名称参数返回
99GetNameUTF-8 字符串,Tundra Pool <8 位十六进制>
100GetSymbolTUN-P
101GetTotalSupply流通中的资金池份额
102GetMarketParams10 个字,160 字节
103GetMarket6 个字,96 字节
104GetPositionkey_a, key_b仓位,格式见下文。不存在时回滚并返回 no such position
105GetPositionKeyAtn第 n 个曾经创建过的仓位键,32 字节
109GetPositionCountGetPositionKeyAt 的条目数
110GetFeeShares已累积但尚未领取的费用份额
998GetFactory创建该资金池的工厂合约
999GetMarketId32 字节市场 ID

GetMarketParams 依次返回:借出代币(2 个字)、抵押品代币(2)、预言机(2)、利率模型(2)、LLTV(1e18 精度)、最低借款额(最小单位)。frUSD 市场返回的是 4, 1776, 2, 0, 4, 727967, 4, 674184, 400000000000000000, 300000000000。

GetTotalSupply 可能比 GetMarket 中的存款份额总数少一些,差额是尚未领取的费用份额。

市场发现:一定要经过工厂合约核对​

任何人都可以用编造的参数克隆资金池模板,克隆出来的合约可以随意回答任何查询。无论资金池 ID 从哪里得来,使用前都要核对:

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

由于任何人都能创建市场,工厂合约不保存市场列表。你可以维护一份自己核对过的市场 ID 列表(上面两个就是 SUBFROST 上架的市场),或者监听 CreateMarket 调用(工厂操作码 2),逐个核对新出现的 ID。

仓位​

个人仓位以其输出脚本为键,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

taproot 地址的脚本是 5120 加上 32 字节的输出公钥,任何比特币库都能给出(例如 bitcoin-cli getaddressinfo 返回的 scriptPubKey)。合约持有的仓位,把脚本部分换成 0x00 || block(16 字节小端序)|| tx(16 字节小端序),并使用它自己的 salt。

GetPosition 返回 borrow_shares(16)|| collateral(16)|| owner,其中 owner 是 0x00 加 32 字节 alkane ID,或 0x01 加 1 字节长度再加脚本。

用市场总额把借款份额换算成欠款,与资金池一样向上取整:

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

要列出资金池中的所有仓位,先读取 GetPositionCount,再对每个 n 调用 GetPositionKeyAt(n)。仓位关闭后再次开仓,同一个键会再出现一次;已关闭的仓位读取时返回 no such position。所以要去重,并跳过这些仓位。

完整的读取示例​

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))

利率​

利率模型可以在不改变任何状态的情况下,报出某个资金池的借款利率。BorrowRateView 是 4:674184 上的操作码 4:

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

它返回 1e18 精度的每区块借款利率,乘以 52,560 就是年利率。elapsed_blocks = 0 时得到的是在这些总额下的即时利率。然后:

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

GetRateAtTarget(操作码 105,参数 pool_block, pool_tx)返回该资金池当前保存的每区块目标利率。

价格与新鲜度​

资金池使用的价格来自市场预言机适配合约上的 price(),操作码 2。它返回每单位抵押品最小单位可换得的借出代币最小单位数,乘以 1e27,或者直接回滚。价格源过期时,它会回滚并返回 tundracle ratio is not fresh; refusing to price collateral。

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

如果想直接查看新鲜度信号,可以读取适配合约的配置,再去问 TUNDRACLE:

调用返回
适配合约 GetConfig,操作码 106TUNDRACLE 市场编号、乘数、除数
适配合约 GetSource,操作码 105TUNDRACLE 的 ID,即 4:9010
TUNDRACLE GetRatio(market),操作码 34ratio(1e8)‖ computed_at ‖ revision ‖ fresh
TUNDRACLE GetFeed(key),操作码 30height ‖ seq ‖ price ‖ applied ‖ updates
TUNDRACLE GetFeedConfig(key),操作码 31registered ‖ max_age ‖ threshold_bps ‖ rejected

4:727967 读取 TUNDRACLE 市场 4(以美元计价的 DIESEL),4:727968 读取市场 5(DIESEL / frBTC,价格源 3 和 4)。只有市场中每个价格源都在 max_age(144)个区块内更新过,fresh 才为 1。请读取 TUNDRACLE 的代理合约 4:9010,不要读它的实现合约。

构建交易​

使用 subfrost-cli tx。它根据描述生成交易计划,在服务端模拟整笔交易,确认无误后才签名。protostone 的写法与 alkanes-cli 相同:[数字] 是 cellpack,[block:tx:amount:target] 是 edict,后面跟着的 vN/pN 依次是 pointer 和 refund。更多 subfrost-cli tx 的例子见交易对。

三条规则​

  1. 调用单独放在一个 protostone 里,不带 edict。 资金池会拒绝带 edict 的授权消息(借款、关闭仓位)。代币由第一个只含 edict 的 protostone(p0)转给调用(p1)。
  2. 调用的 pointer 指向你自己的输出。 对于脚本所有者的仓位,调用的 pointer 必须是付给所有者脚本的真实输出。下面所有例子中,v0 都是你自己的地址(--output self),调用的 pointer 和 refund 都是 v0,借款时 owner_output = 0。
  3. 始终使用同一个地址。 仓位登记在收到借款的那个脚本名下,换一个地址无法关闭。

以下例子都使用 DIESEL / frUSD 资金池 2:98445。frBTC 市场请换成 2:98619 和 frBTC 金额。钱包参数(--keystore FILE --password-file FILE,或 --phrase-file FILE)在 build、sign 和 run 时放在 tx 之前。

存款​

存入 100 frUSD(10,000,000,000 个最小单位),资金池份额回到 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(操作码 10)没有参数:随调用到达的借出代币会全部存入。它返回 assets, shares。

取款​

把资金池份额发回去,取出借出代币。all 表示花掉钱包里的全部余额:

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(操作码 11)会销毁到达的全部份额;如果资金池里未借出的余额不够,会返回 insufficient liquidity。

借款​

OpenBorrow 是操作码 12:[2, 98445, 12, borrow_assets, owner_kind, owner_output, salt]。个人借款时,owner_kind = 0(脚本),owner_output 是你的地址所在的输出序号,salt = 0。

以 300 DIESEL(30,000,000,000)为抵押品,借 3,000 frUSD(300,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'

它返回 key_a, key_b, borrow_assets, borrow_shares。请记下仓位键,或按上文的方法从你的脚本推算出来。从同一地址再次借款,会并入同一个仓位。

OpenBorrow 需要读取预言机,所以价格过期时会失败。以下情况也会失败:仓位将变得不健康、仓位总欠款低于最低借款额、追加借款会抬高仓位的欠款比例。

追加抵押品​

AddCollateral(操作码 14,参数 key_a, key_b)无需许可。没有欠款的仓位会被拒绝:

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

还款​

Repay(操作码 13,参数 key_a, key_b)无需许可。多还是安全的:超过欠款的部分会退回到 pointer。想一次还清时,请比欠款多附一点,因为交易被打包之前利息还在累积。

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

如果部分还款后剩余欠款会低于最低借款额,这次还款最多只还到最低借款额,其余部分退回。它返回实际偿还的 assets, shares。

关闭仓位​

ClosePosition(操作码 15,参数 key_a, key_b)要求欠款为零,并把全部抵押品退给所有者。这次调用不需要附带任何代币,所以只有一个 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 必须是所有者的地址。任何人都可以提交关闭仓位的交易,但抵押品只会落到所有者手里。

其他资金池操作码​

操作码名称参数说明
16Liquidatekey_a, key_b, seized_assets, repaid_shares, callee_block, callee_tx, callee_op见下文
17FlashLoantoken_block, token_tx, assets, callee_block, callee_tx, callee_op免费;只借出本资金池持有的代币;不借出资金池自己的份额
18AccrueInterest无需许可,只做记账
19ClaimFee仅限费用接收方;铸造已累积的费用份额
33SetFeefee必须附带治理代币;最高 25%

创建市场​

CreateMarket 是工厂合约的操作码 2,参数为十个市场参数字,任何人都可以调用:

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

它返回 32 字节的市场 ID 和新资金池的 ID。利率模型必须通过 IsIrmEnabled,LLTV 必须通过 IsLltvEnabled(目前:利率模型 4:674184,LLTV 350000000000000000 或 400000000000000000)。可以先用 ComputeId(操作码 103)查看将要生成的 ID。预言机必须以上文所述的 1e27 精度响应 price()(操作码 2);对新的抵押品来说,这通常意味着要在某个 TUNDRACLE 市场之上部署一个适配合约。新市场以默认费率起步,只有在 SUBFROST 上架之后才会出现在 SUBFROST 应用中。

运行清算机器人​

清算无需许可,清算人越多,每个市场就越安全。SUBFROST 运行着一个清算机器人,其他人也可以运行。

主循环​

每个区块,对你关注的每个市场:

  1. 核对并读取。 通过工厂合约核对市场 ID,然后读取 GetMarketParams 和 GetMarket。

  2. 取价格。 调用该市场预言机的 price()。如果回滚,说明这个市场此刻无法清算,跳过。

  3. 列出仓位。 读取 GetPositionCount,再对每个键调用 GetPositionKeyAt(n) 和 GetPosition。

  4. 检查健康度。 满足下式时,仓位可被清算:

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

    其中 debt 按上文的方法由借款份额向上取整换算而来。

  5. 报价。 选定 repaid_shares(或者 seized_assets;两者只能传一个,另一个传 0):

    LIF            = min(1.15, 1 / (1 − 0.5 × (1 − lltv)))          (两个已上线市场均为 1.15)
    repaid_assets = floor(repaid_shares × (total_borrow_assets + 1) / (total_borrow_shares + 1e6))
    seized_assets = floor(repaid_assets × LIF × 1e27 / price)

    如果仓位所在市场设有最低借款额,部分清算后剩余的欠款不能低于它。清掉全部份额(或拿走全部抵押品)始终允许。

  6. 决策。 你的利润是 seized_assets 的价值减去 repaid_assets,再减去比特币手续费。一笔清算交易大约 350 到 450 vB。

  7. 发送,在第一次尝试确认或被丢弃之前,不要对同一仓位重复发送。

资金池会先计息再报价,所以执行时的欠款可能比你读到的略高。用库存还款时请多附一点余量,多出的部分会退回。

用自己持有的借出代币清算​

附上还款,调用不带 callee 的 Liquidate:

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'

拿到的 DIESEL 和找零都落在 v0。它返回 seized_assets, repaid_assets, bad_debt_assets。

不持有库存:回调路径​

设置 callee 后,资金池先完成清算结算,再以 [callee_op, repaid_assets, seized_assets] 的形式把拿走的抵押品交给 callee,并从 callee 返回的借出代币中扣除还款。callee 返回的其他代币都归清算人。如果 callee 返回的不够,整笔清算会回滚。

4:999999 就是这样一个 callee(回调操作码为 11),它通过 4:65522 上的 AMM 把拿到的抵押品卖成借出代币。它从调用它的资金池读取市场信息,因此不需要任何配置;如果卖出所得不足以还款,它会回滚。这种方式不需要附带任何代币,钱包只需支付矿工费:

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

callee 要求该 AMM 上存在该市场抵押品与借出代币的直接资金池,且深度足以覆盖欠款。DIESEL / frBTC 有这样的资金池(2:77087)。DIESEL / frUSD 的直接资金池很浅,所以 frUSD 市场的清算最好用 frUSD 库存来做。一定要先模拟:回滚的交易同样会在链上确认,同样要付手续费。

你也可以自己写 callee(例如经过多个资金池路由)。它必须接收抵押品,向资金池返回至少 repaid_assets 的借出代币,并把其他想交给清算人的代币一并返回。

实践建议​

  • 手续费。 每次尝试都按下一个区块的费率估算。奖励是还款额的 15%,小仓位只有在手续费低时才划算;最低借款额的设定保证每个仓位在暴跌时的手续费下也值得清算。
  • 价格过期。 预言机过期期间,谁都无法清算。大幅波动之后新价格一到,可能会一下子出现一批可清算的仓位。
  • 卖出价格。 预言机是一天的中位数,你实际能卖出抵押品的价格可能更低,请先查看你要卖入的资金池。
  • 只用一个发送方。 两个机器人实例如果各自记录在途交易,会为同一笔清算付两次钱。

错误信息​

信息原因
no such position键不对,或仓位已关闭
tundracle ratio is not fresh; refusing to price collateral预言机过期,借款和清算暂停
position would be unhealthy借款超过 LLTV
borrow below the market's floor仓位总欠款低于最低借款额
an add may not degrade the position's ratio追加借款时附带的抵押品比例不足
a re-draw must stand on its own attached collateral向已还清但未关闭的仓位重新借款,新借款必须由新附带的抵押品支撑
an spk-owned position takes no salt脚本所有者的 salt 必须为 0
auth: an authorised message may not carry edicts请在前一个 protostone 中转移代币
auth: pointer must target a real output, not a downstream protostone调用的 pointer 必须是一个输出
auth: an spk-owned position can only be acted on from a top-level message合约试图代替脚本所有者操作
repay the debt before closing欠款不为零
position has no debt to defend对没有欠款的仓位调用 AddCollateral
insufficient liquidity取款或借款超过未借出的余额
position is healthy试图清算健康的仓位
a liquidation may not leave a sub-floor remainder部分清算后低于最低借款额,请多清一些
liquidator paid X of the Y owed附带的或 callee 返回的借出代币不够
pass exactly one of assets and shares两个参数都非零或都为零

失败的调用仍然是一笔有效的比特币交易:它会被确认,代币会退回到 refund 指定的输出,资金池状态不变。请检查执行轨迹(trace),不要只看是否确认。