跳到主要内容

Lending Protocol

Audience

This is the technical / developer deep-dive: the on-chain contract, the PSBT structure, the SIGHASH scheme, and the settlement mechanics. If you just want to lend or borrow in the app, see Lending & Borrowing for the user guide.

SUBFROST lending is a peer-to-peer, fixed-term, over-collateralized loan market for Alkanes tokens, settled in a single Bitcoin transaction via a pre-signed PSBT escrow. There is no pooled liquidity and no custodian: a lender and a borrower agree on terms off-chain, and one transaction atomically creates the loan, delivers the borrowed tokens, and locks the collateral in a freshly cloned loan contract.

It is built on the lending-contract-psbt Alkanes contract. Every loan is its own on-chain contract instance, so a loan's entire lifecycle — repayment, collateral release, default, liquidation — is enforced by code, not by a counterparty.


Core ideas

  • Off-chain order book, on-chain settlement. Makers publish offers to a database. Posting broadcasts a small "prep" transaction (which carves the exact loan/collateral amount out of the maker's funding, so the leftover balance stays spendable), but the settlement PSBT stays un-broadcast. Nothing settles until a taker accepts — at which point a single settlement transaction is assembled and broadcast.
  • One transaction = one loan. The settlement tx clones the loan-contract template, initializes it with the agreed terms, hands the loan token to the borrower, and locks the collateral — atomically. The loan is ACTIVE the moment it confirms.
  • No bearer token, no admin. A party's identity is simply the scriptPubKey they receive at in the settlement. Every later action (repay, claim) is authorized by spending to / committing that same script. There are no auth NFTs and no privileged roles.
  • Fixed terms. Interest is principal × APR × duration (not floating), so the exact repayment is known at creation and computable in any state.

Roles & terms

A loan has two parties and a fixed set of terms:

TermMeaning
loan token / amountwhat the borrower receives (e.g. 1 DIESEL)
collateral token / amountwhat the borrower locks to back the loan (e.g. 0.1 frBTC)
APRannual rate, stored in basis points
durationloan term in blocks; the repayment deadline is loan_start + duration

The maker is whoever publishes the offer; the taker accepts it. Either side can be the maker:

  • Lend offer — maker is the creditor (brings the loan token). A taker borrows against it.
  • Loan request — maker is the debitor (brings the collateral). A taker fills it by lending.

Internally the contract only knows creditor (lender) and debitor (borrower); the maker/taker distinction is purely about who published the offer.


Lifecycle at a glance

 maker builds offer + broadcasts prep           taker accepts (one tx)
────────────────────────────────────────► ─────────────────────────► LOAN_ACTIVE
• broadcast "prep" (exact-amount split) • taker prep (token+fee)
• partial settlement PSBT (not broadcast) • append taker input + sign
• POST to offer book DB • broadcast taker prep + settlement (clone+init)

LOAN_ACTIVE ──repay──► REPAID ──claims──► FULLY_CLOSED
└─past deadline─► DEFAULTED ──claim collateral──► DEFAULTED_CLAIMED

The on-chain contract

Template + per-loan clone

A single lending-contract-psbt template is deployed at a block-4 slot (LENDING_PSBT_TEMPLATE_ID, e.g. 4:47876 on devnet). Each loan is a block-6 clone of that template, produced inside the settlement transaction's first protostone. The clone becomes a child contract at [2:seq] and is LOAN_ACTIVE immediately. There is no factory contract — the host's block-6 clone mechanism creates each loan directly.

State machine

0 UNINITIALIZED
2 LOAN_ACTIVE
3 LOAN_REPAID
4 LOAN_DEFAULTED
5 REPAYMENT_CLAIMED
7 COLLATERAL_CLAIMED
8 DEFAULTED_CLAIMED
9 FULLY_CLOSED

The two claims in the happy path (ClaimRepayment by the creditor, ClaimCollateral by the debitor) are independent and order-free from REPAID: each advances the state before paying, so a repeat reverts and the loan reaches FULLY_CLOSED once both have run.

Opcodes

OpNameCallerEffect
0Initialize(settlement)create + arm the loan from the agreed terms
1RepayLoandebitor (permissionless tokens-in)send the loan token back → REPAID
2ClaimRepaymentcreditorpull principal + interest
3ClaimCollateraldebitorreclaim the collateral after repayment
4TriggerDefaultanyone (after deadline)flip an expired ACTIVE loan → DEFAULTED
5ClaimDefaultedCollateralcreditorseize the collateral after default

Views: 90 GetLoanDetails, 91 GetRepaymentAmount, 92 GetState, 93 GetTimeRemaining, 99 GetName.

Authorization model (claims)

A claim (op 2/3/5) has no signature check. Instead the calling protostone's pointer must reference a real transaction output whose scriptPubKey equals the party's stored script, and the payout is routed to that same output. The creditor/debitor scripts are recorded at Initialize from the settlement's receive outputs (each party's taproot address). So to claim, a party simply puts their own address at the protostone's pointer output: only they can produce that script, and the payout lands there. A reconnected wallet works because its taproot address still matches.


