Lending Protocol
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
ACTIVEthe moment it confirms. - No bearer token, no admin. A party's identity is simply the
scriptPubKeythey 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:
| Term | Meaning |
|---|---|
| loan token / amount | what the borrower receives (e.g. 1 DIESEL) |
| collateral token / amount | what the borrower locks to back the loan (e.g. 0.1 frBTC) |
| APR | annual rate, stored in basis points |
| duration | loan 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 + 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 offer book DB • broadcast taker prep
• broadcast 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
| Op | Name | Caller | Effect |
|---|---|---|---|
| 0 | Initialize | (settlement) | create + arm the loan from the agreed terms |
| 1 | RepayLoan | debitor (permissionless tokens-in) | send the loan token back → REPAID |
| 2 | ClaimRepayment | creditor | pull principal + interest |
| 3 | ClaimCollateral | debitor | reclaim the collateral after repayment |
| 4 | TriggerDefault | anyone (after deadline) | flip an expired ACTIVE loan → DEFAULTED |
| 5 | ClaimDefaultedCollateral | creditor | seize 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 = creditor →
creditorOutput = 2(maker),debitorOutput = 3(taker) - maker = debitor →
creditorOutput = 3(taker),debitorOutput = 2(maker)
The two protostones (the OP_RETURN at vout 0)
- p0 — Initialize message. Target
{6, templateTx}(clone the template), opcode0, with the terms as calldata. Itspointeris the debitor output — so the borrowed loan token routes to the borrower. ItsrefundPointeris 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).SINGLEmakes 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).ANYONECANPAYmakes 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 (the "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:
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:
- Builds and signs the prep, and broadcasts it right away — immediately after the settlement-signature gate. Both signatures come under a single wallet approval, and the prep only goes out once that gate has passed, so a refused signature leaves nothing on chain.
- Builds the partial settlement PSBT (3 maker inputs
0x83, the 3 committed outputs, the OP_RETURN) and signs the maker's inputs. - POSTs the signed prep hex + the partial PSBT + the terms to the offer-book database. The stored hex is no longer what puts the prep on chain — it is the taker's idempotent re-broadcast fallback for mempool eviction.
The maker can then go offline. The prep spends the maker's UTXOs into the maker's own outputs, so the funds stay in the maker's wallet — but the prep is a real transaction, and the maker pays its miner fee at post time whether or not the offer is ever taken.
The prep used to be deferred: signed at post time, held off chain, and broadcast by the taker at settlement. Offers from that era still sit in the offer book, which is why the taker path keeps the re-broadcast fallback below.
3. Taker side — accepting (one broadcast)
When a taker accepts:
- Builds + signs their own prep (their token + a BTC fee budget) and broadcasts it.
- Appends their input (input 3,
SIGHASH_ALL) and receive output (vout 3) to the maker's partial PSBT, then signs input 3. The maker's0x83signatures survive untouched. - Broadcasts the settlement — a child of their own just-broadcast prep. The maker's prep has been on chain since the offer was posted, so it is a settled parent, not a package member.
Before broadcasting, the taker probes whether the maker's prep is visible on chain. It normally is, and the step is skipped. Only if it has been evicted from the mempool, or the offer predates broadcast-at-create, does the taker re-broadcast the stored hex.
4. Dynamic fee (carrying the taker's prep)
The settlement spends one unconfirmed parent — the taker's own prep — so the taker sizes the
settlement's fee to carry that pair at the chosen fee rate (Child-Pays-For-Parent).
computePackageSettlementFee computes max(ceil(feeRate × packageVsize) − prepsAlreadyPaid, ceil(feeRate × settlementVsize)). packageVsize covers the taker prep plus the settlement; the
maker prep enters it only on a legacy deferred offer, where it becomes a second unconfirmed parent
and the package widens to match. The floor on the right keeps the child paying its own way when the
deficit is already covered.
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 signed prep tx hex kept as a re-broadcast fallback,
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 three settlement outputs of its prep, prepTxid:0..2, are unspent.
Those are the maker's SIGHASH_SINGLE inputs, so spending one elsewhere invalidates the signed
partial and the offer can never settle — which is why the wallet treats them as locked for as long
as the offer is open, while the prep's change outputs return to the normal spendable set. On a
legacy deferred offer the same reasoning applies one step earlier, to the prep's own inputs, since
spending those double-spends a prep that has not been broadcast yet. The list endpoint prunes
offers that fail this check: 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:
get_factory_children(LENDING_PSBT_TEMPLATE_ID)enumerates every loan clone.- For each clone,
get_keys(/creditor_script, /debitor_script)is matched against the connected wallet's receivescriptPubKey→ that's the wallet's role. - 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|ANYONECANPAYsignatures commit exactly their side; the taker'sSIGHASH_ALLlocks 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.