Skip to main content

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

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 (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:

  1. 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.
  2. Builds the partial settlement PSBT (3 maker inputs 0x83, the 3 committed outputs, the OP_RETURN) and signs the maker's inputs.
  3. 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.

Offers created before 2026-07-12

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:

  1. Builds + signs their own prep (their token + a BTC fee budget) and broadcasts it.
  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 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:

  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.