The settlement transaction

A single tx does everything. Its outputs are fixed:

 vout 0  OP_RETURN   ← the two protostones (Initialize + refund splitter)
vout 1 3000 sats ← protocol fee (LENDING_PROTOCOL_FEE_SATS)
vout 2 dust ← maker's receive output
vout 3 dust ← taker's receive output

Which party is creditor vs debitor maps onto outputs 2/3 by the maker's role:

  • maker = creditorcreditorOutput = 2 (maker), debitorOutput = 3 (taker)
  • maker = debitorcreditorOutput = 3 (taker), debitorOutput = 2 (maker)

The two protostones (the OP_RETURN at vout 0)

  • p0 — Initialize message. Target {6, templateTx} (clone the template), opcode 0, with the terms as calldata. Its pointer is the debitor output — so the borrowed loan token routes to the borrower. Its refundPointer is p1's shadow vout — so if anything reverts, the inputs flow into the splitter instead of being lost.
  • p1 — refund splitter (edicts only). On the revert path it returns the loan token → creditor and the collateral → debitor (amount 0 = "all"), making both parties whole if the clone+init fails.

Initialize calldata argument order (all the terms, in this order):

collateral_token(block,tx) · collateral_amount · loan_token(block,tx) · loan_amount
· duration_blocks · desired_apr(bp) · creditor_output · debitor_output

The Initialize message's pointer must equal debitor_output — this is how the contract knows which output is the borrower (and therefore the loan recipient).

The SIGHASH scheme — why an escrow works

This is the heart of the design. The maker signs before the taker even exists, yet the taker can complete the tx without invalidating the maker's signatures:

  • Maker inputs (0,1,2) → SIGHASH_SINGLE | ANYONECANPAY (0x83). SINGLE makes each maker input commit to only its own corresponding output (so the maker pre-commits outputs 0,1,2 — the OP_RETURN, the protocol fee, and their own receive). ANYONECANPAY makes each input commit to only itself, not the other inputs — so the taker can freely append input 3 later.
  • Taker input (3) → SIGHASH_ALL (0x01). Signed last, it commits the entire assembled tx (all inputs + all outputs, including the taker's own output 3), locking it in place.

The result: the maker publishes a partial transaction committing exactly their side; any taker can later add their input + output and finalize, and the maker's commitment still verifies.

The protocol-fee output (why it exists)

The maker's partial tx has outputs worth more than its inputs (its three dust inputs ≈ 1,638 sats, but it commits the 3,000-sat protocol fee + dust outputs). That negative balance makes the partial tx impossible to broadcast on its own — it stays inert until the taker adds an input that funds the deficit + the miner fee. This is deliberate: it prevents a published offer's PSBT from being broadcast early or out of context, and routes a small protocol fee to the treasury on every settlement.


PSBT lifecycle in detail

1. Exact-amount UTXOs ("prep")

The contract requires the incoming alkanes to equal the agreed amounts exactly — there is no edict-shifter in the settlement OP_RETURN to peel off excess. So before signing, each party splits their token UTXO into exact-amount pieces with a small prep transaction. Splitting into a dedicated exact-amount output has a second benefit: because the maker prep is broadcast at offer time, the leftover (change) is a normal spendable UTXO — posting an offer never ties up the maker's whole balance, even if it all sat on a single 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)

The prep's leading edict peels exactly the needed amount to a dedicated output and routes the leftover (excess token + BTC) to change.

2. Maker side — building the offer

When a maker publishes an offer, the client:

  1. Builds, signs, and broadcasts the prep transaction. The prep carves out the exact-amount inputs the settlement will spend and returns the rest as change, so the maker's remaining balance stays spendable. Broadcasting is instant from the maker's perspective — the offer is posted immediately, without waiting for the prep to confirm.
  2. Builds the partial settlement PSBT (3 maker inputs 0x83, the 3 committed outputs, the OP_RETURN) and signs the maker's inputs. This PSBT is not broadcast — it can't be (see the protocol-fee output above) and only settles when a taker completes it.
  3. POSTs the prep txid + the partial PSBT + the terms to the offer-book database.

The maker can then go offline. Only the small prep tx has touched the chain; the settlement stays inert until a taker fills it.

3. Taker side — accepting (one broadcast)

When a taker accepts:

  1. Builds + signs their own prep (their token + a BTC fee budget).
  2. Appends their input (input 3, SIGHASH_ALL) and receive output (vout 3) to the maker's partial PSBT, then signs input 3. The maker's 0x83 signatures survive untouched.
  3. Broadcasts their own prep and the settlement. The maker prep was already broadcast at offer time, so it's typically confirmed by now; if it's still unconfirmed, the settlement remains its child and rides along in the same package.

4. Dynamic fee (CPFP)

The settlement spends the taker's freshly-broadcast prep (and, if the maker prep hasn't confirmed yet, that too). The taker sizes the settlement's fee to carry the whole package at the chosen fee rate (Child-Pays-For-Parent). computePackageSettlementFee computes max(ceil(feeRate × packageVsize) − prepsAlreadyPaid, ceil(feeRate × settlementVsize)), so any still-unconfirmed parent clears together even though it paid only a minimal prep fee.


