借贷协议
如果你只是想在应用里放贷或借款,请阅读借贷。本页讲的是其底层机制。
SUBFROST 借贷是面向 Alkanes 代币的点对点、固定期限、超额抵押借贷市场,通过预签名的 PSBT 托管,在一笔比特币交易中完成结算。这里没有资金池,也没有托管方。贷方与借方在链下就条款达成一致,随后一笔交易会原子化地创建贷款、把借出的代币发放给借方,并将抵押品锁定在一个新克隆出来的贷款合约中。
每一笔贷款都是它自己独立的链上合约实例,因此贷款的整个生命周期(还款、抵押品释放、违约)都由代码强制执行,而不是依赖交易对手方。
核心理念
- 链下订单簿,链上结算。 挂单方发布已签名但未广播的报价。在吃单方接受之前,一切都不会触及链上;一旦接受,就会组装并广播一笔结算交易。
- 一笔交易,一笔贷款。 结算交易会克隆贷款合约模板,用双方约定的条款对其进行初始化,把贷款代币交给借方,并锁定抵押品,这一切都是原子化完成的。贷款一经确认,状态即为
LOAN_ACTIVE。 - 没有不记名代币,没有管理员。 一方的身份就是他们在结算时所接收到的那个
scriptPubKey。之后的每一个操作,都通过提交同一个脚本来完成授权。这里没有授权 NFT,也没有特权角色。 - 固定条款。 利息为
principal × APR × duration,不是浮动的,因此确切的还款额在创建时就已确定,并且在任何状态下都可以计算出来。
角色与条款
一笔贷款涉及两方,以及一组固定的条款:
| 术语 | 含义 |
|---|---|
| 贷款代币 / 数量(loan token / amount) | 借方收到的资产(例如 1 个 DIESEL) |
| 抵押代币 / 数量(collateral token / amount) | 借方为这笔贷款锁定的抵押资产(例如 0.1 frBTC) |
| APR | 年化利率,以基点存储 |
| 期限(duration) | 贷款期限,以区块数计。还款截止时间为 loan_start + duration |
挂单方(maker) 是发布报价的一方,吃单方(taker) 是接受报价的一方。挂单方可以是任意一边:
- 出借报价(Lend offer)。 挂单方是债权人,提供贷款代币。吃单方以此借入资产。
- 借款请求(Loan request)。 挂单方是债务人,提供抵押品。吃单方通过出借资产来成交。
在合约内部,只认识债权人(贷方)和债务人(借方)这两个角色。挂单方 / 吃单方的区分,纯粹是关于谁发布了这份报价。
生命周期概览
maker builds + signs offer taker accepts (one tx)
────────────────────────────────────────► ─────────────────────────► LOAN_ACTIVE
• prep tx, broadcast at create • taker prep (token+fee)
• partial settlement PSBT • append taker input + sign
• POST to order book • broadcast taker prep
• broadcast settlement (clone+init)
LOAN_ACTIVE ──repay──► REPAID ──claims──► FULLY_CLOSED
└─past deadline─► DEFAULTED ──claim collateral──► DEFAULTED_CLAIMED
链上合约
模板与每笔贷款的克隆
唯一的一个 lending-contract-psbt 模板部署在一个 block-4 槽位上。在主网上它是 4:47876。每一笔贷款都是该模板的一次 block-6 克隆,在结算交易的第一个 protostone 内产生。克隆出来的合约成为位于 [2:seq] 的子合约,并立即处于 LOAN_ACTIVE 状态。这里没有工厂合约:宿主的 block-6 克隆机制直接创建每一笔贷款。
状态机
0 UNINITIALIZED
2 LOAN_ACTIVE
3 LOAN_REPAID
4 LOAN_DEFAULTED
5 REPAYMENT_CLAIMED
7 COLLATERAL_CLAIMED
8 DEFAULTED_CLAIMED
9 FULLY_CLOSED
顺利路径中的两次认领操作(债权人调用的 ClaimRepayment,以及债务人调用的 ClaimCollateral)从 REPAID 状态出发,彼此独立、没有先后顺序要求。每次调用都会先推进状态再付款,因此重复调用会回退(revert),当两次调用都执行完毕后,贷款就会到达 FULLY_CLOSED。
操作码
| 操作码 | 名称 | 调用者 | 作用 |
|---|---|---|---|
| 0 | Initialize | (结算交易) | 根据约定的条款创建并激活这笔贷款 |
| 1 | RepayLoan | 债务人(无需许可的代币转入) | 送回贷款代币,进入 REPAID 状态 |
| 2 | ClaimRepayment | 债权人 | 认领本金加利息 |
| 3 | ClaimCollateral | 债务人 | 还款后取回抵押品 |
| 4 | TriggerDefault | 任何人(截止时间之后) | 把已过期的 ACTIVE 贷款转为 DEFAULTED |
| 5 | ClaimDefaultedCollateral | 债权人 | 违约后没收抵押品 |
视图函数: 90 GetLoanDetails、91 GetRepaymentAmount、92 GetState、93 GetTimeRemaining、99 GetName。
授权模型
领取操作(操作码 2、3 或 5)没有签名检查。取而代之的是,发起调用的 protostone 的 pointer 必须指向一个真实的交易输出,该输出的 scriptPubKey 要与该方存储的脚本一致,付款也会路由到同一个输出上。
债权人和债务人的脚本,是在 Initialize 时从结算交易的接收输出中记录下来的。因此要认领资产,一方只需把自己的地址放在 protostone 的 pointer 输出上:只有他们自己才能生成那个脚本,付款也就会落到那里。重新连接的钱包依然可用,因为它的 taproot 地址不会变。
结算交易
一笔交易完成所有事情。它的输出是固定的:
vout 0 OP_RETURN ← 两个 protostone(Initialize + 退款分割器)
vout 1 3000 sats ← 协议费用
vout 2 dust ← 挂单方的接收输出
vout 3 dust ← 吃单方的接收输出
谁是债权人、谁是债务人,取决于挂单方的角色,进而映射到输出 2 和输出 3 上:
- 挂单方是债权人时,
creditorOutput = 2(挂单方),debitorOutput = 3(吃单方) - 挂单方是债务人时,
creditorOutput = 3(吃单方),debitorOutput = 2(挂单方)
两个 protostone
- p0,即 Initialize 消息。 目标是
{6, templateTx}(克隆模板),操作码为0,携带条款作为 calldata。它的pointer指向债务人的输出,因此借出的贷款代币会路由给借方。它的refundPointer指向 p1 的影子 vout,因此一旦发生回退(revert),输入资金会流入分割器,而不会丢失。 - p1,即退款分割器(仅包含 edicts)。 在回退路径上,它会把贷款代币归还给债权人、把抵押品归还给债务人(数量
0表示"全部"),这样即使克隆和初始化失败,双方也都能全身而退。
Initialize 的 calldata 参数顺序:
collateral_token(block,tx) · collateral_amount · loan_token(block,tx) · loan_amount
· duration_blocks · desired_apr(bp) · creditor_output · debitor_output
Initialize 消息的 pointer 必须等于 debitor_output。合约正是通过这一点,知道哪个输出属于借方,从而知道贷款代币该发给谁。
SIGHASH 方案:托管为何能够成立
这是整个设计的核心。挂单方在吃单方尚不存在时就已经签名,但吃单方依然能够补全交易,而不会使挂单方的签名失效:
- 挂单方的输入(0、1、2)使用
SIGHASH_SINGLE | ANYONECANPAY(0x83)。SINGLE让每个挂单方输入只对自己对应的那个输出做出承诺,因此挂单方预先承诺了输出 0、1、2(分别是 OP_RETURN、协议费用、以及自己的接收输出)。ANYONECANPAY让每个输入只对自身做出承诺,而不涉及其他输入,因此吃单方之后可以自由地追加输入 3。 - 吃单方的输入(3)使用
SIGHASH_ALL(0x01)。 它是最后签名的,会对整笔已组装好的交易做出承诺,把一切都锁定到位。
结果是:挂单方发布的部分交易,恰好只对自己那一侧做出承诺;之后任何吃单方都可以添加自己的输入和输出并完成最终签名(finalize),而挂单方的承诺依然能够通过验证。
协议费用输出为何存在
挂单方的部分交易,输出价值超过了输入价值:它的三个 dust 输入合计约 1,638 sats,但它却承诺了 3,000 sats 的协议费用,加上若干 dust 输出。这个负差额使得这笔部分交易无法单独广播。它会一直保持不活跃状态,直到吃单方添加一个输入,补上这个差额和矿工费用为止。
这是刻意设计的。它防止已发布报价的 PSBT 被过早或脱离上下文地广播,同时也让每一笔结算都向国库支付一小笔协议费用。
PSBT 生命周期详解
1. 精确金额的 UTXO(prep 交易)
合约要求转入的 alkanes 数量必须与约定的金额精确相等,而结算交易的 OP_RETURN 中并没有 edict-shifter 来剥离多余的部分。因此在签名之前,每一方都会用一笔小的 prep 交易,把自己的代币 UTXO 拆分成精确金额的若干份:
maker prep → v0 token-only dust (EXACT loan/collateral) · v1 dust · v2 dust · v3 change · v4 OP_RETURN
(v0/v1/v2 become the maker's three SIGHASH_SINGLE settlement inputs)
taker prep → v0 token + btc (exact token + fee budget) · v1 change · v2 OP_RETURN
(v0 becomes the taker's single settlement input)
prep 交易开头的 edict 会精确地把所需数量剥离到一个专门的输出上,并把剩余部分(多余的代币加上 BTC)路由为找零。
2. 挂单方一侧:构建报价
当挂单方发布一份报价时,客户端会:
- 构建并签名 prep,并在结算签名关卡通过后立即广播它。两次签名在同一次钱包授权中完成,而 prep 只有在该关卡通过之后才会发出,因此一旦用户拒绝签名,链上不会留下任何东西。
- 构建部分结算 PSBT(三个使用
0x83的挂单方输入、三个已承诺的输出、以及 OP_RETURN),并对挂单方的输入进行签名。 - 把已签名的 prep 十六进制数据、部分 PSBT 以及条款一并提交到订单簿。这份十六进制数据不再是让 prep 上链的手段,而是吃单方在内存池驱逐时用来重新广播的幂等兜底。
此后挂单方即可下线。prep 把挂单方的 UTXO 花费到挂单方自己的输出上,因此资金依然留在挂单方的钱包里;但 prep 本身是一笔真实交易,无论这份报价最终是否被接受,挂单方在发布时就已经支付了它的矿工费。
prep 过去是延迟广播的:在发布时签名,保存在链下,由吃单方在结算时广播。那个时期的报价至今仍留在订单簿中,这也正是下文吃单方流程保留重新广播兜底的原因。
3. 吃单方一侧:接受报价
当吃单方接受报价时:
- 构建并签名自己的 prep(自己的代币,加上一笔 BTC 费用预算),并广播它。
- 把自己的输入(输入 3,
SIGHASH_ALL)和接收输出(vout 3)追加到挂单方的部分 PSBT 上,然后对输入 3 进行签名。挂单方的0x83签名不受影响,原样保留。 - 广播结算交易——它是吃单方刚刚广播的那笔 prep 的子交易。挂单方的 prep 自报价发布时就已在链上,因此它是一笔已落定的父交易,而不是交易包的一员。
在广播之前,吃单方会先探测挂单方的 prep 是否已在链上可见。通常都是可见的,这一步会被跳过。只有当它被内存池驱逐,或者这份报价早于"发布即广播"的改动时,吃单方才会重新广播所存的十六进制数据。
4. 动态费用:背负吃单方的 prep
结算交易只花费一笔未确认的父交易,即吃单方自己的 prep,因此吃单方会按照所选的费率来设定结算交易的费用,让它背负起这一对交易(子交易为父交易付费,即 Child Pays For Parent)。这笔费用为:
max( ceil(feeRate × packageVsize) − prepsAlreadyPaid , ceil(feeRate × settlementVsize) )
packageVsize 涵盖吃单方的 prep 加上结算交易;只有在遗留的延迟广播报价上,挂单方的 prep 才会计入其中,此时它成为第二笔未确认的父交易,交易包也随之扩大。右侧的下限则保证在差额已被覆盖时,子交易仍然自付其费用。
贷款生命周期操作
结算完成后,贷款状态为 ACTIVE。持仓页面会根据每一方和当前状态,展示相应的可执行操作。
还款并取回(借方),一笔交易完成
借方的"还款并取回"功能,通过追加第二个 protostone,把两个操作合并进一笔交易:
p0 (shifter) 将恰好等于还款额(本金+利息)的部分路由到 p1
p1 [child, 1] RepayLoan ACTIVE → REPAID
p2 [child, 3] ClaimCollateral REPAID → COLLATERAL_CLAIMED (抵押品 → 借方的输出)
Protostone 会在交易内按顺序执行,因此 ClaimCollateral 能看到刚刚变为 REPAID 的状态,并释放抵押品。一次点击就能让贷款从 ACTIVE 经过 REPAID 到达 COLLATERAL_CLAIMED。
违约并领取(贷方),一笔交易完成
对称地,一旦截止时间已过,贷方的"违约并领取"功能会合并:
p0 [child, 4] TriggerDefault ACTIVE → DEFAULTED
p1 [child, 5] ClaimDefaultedCollateral DEFAULTED → DEFAULTED_CLAIMED (抵押品 → 贷方的输出)
认领还款(贷方)
借方还款之后,贷方会执行 ClaimRepayment 来认领本金加利息。当贷方的还款认领和借方的抵押品取回都执行完毕后,贷款就会变为 FULLY_CLOSED。
中间状态的恢复
如果一笔合并交易只完成了一半(一个 protostone 成功,另一个回退),贷款就会停留在中间状态,此时界面会提供剩余的那一步操作:
- 在"还款并取回"中,如果取回那一步回退了,贷款会停留在
REPAID,借方依然会看到取回抵押品的选项。 - 在"违约并领取"中,如果认领那一步回退了,贷款会停留在
DEFAULTED,贷方依然会看到认领抵押品的选项。
可执行的操作是根据链上实时状态推导出来的,因此收尾的那一步总是会被提供。
利息计算
利息在创建时就已固定,而不是在期限内逐步累积的:
interest = floor( loanRaw × aprBasisPoints × durationBlocks / (APR_PRECISION × BLOCKS_PER_YEAR) )
repayment = principal + interest
APR_PRECISION = 10,000 (basis-point denominator)
BLOCKS_PER_YEAR = 52,560 (144 blocks/day × 365)
举例,1 个 DIESEL,APR 12.5%,期限 1,008 个区块:
100,000,000 × 1250 × 1008 / (10,000 × 52,560) = 239,726
得到的还款额为 1.00239726 DIESEL。
由于还款额完全由条款确定性地推导出来,确切的还款金额在发出报价时就已可知,并且在之后的任何状态下都可以计算。链上的 GetRepaymentAmount 视图函数只在贷款处于 ACTIVE 状态时才会返回这个值。
这里的除法是向下取整的,这带来一个值得注意的后果:如果条款足够小,导致利息四舍五入为零,合约就会拒绝创建(revert)。客户端会镜像同样的校验,因此报价会在表单阶段就失败,而不是等到链上才失败。
抵押品政策
合约本身只强制执行双方约定好的抵押品,仅此而已。它没有价格的概念,没有追加保证金,也没有清算路径。一笔生效中的贷款,只能通过还款、或者违约后的认领来结束。
用户指南中提到的贷款价值比(LTV)上限,是一项应用层的策略,而不是合约规则。它会在报价创建时以及报价仍挂在订单簿中时生效,使用的是链下解析出的美元价格。它绝不会触及一笔已经结算的贷款。由此产生两个后果:
- 一笔在创建时符合 LTV 上限的贷款,无论之后价格如何变动,都始终有效。
- 一份尚无人接受的报价,如果其抵押品价值跌破了应偿还的金额,就可能会被从订单簿中移除,因为它还不是一笔真正的贷款。
链下订单簿
报价存放在数据库中,并按网络实现完全的物理隔离:每个网络都有各自独立的报价数据库,在请求时按网络选择。这里没有共享的报价表,因此一笔测试贷款绝不会出现在主网上。身份与鉴权数据则存放在一个共享的默认数据库中,因为这些数据是全局性的,而不是按网络区分的。
每一份处于开放状态的报价,只保存链下的撮合数据:条款、挂单方的角色与地址、已签名的部分 PSBT、作为重新广播兜底而保存的已签名 prep 交易十六进制数据,以及一个状态字段(open,之后变为 taken 或 cancelled)。一旦贷款结算完成,关于它的任何信息都不再从数据库中读取。生效中的贷款完全存在于链上。
提交内容会与签名进行核验
提交上来的报价不会被直接信任。挂单方的 SIGHASH_SINGLE|ANYONECANPAY 签名必须有效,并且 PSBT 所承诺的输出(OP_RETURN 中的条款与 edicts、协议费用、挂单方的付款)必须与客户端根据所声明条款重新构建出的结果逐字节匹配。因此,存储的元数据不可能偏离挂单方实际签署的内容。这项检查是离线进行的,并适用于每一个网络。
编辑与删除的所有权
编辑一份报价意味着一次完整的重新签名:由于金额已经承诺在结算交易的 OP_RETURN 中,改动金额就需要重新构建并重新签名整个 PSBT。在一次编辑中,旧的报价会与新提交的报价一并被原子化地取消,其授权来自新报价中经过验证的挂单方签名,因此编辑操作不需要单独的取消凭证。
取消或删除操作在服务端受到限制:请求必须携带已连接钱包的地址,并且该地址必须与报价的挂单方地址一致。
失效报价的清理
一份报价只有在其 prep 的三个结算输出 prepTxid:0..2 尚未被花费时才有效。它们正是挂单方的 SIGHASH_SINGLE 输入,因此把其中任何一个花在别处,都会让已签名的部分 PSBT 失效,报价也就永远无法结算。所以只要报价还处于开放状态,钱包就会把它们视为已锁定,而 prep 的找零输出则回到正常的可花费集合中。对于遗留的延迟广播报价,同样的道理要往前推一步,落在 prep 自身的输入上:花费它们等于对一笔尚未广播的 prep 进行双花。列表接口会清理未通过这项检查的报价:在服务端可以访问链的网络上,会删除它们;而在只有浏览器才能访问链的网络上,客户端会隐藏它们。如果 UTXO 检查不可用,就不会进行清理,而是展示一个加载中的状态,而不是冒险删除一份仍然生效的报价。
贷款持仓
生效中和历史贷款都是通过索引器从链上读取的,而不是从数据库读取:
- 对借贷模板调用
get_factory_children,枚举出每一笔贷款的克隆。 - 对每一个克隆,用
get_keys(/creditor_script, /debitor_script)与已连接钱包的接收scriptPubKey进行比对,从而得出该钱包所扮演的角色。 - 实时的条款与状态来自合约的视图函数(
GetLoanDetails、GetRepaymentAmount、GetTimeRemaining)。
界面展示的是一种有效状态。一笔已过截止时间的 ACTIVE 贷款,即便还没有人调用 TriggerDefault,也会被显示为已违约(Defaulted),并且借方的还款操作会被撤下。链上状态只有在真的有人触发之后,才会翻转为 DEFAULTED。剩余时间的计算方式为 deadline − current_tip_height。
钱包与签名
手工构建的流程(创建报价、接受报价、发送 BTC)都会汇聚到同一个签名入口,因此在应用层面,协议对钱包是无感的:构建 PSBT、签名、finalize、广播。
- 应用内密钥库。 使用 BIP86 的 key-path 签名,并带有 BIP-341 tweak。P2WPKH(segwit)费用输入则使用 BIP84 密钥签名。
- 浏览器钱包。 同一个 PSBT 会通过钱包的适配器来签名,在此之前会先修补(patch)taproot 内部密钥,好让钱包能够接受它。
- Finalize 过程对钱包无感。 只有尚未 finalize 的输入才会被 finalize,因此,即便某个自动完成 finalize 的钱包已经预先 finalize 了挂单方的输入,也不会破坏吃单方的 finalize 过程。
alkanes 资产(贷款代币和抵押品)始终存放在 taproot 地址上。BTC 的 prep 费用优先从 taproot 地址获取,其次才是 segwit 地址,这也是双地址钱包用来保持 BTC 干净的方式。
网络配置
只有当某个网络配置了借贷模板 id(即已部署模板所在的 [4, tx] 槽位)之后,该网络才会启用借贷功能。目前启用了借贷的是主网(4:47876),以及本地开发网络。
要把借贷功能带到一个新网络,需要三件事:在该链上部署模板并在配置中设置其槽位、一个按网络区分的订单簿数据库、以及一个能提供 get_factory_children 的索引器。
安全属性
- 原子化结算。 贷款的创建、贷款代币的发放,以及抵押品的锁定,全部在一笔交易中完成。如果克隆与初始化回退,退款分割器会把两笔资金各自归还给对应的所有者。
- 不会过早广播。 挂单方的部分 PSBT,在吃单方为其提供资金之前无法广播,因为协议费用输出使得它的输出价值超过了输入价值。
- 防篡改的托管。 挂单方的
SIGHASH_SINGLE|ANYONECANPAY签名恰好只承诺自己的那一部分,吃单方的SIGHASH_ALL则锁定其余部分。任何一方都无法更改对方已承诺的部分。 - 经过验证的提交。 一份报价所存储的条款,必须与挂单方所签名的输出逐字节匹配,因此订单簿不可能展示挂单方并未承诺过的条款。
- 没有托管方,没有管理员密钥。 授权方式就是"提交你自己的脚本"。抵押品和还款资金,只会释放给那个存储脚本与领取操作输出相匹配的一方。
- 按网络隔离。 报价数据按网络进行物理隔离,因此测试贷款和生产环境的贷款永远不会混在一起。
接下来看什么
- 借贷:同一功能的用户指南。
- Alkanes 元协议:protostone、cellpack,以及本页所构建于其上的合约模型。
- 什么是 SUBFROST:全局概览。