Issuing the NFTs & encoding the terms
π Draft. This chapter has not been reviewed yet β content may be incomplete or change.
Phase 1 of the lending walkthrough. The borrower constructs a loan offer: mint four NFTs, pack the loan terms into two of them, compute the covenant addresses the rest of the protocol locks to, and write it all into an instance file.
Nothing is on-chain as a loan yet after this step β IssueUtilityNFTs only
mints the tokens and records the parameters. But it's the densest action in the
protocol, because it's where four cookbook ideas converge:
- Issuance β minting new Liquid assets whose IDs come from input outpoints (recipe 8).
- Bit-packing β encoding the loan terms into NFT amount fields so the covenants can read them on-chain (recipe 7).
- Tapleaf compute β deriving each covenant's script hash by compiling a
.simf, where some hashes feed into others (recipe 9). - A constructor β
create_instancepersists the whole deal to a file so every later method can read it back (recipe 10).
The constructor and its terms
IssueUtilityNFTs is the class's is_constructor method. Its params are the
loan terms the borrower chooses:
"IssueUtilityNFTs": {
"is_constructor": true,
"params": {
"BORROWER_PUB_KEY": { "type": "pubkey", "source": { "type": "wallet_key" } },
"COLLATERAL_ASSET_ID": { "type": "liquid.asset_id" },
"COLLATERAL_AMOUNT": { "type": "u64" },
"COLLATERAL_DECIMALS_MANTISSA":{ "type": "u8", "default": "8" },
"PRINCIPAL_ASSET_ID": { "type": "liquid.asset_id" },
"PRINCIPAL_AMOUNT": { "type": "u64" },
"PRINCIPAL_DECIMALS_MANTISSA": { "type": "u8" },
"PRINCIPAL_INTEREST_RATE": { "type": "u16" },
"LOAN_EXPIRATION_TIME": { "type": "u32" }
},
...
}
BORROWER_PUB_KEY auto-fills from the borrower's wallet signing key (the
wallet_key source from Parameters).
The interest rate is in basis points (u16, so 10,000 = 100%); the
expiry is a block height (CLTV). The two DECIMALS_MANTISSA values matter for
the encoding below β they let a base-10 amount like "1 L-BTC" be stored compactly
as 1 with a separate exponent of 8.
Minting four NFTs from issuance inputs
New to issuance? This is the first action in the book that mints assets. Asset issuance & NFTs covers the
issuanceblock, why asset IDs come from outpoints, and bearer-token NFTs in isolation β read it first if any of the below is unfamiliar.
A Liquid asset ID is derived from the outpoint of the input that issues it, so
to mint four distinct NFTs you need four distinct wallet UTXOs. That's why the
helper Prepare action splits one UTXO into four beforehand. Each issuance input
declares an issuance block and captures the resulting asset ID with an
on_resolved hook:
{
"id": "borrower_nft_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 },
"on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } }
}
The Borrower and Lender NFTs are issued with asset_amount_sat: 1 β true
single-unit bearer tokens. The two Parameter NFTs are different: their issued
amount is not 1 but the encoded loan terms (next section), so the asset's very
supply carries the offer:
{
"id": "first_params_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 },
"on_resolved": { "set": { "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } }
}
All four asset IDs land in compile_params.* via on_resolved, ready for the
covenant-hash computation. The matching outputs simply send each freshly minted
NFT back to the borrower's wallet. See
Asset issuance & NFTs for the issuance
mechanics in isolation.
Bit-packing the loan terms
Here's the trick that makes the offer self-describing on-chain. Rather than store
the terms off-chain, the protocol packs them into the amount fields of the two
Parameter NFTs, computed in an on_pre_broadcast hook before the transaction is
built:
"on_pre_broadcast": {
"set": {
"compile_params.FIRST_PARAMETERS_ENCODED":
"params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328",
"compile_params.SECOND_PARAMETERS_ENCODED":
"params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432"
}
}
Each multiplier is a power of two β it's a left-shift dressed up as multiplication. The First Parameters amount packs four fields into a 64-bit integer:
| Field | Bits | Width | Multiplier |
|---|---|---|---|
PRINCIPAL_INTEREST_RATE | 0β15 | 16 | Γ1 |
LOAN_EXPIRATION_TIME | 16β42 | 27 | Γ65536 (2ΒΉβΆ) |
COLLATERAL_DECIMALS_MANTISSA | 43β46 | 4 | Γ8796093022208 (2β΄Β³) |
PRINCIPAL_DECIMALS_MANTISSA | 47β50 | 4 | Γ140737488355328 (2β΄β·) |
The Second Parameters amount packs the two base amounts β each divided down by its decimal exponent so it fits in 25 bits:
| Field | Bits | Width | Multiplier |
|---|---|---|---|
COLLATERAL_AMOUNT / 10^collateral_decimals | 0β24 | 25 | Γ1 |
PRINCIPAL_AMOUNT / 10^principal_decimals | 25β49 | 25 | Γ33554432 (2Β²β΅) |
This is exactly the layout the covenants unpack. In
pre_lock.simf
and lending.simf, extract_lending_parameters masks and shifts these same bit
ranges back out, then validate_lending_params asserts they match the covenant's
own compile params:
#![allow(unused)] fn main() { let (interest_rate_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, 16, 0); // bits 0β15 let (loan_expiration_time_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, 27, shift); // bits 16β42 // β¦decimalsβ¦ then from the second NFT, two 25-bit base amounts }
The manifest's packing and the covenant's unpacking are two halves of one wire
format β get the widths or multipliers out of sync and validate_lending_params
aborts the spend. That mutual dependence is the whole reason the encoding lives in
the example as a worked reference rather than something you'd reinvent.
Why pack at all? A covenant can only introspect what's in the transaction. By making the terms the NFTs' amounts, every spend that moves the NFTs carries the terms with it, and each covenant re-derives and re-checks them β no oracle, no side channel. The cost is the 64-bit budget you're packing into, which is why base amounts are stored with a separate decimals exponent.
Computing the covenant addresses
The borrower has to lock collateral to the pre_lock covenant β but pre_lock's
address depends on the lending covenant's hash, which depends on the principal
vault's hash, which depends on the Lender NFT's asset ID, which only exists
after the issuance inputs resolve. create_instance untangles this with a set
of tapleaf compute fields, each compiling a .simf to its script hash:
"create_instance": {
"class": "lending_contract",
"fields": {
"PRINCIPAL_OUTPUT_SCRIPT_HASH": {
"compute": "tapleaf", "simf": "./p2pk.simf",
"params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } }
},
"LENDER_PRINCIPAL_COV_HASH": {
"compute": "tapleaf", "simf": "./asset_auth.simf",
"params": {
"ASSET_ID": { "type": "liquid.asset_id", "value": "LENDER_NFT_ASSET_ID" },
"ASSET_AMOUNT": { "type": "u64", "value": "1" },
"WITH_ASSET_BURN": { "type": "bool", "value": "true" }
}
},
"LENDING_COV_HASH": {
"compute": "tapleaf", "simf": "./lending.simf",
"params": { "β¦": "β¦", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } }
},
"PRE_LOCK_COV_HASH": {
"compute": "tapleaf", "simf": "./pre_lock.simf",
"params": { "β¦": "β¦", "LENDING_COV_HASH": { "type": "bytes32", "value": "LENDING_COV_HASH" } }
},
"β¦": "β¦"
}
}
Read the value strings as references to other fields already computed. The
dependency order forms a chain, not a cycle:
p2pk.simf ββββββββββββββββΊ PRINCIPAL_OUTPUT_SCRIPT_HASH ββ
asset_auth.simf ββββββββββΊ LENDER_PRINCIPAL_COV_HASH ββ β
βΌ β
lending.simf βββββββββββΊ LENDING_COV_HASH βββ¬βββββββββββ€
β β
script_auth.simf(LENDING) βΊ PARAMETERS_NFT_OUTPUT_SCRIPT_HASH,
BORROWER_NFT_OUTPUT_SCRIPT_HASH ββ
βΌ
pre_lock.simf ββββββββββββΊ PRE_LOCK_COV_HASH βββββββββββββββββ
script_auth.simf(PRE_LOCK) βΊ PRELOCK_PARAMETERS_NFT_SCRIPT_HASH
The tool compiles them in dependency order: leaf programs first (p2pk,
asset_auth), then lending (which needs the vault hash), then the script_auth
wrappers keyed to LENDING_COV_HASH, and finally pre_lock (which needs all of
the above) and its own script_auth wrapper.
script_auth.simfcompiled twice. The same program appears as two distinct UTXO types βprelock_script_authandlending_script_authβ because it's compiled with two differentSCRIPT_HASHparams. One wraps the NFTs to thepre_lockcovenant during the offer; the other re-wraps them to thelendingcovenant once the loan is active. Same code, two addresses. The tapleaf compute recipe covers this pattern; covenant UTXO types covers why a.simfplus its params is an address.
One derived field: the interest amount
Most instance fields are either passthrough ("$params.X") or tapleaf hashes. One
is a plain arithmetic derived param:
"PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000"
The borrower never enters the interest amount β only the rate. The actual
satoshis owed are computed once, here, and stored in the instance so RepayLoan
can require exactly principal + interest later. (The lending covenant computes
the same figure on-chain in calculate_interest, so the two agree.)
What you end up with
After broadcast, create_instance writes
lending.instance.json next to the manifest, holding every field above: the four
asset IDs, the four covenant hashes, the packed parameter values, the interest
amount, and the borrower's key. This file is the deal. Every later
method β LockCollateral, SetupLending, RepayLoan, and the rest β is run with
--instance lending.instance.json so the wallet rebuilds the exact same covenant
addresses without re-entering anything. This is the full class / instance model
from Instance, state & constructors.
Run it
IssueUtilityNFTs needs four separate L-BTC UTXOs (one per issuance). Prepare
splits one into four; then run the constructor as the borrower:
# split one wallet UTXO into four for the four issuances
txw run examples/lending/txmanifest.json Prepare \
--wallet borrower.json
A testnet params file
IssueUtilityNFTs would otherwise prompt for every loan term. Drop a
params file next to
the manifest and the tool auto-discovers it β for testnet it looks for
txmanifest.testnet.json in examples/lending/. Here's a complete one for a
single-asset L-BTC loan (borrow testnet L-BTC against testnet L-BTC), so the
faucet can fund both the borrower and the
lender:
{
"COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"COLLATERAL_AMOUNT": "200000",
"COLLATERAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"PRINCIPAL_AMOUNT": "100000",
"PRINCIPAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_INTEREST_RATE": "1000",
"LOAN_EXPIRATION_TIME": "5000000"
}
That id is testnet L-BTC (the same asset the
faucet dispenses). The terms describe a
0.002 L-BTC collateral loan for 0.001 L-BTC principal at 10% interest
(1000 basis points). A few values are worth understanding rather than copying
blindly:
BORROWER_PUB_KEYis absent on purpose β it's awallet_keysource, so it auto-fills from the borrower wallet. The file only carries the terms you choose.*_DECIMALS_MANTISSAis0here, not8. Recall from bit-packing that each amount is stored asamount / 10^decimalsin a 25-bit base field, and the covenant rebuilds it asbase Γ 10^decimals. So the amount must be an exact multiple of10^decimalsand the base must fit in 25 bits (< ~33.5M). Withdecimals = 0the raw satoshi amount goes straight into the base field β perfect for sub-0.335-L-BTC testnet sums. For whole-coin amounts you'd raise the exponent (e.g.8, the manifest's default) so larger figures still fit the 25 bits β but then the amount must be a whole multiple of10^8(β₯ 1 L-BTC), which the faucet won't cover.LOAN_EXPIRATION_TIMEis a block height β set it comfortably ahead of the current testnet tip (check an explorer;5000000is a placeholder). It only matters at liquidation; the constructor just records it.
With the file in place, the constructor reads every value from it β nothing to type:
# mint the NFTs, encode the terms, compute hashes, write the instance file
txw run examples/lending/txmanifest.json IssueUtilityNFTs \
--network testnet --wallet borrower.json
On success the four NFTs are in the borrower's wallet and
examples/lending/lending.instance.json exists. Sync, and confirm the NFTs:
txw sync --wallet borrower.json
txw get-balance --wallet borrower.json # four new single-asset balances
Inspect the instance file
Open examples/lending/lending.instance.json to see what the constructor
recorded β this is what every later method reads back:
{
"instance": {
"class": "lending_contract",
"fields": {
"BORROWER_NFT_ASSET_ID": "f94aff7f54bd4f4076a0aa07635264a32926966e119dc523ac86427d1f2239f7",
"BORROWER_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
"BORROWER_PUB_KEY": "0b9fa04ada4fcaa83b148ae76fee98fa1bd3a84a1eefe42295e0b98e1fbcac72",
"COLLATERAL_AMOUNT": "3452",
"COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"COLLATERAL_DECIMALS_MANTISSA": "0",
"FIRST_PARAMETERS_ENCODED": "327680000100",
"FIRST_PARAMETERS_NFT_ASSET_ID": "c59e58652d6a00ba53e2ef97556210b106aff996fd76a8de8d04bcbef6888775",
"LENDER_NFT_ASSET_ID": "0d51f8bcf2f6fe4e5c6ebc886a7cc87323e8599c5652ddb44bb6ac6c2e690d52",
"LENDER_PRINCIPAL_COV_HASH": "279a4424550ccc694525388d9c461166a1454defadff4204d1a0885bd5ca2b83",
"LENDING_COV_HASH": "135596e46be4b2a229ae25fea585b056e5b909e0aaee87fd6fc785c9201dd6cf",
"LOAN_EXPIRATION_TIME": "5000000",
"PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
"PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": "761c8268a5998d892c41df708dd64c18047b54750a760861af88e68336c1cfea",
"PRE_LOCK_COV_HASH": "4ac452b2c2c79b932d74f7fee114106328ce18d68ad92091ed345c6c22f59f07",
"PRINCIPAL_AMOUNT": "1000",
"PRINCIPAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"PRINCIPAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_INTEREST_AMOUNT": "10",
"PRINCIPAL_INTEREST_RATE": "100",
"PRINCIPAL_OUTPUT_SCRIPT_HASH": "b4966bd0290ef509e7b1a98dd0b3fe54bd1860f9bb163bf3acb3d3a8401d41d4",
"SECOND_PARAMETERS_ENCODED": "33554435452",
"SECOND_PARAMETERS_NFT_ASSET_ID": "5a001563b2384198a09127c3ac6f36d0cc2a980000fd9915c3ca3cc2d6e2c136"
}
}
}
What to look at:
- The four
*_ASSET_IDfields are your freshly minted NFTs β derived from the issuance outpoints, so they're unique to this run. - The four covenant hashes (
PRE_LOCK_COV_HASH,LENDING_COV_HASH,LENDER_PRINCIPAL_COV_HASH, and the*_SCRIPT_HASHwrappers) are the addresses the next steps lock to β computed from those asset IDs and yourBORROWER_PUB_KEY. FIRST_PARAMETERS_ENCODED/SECOND_PARAMETERS_ENCODEDare the bit-packed terms β and also the amounts your two Parameter NFTs were issued with. You can check them by hand: with the decimal fields0,FIRST = INTEREST_RATE + EXPIRY Γ 65536 = 100 + 5000000 Γ 65536 = 327680000100, andSECOND = COLLATERAL + PRINCIPAL Γ 33554432 = 3452 + 1000 Γ 33554432 = 33554435452.
Yours will differ. Almost every value is derived, so a fresh run won't reproduce these β the asset IDs come from your outpoints and the hashes from your key. This sample also happens to come from a run with different terms than the params file above (a
3452-sat collateral loan at 1% interest), so the amounts won't match either. It's here to show the shape and what each field is for.
With the offer constructed, the borrower can put it on-chain: Opening the offer.