Settling: repay, liquidate, withdraw
π Draft. This chapter has not been reviewed yet β content may be incomplete or change.
Phase 4 of the lending walkthrough. The loan is active. It ends one of two ways β the borrower repays and reclaims the collateral, or the lender liquidates it after the deadline β and the lender finally withdraws their principal plus interest from a vault.
By now the collateral sits behind the lending covenant, the borrower holds the
principal, and the lender holds the Lender NFT. This phase resolves the loan. It
reuses the two-path covenant shape from
accepting the offer, adds an
absolute timelock, and introduces the last piece β the
asset_auth.simf
principal vault.
A detour: ClaimLoanFunds
SetupLending delivered the principal to the borrower's p2pk address β a
covenant output, not a plain wallet UTXO. ClaimLoanFunds sweeps it into the
wallet so the borrower can actually use it. It's the
Hello World receive spend, unchanged: one
p2pk input, a Schnorr signature, one wallet output.
"ClaimLoanFunds": {
"inputs": [
{ "id": "principal_in", "utxo_source": { "utxo_type": "p2pk" },
"witnesses": {
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "p2pk_leaf" } },
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "$params.BORROWER_PUB_KEY" } } } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "principal_to_borrower", "destination": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
This is not a lifecycle transition β the loan is still loan_active whether or
not the borrower has swept the principal. It's housekeeping, included to show that
a covenant payout is just another UTXO you spend normally once it's yours.
Run it as the borrower. ClaimLoanFunds also needs --state so the tool knows
which p2pk UTXO to claim β the live one the state file recorded when
SetupLending paid the principal out:
txw run examples/lending/txmanifest.json ClaimLoanFunds \
--instance examples/lending/lending.instance.json \
--state examples/lending/lending.state.json \
--wallet borrower.json
txw sync --wallet borrower.json
The lending covenant: repay or liquidate
lending.simf
is the offer covenant's sibling β same Either<(), ()> PATH selector, different
two outcomes:
fn main() { match witness::PATH { Left(params: ()) => { loan_repayment_path(); }, // borrower repays Right(params: ()) => { loan_liquidation_path(); }, // lender seizes after expiry } }
PATH::LEFT is repayment β anyone can fund it, but it only succeeds if it routes
principal + interest to the lender's vault. PATH::RIGHT is liquidation β gated by
a timelock instead of a signature.
RepayLoan β borrower settles (PATH::LEFT)
The borrower returns principal plus interest and gets the collateral back. The
covenant computes the interest on-chain (calculate_interest, the same basis-point
math the instance stored as PRINCIPAL_INTEREST_AMOUNT) and demands the repayment
land in the lender's vault:
#![allow(unused)] fn main() { fn loan_repayment_path() { assert!(jet::eq_32(jet::current_index(), 0)); ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); // in0βout0 collateral back let first = ensure_input_and_output_assets_eq(1, 2, param::FIRST_PARAMETERS_NFT_ASSET_ID); let second = ensure_input_and_output_assets_eq(2, 3, param::SECOND_PARAMETERS_NFT_ASSET_ID); ensure_input_and_output_assets_with_amount_eq(3, 4, param::BORROWER_NFT_ASSET_ID, 1); // β¦unpack & validate termsβ¦ let owed: u64 = calculate_principal_with_interest(principal_amount, interest_rate); ensure_asset_with_amount(1, false, param::PRINCIPAL_ASSET_ID, owed); // out1 = principal+interest ensure_script_hash(1, false, param::LENDER_PRINCIPAL_COV_HASH); // β¦into the vault ensure_output_is_op_return(2); // burn the params + borrower NFT ensure_output_is_op_return(3); ensure_output_is_op_return(4); } }
Same off-by-one as SetupLending: the repayment is injected at output 1, shifting
the NFT outputs down. The collateral returns to the borrower's wallet (output 0),
the Parameter and Borrower NFTs are burned (the loan is over), and the
principal + interest goes to the vault β not directly to the lender. The manifest
mirrors that layout:
"RepayLoan": {
"inputs": [
{ "id": "lending_in", "utxo_source": { "utxo_type": "lending_collateral" },
"witnesses": { "PATH": { "type": "simplicityhl", "value": "Left(())" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lending_leaf" } } } },
{ "id": "first_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "β¦": "β¦" },
{ "id": "second_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "β¦": "β¦" },
{ "id": "borrower_nft_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "β¦": "β¦" },
{ "id": "repayment_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID",
"amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
{ "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "compile_params.PRINCIPAL_ASSET_ID",
"amount_sat": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" },
{ "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" },
{ "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
{ "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "repayment_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
The repayment input requires PRINCIPAL_AMOUNT + PRINCIPAL_INTEREST_AMOUNT β the
derived interest the constructor
computed and stored. The Lender NFT isn't touched here; it's still in the lender's
wallet, waiting to unlock the vault. RepayLoan is the lifecycle's cooperative
happy path: state moves to repaid.
LiquidateAfterExpiry β lender seizes (PATH::RIGHT)
If the borrower never repays, the lender takes the collateral β but only after the deadline. The covenant enforces that with an absolute timelock:
#![allow(unused)] fn main() { fn loan_liquidation_path() { assert!(jet::eq_32(jet::current_index(), 0)); ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); let first = ensure_input_and_output_assets_eq(1, 1, param::FIRST_PARAMETERS_NFT_ASSET_ID); // identity mapping let second = ensure_input_and_output_assets_eq(2, 2, param::SECOND_PARAMETERS_NFT_ASSET_ID); ensure_input_and_output_assets_with_amount_eq(3, 3, param::LENDER_NFT_ASSET_ID, 1); // β¦unpack & validate termsβ¦ jet::check_lock_height(loan_expiration_time); // β block height must be β₯ expiry ensure_output_is_op_return(1); ensure_output_is_op_return(2); ensure_output_is_op_return(3); } }
check_lock_height is the on-chain half of an nLockTime/CLTV timelock: the spend
is only valid once the chain reaches LOAN_EXPIRATION_TIME. Note the mapping is the
identity here (no payout injected) and the gating token is the Lender NFT,
which the lender brings from their wallet β there's no signature, holding the NFT
is the authorisation. The collateral lands in the lender's wallet; the NFTs burn.
The manifest also declares a friendly pre-build validations check so you get a
clean error instead of a rejected broadcast if you try too early:
"validations": [
{ "id": "expiry_reached",
"rule": { "type": "arithmetic", "expr": "current_block_height >= compile_params.LOAN_EXPIRATION_TIME" },
"error": { "code": "TIMELOCK_NOT_ELAPSED", "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." } }
]
This is the unilateral escape hatch for the lender, the mirror image of the
borrower's CancelOffer: state moves to liquidated. Compare the relative
timelock (check_lock_distance) in the Last Will β
that one counts blocks since the UTXO was created; this one names an absolute
height.
The principal vault: asset_auth.simf
Whichever way the loan settled honestly, RepayLoan parked the lender's money in a
lender_principal_vault UTXO rather than paying the lender directly. Why the extra
hop? Because at repayment time the transaction is driven by the borrower β they
shouldn't dictate the lender's receiving address, and the lender may be offline. The
vault holds the funds under a covenant only the Lender NFT holder can open:
#![allow(unused)] fn main() { fn auth_with_burn_check(input_asset_index: u32, output_asset_index: u32) { ensure_asset_and_amount_eq(input_asset_index, true, param::ASSET_ID, param::ASSET_AMOUNT); // LENDER_NFT, 1 ensure_asset_and_amount_eq(output_asset_index, false, param::ASSET_ID, param::ASSET_AMOUNT); match param::WITH_ASSET_BURN { true => ensure_output_is_op_return(output_asset_index), // and the NFT must be burned false => {}, } } }
Compiled with ASSET_ID = LENDER_NFT_ASSET_ID, ASSET_AMOUNT = 1, and
WITH_ASSET_BURN = true, it says: to move what's in this vault, you must spend the
Lender NFT as an input and burn it as an output. The NFT is a one-shot key.
ClaimPrincipalWithInterest β lender withdraws
The lender opens the vault by co-spending and burning their NFT, sending the principal + interest wherever they like:
"ClaimPrincipalWithInterest": {
"params": { "lender_destination": { "type": "address" } },
"inputs": [
{ "id": "vault_in", "utxo_source": { "utxo_type": "lender_principal_vault" },
"witnesses": {
"INPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_in)" },
"OUTPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_burned)" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lender_principal_vault_leaf" } } } },
{ "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "principal_interest_out", "destination": "params.lender_destination", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" },
{ "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
The index_of(...) formula is the key trick: the covenant needs to know which
input is the NFT and which output burns it, but those positions depend on how the
builder ordered the transaction. Rather than hard-code indices, the witnesses are
computed with index_of(id), which
resolves an input/output id to its final position β so the covenant's
INPUT_ASSET_INDEX / OUTPUT_ASSET_INDEX always point at the right slots. State
moves to settled, and the loan is fully wound down.
Why a vault instead of paying the lender directly β and "accumulation." The
asset_authpattern decouples when funds are paid in from when they're collected. A lender running many loans accrues a vault per loan, each unlocked by its own Lender NFT, and can sweep them on their own schedule from any wallet that holds the NFTs β without the borrowers needing the lender's addresses. It's a reusable building block: an asset-gated, burn-on-spend output.
Run it
The two settlements are run by different parties. The borrower repays β
returning principal + interest and reclaiming the collateral (you'll have swept the
principal with ClaimLoanFunds above):
# --- Borrower repays ---
txw run examples/lending/txmanifest.json RepayLoan \
--instance examples/lending/lending.instance.json --wallet borrower.json
Then the lender drains the vault:
# --- Lender collects principal + interest ---
txw run examples/lending/txmanifest.json ClaimPrincipalWithInterest \
--instance examples/lending/lending.instance.json --wallet lender.json
Or, if the borrower defaulted, the lender liquidates once the chain has passed
LOAN_EXPIRATION_TIME (before then, the TIMELOCK_NOT_ELAPSED validation stops
you):
txw run examples/lending/txmanifest.json LiquidateAfterExpiry \
--instance examples/lending/lending.instance.json --wallet lender.json
Remember to sync each wallet after a broadcast.
Liquidation needs the chain at the expiry height.
check_lock_heightis an absolute-height lock, so on testnet you either set a near-futureLOAN_EXPIRATION_TIMEat construction or wait for the height to arrive. Treat the liquidation path as illustrative until the chain catches up to the deadline you chose.
You've reached the end
That's the whole protocol: a borrower and a lender transacting a collateralised loan with no escrow, every rule β terms, amounts, timelock, payout routing β enforced by five small Simplicity covenants and four NFTs. Along the way you've now seen, working together, every concept the cookbook introduced one recipe at a time: covenant UTXO types, multiple spending paths, issuance & NFTs, formulas & derived params, hooks & tapleaf compute, and the class / instance model.
For the precise rules behind anything here, the authoritative reference is
Spec.md.