Loan lifecycle operations

After settlement the loan is ACTIVE. The portfolio surfaces the right action per party + state.

Repay and reclaim (borrower) — one transaction

The borrower's "Repay and reclaim" combines two operations in a single tx by appending a second protostone:

 p0 (shifter)  route exactly the repayment (principal+interest) into p1
p1 [child, 1] RepayLoan ACTIVE → REPAID
p2 [child, 3] ClaimCollateral REPAID → COLLATERAL_CLAIMED (collateral → borrower's output)

Protostones execute in order within the tx, so ClaimCollateral (op 3) sees the just-REPAID state and releases the collateral. One click takes the loan ACTIVE → REPAID → COLLATERAL_CLAIMED.

Default and claim (lender) — one transaction

Symmetrically, once the deadline passes, the lender's "Default & claim" combines:

 p0  [child, 4] TriggerDefault           ACTIVE → DEFAULTED
p1 [child, 5] ClaimDefaultedCollateral DEFAULTED → DEFAULTED_CLAIMED (collateral → lender's output)

Claim repayment (lender)

After the borrower repays, the lender runs ClaimRepayment (op 2) to pull the principal + interest. Once both the lender's repayment claim and the borrower's collateral reclaim have run, the loan is FULLY_CLOSED.

Intermediary recovery

If a combined transaction only half-lands (one protostone succeeds, the other reverts), the loan simply sits in the intermediate state and the UI offers the remaining step:

  • repay+reclaim where the reclaim reverted → loan at REPAID → borrower still sees Claim Collateral.
  • default+claim where the claim reverted → loan at DEFAULTED → lender still sees Claim Collateral.

The available actions are derived from the live on-chain state, so the finish step is always offered.


Interest math

Interest is fixed (not time-accrued during the term):

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

Example — 1 DIESEL at 12.5% APR for 1008 blocks: 100_000_000 × 1250 × 1008 / (10_000 × 52_560) = 239,726 → repayment 1.00239726 DIESEL.

Because it's deterministic from the terms, the exact repayment is known at offer time and computable in any later state (the on-chain GetRepaymentAmount view only returns it while ACTIVE).


Off-chain offer book

Offers live in PostgreSQL, with complete physical isolation per network — a separate database per network (subfrost_devnet, subfrost_mainnet, …), selected at request time. There is no shared offers table, so a devnet test loan can never appear on mainnet. (Identity/auth data stays on the shared default database; those are global, not per-network.)

Each open offer row holds only the off-chain matching data: the terms, the maker's role + address, the signed partial PSBT, the prep txid (the prep itself is already broadcast to the chain), and a status (open → taken | cancelled). Once a loan settles, nothing about it is read from the DB — the live loan lives entirely on-chain.

Ownership of edits / deletes

Editing an offer is a full re-sign (the amounts are committed in the settlement's OP_RETURN, so changing them rebuilds and re-signs the whole PSBT). Cancelling/deleting is gated server-side: the request must carry the connected wallet's address and it must equal the offer's makerAddress.

Dead-offer pruning

An offer is only valid while the prep's exact-amount output is unspent. The maker prep is already on the chain, but nothing stops the maker from spending that prepared UTXO in another transaction before a taker fills the offer — if they do, the settlement can never confirm. The list endpoint prunes such offers: on networks the server can reach it deletes them; where the chain is only reachable in the browser (devnet) the client hides them. If the UTXO check is unavailable it does not prune — it surfaces a loading state rather than risk deleting a live offer.


Loan portfolio (the registry)

Active and historical loans are read from the chain via the indexer (espo / quspo), not the DB:

  1. get_factory_children(LENDING_PSBT_TEMPLATE_ID) enumerates every loan clone.
  2. For each clone, get_keys(/creditor_script, /debitor_script) is matched against the connected wallet's receive scriptPubKey → that's the wallet's role.
  3. Live terms + state come from the contract views (GetLoanDetails, GetRepaymentAmount, GetTimeRemaining).

The UI shows an effective state: an ACTIVE loan past its deadline is displayed as Defaulted (red) even before anyone calls TriggerDefault, and the borrower's repay action is withdrawn — the on-chain state only flips to DEFAULTED once someone triggers it. Time remaining is computed as deadline − current_tip_height.


Wallets & signing

The hand-built flows (offer creation, take, BTC send) all funnel through one signing entrypoint, so the protocol is wallet-agnostic at the app layer: build PSBT → sign → finalize → broadcast.

  • Keystore (in-app): BIP86 key-path signing with the BIP-341 tweak; P2WPKH (segwit) fee inputs signed with the BIP84 key.
  • Browser wallets (Xverse, OYL, UniSat, …): the same PSBT is signed via the wallet's adapter, after patching the taproot internal key so the wallet accepts it.
  • Finalization is wallet-agnostic: only not-yet-finalized inputs are finalized, so a self- finalizing wallet's pre-finalized maker inputs don't break the taker's finalize.

The alkanes (loan/collateral) always live at the taproot address; the BTC prep fee is sourced from the taproot address first, then the segwit address (where dual-address wallets and the devnet faucet keep clean BTC).


Frontend

The /lend page mirrors the swap UI:

  • /lend — a markets table of lendable tokens with average APY (same style as the markets tab).
  • /lend?lend=<id>&collateral=<id> — a pair view: an order book on the left (loan requests on top, loan offers on the bottom, sorted by APY like a sell/buy book) and a Lend/Borrow form on the right.
  • A bottom My Loans section with tabs: Active Loans, Closed Loans, My Offers.

Taking an offer or running a lifecycle action opens a confirmation modal (loan summary, what you put in / get out, fee rate, and — for borrowers — a collateral-forfeiture warning).


Network configuration

Lending is enabled on a network only when its LENDING_PSBT_TEMPLATE_ID is configured (the deployed template's [4:tx] slot). Bringing lending to a new network requires: deploying the template on that chain and setting its slot in config, a per-network offer-book database, and an indexer that serves get_factory_children.


Security properties — summary

  • Atomic settlement. The loan, the loan delivery, and the collateral lock all happen in one tx; if the clone+init reverts, the refund splitter returns both legs to their owners.
  • No early broadcast. The maker's partial PSBT can't be broadcast until a taker funds it (the protocol-fee output makes its outputs exceed its inputs).
  • Tamper-evident escrow. The maker's SIGHASH_SINGLE|ANYONECANPAY signatures commit exactly their side; the taker's SIGHASH_ALL locks the rest. Neither can alter the other's committed portion.
  • No custodian / no admin keys. Authorization is "spend to / commit your own script"; collateral and repayments are only ever released to the party whose stored script matches the claim's output.
  • Per-network isolation. Offer data is physically separated per network, so test and production loans can never mix.