diff --git a/CHANGELOG.md b/CHANGELOG.md index 83858d1..8f108b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added - Release binaries for macOS (Apple Silicon). -- Supports manifest format [`0.3.1`](docs/format-changelog.md), which adds program pinning. `0.3.0` manifests still load. +- Supports manifest format [`0.3.1`](docs/format-changelog.md), which adds program pinning and an action-level `lock_time`. `0.3.0` manifests still load. - `pin` command: writes every program's current hash into a manifest without reformatting it; `pin --check` fails if any is missing or stale. - `validate --strict`: unpinned programs are errors rather than warnings. - `run --debug`: development mode. For now the same as `--allow-unpinned`; it may enable more development-only behaviour later. @@ -23,6 +23,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - `validate` errors when a manifest uses a Simplicity program in a `tapleaf` or `simf_fn` compute, or a `programs` entry, without declaring `"requires": ["simplicity"]`. Previously only covenant `utxo_types` triggered this. Add `"simplicity"` to `requires` if `validate` now reports it. - `validate` also checks each program file: that it exists, matches its hash, meets its compiler requirement, and declares the witnesses the manifest supplies. +- On Liquid, `run` refuses to finalize a Simplicity input whose program may cost more than its witness's budget, and says how many bytes short it is. Previously the transaction was built (or exported with `--export-pset`) and the node rejected it with `Program's execution cost could exceed budget`. ## [0.3.0] - 2026-10-06 diff --git a/docs/format-changelog.md b/docs/format-changelog.md index d079cb0..59cee62 100644 --- a/docs/format-changelog.md +++ b/docs/format-changelog.md @@ -40,6 +40,12 @@ unless it uses a Simplicity program without declaring `simplicity`. compiler-version requirement; a manifest is pinned when all its programs are. Wallets must refuse unpinned manifests and programs that don't match their hash. Tools may accept them for development. +- **`lock_time` on actions**: `{"height": }` or `{"time": }` sets the + transaction's `nLockTime`, which the `check_lock_height` and `check_lock_time` jets read. + A height must be below 500,000,000 and a time (unix seconds) at least that, since + consensus tells them apart by size. A lock time is ignored when every input is final, + so each input that gives no `sequence` gets `0xfffffffe`. Without `lock_time`, + `nLockTime` is zero. ### Rules diff --git a/examples/README.md b/examples/README.md index b14edfa..aaf727c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,8 +14,10 @@ Roughly in order of complexity: | [`last_will`](last_will) | Liquid | A recursive covenant with a timelocked inheritance path, a cold-key break-out and a hot-key refresh. | | [`dex`](dex) | Liquid | Tessera: a keyless atomic-swap offer anyone can fill, or refund after a timeout. | | [`deadcat_v3`](deadcat_v3) | Liquid | A binary prediction market with on-chain oracle resolution and confidential reissuance tokens. Derived from [Deadcat](https://github.com/Resolvr-io/deadcat); not interoperable with it. | +| [`oracle_demo`](oracle_demo) | Liquid | Burn L-BTC for a stablecoin at the current price, checked by a challenge window: ECDSA-signed oracle statements in [Mycelia Signal](https://github.com/jonathanbulkeley/Mycelia-Signal-Sovereign-Oracle)'s format, verified and parsed on chain, from oracle keys a cold key certifies. Issues its asset with a second manifest in `issue_usdt/`. | | [`lending_v2`](lending_v2) | Liquid | Peer-to-peer collateralised lending, wire-compatible with [simplicity-lending](https://github.com/BlockstreamResearch/simplicity-lending). | | [`lending_v3`](lending_v3) | Liquid | Work in progress: the redesigned "issuance factory" version of simplicity-lending — create a factory, then offer, accept, cancel, claim and repay loans. | +| [`lending_v4`](lending_v4) | Liquid | lending_v3 plus liquidation at the current oracle price, through a challenge window: the lender starts it with a certified statement, anyone with a newer one can cancel it. A new protocol version, not interoperable with lending_v3. | -The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`) -include full walkthroughs. +The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`, +`lending_v4`, `oracle_demo`) include full walkthroughs. diff --git a/examples/lending_v4/README.md b/examples/lending_v4/README.md new file mode 100644 index 0000000..92d3539 --- /dev/null +++ b/examples/lending_v4/README.md @@ -0,0 +1,165 @@ +# lending_v4 + +[lending_v3](../lending_v3)'s issuance-factory lending protocol, plus liquidation at the +**current** price, through a challenge window. + +lending_v3 liquidates only once a loan expires. Before then, a lender can't act on a +falling price: a program can check that an oracle signed a price, but not that the price +is current, so a lender could present a stale low price to seize collateral the loan +still covers. lending_v4 lets the lender start a liquidation with a signed statement, and +gives anyone with a newer statement time to stop it: + +1. **StartLiquidation** (the lender, showing the lender NFT): a certified statement at + which the debt is more than `LIQUIDATION_LTV_BPS` of the collateral's value. The loan + moves to a pending state that records the statement's timestamp and price. +2. **CancelLiquidation** (anyone): a certified statement newer than the claim, at which the + loan isn't liquidatable. The loan goes back to active. +3. **FinishLiquidation** (the lender): once `WINDOW_BLOCKS` have passed, the lender NFT is + burned and the collateral released, as on expiry. + +While a liquidation is pending, the borrower can still repay in full +(**RepayDuringLiquidation**), so a borrower racing to repay always wins. + +This is a new protocol version. It does not interoperate with lending_v3 or with the +deployed simplicity-lending indexer: every covenant address is different. lending_v3's +indexer constants (the program-id tags, the protocol-fee keeper) are kept only as they +were. + +## The liquidation threshold + +`LIQUIDATION_LTV_BPS` defaults to 8000: a loan may be liquidated once the debt is more than +80% of the collateral's value, that is, once the collateral is worth less than 125% of the +debt. That is in line with common practice for bitcoin-backed loans, which typically +liquidate somewhere around 75–85% loan-to-value, and leaves room for the price to move +during the window. + +The collateral is valued as `COLLATERAL_AMOUNT * price / 100` principal base units, which +assumes satoshis of collateral against a dollar asset with 8 decimal places: L-BTC against +tUSDT from [`oracle_demo/issue_usdt`](../oracle_demo/issue_usdt). + +## How it works + +Statements, certificates and the check that a statement isn't dated in the future are +[oracle_demo](../oracle_demo)'s, copied into [`lending.simf`](lending.simf) because the +wallet compiles each program from a single file. The ECDSA, parsing and certificate code +is unchanged from there. + +The loan's state lives in storage slot 0, as in lending_v3, with one new value: + +| State | Slot 0 (32 bytes) | +|-------|-------------------| +| offer | `0` | +| active | `1` | +| liquidation pending | `claim timestamp ‖ claim price ‖ 0 ‖ 2`, 8 bytes each, big-endian | + +Offer and active are encoded exactly as in lending_v3, so their addresses are derived the +same way. Every pending claim has its own address. + +The new paths sit behind a separate witness, `LIQUIDATION`. When it is `None`, the program +runs lending_v3's paths unchanged, selected by `PATH` as before. + +| Action | Spends | Creates | The covenant checks | +|--------|--------|---------|---------------------| +| `StartLiquidation` | active loan (input 0), lender NFT (input 1) | pending loan (output 0), lender NFT back (output 1) | the statement and certificate; that it isn't dated more than `MAX_FUTURE_SECONDS` ahead (the lock time); that the loan is liquidatable at its price | +| `CancelLiquidation` | pending loan (input 0) | active loan (output 0) | a newer certified statement at which it isn't | +| `FinishLiquidation` | pending loan (input 0), lender NFT (input 1) | the NFT burned (output 0), the collateral to the lender | that input 0 is at least `WINDOW_BLOCKS` old | +| `RepayDuringLiquidation` | borrower NFT, pending loan (input 1), the repayment | as `RepayLoan` | as `RepayLoan`, from the pending state | + +## Running it on Liquid testnet + +You need a funded Liquid testnet wallet holding some tUSDT to lend; see +[oracle_demo](../oracle_demo) for issuing it. Here one wallet plays borrower, lender and +watcher. `run` asks for every param, so put them in the `--params` files; the liquidation +actions need a `fee_rate` of at least 0.15 sat/vB (see Limitations). After each broadcast, +wait for it to confirm and `sync` before the next. + +```sh +W=wallet.json +TXM=tx-manifest-wallet +ORACLE=../oracle_demo/fake_oracle.py +cd examples/lending_v4 +``` + +**1. A loan.** `CreateFactory` mints the factory. `CreateOffer` locks the collateral and +takes the loan terms, including the four new ones: `COLD_PUBKEY` (by default +oracle_demo's example cold key), `MAX_FUTURE_SECONDS`, `LIQUIDATION_LTV_BPS` and +`WINDOW_BLOCKS`. Pass it the factory's state file. `AcceptOffer` lends the principal. + +```sh +$TXM run txmanifest.json CreateFactory --wallet $W --params factory.json +$TXM run txmanifest.json CreateOffer --wallet $W --params offer.json --state txmanifest.state.1.json +$TXM run txmanifest.json AcceptOffer --wallet $W --params fee.json \ + --instance txmanifest.instance.2.json --state txmanifest.state.2.json +``` + +**2. A crash, contested.** The lender starts a liquidation at $60,000. The price recovers, +and a watcher cancels it with a newer statement. `--claim` records the statement's price +and timestamp as the claim; `--merge` carries the claim into the cancel. + +```sh +python3 $ORACLE certify --valid-from $(( $(date +%s) - 7200 )) --out cert.json +python3 $ORACLE sign --price 60000.00 --out crash.json +python3 $ORACLE statement-params crash.json --cert cert.json --claim --out start.json +$TXM run txmanifest.json StartLiquidation --wallet $W --params start.json \ + --instance txmanifest.instance.2.json --state txmanifest.state.3.json + +python3 $ORACLE sign --price 84231.50 --out recovered.json +python3 $ORACLE statement-params recovered.json --cert cert.json --merge start.json --out cancel.json +$TXM run txmanifest.json CancelLiquidation --wallet $W --params cancel.json \ + --instance txmanifest.instance.2.json --state txmanifest.state.4.json +``` + +**3. A crash, uncontested.** Start again at a newer low price, wait out the window, and +finish. Finish takes the start's params file, for the claim. + +```sh +python3 $ORACLE sign --price 59000.00 --out crash2.json +python3 $ORACLE statement-params crash2.json --cert cert.json --claim --out start2.json +$TXM run txmanifest.json StartLiquidation --wallet $W --params start2.json \ + --instance txmanifest.instance.2.json --state txmanifest.state.5.json + +# Finish before 5 blocks have passed and the node refuses it: non-BIP68-final. +$TXM run txmanifest.json FinishLiquidation --wallet $W --params start2.json \ + --instance txmanifest.instance.2.json --state txmanifest.state.6.json +``` + +### A run on Liquid testnet + +A loan of 1.5 tUSDT against 3,000 sat at 1%, a debt of 151,500,000 base units: liquidatable +below $63,125.00. + +| Step | Transaction | +|------|-------------| +| `CreateFactory` | [`45dafde2…45f6`](https://liquid.network/testnet/tx/45dafde2fdd627a2251fdf61ba758bdc5d47e1e7047e1a637c07570dd9f745f6) | +| `CreateOffer` | [`7fa41183…52ed`](https://liquid.network/testnet/tx/7fa41183e28130a19eb64f18605211bebfd77bbe655337f712b909167fe352ed) | +| `AcceptOffer` | [`88ed904d…579f`](https://liquid.network/testnet/tx/88ed904ddca2a64f8a718ddb43ebd445f060c93ce2cf7409dd8df586a4a4579f) | +| `StartLiquidation` at 60000.00 | [`6ceb7f96…75e7`](https://liquid.network/testnet/tx/6ceb7f96e8f6f75f340aac7f2f82adf492f843885f390557534a121cf61f75e7) | +| `CancelLiquidation` at 84231.50 | [`74948e4f…29bb`](https://liquid.network/testnet/tx/74948e4f5f8fc15f1de44a1d2a7d9c0044ac923b1bf7b37e63552aedb72f29bb) | +| `StartLiquidation` at 59000.00 | [`8259dd1a…1b9d`](https://liquid.network/testnet/tx/8259dd1aaee8e81bfff44f19e21f43ae56e294269d1688acba4a69e440961b9d) | +| `FinishLiquidation`: lender NFT burned, 3,000 sat to the lender | [`73cd23e7…293f`](https://liquid.network/testnet/tx/73cd23e7399ab9b80c862fb9078a1273efbedf2ceb19d3158bf16d1bf481293f) | + +## Limitations + +- **A pending liquidation can be cancelled until it is finished**, not only during the + window: a program can't require that a height has *not* been reached. +- **A cancelled liquidation can be started again with the same statement.** The active + state doesn't remember the cancelling statement, so the lender can replay the old low + price; each attempt can be cancelled again, at the cost of fees on both sides. + Recording the cancelling statement's timestamp in the active state would close it. +- **Only BTC against a dollar asset with 8 decimals**, as above. Other pairs need a + different valuation. +- **One statement per action.** No quorum of oracles, no bond, no reward for a canceller. +- **Fees.** The wallet's fee estimate assumes a smaller covenant witness than this + program's, so at the minimum 0.1 sat/vB a liquidation is refused (`min relay fee not + met`). Use 0.15. +- **The debt is fixed per instance**, as in lending_v3, so partial repayments aren't + tracked into the liquidation state. + +## Tests + +`cargo test -p tx-manifest-lib --test lending_v4` runs the new paths on the Simplicity +interpreter: a start on either side of the threshold, without the lender NFT, at the wrong +claim or ahead of its lock time; a cancel with a newer good price, a still-low price or an +older statement; a finish before and after the window and without burning the NFT; +repayment while pending, and lending_v3's repayment still working only on an active loan; +and each path's execution budget. diff --git a/examples/lending_v4/asset_auth.simf b/examples/lending_v4/asset_auth.simf new file mode 100644 index 0000000..6abb6f5 --- /dev/null +++ b/examples/lending_v4/asset_auth.simf @@ -0,0 +1,39 @@ +fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn ensure_output_is_op_return(index: u32) { + match jet::output_null_datum(index, 0) { + Some(entry: Option>>) => (), + None => panic!(), + } +} + +fn ensure_asset_and_amount_eq(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + assert!(jet::eq_256(asset_bits, expected_asset_bits)); + assert!(jet::eq_64(amount, expected_amount)); +} + +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); + 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); + }, + false => {}, + } +} + +fn main() { + auth_with_burn_check(witness::INPUT_ASSET_INDEX, witness::OUTPUT_ASSET_INDEX); +} \ No newline at end of file diff --git a/examples/lending_v4/asset_auth_vault.simf b/examples/lending_v4/asset_auth_vault.simf new file mode 100644 index 0000000..8191f34 --- /dev/null +++ b/examples/lending_v4/asset_auth_vault.simf @@ -0,0 +1,245 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option>>) => true, + None => false, + } +} + +// Math helpers + +fn safe_add_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::add_64(first, second); + + result +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(::into(flag_1), ::into(flag_2))); +} + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_output_is_not_an_op_return(index: u32) { + check_flags_eq(is_op_return(index), false); +} + +fn ensure_asset_and_amount_ge(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + assert!(jet::eq_256(asset_bits, expected_asset_bits)); + assert!(jet::le_64(expected_amount, amount)); +} + +fn ensure_active_status(expected_status: bool) { + assert!(jet::eq_1(::into(expected_status), ::into(param::IS_ACTIVE))); +} + +fn ensure_output_script_hash(index: u32, expected_script_hash: u256) { + check_script_hashes_eq(get_script_hash(index, false), expected_script_hash); +} + +fn ensure_vault_asset(index: u32, is_input_index: bool) -> u64 { + let (vault_asset_id, vault_amount): (u256, u64) = get_asset_and_amount(index, is_input_index); + + check_assets_eq(vault_asset_id, param::VAULT_ASSET_ID); + + vault_amount +} + +fn ensure_vault_amount_after_supplying(vault_output_index: u32, amount_to_supply: u64) { + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); + let output_vault_amount: u64 = ensure_vault_asset(vault_output_index, false); + + let expected_output_vault_amount: u64 = safe_add_64(vault_amount, amount_to_supply); + + check_asset_amounts_eq(output_vault_amount, expected_output_vault_amount); +} + +// Auth logic + +fn auth_with_burn_check( + input_asset_index: u32, + output_asset_index: u32, + expected_asset_id: u256, + expected_minimal_asset_amount: u64, + with_asset_burn: bool +) { + ensure_asset_and_amount_ge(input_asset_index, true, expected_asset_id, expected_minimal_asset_amount); + ensure_asset_and_amount_ge(output_asset_index, false, expected_asset_id, expected_minimal_asset_amount); + + match with_asset_burn { + true => { + ensure_output_is_op_return(output_asset_index); + }, + false => { + ensure_output_is_not_an_op_return(output_asset_index) + }, + } +} + +// Main paths logic + +fn withdraw_all(input_keeper_index: u32, output_keeper_index: u32) { + ensure_active_status(false); + + auth_with_burn_check( + input_keeper_index, + output_keeper_index, + param::KEEPER_AUTH_ASSET_ID, + param::KEEPER_AUTH_ASSET_AMOUNT, + param::WITH_KEEPER_ASSET_BURN + ); + + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); +} + +fn withdraw_part(input_keeper_index: u32, output_keeper_index: u32, vault_output_index: u32, amount_to_withdraw: u64) { + ensure_active_status(true); + + auth_with_burn_check( + input_keeper_index, + output_keeper_index, + param::KEEPER_AUTH_ASSET_ID, + param::KEEPER_AUTH_ASSET_AMOUNT, + false + ); + + ensure_output_script_hash(vault_output_index, jet::current_script_hash()); + + let vault_amount: u64 = ensure_vault_asset(jet::current_index(), true); + let output_vault_amount: u64 = ensure_vault_asset(vault_output_index, false); + + assert!(jet::lt_64(amount_to_withdraw, vault_amount)); + + let (carry, vault_change): (bool, u64) = jet::subtract_64(vault_amount, amount_to_withdraw); + + check_asset_amounts_eq(output_vault_amount, vault_change); +} + +fn supply(input_supplier_index: u32, output_supplier_index: u32, vault_output_index: u32, amount_to_supply: u64) { + ensure_active_status(true); + + auth_with_burn_check( + input_supplier_index, + output_supplier_index, + param::SUPPLIER_AUTH_ASSET_ID, + 1, // Allow any amount of the supplier auth asset + false + ); + + ensure_output_script_hash(vault_output_index, jet::current_script_hash()); + + ensure_vault_amount_after_supplying(vault_output_index, amount_to_supply); +} + +// TODO: Add goal logic to finalization flow +fn final_supply( + input_supplier_index: u32, + output_supplier_index: u32, + finalized_vault_output_index: u32, + amount_to_supply: u64 +) { + ensure_active_status(true); + + auth_with_burn_check( + input_supplier_index, + output_supplier_index, + param::SUPPLIER_AUTH_ASSET_ID, + 1, // Allow any amount of the supplier auth asset + param::WITH_SUPPLIER_ASSET_BURN + ); + + ensure_output_script_hash(finalized_vault_output_index, param::FINALIZED_VAULT_COV_HASH); + + ensure_vault_amount_after_supplying(finalized_vault_output_index, amount_to_supply); +} + +fn main() { + match witness::PATH { + Left(withdraw_params: Either<(u32, u32), (u32, u32, u32, u64)>) => { + match withdraw_params { + Left(withdraw_all_params: (u32, u32)) => { + let (input_keeper_index, output_keeper_index): (u32, u32) = withdraw_all_params; + + withdraw_all(input_keeper_index, output_keeper_index); + }, + Right(withdraw_part_params: (u32, u32, u32, u64)) => { + let ( + input_keeper_index, + output_keeper_index, + vault_output_index, + amount_to_withdraw, + ): (u32, u32, u32, u64) = withdraw_part_params; + + withdraw_part(input_keeper_index, output_keeper_index, vault_output_index, amount_to_withdraw); + }, + } + }, + Right(supply_params: Either<(u32, u32, u32, u64), (u32, u32, u32, u64)>) => { + match supply_params { + Left(supply_params: (u32, u32, u32, u64)) => { + let ( + input_supplier_index, + output_supplier_index, + vault_output_index, + amount_to_supply, + ): (u32, u32, u32, u64) = supply_params; + + supply(input_supplier_index, output_supplier_index, vault_output_index, amount_to_supply); + }, + Right(final_supply_params: (u32, u32, u32, u64)) => { + let ( + input_supplier_index, + output_supplier_index, + finalized_vault_output_index, + amount_to_supply, + ): (u32, u32, u32, u64) = final_supply_params; + + final_supply( + input_supplier_index, + output_supplier_index, + finalized_vault_output_index, + amount_to_supply + ); + } + } + }, + } +} \ No newline at end of file diff --git a/examples/lending_v4/data/params.json b/examples/lending_v4/data/params.json new file mode 100644 index 0000000..0949cbd --- /dev/null +++ b/examples/lending_v4/data/params.json @@ -0,0 +1,16 @@ +{ + "AMOUNTS_DECIMALS": "0", + "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + "COLLATERAL_AMOUNT": "3400", + "PRINCIPAL_ASSET_ID": "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", + "PRINCIPAL_AMOUNT": "1000", + "PRINCIPAL_INTEREST_RATE": "100", + "LOAN_EXPIRATION_TIME": "5000000", + "FACTORY_ASSET_ID": "6c7c38dbd33228a4fac33c46d38f0deb1fcf8f27e0279227d7ac868486d1fd18", + "PROTOCOL_FEE_KEEPER_ASSET_ID": "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", + "ZERO_HASH": "0000000000000000000000000000000000000000000000000000000000000000", + "COLD_PUBKEY": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3", + "MAX_FUTURE_SECONDS": "300", + "LIQUIDATION_LTV_BPS": "8000", + "WINDOW_BLOCKS": "5" +} diff --git a/examples/lending_v4/issuance_factory.simf b/examples/lending_v4/issuance_factory.simf new file mode 100644 index 0000000..8a2fc92 --- /dev/null +++ b/examples/lending_v4/issuance_factory.simf @@ -0,0 +1,242 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_explicit_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn get_current_explicit_asset() -> u256 { + let asset: Asset1 = jet::current_asset(); + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + + asset_bits +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option>>) => true, + None => false, + } +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(::into(flag_1), ::into(flag_2))); +} + +// Ensure functions + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_io_script_hashes_eq(input_index: u32, output_index: u32) { + let input_script_hash: u256 = get_script_hash(input_index, true); + let output_script_hash: u256 = get_script_hash(output_index, false); + + check_script_hashes_eq(input_script_hash, output_script_hash); +} + +fn ensure_non_zero_amount(amount: u64) { + assert!(jet::some_64(amount)); +} + +fn ensure_output_asset_and_amount(output_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_io_asset_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> (u64, u64) { + let (input_asset_bits, input_amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + let (output_asset_bits, output_amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(input_asset_bits, expected_asset_bits); + check_assets_eq(input_asset_bits, output_asset_bits); + + (input_amount, output_amount) +} + +fn ensure_io_asset_and_amount_eq( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + check_asset_amounts_eq(input_amount, expected_amount); + check_asset_amounts_eq(input_amount, output_amount); +} + +fn ensure_input_asset_and_amount_burn( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_asset_amount: u64 +) { + ensure_io_asset_and_amount_eq(input_index, output_index, expected_asset_bits, expected_asset_amount); + + ensure_output_is_op_return(output_index); +} + +fn ensure_zero_bit(bit: bool) { assert!(jet::eq_1(::into(bit), 0)); } + +// Math helpers + +fn safe_add_32(first: u32, second: u32) -> u32 { + let (carry, result): (bool, u32) = jet::add_32(first, second); + + check_flags_eq(carry, false); + + result +} + +// Main paths helpers + +fn get_reissuance_flag(index: u8) -> bool { + let shifted: u64 = jet::right_shift_64(index, param::REISSUANCE_FLAGS); + let bit_val: u1 = jet::rightmost_64_1(shifted); + + ::into(bit_val) +} + +fn verify_issuance(input_index: u32, output_index: u32, reissuance_flag: bool) { + let contract_hash: u256 = unwrap(unwrap(jet::new_issuance_contract(input_index))); + let outpoint: Outpoint = unwrap(jet::input_prev_outpoint(input_index)); + let issuance_entropy: u256 = jet::calculate_issuance_entropy(outpoint, contract_hash); + + let explicit_reissuance_tokens_amount: u64 = unwrap_right::<(u1, u256)>(unwrap(unwrap( + jet::issuance_token_amount(input_index) + ))); + + match reissuance_flag { + // TODO: Add confidential reissuance tokens verification + true => { + ensure_non_zero_amount(explicit_reissuance_tokens_amount); + }, + false => { + check_asset_amounts_eq(explicit_reissuance_tokens_amount, 0); + }, + }; + + let issuance_amount: Amount1 = unwrap(unwrap(jet::issuance_asset_amount(input_index))); + let issuance_asset: ExplicitAsset = unwrap(unwrap(jet::issuance_asset(input_index))); + + let issuance_amount: u64 = unwrap_right::<(u1, u256)>(issuance_amount); + + ensure_output_asset_and_amount(output_index, issuance_asset, issuance_amount); +} + +fn verify_issuance_step(acc: (), start_output_index: u32, i: u8) -> Either<(), ()> { + match jet::le_8(param::ISSUING_UTXOS_COUNT, i) { + true => Left(()), + false => { + let step_index: u32 = <(u16, u16)>::into((0, <(u8, u8)>::into((0, i)))); + + let input_index: u32 = safe_add_32(jet::current_index(), step_index); + let output_index: u32 = safe_add_32(start_output_index, step_index); + + verify_issuance(input_index, output_index, get_reissuance_flag(i)); + + Right(()) + } + } +} + +// Assets issuance flow + +fn get_asset_issuance_issuance_factory_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn get_asset_issuance_start_issued_output_index(start_output_index: u32) -> u32 { + safe_add_32(start_output_index, 2) +} + +fn issue_new_assets(output_index: u32) { + let (auth_nft_input_index, auth_nft_output_index): (u32, u32) = (0, output_index); + let ( + issuance_factory_input_index, + issuance_factory_output_index + ): (u32, u32) = get_asset_issuance_issuance_factory_indexes(auth_nft_input_index, auth_nft_output_index); + + assert!(jet::eq_32(issuance_factory_input_index, jet::current_index())); + + let current_asset_bits: u256 = get_current_explicit_asset(); + + ensure_io_asset_and_amount_eq(auth_nft_input_index, auth_nft_output_index, current_asset_bits, 1); + ensure_io_asset_and_amount_eq(issuance_factory_input_index, issuance_factory_output_index, current_asset_bits, 1); + + ensure_io_script_hashes_eq(issuance_factory_input_index, issuance_factory_output_index); + + let issued_outputs_start_index: u32 = get_asset_issuance_start_issued_output_index(auth_nft_output_index); + + unwrap_left::<()>(for_while::((), issued_outputs_start_index)); +} + +// Factory removing flow + +fn get_removing_auth_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn remove_factory(output_index: u32) { + let (issuance_factory_input_index, issuance_factory_output_index): (u32, u32) = (0, output_index); + let ( + auth_nft_input_index, + auth_nft_output_index + ): (u32, u32) = get_removing_auth_nft_indexes(issuance_factory_input_index, issuance_factory_output_index); + + assert!(jet::eq_32(issuance_factory_input_index, jet::current_index())); + + let current_asset_bits: u256 = get_current_explicit_asset(); + + ensure_input_asset_and_amount_burn( + issuance_factory_input_index, + issuance_factory_output_index, + current_asset_bits, + 1 + ); + ensure_input_asset_and_amount_burn(auth_nft_input_index, auth_nft_output_index, current_asset_bits, 1); +} + +fn main() { + match witness::PATH { + Left(output_index: u32) => { + issue_new_assets(output_index); + }, + Right(output_index: u32) => { + remove_factory(output_index); + } + } +} \ No newline at end of file diff --git a/examples/lending_v4/lending.simf b/examples/lending_v4/lending.simf new file mode 100644 index 0000000..92b16de --- /dev/null +++ b/examples/lending_v4/lending.simf @@ -0,0 +1,958 @@ +// Helper getters + +fn get_script_hash(index: u32, is_input_index: bool) -> u256 { + let script_hash: u256 = match is_input_index { + true => unwrap(jet::input_script_hash(index)), + false => unwrap(jet::output_script_hash(index)), + }; + + script_hash +} + +fn get_explicit_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { + let pair: (Asset1, Amount1) = match is_input_index { + true => unwrap(jet::input_amount(index)), + false => unwrap(jet::output_amount(index)), + }; + let (asset, amount): (Asset1, Amount1) = pair; + let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); + let amount: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_bits, amount) +} + +fn is_op_return(output_index: u32) -> bool { + match jet::output_null_datum(output_index, 0) { + Some(entry: Option>>) => true, + None => false, + } +} + +// Check helpers + +fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { + assert!(jet::eq_64(asset_amount_1, asset_amount_2)); +} + +fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { + assert!(jet::eq_256(asset_bits_1, asset_bits_2)); +} + +fn check_script_hashes_eq(script_1: u256, script_2: u256) { + assert!(jet::eq_256(script_1, script_2)); +} + +fn check_flags_eq(flag_1: bool, flag_2: bool) { + assert!(jet::eq_1(::into(flag_1), ::into(flag_2))); +} + +// Math helpers + +fn safe_add_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::add_64(first, second); + + check_flags_eq(carry, false); + + result +} + +fn safe_add_32(first: u32, second: u32) -> u32 { + let (carry, result): (bool, u32) = jet::add_32(first, second); + + check_flags_eq(carry, false); + + result +} + +fn safe_sub_64(first: u64, second: u64) -> u64 { + let (carry, result): (bool, u64) = jet::subtract_64(first, second); + + check_flags_eq(carry, false); + + result +} + +fn min_64(first: u64, second: u64) -> u64 { + match jet::lt_64(first, second) { + true => first, + false => second, + } +} + +// Ensure functions + +fn ensure_output_is_op_return(index: u32) { + check_flags_eq(is_op_return(index), true); +} + +fn ensure_input_script_hash(input_index: u32, expected_script_hash: u256) { + let script_hash: u256 = get_script_hash(input_index, true); + + check_script_hashes_eq(script_hash, expected_script_hash); +} + +fn ensure_output_script_hash(output_index: u32, expected_script_hash: u256) { + let script_hash: u256 = get_script_hash(output_index, false); + + check_script_hashes_eq(script_hash, expected_script_hash); +} + +fn ensure_script_hash_transition( + input_index: u32, + output_index: u32, + expected_input_script_hash: u256, + expected_output_script_hash: u256 +) { + ensure_input_script_hash(input_index, expected_input_script_hash); + ensure_output_script_hash(output_index, expected_output_script_hash); +} + +fn ensure_input_asset_and_amount(input_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_output_asset_and_amount(output_index: u32, expected_asset_bits: u256, expected_amount: u64) { + let (asset_bits, amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(asset_bits, expected_asset_bits); + check_asset_amounts_eq(amount, expected_amount); +} + +fn ensure_io_asset_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> (u64, u64) { + let (input_asset_bits, input_amount): (u256, u64) = get_explicit_asset_and_amount(input_index, true); + let (output_asset_bits, output_amount): (u256, u64) = get_explicit_asset_and_amount(output_index, false); + + check_assets_eq(input_asset_bits, expected_asset_bits); + check_assets_eq(input_asset_bits, output_asset_bits); + + (input_amount, output_amount) +} + +fn ensure_io_asset_and_amount_eq( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + check_asset_amounts_eq(input_amount, expected_amount); + check_asset_amounts_eq(input_amount, output_amount); +} + +fn ensure_input_asset_and_amount_burn( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + expected_asset_amount: u64 +) { + ensure_io_asset_and_amount_eq(input_index, output_index, expected_asset_bits, expected_asset_amount); + + ensure_output_is_op_return(output_index); +} + +fn ensure_asset_transition_with_additional_amount( + input_index: u32, + output_index: u32, + expected_asset_bits: u256, + additional_amount: u64 +) { + let (input_amount, output_amount): (u64, u64) = ensure_io_asset_eq(input_index, output_index, expected_asset_bits); + + let new_asset_amount: u64 = safe_add_64(input_amount, additional_amount); + + check_asset_amounts_eq(output_amount, new_asset_amount); +} + +// Basis points math + +fn get_max_basis_points() -> u64 { + 10_000 +} + +fn apply_basis_points(amount: u64, bps: u64) -> u64 { + let amount_with_bps: u128 = jet::multiply_64(amount, bps); + + // TODO: Handle case when hi > 0 + let (hi, lo): (u64, u64) = ::into(amount_with_bps); + check_asset_amounts_eq(hi, 0); + + let result: u64 = jet::divide_64(lo, get_max_basis_points()); + + result +} + +// Storage helpers + +fn get_is_active_slot_leaf(is_active: bool) -> u256 { + let is_active_num: u64 = jet::left_pad_low_1_64(::into(is_active)); + let is_active_num: u256 = <(u128, u128)>::into((0, <(u64, u64)>::into((0, is_active_num)))); + + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, is_active_num); + + jet::sha_256_ctx_8_finalize(state_ctx2) +} + +fn get_borrower_debt_slot_leaf(borrower_debt: u64) -> u256 { + let borrower_debt: u256 = <(u128, u128)>::into((0, <(u64, u64)>::into((0, borrower_debt)))); + + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, borrower_debt); + + jet::sha_256_ctx_8_finalize(state_ctx2) +} + +/// The script hash of this program's address with `state_slot_leaf` as storage slot 0. +fn get_script_hash_for_state_slot(state_slot_leaf: u256, borrower_debt: u64) -> u256 { + let borrower_debt_slot_leaf: u256 = get_borrower_debt_slot_leaf(borrower_debt); + + let first_tap_node: u256 = jet::build_tapbranch(jet::tapleaf_hash(), state_slot_leaf); + let tap_node: u256 = jet::build_tapbranch(first_tap_node, borrower_debt_slot_leaf); + + // Compute a TapTweak + let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; + let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); + + // Turn the TapTweak into a script hash + let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); + let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32 + let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); + + jet::sha_256_ctx_8_finalize(hash_ctx3) +} + +fn get_script_hash_for_storage(is_active: bool, borrower_debt: u64) -> u256 { + get_script_hash_for_state_slot(get_is_active_slot_leaf(is_active), borrower_debt) +} + +// Main paths helpers + +fn get_protocol_fee_bps() -> u64 { + 1_000 // 10% +} + +fn get_protocol_fee_amount(fee_amount: u64) -> u64 { + apply_basis_points(fee_amount, get_protocol_fee_bps()) +} + +fn get_total_fee_amount() -> u64 { + apply_basis_points(param::PRINCIPAL_AMOUNT, param::PRINCIPAL_INTEREST_RATE) +} + +fn get_total_amount_to_repay() -> u64 { + safe_add_64(param::PRINCIPAL_AMOUNT, get_total_fee_amount()) +} + +fn split_repayment_by_fees(fee_left: u64, amount_to_repay: u64) -> (u64, u64) { + let fee_repaid: u64 = min_64(fee_left, amount_to_repay); + let protocol_fee_repaid: u64 = get_protocol_fee_amount(fee_repaid); + + (fee_repaid, protocol_fee_repaid) +} + +fn validate_vaults( + lender_vault_indexes: (u32, u32), + protocol_fee_vault_indexes: (u32, u32), + current_borrower_debt: u64, + amount_to_repay: u64 +) { + let total_fee_amount: u64 = get_total_fee_amount(); + + let already_repaid_amount: u64 = safe_sub_64(get_total_amount_to_repay(), current_borrower_debt); + + let ( + already_repaid_fee, + already_repaid_protocol_fee + ): (u64, u64) = split_repayment_by_fees(total_fee_amount, already_repaid_amount); + + let fee_left: u64 = safe_sub_64(total_fee_amount, already_repaid_fee); + + let (fee_repaid, protocol_fee_repaid): (u64, u64) = split_repayment_by_fees(fee_left, amount_to_repay); + + let total_repaid_protocol_fee: u64 = safe_add_64(already_repaid_protocol_fee, protocol_fee_repaid); + let additional_lender_vault_amount: u64 = safe_sub_64(amount_to_repay, protocol_fee_repaid); + + let total_protocol_fee: u64 = get_protocol_fee_amount(total_fee_amount); + + let lender_vault_output_hash: u256 = match jet::eq_64(current_borrower_debt, amount_to_repay) { + true => param::FINALIZED_LENDER_VAULT_COV_HASH, + false => param::LENDER_VAULT_COV_HASH, + }; + let protocol_fee_vault_output_hash: u256 = match jet::eq_64(total_repaid_protocol_fee, total_protocol_fee) { + true => param::FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH, + false => param::PROTOCOL_FEE_VAULT_COV_HASH, + }; + + let (lender_vault_input_index, lender_vault_output_index): (u32, u32) = lender_vault_indexes; + let (protocol_fee_vault_input_index, protocol_fee_vault_output_index): (u32, u32) = protocol_fee_vault_indexes; + + match jet::some_64(already_repaid_amount) { + true => { + ensure_asset_transition_with_additional_amount( + lender_vault_input_index, + lender_vault_output_index, + param::PRINCIPAL_ASSET_ID, + additional_lender_vault_amount + ); + ensure_script_hash_transition( + lender_vault_input_index, + lender_vault_output_index, + param::LENDER_VAULT_COV_HASH, + lender_vault_output_hash + ); + + match jet::lt_64(already_repaid_protocol_fee, total_protocol_fee) { + true => { + ensure_asset_transition_with_additional_amount( + protocol_fee_vault_input_index, + protocol_fee_vault_output_index, + param::PRINCIPAL_ASSET_ID, + protocol_fee_repaid + ); + ensure_script_hash_transition( + protocol_fee_vault_input_index, + protocol_fee_vault_output_index, + param::PROTOCOL_FEE_VAULT_COV_HASH, + protocol_fee_vault_output_hash + ); + }, + false => {}, + }; + }, + false => { + ensure_output_asset_and_amount( + lender_vault_output_index, + param::PRINCIPAL_ASSET_ID, + additional_lender_vault_amount + ); + ensure_output_script_hash(lender_vault_output_index, lender_vault_output_hash); + + ensure_output_asset_and_amount( + protocol_fee_vault_output_index, + param::PRINCIPAL_ASSET_ID, + protocol_fee_repaid + ); + ensure_output_script_hash(protocol_fee_vault_output_index, protocol_fee_vault_output_hash); + }, + }; +} + +// Partial repayment flow + +fn get_partial_repayment_offer_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 1)) +} + +fn get_partial_repayment_lender_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 2)) +} + +fn get_partial_repayment_protocol_fee_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 3), safe_add_32(start_output_index, 3)) +} + +fn partial_repay_offer(current_debt: u64, amount_to_repay: u64) { + let (borrower_nft_input_index, borrower_nft_output_index): (u32, u32) = (0, 0); + let ( + offer_input_index, + offer_output_index + ): (u32, u32) = get_partial_repayment_offer_indexes(borrower_nft_input_index, borrower_nft_output_index); + + assert!(jet::eq_32(jet::current_index(), offer_input_index)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + ensure_input_script_hash(offer_input_index, current_script_hash); + + assert!(jet::some_64(amount_to_repay)); + assert!(jet::lt_64(amount_to_repay, current_debt)); + + ensure_io_asset_and_amount_eq( + offer_input_index, + offer_output_index, + param::COLLATERAL_ASSET_ID, + param::COLLATERAL_AMOUNT + ); + + let new_debt: u64 = safe_sub_64(current_debt, amount_to_repay); + let new_offer_script_hash: u256 = get_script_hash_for_storage(true, new_debt); + + ensure_output_script_hash(offer_output_index, new_offer_script_hash); + + ensure_io_asset_and_amount_eq( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + + validate_vaults( + get_partial_repayment_lender_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + get_partial_repayment_protocol_fee_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + current_debt, + amount_to_repay + ); +} + +// Full repayment flow + +fn get_full_repayment_offer_input_index(start_input_index: u32) -> u32 { + safe_add_32(start_input_index, 1) +} + +fn get_full_repayment_lender_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 1)) +} + +fn get_full_repayment_protocol_fee_vault_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 3), safe_add_32(start_output_index, 2)) +} + +/// Full repayment of the loan at `current_script_hash`: active, or pending liquidation. +fn full_repay_from(current_script_hash: u256, current_debt: u64) { + let (borrower_nft_input_index, borrower_nft_output_index): (u32, u32) = (0, 0); + let offer_input_index: u32 = get_full_repayment_offer_input_index(borrower_nft_input_index); + + assert!(jet::eq_32(jet::current_index(), offer_input_index)); + + ensure_input_script_hash(offer_input_index, current_script_hash); + + ensure_input_asset_and_amount(offer_input_index, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + ensure_input_asset_and_amount_burn( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + + validate_vaults( + get_full_repayment_lender_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + get_full_repayment_protocol_fee_vault_indexes(borrower_nft_input_index, borrower_nft_output_index), + current_debt, + current_debt + ); +} + +fn full_repay_offer(current_debt: u64) { + full_repay_from(get_script_hash_for_storage(true, current_debt), current_debt); +} + +// Liquidation flow + +fn get_liquidation_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), start_output_index) +} + +fn liquidate_offer(current_debt: u64) { + let (lending_input_index, lending_output_index): (u32, u32) = (jet::current_index(), 0); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_liquidation_lender_nft_indexes(lending_input_index, lending_output_index); + + assert!(jet::eq_32(lending_input_index, 0)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + ensure_input_script_hash(lending_input_index, current_script_hash); + + jet::check_lock_height(param::LOAN_EXPIRATION_TIME); + + ensure_input_asset_and_amount(lending_input_index, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + + ensure_input_asset_and_amount_burn(lender_nft_input_index, lender_nft_output_index, param::LENDER_NFT_ASSET_ID, 1); +} + +// Offer acceptance flow + +fn get_acceptance_principal_output_index(start_output_index: u32) -> u32 { + safe_add_32(start_output_index, 1) +} + +fn get_acceptance_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), safe_add_32(start_output_index, 2)) +} + +fn accept_offer() { + let (lending_input_index, lending_output_index): (u32, u32) = (jet::current_index(), 0); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_acceptance_lender_nft_indexes(lending_input_index, lending_output_index); + let principal_output_index: u32 = get_acceptance_principal_output_index(lending_output_index); + + assert!(jet::eq_32(lending_input_index, 0)); + + let total_amount_to_repay: u64 = get_total_amount_to_repay(); + let current_script_hash: u256 = get_script_hash_for_storage(false, total_amount_to_repay); + let active_offer_script_hash: u256 = get_script_hash_for_storage(true, total_amount_to_repay); + + ensure_io_asset_and_amount_eq( + lending_input_index, + lending_output_index, + param::COLLATERAL_ASSET_ID, + param::COLLATERAL_AMOUNT + ); + ensure_script_hash_transition( + lending_input_index, + lending_output_index, + current_script_hash, + active_offer_script_hash + ); + + ensure_io_asset_and_amount_eq(lender_nft_input_index, lender_nft_output_index, param::LENDER_NFT_ASSET_ID, 1); + + ensure_output_asset_and_amount(principal_output_index, param::PRINCIPAL_ASSET_ID, param::PRINCIPAL_AMOUNT); + ensure_output_script_hash(principal_output_index, param::PRINCIPAL_OUTPUT_SCRIPT_HASH); +} + +// Offer cancellation flow + +fn get_cancellation_lender_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 1), start_output_index) +} + +fn get_cancellation_borrower_nft_indexes(start_input_index: u32, start_output_index: u32) -> (u32, u32) { + (safe_add_32(start_input_index, 2), safe_add_32(start_output_index, 1)) +} + +fn cancel_offer() { + let lending_input_index: u32 = jet::current_index(); + let ( + lender_nft_input_index, + lender_nft_output_index + ): (u32, u32) = get_cancellation_lender_nft_indexes(lending_input_index, 0); + let ( + borrower_nft_input_index, + borrower_nft_output_index + ): (u32, u32) = get_cancellation_borrower_nft_indexes(lending_input_index, 0); + + assert!(jet::eq_32(lending_input_index, 0)); + + let current_script_hash: u256 = get_script_hash_for_storage(false, get_total_amount_to_repay()); + ensure_input_script_hash(lending_input_index, current_script_hash); + + ensure_input_asset_and_amount(lending_input_index, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + + ensure_input_asset_and_amount_burn( + borrower_nft_input_index, + borrower_nft_output_index, + param::BORROWER_NFT_ASSET_ID, + 1 + ); + ensure_input_asset_and_amount_burn( + lender_nft_input_index, + lender_nft_output_index, + param::LENDER_NFT_ASSET_ID, + 1 + ); +} + +// ============================================================================ +// Oracle liquidation +// +// Before the loan expires, the lender can liquidate at the current price, through a +// challenge window: +// +// start the lender presents a certified oracle statement at which the collateral is +// worth less than the debt allows (LIQUIDATION_LTV_BPS), and the loan moves to +// the pending state, recording the statement's timestamp and price +// cancel anyone presents a newer certified statement at which it isn't, and the loan +// goes back to active +// finish after WINDOW_BLOCKS, the lender burns the lender NFT and takes the +// collateral, as on expiry +// repay the borrower can still repay in full while it is pending +// +// The statement, certificate and lock-time checks are oracle_demo's (oracle_mint.simf), +// copied because this wallet compiles each program from a single file. +// ============================================================================ + +// Boolean helpers (from simplicityhl-std, lib/binary.simf) + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn and(a: bool, b: bool) -> bool { + ::into(jet::and_1(::into(a), ::into(b))) +} + +// ECDSA (verbatim from lib/secp256k1/ecdsa.simf, a proposed addition to simplicityhl-std) + +/// Check that a signature component is a canonical scalar in `[1, n - 1]`. +/// +/// The scalar jets reduce their inputs modulo `n`, so without this check `s + n` would +/// verify wherever `s` does. +fn is_signature_scalar(a: Scalar) -> bool { + and(jet::eq_256(jet::scalar_normalize(a), a), not(jet::scalar_is_zero(a))) +} + +/// Decompress a public key, rejecting an x-coordinate that is not reduced modulo `p`. +/// +/// `jet::decompress` reduces the x-coordinate itself, so without this check `x + p` would +/// decode to the same key as `x`. +fn decompress_public_key(pk: Point) -> Option { + let (_, x): (u1, Fe) = pk; + + match jet::eq_256(jet::fe_normalize(x), x) { + true => jet::decompress(pk), + false => None, + } +} + +/// Check an ECDSA signature `(r, s)` over the message digest `msg` against the compressed +/// public key `pk`, as in SEC 1 v2.0, section 4.1.4. +/// +/// Like SEC 1, and unlike Bitcoin's low-S rule, this accepts both `(r, s)` and `(r, n - s)`. +pub fn ecdsa_is_valid(pk: Point, msg: Message, sig: (Scalar, Scalar)) -> bool { + let (r, s): (Scalar, Scalar) = sig; + + match decompress_public_key(pk) { + Some(q: Ge) => { + // The scalar jets reduce `msg` modulo `n`. If `s = 0` then `w = 0`, and the range + // check below rejects the signature. + let w: Scalar = jet::scalar_invert(s); + let u1: Scalar = jet::scalar_multiply(msg, w); + let u2: Scalar = jet::scalar_multiply(r, w); + + // R = u1 * G + u2 * Q + let q: Gej = (q, 1); + let big_r: Gej = jet::linear_combination_1((u2, q), u1); + + let r_matches: bool = match jet::gej_normalize(big_r) { + Some(big_r: Ge) => { + let (x, _): (Fe, Fe) = big_r; + // x < p < 2n, so reducing once gives x mod n. + jet::eq_256(jet::scalar_normalize(x), r) + }, + None => false, + }; + + and(and(is_signature_scalar(r), is_signature_scalar(s)), r_matches) + }, + None => false, + } +} + +/// Assert that `(r, s)` is a valid ECDSA signature over the message digest `msg` for the +/// compressed public key `pk`. +pub fn ecdsa_verify(pk: Point, msg: Message, sig: (Scalar, Scalar)) { + assert!(ecdsa_is_valid(pk, msg, sig)); +} + +fn safe_multiply_64(first: u64, second: u64) -> u64 { + let (hi, lo): (u64, u64) = ::into(jet::multiply_64(first, second)); + check_asset_amounts_eq(hi, 0); + lo +} + +// Oracle statements + +/// COLD_PUBKEY's word that an oracle key may sign statements timestamped from +/// `valid_from` to `valid_until`, inclusive: the key as its compressed prefix byte and +/// x-coordinate, the range, and the BIP340 signature. +type Certificate = ((u8, u256), u64, u64, Signature); + +/// A canonical string's variable fields: the price's integer digits, its two fraction +/// digits, sources, method, timestamp digits and nonce. Then the oracle's signature +/// `(r, s)`, and the certificate for its key. +type Statement = ( + List, + (u8, u8), + List, + List, + List, + List, + (Scalar, Scalar), + Certificate +); + +/// Check COLD_PUBKEY signed `cert`, and return the oracle key and the range it covers. +/// +/// The signed message is `tagged_hash("oracle-demo/oracle-cert/v1", key || from || until)` +/// with the key in its 33-byte compressed form, so no other signature by the cold key can +/// pass for a certificate. +fn verify_certificate(cert: Certificate) -> (Point, u64, u64) { + let (key, valid_from, valid_until, sig): Certificate = cert; + let (prefix, x): (u8, u256) = key; + assert!(or(jet::eq_8(prefix, 2), jet::eq_8(prefix, 3))); + + // sha256("oracle-demo/oracle-cert/v1") + let tag: u256 = 0x026a11d3eb897194ef8efa87e6d28690e25bee5a89ee37142b5ba3816dcc4b89; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, prefix); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, x); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, valid_from); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, valid_until); + jet::bip_0340_verify((param::COLD_PUBKEY, jet::sha_256_ctx_8_finalize(ctx)), sig); + + ((::into(jet::eq_8(prefix, 3)), x), valid_from, valid_until) +} + +/// Hash one decimal digit and fold it into the number read so far. +fn digit_step(byte: u8, acc: (Ctx8, u64)) -> (Ctx8, u64) { + let (ctx, value): (Ctx8, u64) = acc; + assert!(jet::le_8(48, byte)); + assert!(jet::le_8(byte, 57)); + let (_, digit): (bool, u8) = jet::subtract_8(byte, 48); + let value: u64 = safe_add_64(safe_multiply_64(value, 10), jet::left_pad_low_8_64(digit)); + (jet::sha_256_ctx_8_add_1(ctx, byte), value) +} + +/// Hash one byte of a text field, which may be anything but the '|' separator. +fn text_step(byte: u8, ctx: Ctx8) -> Ctx8 { + assert!(not(jet::eq_8(byte, 124))); + jet::sha_256_ctx_8_add_1(ctx, byte) +} + +fn add_separator(ctx: Ctx8) -> Ctx8 { + jet::sha_256_ctx_8_add_1(ctx, 124) +} + +/// Rebuild a statement's canonical string, check a certified oracle signed it within its +/// certificate's range, and return its price in US cents and its timestamp. +fn verify_statement(statement: Statement) -> (u64, u64) { + let (price_int, price_frac, sources, method, timestamp, nonce, sig, cert): Statement = + statement; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + // "v1|PRICE|BTCUSD|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_16(ctx, 0x76317c50524943457c4254435553447c); + let (ctx, price): (Ctx8, u64) = fold::(price_int, (ctx, 0)); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 46); // "." + let (frac_tens, frac_units): (u8, u8) = price_frac; + let (ctx, price): (Ctx8, u64) = digit_step(frac_units, digit_step(frac_tens, (ctx, price))); + // "|USD|2|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_4(ctx, 0x7c555344); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x7c32); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(sources, ctx); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(method, ctx); + let ctx: Ctx8 = add_separator(ctx); + let (ctx, timestamp): (Ctx8, u64) = fold::(timestamp, (ctx, 0)); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(nonce, ctx); + + let (oracle_key, valid_from, valid_until): (Point, u64, u64) = verify_certificate(cert); + assert!(jet::le_64(valid_from, timestamp)); + assert!(jet::le_64(timestamp, valid_until)); + ecdsa_verify(oracle_key, jet::sha_256_ctx_8_finalize(ctx), sig); + (price, timestamp) +} + +/// Assert that `timestamp` is less than MAX_FUTURE_SECONDS past the chain's median time. +/// +/// A program can't read the time, but a transaction whose lock time is a time can only be +/// mined once the chain's median time past is beyond it. Demanding a lock time of at least +/// `timestamp - MAX_FUTURE_SECONDS` therefore bounds how far ahead of the chain a request's +/// statement can be dated. Without it, a leaked oracle key could date a request past every +/// honest statement, and nobody could challenge it until real time caught up. +fn ensure_not_from_the_future(timestamp: u64) { + let (high, timestamp): (u32, u32) = ::into(timestamp); + assert!(jet::is_zero_32(high)); + let (borrow, earliest): (bool, u32) = jet::subtract_32(timestamp, param::MAX_FUTURE_SECONDS); + assert!(not(borrow)); + jet::check_lock_time(earliest); +} + +/// Assert that WINDOW_BLOCKS have passed since this input, the pending liquidation, was +/// confirmed. +/// +/// This reads the input's own nSequence, which BIP68 enforces input by input. It avoids +/// `check_lock_distance`, which reads the largest lock across all inputs, so another +/// input's lock would satisfy it. +fn ensure_window_elapsed() { + assert!(jet::le_32(2, jet::version())); + let (flags, blocks): (u16, u16) = ::into(jet::current_sequence()); + // Bit 31 disables the lock; bit 22 would count it in 512-second units, not blocks. + assert!(jet::is_zero_16(jet::and_16(flags, 0x8040))); + assert!(jet::le_16(param::WINDOW_BLOCKS, blocks)); +} + +// Liquidation state + +/// A pending liquidation's claim: the statement's timestamp and its price in US cents. +type Claim = (u64, u64); + +/// Storage slot 0 for a pending liquidation: `claim_timestamp || claim_price || 0 || 2`. +/// The offer and active states are 0 and 1 in the low 8 bytes, as in lending_v3. +fn get_liquidation_slot_leaf(claim: Claim) -> u256 { + let (claim_timestamp, claim_price): Claim = claim; + let state: u256 = <(u128, u128)>::into(( + <(u64, u64)>::into((claim_timestamp, claim_price)), + <(u64, u64)>::into((0, 2)) + )); + + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, state); + + jet::sha_256_ctx_8_finalize(state_ctx2) +} + +fn get_script_hash_for_liquidation(claim: Claim, borrower_debt: u64) -> u256 { + get_script_hash_for_state_slot(get_liquidation_slot_leaf(claim), borrower_debt) +} + +/// Whether the loan may be liquidated at `price_cents`: whether the debt is more than +/// LIQUIDATION_LTV_BPS of the collateral's value. +/// +/// The collateral is valued in principal base units as `COLLATERAL_AMOUNT * price / 100`, +/// which assumes satoshis of collateral against a dollar asset with 8 decimal places, as +/// L-BTC against tUSDT. +fn is_liquidatable(borrower_debt: u64, price_cents: u64) -> bool { + let collateral_value: u64 = jet::divide_64(safe_multiply_64(param::COLLATERAL_AMOUNT, price_cents), 100); + jet::lt_64( + safe_multiply_64(collateral_value, param::LIQUIDATION_LTV_BPS), + safe_multiply_64(borrower_debt, get_max_basis_points()) + ) +} + +// Liquidation paths + +/// Start: spend the active loan (input 0) with the lender NFT (input 1, kept at output 1), +/// and move it to the pending state (output 0). +fn start_liquidation(current_debt: u64, statement: Statement) { + assert!(jet::eq_32(jet::current_index(), 0)); + ensure_input_script_hash(0, get_script_hash_for_storage(true, current_debt)); + ensure_input_asset_and_amount(0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + ensure_io_asset_and_amount_eq(1, 1, param::LENDER_NFT_ASSET_ID, 1); + + let (price, timestamp): (u64, u64) = verify_statement(statement); + ensure_not_from_the_future(timestamp); + assert!(is_liquidatable(current_debt, price)); + + ensure_output_script_hash(0, get_script_hash_for_liquidation((timestamp, price), current_debt)); + ensure_output_asset_and_amount(0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); +} + +/// Cancel: with a statement newer than the claim at which the loan isn't liquidatable, +/// move the pending loan (input 0) back to active (output 0). Anyone may. +fn cancel_liquidation(current_debt: u64, claim: Claim, statement: Statement) { + assert!(jet::eq_32(jet::current_index(), 0)); + ensure_input_script_hash(0, get_script_hash_for_liquidation(claim, current_debt)); + ensure_input_asset_and_amount(0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + + let (claim_timestamp, _): Claim = claim; + let (price, timestamp): (u64, u64) = verify_statement(statement); + assert!(jet::lt_64(claim_timestamp, timestamp)); + assert!(not(is_liquidatable(current_debt, price))); + + ensure_output_script_hash(0, get_script_hash_for_storage(true, current_debt)); + ensure_output_asset_and_amount(0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); +} + +/// Finish: after the window, burn the lender NFT (input 1, to output 0) and release the +/// collateral of the pending loan (input 0), as on expiry. +/// +/// Nothing stops a cancel after the window: a program can't require that a height has +/// *not* been reached. A pending liquidation stays open to cancel until it is finished. +fn finish_liquidation(current_debt: u64, claim: Claim) { + assert!(jet::eq_32(jet::current_index(), 0)); + ensure_input_script_hash(0, get_script_hash_for_liquidation(claim, current_debt)); + ensure_window_elapsed(); + ensure_input_asset_and_amount(0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); + ensure_input_asset_and_amount_burn(1, 0, param::LENDER_NFT_ASSET_ID, 1); +} + +/// One step of an oracle liquidation: start with the debt; cancel, finish or repay with +/// the debt and the pending claim. +type LiquidationStep = Either, Either<(u64, Claim), (u64, Claim)>>; + +fn liquidation_path(step: LiquidationStep, statement: Statement) { + match step { + Left(open_or_cancel: Either) => match open_or_cancel { + Left(current_debt: u64) => start_liquidation(current_debt, statement), + Right(pending: (u64, Claim)) => { + let (current_debt, claim): (u64, Claim) = pending; + cancel_liquidation(current_debt, claim, statement) + }, + }, + Right(finish_or_repay: Either<(u64, Claim), (u64, Claim)>) => match finish_or_repay { + Left(pending: (u64, Claim)) => { + let (current_debt, claim): (u64, Claim) = pending; + finish_liquidation(current_debt, claim) + }, + Right(pending: (u64, Claim)) => { + let (current_debt, claim): (u64, Claim) = pending; + full_repay_from(get_script_hash_for_liquidation(claim, current_debt), current_debt) + }, + }, + } +} + +/// One word of budget padding, which must be zero. +fn padding_word(word: u256, acc: ()) -> () { + assert!(jet::eq_256(word, 0)); + acc +} + +/// The paths lending_v3 had. +fn lending_path(path: Either, Either, u64>>) { + match path { + Left(pending_offer_params: Either<(), ()>) => { + match pending_offer_params { + Left(params: ()) => { + accept_offer(); + }, + Right(params: ()) => { + cancel_offer(); + }, + } + }, + Right(active_offer_params: Either, u64>) => { + match active_offer_params { + Left(repayment_params: Either<(u64, u64), u64>) => { + match repayment_params { + Left(partial_repayment_params: (u64, u64)) => { + let (current_debt, amount_to_repay): (u64, u64) = partial_repayment_params; + + partial_repay_offer(current_debt, amount_to_repay); + }, + Right(current_debt: u64) => { + full_repay_offer(current_debt); + }, + } + }, + Right(current_debt: u64) => { + liquidate_offer(current_debt); + } + } + }, + } +} + +fn main() { + // A program may not cost more than its witness's size buys, and checking a statement + // costs more than the statement's own bytes. Liquid does not relay the annex that + // would otherwise pad it, so the paths that check one are padded here with zero + // words. Other paths leave the list empty. + fold::(witness::BUDGET_PAD, ()); + + let statement: Statement = ( + witness::STATEMENT_PRICE_INT, + witness::STATEMENT_PRICE_FRAC, + witness::STATEMENT_SOURCES, + witness::STATEMENT_METHOD, + witness::STATEMENT_TIMESTAMP, + witness::STATEMENT_NONCE, + witness::STATEMENT_SIG, + ( + witness::CERT_ORACLE_KEY, + witness::CERT_VALID_FROM, + witness::CERT_VALID_UNTIL, + witness::CERT_SIG + ) + ); + + match witness::LIQUIDATION { + Some(step: LiquidationStep) => liquidation_path(step, statement), + None => lending_path(witness::PATH), + } +} diff --git a/examples/lending_v4/script_auth.simf b/examples/lending_v4/script_auth.simf new file mode 100644 index 0000000..35566f9 --- /dev/null +++ b/examples/lending_v4/script_auth.simf @@ -0,0 +1,13 @@ +fn ensure_input_script_hash(input_script_index: u32, expected_script_hash: u256) { + let actual_script_hash: u256 = unwrap(jet::input_script_hash(input_script_index)); + + assert!(jet::eq_256(actual_script_hash, expected_script_hash)); +} + +fn script_auth_check(input_script_index: u32) { + ensure_input_script_hash(input_script_index, param::SCRIPT_HASH); +} + +fn main() { + script_auth_check(witness::INPUT_SCRIPT_INDEX); +} \ No newline at end of file diff --git a/examples/lending_v4/txmanifest.json b/examples/lending_v4/txmanifest.json new file mode 100644 index 0000000..0f9da6d --- /dev/null +++ b/examples/lending_v4/txmanifest.json @@ -0,0 +1,2308 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "lending_v4 starts as a copy of lending_v3, the issuance-factory lending protocol, and adds liquidation at the current oracle price with a challenge window. It is a new protocol version: it does not interoperate with lending_v3 or the deployed simplicity-lending indexer, so lending_v3's interop constants (factory params, protocol-fee keeper, program-id tags) are kept only as they were.", + "manifest_version": "0.3.1", + "protocol": "simplicity-lending-v4", + "requires": ["simplicity"], + "simplicity_hl": { "version": "0.7.1", "debug_symbols": true }, + "description": "lending_v4 — lending_v3's issuance-factory lending protocol, plus liquidation at the current price before expiry: the lender starts it with a certified oracle statement, anyone with a newer statement at which the loan is fine can cancel it, and after WINDOW_BLOCKS the lender takes the collateral. The borrower can repay in full while it is pending.", + "utxo_types": { + "issuance_factory": { + "description": "The persistent issuance-factory covenant. Holds 1 unit of the factory asset and, when spent (IssueAssets path), mints borrower/lender NFTs for a new offer while recreating itself. Its address depends ONLY on (ISSUING_UTXOS_COUNT, REISSUANCE_FLAGS) — NOT on the factory asset id — so for the deployed (2, 0) it is the fixed spk 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b (verified reproduced by txmanifest_lib/tests/interop/issuance_factory.rs). Compiled with debug symbols to match the deployed CMR.", + "script": { + "type": "simplicity", + "source": "./issuance_factory.simf", + "source_hash": "sha256:dd1e7f89c6843291b8939d33c4f6ec57a88037726a8ecf40b76eeee024d24438", + "compile_params": { + "ISSUING_UTXOS_COUNT": "ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "REISSUANCE_FLAGS" + } + }, + "asset": "FACTORY_ASSET_ID" + }, + "lending_collateral": { + "description": "The lending (collateral) covenant — out[5] of an offer-creation tx. Holds COLLATERAL_AMOUNT of the collateral asset with 2 taproot storage slots (slot0 = is_active, slot1 = current_debt). Its address is the CMR of lending.simf compiled with the full offer params + the 5 nested AssetAuth/AssetAuthVault cov-hashes (task 07), folded with the 2 storage leaves (task 01/02). Reproduced byte-exactly for live offer 43ab4efe (txmanifest_lib/tests/interop/lending_collateral.rs). NOTE: slot1 (current_debt) is dynamic; expressing it as a computed 32-byte storage leaf needs the engine extension tracked in upnext/10.", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "source_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH", + "COLD_PUBKEY": "COLD_PUBKEY", + "MAX_FUTURE_SECONDS": "MAX_FUTURE_SECONDS", + "LIQUIDATION_LTV_BPS": "LIQUIDATION_LTV_BPS", + "WINDOW_BLOCKS": "WINDOW_BLOCKS" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID" + }, + "lending_collateral_active": { + "description": "The lending (collateral) covenant in the ACTIVE state (after AcceptOffer). Identical to lending_collateral except storage slot0 = is_active = 1 (value 1, byte[31]=0x01), which changes the covenant address. slot1 = CURRENT_DEBT (unchanged on a fresh accept). De-risked by txmanifest_lib/tests/interop/lending_active.rs (address flips vs pending).", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "source_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH", + "COLD_PUBKEY": "COLD_PUBKEY", + "MAX_FUTURE_SECONDS": "MAX_FUTURE_SECONDS", + "LIQUIDATION_LTV_BPS": "LIQUIDATION_LTV_BPS", + "WINDOW_BLOCKS": "WINDOW_BLOCKS" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000001" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID" + }, + "lending_collateral_liquidating": { + "description": "The lending covenant with a liquidation PENDING. Identical to lending_collateral_active except storage slot0, which records the claim: CLAIM_TIMESTAMP || CLAIM_PRICE || 0 || 2, each 8 bytes big-endian. Every claim is a different address.", + "script": { + "type": "simplicity", + "source": "./lending.simf", + "source_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", + "compile_params": { + "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", + "BORROWER_NFT_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "LOAN_EXPIRATION_TIME", + "LENDER_VAULT_COV_HASH": "LENDER_VAULT_COV_HASH", + "FINALIZED_LENDER_VAULT_COV_HASH": "FINALIZED_LENDER_VAULT_COV_HASH", + "PROTOCOL_FEE_VAULT_COV_HASH": "PROTOCOL_FEE_VAULT_COV_HASH", + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + "PRINCIPAL_OUTPUT_SCRIPT_HASH": "PRINCIPAL_OUTPUT_SCRIPT_HASH", + "COLD_PUBKEY": "COLD_PUBKEY", + "MAX_FUTURE_SECONDS": "MAX_FUTURE_SECONDS", + "LIQUIDATION_LTV_BPS": "LIQUIDATION_LTV_BPS", + "WINDOW_BLOCKS": "WINDOW_BLOCKS" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + { + "value": "params.CLAIM_TIMESTAMP", + "type": "u64", + "endian": "be" + }, + { + "value": "params.CLAIM_PRICE", + "type": "u64", + "endian": "be" + }, + "0x0000000000000000", + "0x0000000000000002" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "instance.CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + }, + "asset": "COLLATERAL_ASSET_ID" + }, + "principal_asset_auth": { + "description": "AssetAuth covenant holding the loan principal after activation — out[1] of AcceptOffer. Keyed by the borrower NFT (AssetAuth(borrower_nft, amount 1, with_asset_burn=false)); the borrower later claims the principal by presenting the borrower NFT. sha256(its spk) == PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "script": { + "type": "simplicity", + "source": "./asset_auth.simf", + "source_hash": "sha256:cfd27392a1571e2ee47e0399b5db0c5009ec2cdee11e0f0283a85d0b17e664b6", + "compile_params": { + "ASSET_ID": "BORROWER_NFT_ASSET_ID", + "ASSET_AMOUNT": "1", + "WITH_ASSET_BURN": "false" + } + }, + "asset": "PRINCIPAL_ASSET_ID" + }, + "lender_vault_finalized": { + "description": "The lender's settlement vault in its FINALIZED state — RepayLoan out[1]. A FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=ZERO_HASH) holding the whole repayment less the protocol fee. 'Finalized' = the debt is fully cleared, so the vault no longer accepts supply; the lender withdraws from it by presenting (and burning) the lender NFT. The lending covenant hard-codes sha256(this spk) as FINALIZED_LENDER_VAULT_COV_HASH and enforces it on the repayment output, so this utxo_type MUST compile to that exact hash — asserted by lending_v3_repay_loan_vault_outputs_match_covenant_hashes.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "source_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "LENDER_NFT_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "true", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID" + }, + "protocol_fee_vault_finalized": { + "description": "The protocol-fee settlement vault in its FINALIZED state — RepayLoan out[2]. Same shape as lender_vault_finalized but keyed to the protocol-fee keeper asset and with keeper_burn=FALSE (the keeper asset is a shared, reusable fee-collection NFT, so it is preserved rather than burned on withdrawal). Holds the 10% protocol cut of the loan interest. The lending covenant hard-codes sha256(this spk) as FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH and enforces it on the repayment output.", + "script": { + "type": "simplicity", + "source": "./asset_auth_vault.simf", + "source_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "compile_params": { + "VAULT_ASSET_ID": "PRINCIPAL_ASSET_ID", + "KEEPER_AUTH_ASSET_ID": "PROTOCOL_FEE_KEEPER_ASSET_ID", + "SUPPLIER_AUTH_ASSET_ID": "BORROWER_NFT_ASSET_ID", + "KEEPER_AUTH_ASSET_AMOUNT": "1", + "FINALIZED_VAULT_COV_HASH": "ZERO_HASH", + "IS_ACTIVE": "false", + "WITH_KEEPER_ASSET_BURN": "false", + "WITH_SUPPLIER_ASSET_BURN": "true" + } + }, + "asset": "PRINCIPAL_ASSET_ID" + }, + "lender_nft_script_auth": { + "description": "ScriptAuth covenant holding the lender NFT during a pending offer — out[3]. Keyed by SCRIPT_HASH = sha256(lending covenant scriptPubKey, INCLUDING its 2 storage slots) i.e. the pending offer's own script hash (ScriptAuth::from_simplex_program). NOTE: the manifest's tapleaf/script-hash compute currently hashes the storage-less covenant; hashing a covenant-with-storage needs the engine extension tracked in upnext/11.", + "script": { + "type": "simplicity", + "source": "./script_auth.simf", + "source_hash": "sha256:ffe473ee77ac0b51d90f1b7f1b26d883a64b40e7276212aedf9f4bfadb54e8f7", + "compile_params": { + "SCRIPT_HASH": "LENDING_COV_SCRIPT_HASH" + } + }, + "asset": "LENDER_NFT_ASSET_ID" + } + }, + "actions": {}, + "contract_templates": { + "issuance_factory": { + "description": "A persistent issuance factory. Created once (CreateFactory); the owner holds the auth NFT and reuses the factory covenant to mint many lending offers. The deployed indexer's FactoryCreationsTracker is seeded with hardcoded (issuing_utxos_count=2, reissuance_flags=0) — offers minted from a factory with any other parameters will not be detected.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Asset id of the factory asset, issued (total amount 2) in the creation tx. One unit stays in the factory covenant, one is the wallet-held auth NFT. Derived from the issuance input's outpoint + entropy and captured on_resolved." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Number of UTXOs the factory may use for issuance in a single tx. Baked into the covenant (param::ISSUING_UTXOS_COUNT). Must be 2 to match the deployed indexer." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Per-slot reissuance bitmask baked into the covenant (param::REISSUANCE_FLAGS). Must be 0 to match the deployed indexer." + } + }, + "actions": { + "CreateFactory": { + "description": "Issue the factory asset (total 2) from a wallet L-BTC UTXO, keep 1 unit as the owner's auth NFT (out[0], wallet p2wpkh), lock 1 unit in the issuance_factory covenant (out[1]), and publish the 13-byte creation OP_RETURN (out[2]). This layout is exactly what the deployed indexer's FactoryCreationsTracker requires: IssuanceFactory::try_from_tx reads the metadata at output index 2, verifies program_id + params, then finds the single amount-1 program output (covenant) and the single amount-1 auth output. Writes the instance file recording FACTORY_ASSET_ID.", + "params": { + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "default": "2", + "description": "Factory issuing-UTXOs count. Leave at the default 2 — the deployed indexer only detects factories (and their offers) with issuing_utxos_count == 2." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "default": "0", + "description": "Factory reissuance-flags bitmask. Leave at the default 0 — the deployed indexer only detects factories with reissuance_flags == 0." + }, + "FACTORY_PROGRAM_ID": { + "type": "bytes", + "default": "dd1e7f89", + "description": "The 4-byte protocol message-type tag that prefixes the factory-creation OP_RETURN (out[2]). The indexer's IssuanceFactory::try_from_tx rejects the tx unless these bytes match. This is a fixed constant, NOT a per-run input — do not override. How it is derived (recompute ONLY if you edit issuance_factory.simf, which also changes the covenant address): the first 4 bytes of SHA-256 of the LF-normalized issuance_factory.simf source text. (Value verified against on-chain data; the engine no longer computes it — it's a plain constant so the tx-encoder stays protocol-agnostic.)" + } + }, + "create_instance": { + "fields": { + "FACTORY_ASSET_ID": "$instance.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "$params.ISSUING_UTXOS_COUNT", + "REISSUANCE_FLAGS": "$params.REISSUANCE_FLAGS" + } + }, + "inputs": [ + { + "id": "factory_issuance_input", + "description": "Wallet L-BTC UTXO whose outpoint (+ entropy) determines FACTORY_ASSET_ID. Carries a NEW issuance of the factory asset, amount 2 (FACTORY_ASSET_TOTAL_AMOUNT), 0 reissuance tokens — matching cli/commands/factory/core.rs::create.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": 2, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.FACTORY_ASSET_ID": "asset" + } + }, + "ui": { + "label": "the L-BTC input that mints the factory asset", + "role": "issuance" + } + } + ], + "outputs": [ + { + "id": "auth_nft_out", + "description": "The owner's auth NFT: 1 unit of the factory asset paid to the wallet's index-0 P2WPKH (output 0). Whoever holds this authorizes minting offers from the factory.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "your factory auth NFT — keep it to mint offers", + "role": "auth_nft" + } + }, + { + "id": "factory_program_out", + "description": "1 unit of the factory asset locked in the issuance_factory covenant (output 1). This is the persistent program UTXO the indexer tracks and later offers spend + recreate.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "the factory covenant — recreated on every mint", + "role": "covenant" + } + }, + { + "id": "creation_op_return", + "description": "Creation metadata OP_RETURN (output 2, index-fixed as CREATION_METADATA_OUTPUT_INDEX). 13 bytes: program_id (4, sha256(issuance_factory.simf source)[..4]) || issuing_utxos_count (1, u8) || reissuance_flags (8, u64 LE). Exactly IssuanceFactoryCreationMetadata::encode.", + "destination": { + "type": "op_return" + }, + "data": { + "parts": [ + { + "type": "bytes", + "value": "instance.FACTORY_PROGRAM_ID" + }, + { + "type": "u8", + "value": "instance.ISSUING_UTXOS_COUNT" + }, + { + "type": "u64", + "value": "instance.REISSUANCE_FLAGS", + "endian": "le" + } + ] + }, + "required_index": 2, + "ui": { + "label": "indexer data: this factory and its parameters", + "role": "protocol_data", + "group": "burned / protocol data" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "create a reusable issuance factory — the minter you will create lending offers from" + } + } + }, + "lending_contract": { + "description": "A single P2P lending offer minted from an issuance factory (lending_v3). CreateOffer computes the lending (collateral) covenant address (out[5]) from the offer terms and the nested AssetAuth/AssetAuthVault cov-hash chain (task 07), matching simplicity-lending's LendingOfferParameters::build_arguments. The 5 nested hashes reproduce live offer 43ab4efe out[5] byte-exactly (txmanifest_lib/tests/interop/lending_collateral.rs). Full on-chain assembly (dynamic storage leaves, script_auth-over-storage) is tracked in task 06 + upnext/10-11.", + "fields": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance). Its auth NFT is spent in[0]/recreated out[0], and the factory covenant (in[1]) is recreated out[1]." + }, + "ISSUING_UTXOS_COUNT": { + "type": "u8", + "description": "Factory issuing-UTXOs count (constant 2). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "REISSUANCE_FLAGS": { + "type": "u64", + "description": "Factory reissuance-flags bitmask (constant 0). Needed to compute the issuance_factory covenant address for in[1]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan (principal) asset id." + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Borrower NFT, issued from the factory covenant (in[1]) at offer creation." + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Lender NFT, issued from the collateral input (in[2]) at offer creation." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id. A fixed asset configured in the deployed indexer's OffersCreationTracker; the offer's lending covenant bakes in its AssetAuthVault hashes." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units) locked in out[5]." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal requested (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points. NOTE u64 in v3 (build_arguments casts the u16 OfferParameters value to u64) — a CMR-relevant change from v2's u16." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "CLTV block height for liquidation." + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=lender_nft, supplier=borrower_nft, keeper_burn=true, supplier_burn=true, is_active=false, finalized=0)." + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE lender AssetAuthVault (same params, is_active=true, finalized=FINALIZED_LENDER_VAULT_COV_HASH)." + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of FinalizedAssetAuthVault(vault=principal, keeper=protocol_fee_keeper, supplier=borrower_nft, keeper_burn=FALSE, supplier_burn=true, is_active=false, finalized=0)." + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "description": "sha256(spk) of the ACTIVE protocol-fee AssetAuthVault (same params, is_active=true, finalized=FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH)." + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(spk) of AssetAuth(borrower_nft, amount 1, with_asset_burn=false). The principal is paid to this borrower-NFT-keyed AssetAuth covenant (v3 replaces v2's wallet-hash borrower_output_script_hash)." + }, + "CURRENT_DEBT": { + "type": "u64", + "description": "Total amount to repay = principal + interest, where interest = principal * PRINCIPAL_INTEREST_RATE / 10000 (integer division, = get_total_amount_to_repay / apply_basis_points). Encoded as u64 BE in bytes[24..32] of the lending covenant's storage slot1 (out[5])." + }, + "LENDING_COV_SCRIPT_HASH": { + "type": "bytes32", + "description": "sha256(scriptPubKey) of the pending lending covenant INCLUDING its 2 storage slots (= sha256 of out[5]'s spk). The lender_nft_script_auth covenant (out[3]) commits to this via ScriptAuth::from_simplex_program(pending_offer). Computed with a tapleaf-over-lending.simf that folds the same storage leaves (task 11)." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero FINALIZED_VAULT_COV_HASH baked into the finalized (non-active) AssetAuthVault variants. A fixed constant, kept as a field so the *_vault_finalized utxo_types can reference it by name and inherit its declared bytes32 type. Do not inline the zeros as a literal in compile_params: 64 zero characters are all ASCII digits, so the engine's value-based type inference would type it u64 and mis-compile the covenant." + }, + "COLD_PUBKEY": { + "type": "pubkey", + "description": "x-only BIP340 key that certifies oracle keys. In practice, a committee's aggregate key." + }, + "MAX_FUTURE_SECONDS": { + "type": "u32", + "description": "How far past the chain's median time a liquidation's statement may be dated, in seconds." + }, + "LIQUIDATION_LTV_BPS": { + "type": "u64", + "description": "The loan may be liquidated once the debt is more than this share of the collateral's value, in basis points." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "description": "Blocks a liquidation must wait before it can be finished." + } + }, + "actions": { + "CreateOffer": { + "description": "Mint a pending offer from the factory: spend the factory auth NFT (in[0]) + factory covenant issuing the borrower NFT (in[1], IssueAssets{output_index:0}) + collateral input issuing the lender NFT (in[2]); recreate the factory covenant (out[1]), pay borrower NFT to wallet (out[2]), lock lender NFT in a ScriptAuth keyed to the offer (out[3]), publish the 50-byte lending metadata OP_RETURN (out[4]), and lock collateral in the lending covenant with 2 storage slots (out[5]). create_instance computes the nested cov-hash chain that fixes the out[5] address (task 07).", + "params": { + "FACTORY_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The issuance factory's asset id (from the factory instance file). Its auth NFT is spent in[0] and recreated out[0]/out[1]." + }, + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Collateral asset id (e.g. L-BTC)." + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Loan asset id." + }, + "PROTOCOL_FEE_KEEPER_ASSET_ID": { + "type": "liquid.asset_id", + "description": "Protocol-fee keeper asset id (must match the deployed indexer's configured value)." + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "description": "Collateral amount (base units)." + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "description": "Principal amount (base units)." + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "description": "Interest rate in basis points (u64 in v3)." + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "description": "Liquidation CLTV height." + }, + "ZERO_HASH": { + "type": "bytes32", + "default": "0000000000000000000000000000000000000000000000000000000000000000", + "description": "The all-zero finalized_vault_cov_hash used by finalized (non-active) AssetAuthVault variants. Constant; do not override." + }, + "LENDING_PROGRAM_ID": { + "type": "bytes", + "default": "f80c6162", + "description": "The 4-byte protocol message-type tag that prefixes the offer-creation OP_RETURN (out[4]); the indexer keys offer detection on it. Fixed constant, NOT a per-run input — do not override. Derived (recompute ONLY if you edit lending.simf, which also changes the covenant address) as the first 4 bytes of SHA-256 of the LF-normalized lending.simf source text. Verified against on-chain data; the engine treats it as a plain constant so the tx-encoder stays protocol-agnostic." + }, + "COLD_PUBKEY": { + "type": "pubkey", + "default": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3", + "description": "x-only BIP340 key that certifies oracle keys. The default is oracle_demo's public example cold key: testnet only. In practice, a committee's aggregate key (MuSig2 or FROST)." + }, + "MAX_FUTURE_SECONDS": { + "type": "u32", + "default": "300", + "description": "How far past the chain's median time a liquidation's statement may be dated, in seconds. Liquid's median time runs about 5-6 minutes behind the clock, so 300 lets a fresh statement be used within about a minute." + }, + "LIQUIDATION_LTV_BPS": { + "type": "u64", + "default": "8000", + "description": "The loan may be liquidated once the debt is more than this share of the collateral's value. 8000 is an 80% loan-to-value, where collateral is worth less than 125% of the debt." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "default": "5", + "description": "Blocks a liquidation must wait, open to challenge, before it can be finished. Liquid makes a block a minute." + } + }, + "create_instance": { + "fields": { + "FACTORY_ASSET_ID": "$params.FACTORY_ASSET_ID", + "ISSUING_UTXOS_COUNT": "2", + "REISSUANCE_FLAGS": "0", + "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", + "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", + "PROTOCOL_FEE_KEEPER_ASSET_ID": "$params.PROTOCOL_FEE_KEEPER_ASSET_ID", + "ZERO_HASH": "$params.ZERO_HASH", + "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", + "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", + "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", + "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", + "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", + "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", + "COLD_PUBKEY": "$params.COLD_PUBKEY", + "MAX_FUTURE_SECONDS": "$params.MAX_FUTURE_SECONDS", + "LIQUIDATION_LTV_BPS": "$params.LIQUIDATION_LTV_BPS", + "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", + "CURRENT_DEBT": "params.PRINCIPAL_AMOUNT + params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "simf_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "LENDER_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "simf_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "true" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "simf_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "ZERO_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "false" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "tapleaf", + "simf": "./asset_auth_vault.simf", + "simf_hash": "sha256:3a1bbaa2e28fa84f2573fb06a90fcf450b401733009102cb878c612194be7d7f", + "params": { + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PROTOCOL_FEE_KEEPER_ASSET_ID" + }, + "SUPPLIER_AUTH_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "KEEPER_AUTH_ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "FINALIZED_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "IS_ACTIVE": { + "type": "bool", + "value": "true" + }, + "WITH_KEEPER_ASSET_BURN": { + "type": "bool", + "value": "false" + }, + "WITH_SUPPLIER_ASSET_BURN": { + "type": "bool", + "value": "true" + } + } + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./asset_auth.simf", + "simf_hash": "sha256:cfd27392a1571e2ee47e0399b5db0c5009ec2cdee11e0f0283a85d0b17e664b6", + "params": { + "ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "ASSET_AMOUNT": { + "type": "u64", + "value": "1" + }, + "WITH_ASSET_BURN": { + "type": "bool", + "value": "false" + } + } + }, + "LENDING_COV_SCRIPT_HASH": { + "type": "tapleaf", + "simf": "./lending.simf", + "simf_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", + "params": { + "COLLATERAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "COLLATERAL_ASSET_ID" + }, + "PRINCIPAL_ASSET_ID": { + "type": "liquid.asset_id", + "value": "PRINCIPAL_ASSET_ID" + }, + "BORROWER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "BORROWER_NFT_ASSET_ID" + }, + "LENDER_NFT_ASSET_ID": { + "type": "liquid.asset_id", + "value": "LENDER_NFT_ASSET_ID" + }, + "COLLATERAL_AMOUNT": { + "type": "u64", + "value": "COLLATERAL_AMOUNT" + }, + "PRINCIPAL_AMOUNT": { + "type": "u64", + "value": "PRINCIPAL_AMOUNT" + }, + "PRINCIPAL_INTEREST_RATE": { + "type": "u64", + "value": "PRINCIPAL_INTEREST_RATE" + }, + "LOAN_EXPIRATION_TIME": { + "type": "u32", + "value": "LOAN_EXPIRATION_TIME" + }, + "LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "LENDER_VAULT_COV_HASH" + }, + "FINALIZED_LENDER_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_LENDER_VAULT_COV_HASH" + }, + "PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "PROTOCOL_FEE_VAULT_COV_HASH" + }, + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH": { + "type": "bytes32", + "value": "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH" + }, + "PRINCIPAL_OUTPUT_SCRIPT_HASH": { + "type": "bytes32", + "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" + }, + "COLD_PUBKEY": { + "type": "pubkey", + "value": "COLD_PUBKEY" + }, + "MAX_FUTURE_SECONDS": { + "type": "u32", + "value": "MAX_FUTURE_SECONDS" + }, + "LIQUIDATION_LTV_BPS": { + "type": "u64", + "value": "LIQUIDATION_LTV_BPS" + }, + "WINDOW_BLOCKS": { + "type": "u16", + "value": "WINDOW_BLOCKS" + } + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + }, + { + "type": "tapdata", + "payload": [ + { + "value": "CURRENT_DEBT", + "type": "u64", + "endian": "be", + "pad_to": 32, + "align": "right" + } + ] + } + ] + } + } + }, + "inputs": [ + { + "id": "factory_auth_in", + "description": "Factory auth NFT (1 unit of the factory asset) from the wallet — authorizes minting an offer. Recreated at out[0] (input 0).", + "utxo_source": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_in", + "description": "The issuance_factory covenant UTXO (input 1). Spent via the IssueAssets path, issuing the borrower NFT from this covenant's outpoint; the covenant is recreated at out[1]. Covenant-sourced issuance — task 03.", + "utxo_source": { + "utxo_type": "issuance_factory" + }, + "required_index": 1, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either", + "value": "Left(0)", + "description": "IssueAssets path (PATH::LEFT). The u32 payload is the auth-NFT output index (0)." + } + }, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.BORROWER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "collateral_in", + "description": "Borrower's collateral wallet UTXO (input 2), sized >= COLLATERAL_AMOUNT. Also carries the lender-NFT issuance (issued from this input's outpoint).", + "utxo_source": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.COLLATERAL_AMOUNT" + }, + "required_index": 2, + "issuance": { + "kind": "new", + "asset_amount_sat": 1, + "inflation_amount_sat": 0 + }, + "on_resolved": { + "set": { + "instance.LENDER_NFT_ASSET_ID": "asset" + } + }, + "ui": { + "label": "collateral to be locked for the loan", + "role": "collateral" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "factory_auth_out", + "description": "Factory auth NFT (1) back to the wallet (output 0). This is the auth-NFT output index the IssueAssets witness commits to.", + "destination": "wallet", + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 0, + "ui": { + "label": "auth NFT for issuance factory (returned)", + "role": "auth_nft" + } + }, + { + "id": "factory_covenant_out", + "description": "The issuance_factory covenant recreated (output 1), holding 1 unit of the factory asset.", + "destination": { + "utxo_type": "issuance_factory" + }, + "asset": "instance.FACTORY_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "issuance factory covenant", + "role": "covenant" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower NFT (1) to the wallet (output 2).", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "new NFT used for claiming the principal later", + "role": "borrower_nft" + } + }, + { + "id": "lender_nft_locked", + "description": "Lender NFT (1) locked in the lender_nft_script_auth covenant (output 3), keyed to the pending offer's own script hash (LENDING_COV_SCRIPT_HASH).", + "destination": { + "utxo_type": "lender_nft_script_auth" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 3, + "ui": { + "label": "newly created NFT to be sent to the lender", + "role": "lender_nft" + } + }, + { + "id": "creation_op_return", + "description": "Lending creation metadata OP_RETURN (output 4), 50 bytes: program_id(lending.simf)[4] || principal_asset_id[32, internal] || principal_amount[8, u64 LE] || loan_expiration[4, u32 LE] || interest_rate[2, u16 LE]. Matches lending metadata::encode (task 04). Interest rate is u16 in the OP_RETURN even though it is u64 in the covenant params.", + "destination": { + "type": "op_return" + }, + "data": { + "$comment": "50-byte layout must match lending metadata::encode exactly, in this order.", + "parts": [ + { + "type": "bytes", + "value": "instance.LENDING_PROGRAM_ID", + "$comment": "4-byte message-type tag; see the LENDING_PROGRAM_ID param for how it's derived." + }, + { + "type": "liquid.asset_id", + "value": "instance.PRINCIPAL_ASSET_ID" + }, + { + "type": "u64", + "value": "instance.PRINCIPAL_AMOUNT" + }, + { + "type": "u32", + "value": "instance.LOAN_EXPIRATION_TIME" + }, + { + "type": "u16", + "value": "instance.PRINCIPAL_INTEREST_RATE" + } + ] + }, + "required_index": 4, + "ui": { + "$comment": "Full structured UI hint (all fields). A bare string is shorthand for just `label`. `role` is a semantic tag; `group` overrides the derived net-effect account heading; `hide: true` drops this leg from the diff entirely.", + "label": "indexer data: the offer and its terms", + "role": "protocol_data", + "group": "burned / protocol data", + "hide": false + } + }, + { + "id": "lending_collateral_out", + "description": "Collateral locked in the lending covenant (output 5), COLLATERAL_AMOUNT with 2 pending storage slots (is_active=0, current_debt).", + "destination": { + "utxo_type": "lending_collateral" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 5, + "ui": { + "label": "collateral prelocked in the lending covenant", + "role": "collateral" + } + }, + { + "id": "collateral_change", + "description": "Collateral-asset change back to the wallet.", + "destination": "change", + "asset": "instance.COLLATERAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the collateral", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the wallet.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "create an offer to borrow {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} by locking {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} as collateral" + }, + "AcceptOffer": { + "description": "A lender activates a pending offer (offer_open → loan_active). Spends the pending lending covenant (in[0], Accept witness Left(Left())) and the lender NFT locked in the ScriptAuth (in[1], authorizing input 0), supplies the principal (in[2]). Outputs: the lending covenant re-committed ACTIVE (out[0], storage is_active=1) still holding the collateral; the principal paid into an AssetAuth(borrower_nft) covenant the borrower can later claim (out[1]); the lender NFT returned to the lender's wallet (out[2]). Mirrors LendingOffer::attach_acceptance. Loads the offer instance written by CreateOffer.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Accept path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either, u64>>", + "value": "Left(Left(()))", + "description": "OfferAcceptance (PATH::Left(Left(())))." + }, + "LIQUIDATION": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the open offer you are accepting", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1). The ScriptAuth authorizes its spend by checking input 0 is the pending offer covenant.", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "Index of the pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT — your claim on this loan", + "role": "lender_nft" + } + }, + { + "id": "principal_in", + "description": "Lender's wallet UTXO providing the loan principal (input 2), sized >= PRINCIPAL_AMOUNT.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.PRINCIPAL_AMOUNT" + }, + "required_index": 2, + "ui": { + "label": "the principal you are lending", + "role": "principal" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "active_offer_out", + "description": "The lending covenant re-committed in the ACTIVE state (output 0), still holding COLLATERAL_AMOUNT of collateral; storage slot0 = is_active = 1.", + "destination": { + "utxo_type": "lending_collateral_active" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the loan, now live — holds the collateral", + "role": "covenant" + } + }, + { + "id": "principal_out", + "description": "The principal paid into an AssetAuth(borrower_nft) covenant (output 1), PRINCIPAL_AMOUNT. The borrower claims it later with the borrower NFT. Enforced by the covenant's PRINCIPAL_OUTPUT_SCRIPT_HASH.", + "destination": { + "utxo_type": "principal_asset_auth" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the principal, locked where only the borrower can claim it", + "role": "principal" + } + }, + { + "id": "lender_nft_out", + "description": "Lender NFT (1) returned to the lender's wallet (output 2) — unlocked from the ScriptAuth on activation.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 2, + "ui": { + "label": "your lender NFT — keep it, it is how you collect the repayment", + "role": "lender_nft" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the lender.", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the principal", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the lender.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "lend {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol} against {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} of collateral" + }, + "CancelOffer": { + "description": "The borrower withdraws a pending offer before activation (offer_open → cancelled). Spends the pending lending covenant (in[0], Cancel witness Left(Right())) and the lender NFT from its ScriptAuth (in[1]) and the borrower NFT from the wallet (in[2]); burns both NFTs to OP_RETURN (out[0] lender, out[1] borrower — index-fixed by the covenant) and returns the collateral to the borrower (out[2]). Mirrors LendingOffer::attach_cancellation. The covenant only checks each NFT lands on an OP_RETURN, so data-less burns suffice.", + "inputs": [ + { + "id": "pending_offer_in", + "description": "The pending lending (collateral) covenant UTXO (input 0). Cancel path.", + "utxo_source": { + "utxo_type": "lending_collateral" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either, u64>>", + "value": "Left(Right(()))", + "description": "OfferCancellation (PATH::Left(Right(())))." + }, + "LIQUIDATION": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "your open offer — the covenant holding your collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Lender NFT held in the lender_nft_script_auth covenant (input 1); burned at out[0].", + "utxo_source": { + "utxo_type": "lender_nft_script_auth" + }, + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "witnesses": { + "INPUT_SCRIPT_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "0", + "description": "The pending-offer covenant input this ScriptAuth is bound to (input 0)." + } + }, + "ui": { + "label": "the lender NFT — destroyed, nobody took the offer", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 2); burned at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 2, + "ui": { + "label": "your borrower NFT — destroyed below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lender_nft_burned", + "description": "Lender NFT burned to OP_RETURN (output 0).", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "lender NFT destroyed — the offer can never be accepted now", + "role": "lender_nft" + } + }, + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 1).", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT, destroyed", + "role": "borrower_nft" + } + }, + { + "id": "collateral_returned", + "description": "Collateral returned to the borrower's wallet (output 2).", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 2, + "ui": { + "label": "your collateral, returned to you", + "role": "collateral" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ], + "intent": "cancel your offer and take back the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral" + }, + "ClaimPrincipal": { + "intent": "claim the loan principal of {instance.PRINCIPAL_AMOUNT} {instance.PRINCIPAL_ASSET_ID:symbol}", + "description": "The borrower withdraws the loan principal from the AssetAuth(borrower_nft) covenant created at activation (AcceptOffer out[1]). Spends that covenant (in[0]) while presenting the borrower NFT (in[1], INPUT_ASSET_INDEX=1) and preserving it (out[1], OUTPUT_ASSET_INDEX=1); the covenant only checks the auth NFT is present and re-output (WITH_ASSET_BURN=false), so the principal (out[0]) flows freely to the borrower. Mirrors AssetAuth::attach_unlocking (unlocks_without_burn_with_one_explicit_output). The offer stays active (loan_active → loan_active); collateral is unaffected.", + "inputs": [ + { + "id": "principal_asset_auth_in", + "description": "The AssetAuth(borrower_nft) covenant UTXO holding the principal (input 0).", + "utxo_source": { + "utxo_type": "principal_asset_auth" + }, + "required_index": 0, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "witnesses": { + "INPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the input carrying the borrower auth NFT (input 1)." + }, + "OUTPUT_ASSET_INDEX": { + "type": "simplicityhl", + "simplicity_type": "u32", + "value": "1", + "description": "Index of the output the borrower auth NFT is preserved at (output 1)." + } + }, + "ui": { + "label": "the covenant holding your borrowed funds", + "role": "covenant" + } + }, + { + "id": "borrower_nft_in", + "description": "Borrower auth NFT from the wallet (input 1) — proves the borrower and is re-output at out[1].", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 1, + "ui": { + "label": "your borrower NFT — returned to you below", + "role": "borrower_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "principal_claimed", + "description": "The loan principal released to the borrower's wallet (output 0), PRINCIPAL_AMOUNT.", + "destination": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "instance.PRINCIPAL_AMOUNT", + "confidential": false, + "required_index": 0, + "ui": { + "label": "the borrowed funds, released to you", + "role": "principal" + } + }, + { + "id": "borrower_nft_out", + "description": "Borrower auth NFT (1) preserved back to the wallet (output 1) — checked by the covenant.", + "destination": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "your borrower NFT — you need it to repay", + "role": "borrower_nft" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ] + }, + "RepayLoan": { + "intent": "repay the loan in full — {instance.CURRENT_DEBT} {instance.PRINCIPAL_ASSET_ID:symbol} (principal + interest) — and release the {instance.COLLATERAL_AMOUNT} {instance.COLLATERAL_ASSET_ID:symbol} collateral", + "description": "The borrower clears the whole debt in one transaction and takes the collateral back (loan_active → repaid). Spends the ACTIVE lending covenant (in[1], FullRepayment witness) while presenting and BURNING the borrower NFT (in[0]/out[0]) — burning the NFT is what makes this final: it is the auth token for ClaimPrincipal and for supplying the vaults, so no further action on the offer is possible. The repayment is split into two freshly-created settlement vaults the covenant's baked-in hashes pin exactly: the lender's (out[1], debt minus the protocol fee) and the protocol's (out[2], 10% of the interest). The collateral (out[3]) is then unconstrained and goes back to the borrower's wallet. Mirrors LendingOffer::attach_full_repayment + attach_vaults in the NoRepayments phase. SCOPE: this models a full repayment of an UNTOUCHED active loan (no prior partial repayment), which is the case the covenant's validate_vaults reaches via `already_repaid_amount == 0` — the branch that CREATES the vaults as plain outputs rather than spending existing ones. A repayment after a partial one must instead spend + top up the live vaults (AssetAuthVault supply/final_supply witnesses); see backlog/08.", + "params": { + "TOTAL_PROTOCOL_FEE": { + "type": "u64", + "compute": "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The protocol's cut, paid to out[2]: 10% (PROTOCOL_FEE_BPS=1000) of the total loan interest, where interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000. Auto-computed; do not override — the covenant recomputes it and rejects any other split. Both divisions floor, and in this order, exactly as lending.simf's apply_basis_points chain does (get_protocol_fee_amount(get_total_fee_amount())); folding them into one division would round differently." + }, + "LENDER_VAULT_AMOUNT": { + "type": "u64", + "compute": "instance.CURRENT_DEBT - instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The lender's share, paid to out[1]: the entire debt less TOTAL_PROTOCOL_FEE (= the covenant's additional_lender_vault_amount = amount_to_repay - protocol_fee_repaid). Auto-computed; do not override. Spelled out from instance fields rather than referencing TOTAL_PROTOCOL_FEE so the two formulas are independent of the order params are evaluated in." + } + }, + "inputs": [ + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 0) — authorizes the repayment and is burned at out[0]. The covenant fixes this at input 0 (full_repay_offer's borrower_nft_input_index).", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — burned, closing the loan", + "role": "borrower_nft" + } + }, + { + "id": "active_offer_in", + "description": "The ACTIVE lending (collateral) covenant UTXO (input 1) — storage is_active=1, current_debt=CURRENT_DEBT. FullRepayment path. The covenant asserts its own input index is 1 and that its script hash commits to storage(true, current_debt), which is what ties the witness's debt payload to reality.", + "utxo_source": { + "utxo_type": "lending_collateral_active" + }, + "required_index": 1, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either, u64>>", + "value": "Right(Left(Right(instance.CURRENT_DEBT)))", + "description": "FullRepayment (PATH::Right(Left(Right(current_debt)))). The u64 payload is the debt being cleared; it is not trusted input — the covenant recomputes the input's expected script hash from it and compares against the real one." + }, + "LIQUIDATION": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the live loan — the covenant holding your collateral", + "role": "covenant" + } + }, + { + "id": "repayment_in", + "description": "The borrower's principal-asset UTXO funding the repayment (input 2), sized >= CURRENT_DEBT. The covenant does not constrain this input (in this branch input indexes 2/3 are the unused vault-input slots); the repayment is enforced on the vault OUTPUTS instead, so any funding source works.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.CURRENT_DEBT" + }, + "required_index": 2, + "ui": { + "label": "your funds to repay — principal plus interest", + "role": "repayment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 0) — covenant-fixed index. Data-less burn; the covenant only checks is_op_return.", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — destroyed, closing the loan", + "role": "borrower_nft", + "group": "burned / protocol data" + } + }, + { + "id": "lender_vault_out", + "description": "The lender's finalized settlement vault (output 1), holding LENDER_VAULT_AMOUNT of the principal asset. Covenant-fixed: full_repay_offer's lender-vault output index is 1, and validate_vaults pins the script hash to FINALIZED_LENDER_VAULT_COV_HASH (finalized, not active, because current_debt == amount_to_repay). The lender collects it later with the lender NFT.", + "destination": { + "utxo_type": "lender_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.LENDER_VAULT_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the lender's repayment, parked for them to collect", + "role": "vault" + } + }, + { + "id": "protocol_fee_vault_out", + "description": "The protocol-fee finalized settlement vault (output 2), holding TOTAL_PROTOCOL_FEE of the principal asset. Covenant-fixed index 2; script hash pinned to FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH (finalized because this repayment clears the entire protocol fee at once).", + "destination": { + "utxo_type": "protocol_fee_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.TOTAL_PROTOCOL_FEE", + "confidential": false, + "required_index": 2, + "ui": { + "label": "the protocol's fee — 10% of the interest", + "role": "vault" + } + }, + { + "id": "collateral_returned", + "description": "The collateral released back to the borrower's wallet (output 3), COLLATERAL_AMOUNT. Index 3 is this manifest's choice, not the covenant's: full_repay_offer only checks the collateral on the INPUT side and adds no program output, so once the vaults are satisfied the collateral is unconstrained.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 3, + "ui": { + "label": "your collateral, released back to you", + "role": "collateral" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the borrower (anything above CURRENT_DEBT).", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the repayment", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ] + }, + "StartLiquidation": { + "$comment": "Generate the statement params with ../oracle_demo/fake_oracle.py statement-params --claim. The lock time is what proves the statement isn't dated more than MAX_FUTURE_SECONDS ahead of the chain: until the chain's median time passes it, the node refuses the transaction as non-final.", + "description": "Start liquidating before expiry: present a certified price statement at which the collateral is worth less than the debt allows (LIQUIDATION_LTV_BPS), and move the loan to the pending state. The lender NFT authorises it and comes back to you. Opens the challenge window.", + "intent": "start liquidating the loan at {params.CLAIM_PRICE} cents per BTC", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The statement's oracle signature, as (r, s)." + }, + "CERT_ORACLE_KEY": { + "type": "string", + "description": "The key that signed the statement, from its certificate, as (prefix, x-coordinate)." + }, + "CERT_VALID_FROM": { + "type": "u64", + "description": "The certificate's first valid statement timestamp." + }, + "CERT_VALID_UNTIL": { + "type": "u64", + "description": "The certificate's last valid statement timestamp." + }, + "CERT_SIG": { + "type": "bytes", + "description": "The cold key's 64-byte BIP340 signature on the certificate, 0x-prefixed." + }, + "CLAIM_PRICE": { + "type": "u64", + "description": "The statement's price, in US cents per BTC." + }, + "CLAIM_TIMESTAMP": { + "type": "u64", + "description": "The statement's timestamp." + } + }, + "lock_time": { + "time": "params.CLAIM_TIMESTAMP - instance.MAX_FUTURE_SECONDS" + }, + "inputs": [ + { + "id": "active_offer_in", + "description": "The active loan (input 0). It checks the statement and the whole transaction.", + "utxo_source": { + "utxo_type": "lending_collateral_active" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": "unused", + "LIQUIDATION": { + "type": "simplicityhl", + "simplicity_type": "Option, Either<(u64, (u64, u64)), (u64, (u64, u64))>>>", + "value": "Some(Left(Left(instance.CURRENT_DEBT)))" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "CERT_ORACLE_KEY": { + "type": "simplicityhl", + "simplicity_type": "(u8, u256)", + "value": "params.CERT_ORACLE_KEY" + }, + "CERT_VALID_FROM": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_FROM" + }, + "CERT_VALID_UNTIL": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_UNTIL" + }, + "CERT_SIG": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.CERT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the live loan, holding the collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Your lender NFT (input 1), which authorises starting a liquidation. It comes back to you at output 1.", + "utxo_source": "wallet", + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "ui": { + "label": "your lender NFT, shown and kept", + "role": "lender_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "liquidating_out", + "description": "Output 0: the loan in the pending state, still holding the collateral, at an address that records the claim's timestamp and price.", + "destination": { + "utxo_type": "lending_collateral_liquidating" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0, + "ui": { + "label": "the loan, pending liquidation", + "role": "covenant" + } + }, + { + "id": "lender_nft_out", + "description": "Output 1: your lender NFT, back to you. Explicit, because the covenant reads its amount.", + "destination": "wallet", + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "confidential": false, + "required_index": 1, + "ui": { + "label": "your lender NFT, returned", + "role": "lender_nft" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to you.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "CancelLiquidation": { + "$comment": "Generate the params with ../oracle_demo/fake_oracle.py statement-params, passing the start's params with --merge so the claim comes along. Anyone can run it.", + "description": "Cancel a pending liquidation with a certified statement newer than its claim, at which the loan isn't liquidatable. The loan goes back to active.", + "intent": "cancel the liquidation claimed at {params.CLAIM_PRICE} cents per BTC", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The newer statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The newer statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The newer statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The newer statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The newer statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The newer statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The newer statement's oracle signature, as (r, s)." + }, + "CERT_ORACLE_KEY": { + "type": "string", + "description": "The key that signed the newer statement, from its certificate, as (prefix, x-coordinate)." + }, + "CERT_VALID_FROM": { + "type": "u64", + "description": "The certificate's first valid statement timestamp." + }, + "CERT_VALID_UNTIL": { + "type": "u64", + "description": "The certificate's last valid statement timestamp." + }, + "CERT_SIG": { + "type": "bytes", + "description": "The cold key's 64-byte BIP340 signature on the certificate, 0x-prefixed." + }, + "CLAIM_PRICE": { + "type": "u64", + "description": "The pending liquidation's price, in US cents per BTC." + }, + "CLAIM_TIMESTAMP": { + "type": "u64", + "description": "The pending liquidation's timestamp." + } + }, + "inputs": [ + { + "id": "liquidating_in", + "description": "The loan pending liquidation (input 0). The covenant recomputes its address from the debt and the claim.", + "utxo_source": { + "utxo_type": "lending_collateral_liquidating" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": "unused", + "LIQUIDATION": { + "type": "simplicityhl", + "simplicity_type": "Option, Either<(u64, (u64, u64)), (u64, (u64, u64))>>>", + "value": "Some(Left(Right((instance.CURRENT_DEBT, (params.CLAIM_TIMESTAMP, params.CLAIM_PRICE)))))" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "CERT_ORACLE_KEY": { + "type": "simplicityhl", + "simplicity_type": "(u8, u256)", + "value": "params.CERT_ORACLE_KEY" + }, + "CERT_VALID_FROM": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_FROM" + }, + "CERT_VALID_UNTIL": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_UNTIL" + }, + "CERT_SIG": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.CERT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the loan pending liquidation, holding the collateral", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "active_out", + "description": "Output 0: the loan, active again, still holding the collateral.", + "destination": { + "utxo_type": "lending_collateral_active" + }, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "required_index": 0, + "ui": { + "label": "the loan, active again", + "role": "covenant" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to you.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "FinishLiquidation": { + "$comment": "Use the start's params file. The relative timelock on input 0 is the challenge window.", + "description": "Finish a liquidation once the window has passed: burn the lender NFT and take the collateral.", + "intent": "finish the liquidation and take {instance.COLLATERAL_AMOUNT} of {instance.COLLATERAL_ASSET_ID:symbol}", + "params": { + "CLAIM_PRICE": { + "type": "u64", + "description": "The pending liquidation's price, in US cents per BTC." + }, + "CLAIM_TIMESTAMP": { + "type": "u64", + "description": "The pending liquidation's timestamp." + } + }, + "inputs": [ + { + "id": "liquidating_in", + "description": "The loan pending liquidation (input 0). The covenant recomputes its address from the debt and the claim.", + "utxo_source": { + "utxo_type": "lending_collateral_liquidating" + }, + "required_index": 0, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "sequence": { + "relative_blocks": "instance.WINDOW_BLOCKS" + }, + "witnesses": { + "PATH": "unused", + "LIQUIDATION": { + "type": "simplicityhl", + "simplicity_type": "Option, Either<(u64, (u64, u64)), (u64, (u64, u64))>>>", + "value": "Some(Right(Left((instance.CURRENT_DEBT, (params.CLAIM_TIMESTAMP, params.CLAIM_PRICE)))))" + }, + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the loan pending liquidation, holding the collateral", + "role": "covenant" + } + }, + { + "id": "lender_nft_in", + "description": "Your lender NFT (input 1), burned at output 0 as on an expiry liquidation.", + "utxo_source": "wallet", + "required_index": 1, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "ui": { + "label": "your lender NFT, to be burned", + "role": "lender_nft" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lender_nft_burned", + "description": "Output 0: the lender NFT, burned. The covenant only checks it is an OP_RETURN.", + "destination": { + "type": "op_return" + }, + "asset": "instance.LENDER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your lender NFT, destroyed, closing the loan", + "role": "lender_nft", + "group": "burned / protocol data" + } + }, + { + "id": "collateral_out", + "description": "The collateral, to you.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "ui": { + "label": "the collateral, to you", + "role": "settlement" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to you.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "RepayDuringLiquidation": { + "intent": "repay the loan while its liquidation is pending", + "description": "Repay the whole debt while a liquidation is pending, and take the collateral back. Same as RepayLoan, from the pending state: a borrower can always beat a liquidation by repaying.", + "params": { + "TOTAL_PROTOCOL_FEE": { + "type": "u64", + "compute": "instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The protocol's cut, paid to out[2]: 10% (PROTOCOL_FEE_BPS=1000) of the total loan interest, where interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000. Auto-computed; do not override — the covenant recomputes it and rejects any other split. Both divisions floor, and in this order, exactly as lending.simf's apply_basis_points chain does (get_protocol_fee_amount(get_total_fee_amount())); folding them into one division would round differently." + }, + "LENDER_VAULT_AMOUNT": { + "type": "u64", + "compute": "instance.CURRENT_DEBT - instance.PRINCIPAL_AMOUNT * instance.PRINCIPAL_INTEREST_RATE / 10000 * 1000 / 10000", + "description": "The lender's share, paid to out[1]: the entire debt less TOTAL_PROTOCOL_FEE (= the covenant's additional_lender_vault_amount = amount_to_repay - protocol_fee_repaid). Auto-computed; do not override. Spelled out from instance fields rather than referencing TOTAL_PROTOCOL_FEE so the two formulas are independent of the order params are evaluated in." + }, + "CLAIM_PRICE": { + "type": "u64", + "description": "The pending liquidation's price, in US cents per BTC." + }, + "CLAIM_TIMESTAMP": { + "type": "u64", + "description": "The pending liquidation's timestamp." + } + }, + "inputs": [ + { + "id": "borrower_nft_in", + "description": "Borrower NFT from the wallet (input 0) — authorizes the repayment and is burned at out[0]. The covenant fixes this at input 0 (full_repay_offer's borrower_nft_input_index).", + "utxo_source": "wallet", + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — burned, closing the loan", + "role": "borrower_nft" + } + }, + { + "id": "liquidating_in", + "description": "The loan pending liquidation (input 1), FullRepayment from the pending state. The covenant recomputes its address from the debt and the claim.", + "utxo_source": { + "utxo_type": "lending_collateral_liquidating" + }, + "required_index": 1, + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "witnesses": { + "PATH": "unused", + "LIQUIDATION": { + "type": "simplicityhl", + "simplicity_type": "Option, Either<(u64, (u64, u64)), (u64, (u64, u64))>>>", + "value": "Some(Right(Right((instance.CURRENT_DEBT, (params.CLAIM_TIMESTAMP, params.CLAIM_PRICE)))))" + }, + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the loan pending liquidation, holding your collateral", + "role": "covenant" + } + }, + { + "id": "repayment_in", + "description": "The borrower's principal-asset UTXO funding the repayment (input 2), sized >= CURRENT_DEBT. The covenant does not constrain this input (in this branch input indexes 2/3 are the unused vault-input slots); the repayment is enforced on the vault OUTPUTS instead, so any funding source works.", + "utxo_source": "wallet", + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": { + "min_amount": "instance.CURRENT_DEBT" + }, + "required_index": 2, + "ui": { + "label": "your funds to repay — principal plus interest", + "role": "repayment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the transaction fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "borrower_nft_burned", + "description": "Borrower NFT burned to OP_RETURN (output 0) — covenant-fixed index. Data-less burn; the covenant only checks is_op_return.", + "destination": { + "type": "op_return" + }, + "asset": "instance.BORROWER_NFT_ASSET_ID", + "amount_sat": 1, + "required_index": 0, + "ui": { + "label": "your borrower NFT — destroyed, closing the loan", + "role": "borrower_nft", + "group": "burned / protocol data" + } + }, + { + "id": "lender_vault_out", + "description": "The lender's finalized settlement vault (output 1), holding LENDER_VAULT_AMOUNT of the principal asset. Covenant-fixed: full_repay_offer's lender-vault output index is 1, and validate_vaults pins the script hash to FINALIZED_LENDER_VAULT_COV_HASH (finalized, not active, because current_debt == amount_to_repay). The lender collects it later with the lender NFT.", + "destination": { + "utxo_type": "lender_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.LENDER_VAULT_AMOUNT", + "confidential": false, + "required_index": 1, + "ui": { + "label": "the lender's repayment, parked for them to collect", + "role": "vault" + } + }, + { + "id": "protocol_fee_vault_out", + "description": "The protocol-fee finalized settlement vault (output 2), holding TOTAL_PROTOCOL_FEE of the principal asset. Covenant-fixed index 2; script hash pinned to FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH (finalized because this repayment clears the entire protocol fee at once).", + "destination": { + "utxo_type": "protocol_fee_vault_finalized" + }, + "asset": "instance.PRINCIPAL_ASSET_ID", + "amount_sat": "params.TOTAL_PROTOCOL_FEE", + "confidential": false, + "required_index": 2, + "ui": { + "label": "the protocol's fee — 10% of the interest", + "role": "vault" + } + }, + { + "id": "collateral_returned", + "description": "The collateral released back to the borrower's wallet (output 3), COLLATERAL_AMOUNT. Index 3 is this manifest's choice, not the covenant's: full_repay_offer only checks the collateral on the INPUT side and adds no program output, so once the vaults are satisfied the collateral is unconstrained.", + "destination": "wallet", + "asset": "instance.COLLATERAL_ASSET_ID", + "amount_sat": "instance.COLLATERAL_AMOUNT", + "confidential": false, + "required_index": 3, + "ui": { + "label": "your collateral, released back to you", + "role": "collateral" + } + }, + { + "id": "principal_change", + "description": "Principal-asset change back to the borrower (anything above CURRENT_DEBT).", + "destination": "change", + "asset": "instance.PRINCIPAL_ASSET_ID", + "optional": true, + "ui": { + "label": "change from the repayment", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change back to the borrower.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "change from paying fees", + "role": "change" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/examples/oracle_demo/README.md b/examples/oracle_demo/README.md new file mode 100644 index 0000000..a173b63 --- /dev/null +++ b/examples/oracle_demo/README.md @@ -0,0 +1,279 @@ +# oracle_demo + +Burn L-BTC for a stablecoin at the **current** BTC/USD price, on Liquid. + +A Simplicity program can check that an oracle signed a price, but not that the price is +current: nothing on chain says what time it is now. Without that check, a user could +present an old statement from when the price was higher, and mint more than their L-BTC is +worth. This example closes the gap with a challenge window instead of an oracle-side +expiry mechanism: + +1. **RequestMint**: the user presents a signed price statement. The covenant checks the + signature, locks the L-BTC and reserves the tUSDT it buys at that price. +2. **Challenge**: until the request is finalized, anyone holding a *newer* statement whose + price differs by more than a tolerance can cancel it. The L-BTC is refunded and the + tUSDT goes back to the treasury. +3. **Finalize**: once the window has passed (5 blocks, about 5 minutes, by default), + anyone can complete the request. The tUSDT goes to the user and the L-BTC is burned. + +A stale request is safe to submit only if nobody is watching. A watcher needs nothing but +the latest statement and a fee, so one honest watcher is enough. + +## Oracle statements + +Statements use [Mycelia Signal](https://github.com/jonathanbulkeley/Mycelia-Signal-Sovereign-Oracle/blob/main/docs/DEMO.md)'s +format, signed with secp256k1 ECDSA over the sha256 of a canonical string: + +```json +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910", + "signature": "MEQCICzgChRNZZFqQ4VF1/TluMyqneOcwBfPnSviptH2X9QgAiA/GR4IyZIIALG76QjUwiqcdUKfVm69Y0+bqvzhiWyNqw==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} +``` + +The statements here are fake. [`fake_oracle.py`](fake_oracle.py) signs them with a local +test key, [`data/example_oracle_key.json`](data/example_oracle_key.json), which is public: anyone can sign +"oracle" statements with it. Use it on testnet only. The same goes for the cold key below. + +The covenant verifies the string itself. It cannot split a string, so the witness carries +the variable fields already separated (price digits, sources, method, timestamp, nonce), +and the program hashes them back together between the fixed parts (`v1|PRICE|BTCUSD|`, +`.`, `|USD|2|` and the separators). Number fields must be all digits and text fields may +not contain `|`. A signed string can therefore only be read one way, and a witness can't +move a field boundary to make some other number read as the timestamp. The ECDSA check is +`lib/secp256k1/ecdsa.simf`, a proposed addition to +[simplicityhl-std](https://github.com/BlockstreamResearch/simplicityhl-std) that is not +yet upstream. It is copied into the program because the wallet compiles each program from +a single file. + +## Which oracle keys count + +The service doesn't fix an oracle key. It fixes a **cold key**, `COLD_PUBKEY`, which +certifies oracle keys: a BIP340 signature saying "this oracle key may sign statements +timestamped from `valid_from` to `valid_until`". Each statement is presented with the +certificate for the key that signed it, and the covenant checks both signatures and that +the statement's timestamp is in range. Request and challenge statements can come from +different certified keys. + +```json +{ + "oracle_pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "valid_from": 1741500000, + "valid_until": 1744092000, + "cold_pubkey": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3", + "signature": "…", + "scheme": "bip340", + "tag": "oracle-demo/oracle-cert/v1" +} +``` + +The cold key signs `tagged_hash("oracle-demo/oracle-cert/v1", pubkey ‖ valid_from ‖ +valid_until)`, the key in its 33-byte compressed form and the times as 8-byte big-endian +integers. The tag keeps any other signature by the cold key from passing for a +certificate. + +- **A new or rotated oracle needs a new certificate, not a new deployment.** The oracle + takes no part: the cold key certifies whatever key the oracle already publishes. +- **The cold key can be a committee.** A MuSig2 or FROST aggregate key signs like any + other BIP340 key, so `M`-of-`N` approval needs no change to the program. +- **Revocation is expiry.** Nothing on chain can say a certificate was withdrawn, so keep + certificates short-lived, with the next one issued before the last expires. +- **The range is checked against the statement's timestamp, not the chain's time**, which + a program can't read. An expired certificate still covers its own old statements, which + is harmless: they are stale, so any current statement can challenge them. + +### Statements dated in the future + +A certificate alone doesn't limit what a leaked oracle key can do. It could sign a +statement dated at the end of its certificate's range, weeks ahead, at any price. No +honest statement is newer, so nothing could challenge the request before it finalized. + +So a request's transaction carries a lock time of `timestamp - MAX_FUTURE_SECONDS`, and +the covenant checks it (`check_lock_time`). Consensus only mines a time-locked +transaction once the chain's median time past is beyond its lock time, so a statement +can be dated at most `MAX_FUTURE_SECONDS` ahead of the chain, and any fresh statement can +challenge a forged one. Before that point the node refuses the transaction as +`non-final`. + +Liquid's median time runs 5–6 minutes behind the clock, so the default of 300 seconds +means a fresh statement can be used within about a minute, while a forged one can't be +dated past the present. A larger value removes the wait but lets a forged statement be +dated that much further ahead, and the window has to grow to match. + +## How it works + +One program, [`oracle_mint.simf`](oracle_mint.simf), holds two kinds of address, told apart +by a tapdata leaf beside the program leaf: + +| State | Tapdata leaf | Holds | +|-------|--------------|-------| +| treasury | `0x00` | the tUSDT supply | +| pending | `0x01 ‖ price ‖ timestamp ‖ L-BTC amount ‖ recipient` | one request: its L-BTC and its reserved tUSDT, two UTXOs at one address | + +| Action | Spends | Creates | The covenant checks | +|--------|--------|---------|---------------------| +| `RequestMint` | treasury (input 0), wallet L-BTC | pending L-BTC, pending tUSDT, treasury change | the statement and its certificate; that the tUSDT is `L-BTC × price` and both outputs pay the request's pending address | +| `Finalize` | pending L-BTC (input 0), pending tUSDT (input 1) | tUSDT to the recipient, L-BTC burned | that input 0 is at least `WINDOW_BLOCKS` old, through its own `nSequence` | +| `Challenge` | pending L-BTC (input 0), pending tUSDT (input 1) | L-BTC refund, tUSDT to the treasury | a newer certified statement, whose price differs from the request's by more than `TOLERANCE_BPS` | + +The tUSDT input of a pending pair only checks that it sits beside its L-BTC at input 0, +which checks the whole transaction. That shortcut is refused at the treasury address, +because otherwise a second treasury UTXO could be spent alongside a request with nothing +checking where its tUSDT went. + +Both assets have 8 decimal places, so a request for `A` satoshis at `P` cents buys +`A × P / 100` tUSDT base units, rounded down. + +tUSDT is issued by a separate manifest, [`issue_usdt/`](issue_usdt), because the service's +addresses depend on the asset id. The id only exists once the issuing transaction has +chosen its input. + +## Files + +| File | What it is | +|------|------------| +| [`txmanifest.json`](txmanifest.json) | The mint service: `CreateTreasury`, `RequestMint`, `Finalize`, `Challenge`. | +| [`oracle_mint.simf`](oracle_mint.simf) | The covenant. | +| [`issue_usdt/txmanifest.json`](issue_usdt/txmanifest.json) | Issues the tUSDT supply: `IssueUSDT`. | +| [`fake_oracle.py`](fake_oracle.py) | Signs statements, certifies oracle keys with the cold key, verifies both, and turns them into `--params` files. Needs `pip install ecdsa`. | +| [`data/`](data) | The test oracle and cold keys, the oracle's certificate, and the signed statements the tests use. | + +## Running it on Liquid testnet + +You need a funded Liquid testnet wallet (see the [quick start](../../README.md#quick-start-liquid-testnet)). +Each request below locks 5,000 sat; the honest one burns it and the others are refunded. `run` asks for a fee rate and confirms before each +broadcast; after each one, wait for it to confirm and `sync` before the next. + +```sh +W=wallet.json +TXM=tx-manifest-wallet +cd examples/oracle_demo +``` + +**1. Certify the oracle, issue tUSDT and start the service.** `certify` signs a 30-day +certificate for the oracle key with the cold key. `IssueUSDT` writes +`issue_usdt/txmanifest.instance.1.json`; its `USD_ASSET` is the new asset id. +`CreateTreasury` asks for it, for the amount to lock (`SUPPLY`; lock all of it, by default +100000000000000), and for `COLD_PUBKEY`, which `fake_oracle.py cold-keygen` printed when +the key was made: `b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3` for +the committed one. The window, tolerance and `MAX_FUTURE_SECONDS` have defaults. + +```sh +python3 fake_oracle.py certify --valid-from $(( $(date +%s) - 7200 )) --out cert.json +$TXM run issue_usdt/txmanifest.json IssueUSDT --wallet $W +$TXM sync --wallet $W +$TXM run txmanifest.json CreateTreasury --wallet $W +``` + +`CreateTreasury` writes `txmanifest.instance.1.json` and `txmanifest.state.1.json`. Every +later action takes the instance and the newest state file, and writes the next state file. + +**2. An honest mint.** Sign a statement at the current price, request, wait out the window, +finalize. `--recipient` is the address that receives the tUSDT, or the refund. Use one +from `$TXM info --wallet $W`. + +```sh +python3 fake_oracle.py sign --price 84231.50 --out now.json +python3 fake_oracle.py request-params now.json --cert cert.json --lbtc-amount 5000 --recipient
--out request.json + +$TXM run txmanifest.json RequestMint --wallet $W --params request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.1.json + +# If the node refuses the request as non-final, the chain's median time hasn't caught up +# with the statement yet; run it again in a minute. + +# Finalize before 5 blocks have passed and the node refuses it: non-BIP68-final. +$TXM run txmanifest.json Finalize --wallet $W --params request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.2.json +``` + +**3. A stale price, challenged.** Request with a statement from an hour ago, when the price +was higher, then challenge it with a current one. The challenge needs no wait, and any +wallet can send it. + +```sh +python3 fake_oracle.py sign --price 95000.00 --timestamp $(( $(date +%s) - 3600 )) --out stale.json +python3 fake_oracle.py request-params stale.json --cert cert.json --lbtc-amount 5000 --recipient
--out stale-request.json +$TXM run txmanifest.json RequestMint --wallet $W --params stale-request.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.3.json + +python3 fake_oracle.py sign --price 84231.50 --out now.json +python3 fake_oracle.py challenge-params stale-request.json now.json --cert cert.json --out challenge.json +$TXM run txmanifest.json Challenge --wallet $W --params challenge.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.4.json +``` + +A challenge with a statement within the tolerance (0.5% by default), or one no newer than +the request's, fails the covenant's dry run and is never broadcast. + +**4. A second oracle.** Make another oracle key and certify it. Its statements are as good +as the first oracle's, with no change to the service: here it challenges a request the +first oracle signed. + +```sh +python3 fake_oracle.py keygen --out oracle_b.json +python3 fake_oracle.py certify --oracle-key oracle_b.json --out cert_b.json + +python3 fake_oracle.py sign --price 84231.50 --out now.json +python3 fake_oracle.py request-params now.json --cert cert.json --lbtc-amount 5000 --recipient
--out request2.json +$TXM run txmanifest.json RequestMint --wallet $W --params request2.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.5.json + +python3 fake_oracle.py sign --key oracle_b.json --price 85500.00 --out b.json +python3 fake_oracle.py challenge-params request2.json b.json --cert cert_b.json --out challenge_b.json +$TXM run txmanifest.json Challenge --wallet $W --params challenge_b.json \ + --instance txmanifest.instance.1.json --state txmanifest.state.6.json +``` + +### A run on Liquid testnet + +| Step | Transaction | +|------|-------------| +| `IssueUSDT` | [`e1578ccc…f2df`](https://liquid.network/testnet/tx/e1578ccc5fd520edfab33293751b7d9d908ce6524a3cb9686102b4a64e6df2df) | +| `CreateTreasury` | [`fff533b8…16fb`](https://liquid.network/testnet/tx/fff533b895423ce1113022afa1f7592fb6c2fdc808243fd286afd39fdb0e16fb) | +| `RequestMint` at 84231.50 | [`0986f107…8480`](https://liquid.network/testnet/tx/0986f107a58912e0b95af1b811ce8332db066d35100221fae75e508832478480) | +| `RequestMint` with a statement dated an hour ahead | refused by the node: `non-final` | +| `Finalize`: 4.211575 tUSDT paid, 5,000 sat burned | [`73a7b3e0…0005`](https://liquid.network/testnet/tx/73a7b3e0b7b73b6c894f0f6de11a7a39e695e01203b22800b0b3dba512010005) | +| `RequestMint` at a stale 95000.00 | [`688a7913…0956`](https://liquid.network/testnet/tx/688a79138a45dd8fcd9546698b6cf9027bf4ba2cc01431fae69ea9a4e4fd0956) | +| `Challenge` at 84231.50: 2,000 sat refunded, 1.9 tUSDT back to the treasury | [`048e1905…0302`](https://liquid.network/testnet/tx/048e1905264c97bcfc8323d4c19bbbf56b305bc2c71769a70856c57ed9a50302) | +| `RequestMint` at 84231.50, signed by the first oracle | [`0aef98c6…4bd6`](https://liquid.network/testnet/tx/0aef98c62bbde15501146808b99852878723e185603e75a990d0a9b22a394bd6) | +| `Challenge` at 85500.00, signed by a second oracle certified after deployment | [`cf642a34…4a2a`](https://liquid.network/testnet/tx/cf642a343b7ad5fc080a27cf6f2dfbdb05bf567c534b16cfd1fc0fea53fb4a2a) | + +## Limitations + +- **A request can be challenged until it is finalized, not only during the window.** A + program can't require that a height has *not* been reached. If the price moves past the + tolerance after the window but before `Finalize`, an honest request can still be + cancelled. The user loses only fees, and finalizing promptly avoids it. +- **A leaked oracle key can still cancel honest requests** with forged newer statements, + until its certificate expires. Users lose only fees. Its forged requests can always be + challenged, as long as an honest oracle is publishing and someone is watching. +- **The cold key is trusted completely.** Whoever holds it can certify their own oracle. + It is used rarely and can be a committee, but there is no check beyond its signature. +- **One statement per request.** There is no quorum of oracles and no reward for a + challenger. +- **Requests run one at a time per state file.** When a state file holds several pending + requests, the wallet takes the first one it finds; pass `--input` to choose another. +- **Only `BTCUSD` with two decimals.** The program fixes `v1|PRICE|BTCUSD|…|USD|2|`, and + the price must be printed with exactly two fraction digits. +- **Budget padding.** Checking a statement costs more execution budget than the + statement's bytes buy, so `RequestMint` and `Challenge` pad the witness with zero words + (`BUDGET_PAD`). Liquid doesn't relay the annex that would otherwise pad it. If `run` + reports that the program's cost exceeds its budget, add more zeros. + +## Tests + +`cargo test -p tx-manifest-lib --test oracle_demo` runs every spending path on the +Simplicity interpreter against transactions it builds, with the statements in `data/`. +It covers forged prices, a `|` hidden in a text field, a finalize before the window, a +challenge within the tolerance or with an older statement, a second treasury UTXO spent +alongside a request, and the execution budget of each path. For certificates: a statement +outside its certificate's range, a certificate from another key, for another oracle, or +with its range altered, and a challenge from an oracle certified after the request. And +for the lock time: one that lets a statement be dated further ahead, none at all, a +height, and a lock time with every input final. The +statements in `data/` carry the certificate `fake_oracle.py` made, so the program checks the +script's BIP340 signer. diff --git a/examples/oracle_demo/data/cert_oracle.json b/examples/oracle_demo/data/cert_oracle.json new file mode 100644 index 0000000..608d336 --- /dev/null +++ b/examples/oracle_demo/data/cert_oracle.json @@ -0,0 +1,9 @@ +{ + "oracle_pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "valid_from": 1741500000, + "valid_until": 1744092000, + "cold_pubkey": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3", + "signature": "80a1dc208359b19ae77531b66e119114fee0f371ae4dbeea4afeb56515228035424a7ad4893d90c1b440f289e1ec4699b6f775b2815f86594a92d63bfa715b7e", + "scheme": "bip340", + "tag": "oracle-demo/oracle-cert/v1" +} diff --git a/examples/oracle_demo/data/example_cold_key.json b/examples/oracle_demo/data/example_cold_key.json new file mode 100644 index 0000000..9c3a364 --- /dev/null +++ b/examples/oracle_demo/data/example_cold_key.json @@ -0,0 +1,5 @@ +{ + "note": "PUBLIC TEST KEY: the oracle_demo example's cold key, which certifies oracle keys. Anyone can sign certificates with it. Testnet only: never use it for anything of value.", + "secret": "d7fd3aa5d52bb980d68d39644838c33328945f7709e6808da661f1a928aec33b", + "pubkey": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3" +} diff --git a/examples/oracle_demo/data/example_oracle_key.json b/examples/oracle_demo/data/example_oracle_key.json new file mode 100644 index 0000000..ca854d7 --- /dev/null +++ b/examples/oracle_demo/data/example_oracle_key.json @@ -0,0 +1,5 @@ +{ + "note": "PUBLIC TEST KEY for the oracle_demo example's fake oracle. Anyone can sign statements with it. Testnet only: never use it for anything of value.", + "secret": "6b9370fd63ad6037c3a85ddb22bcd0d871eff4a4a27496e453f606c48cffe089", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0" +} diff --git a/examples/oracle_demo/data/statement_close.json b/examples/oracle_demo/data/statement_close.json new file mode 100644 index 0000000..6242cb9 --- /dev/null +++ b/examples/oracle_demo/data/statement_close.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84400.00|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482913", + "signature": "MEUCIQChHZN0Sfnv/XrRX/yErXbh7LQclZhx4wXfXtvg6/WcZwIgcQZ/bVYZ1qGa/Bm2UwALaEi9hCVTcTcofAk1+mse30U=", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_fell.json b/examples/oracle_demo/data/statement_fell.json new file mode 100644 index 0000000..a9d0da4 --- /dev/null +++ b/examples/oracle_demo/data/statement_fell.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|83000.00|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482912", + "signature": "MEQCIEWtechNGhh28F7Ho6vFNKO7BrJLvCbSCl4LJK8PsPO7AiBhn3OBV/fcs4Q4xWG2evXGwp9ysIpzeY/eP4+CuioYiQ==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_older.json b/examples/oracle_demo/data/statement_older.json new file mode 100644 index 0000000..eb88261 --- /dev/null +++ b/examples/oracle_demo/data/statement_older.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|85231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514100|482909", + "signature": "MEUCIQDTrgQfPwAPn33sZkQom4ql2f8twwg+RPZL2FSP8RqFPwIgYCkdTMNajEcD9wERCgC+8cL5i471YpLT5cR84iOafnQ=", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_request.json b/examples/oracle_demo/data/statement_request.json new file mode 100644 index 0000000..88b4d0c --- /dev/null +++ b/examples/oracle_demo/data/statement_request.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910", + "signature": "MEQCICzgChRNZZFqQ4VF1/TluMyqneOcwBfPnSviptH2X9QgAiA/GR4IyZIIALG76QjUwiqcdUKfVm69Y0+bqvzhiWyNqw==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/data/statement_rose.json b/examples/oracle_demo/data/statement_rose.json new file mode 100644 index 0000000..63ebe2d --- /dev/null +++ b/examples/oracle_demo/data/statement_rose.json @@ -0,0 +1,7 @@ +{ + "domain": "BTCUSD", + "canonical": "v1|PRICE|BTCUSD|85231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514700|482911", + "signature": "MEQCIE5S2ynJq7uSksklsV581IJslQDeEGWKrxQfUKuSH3IvAiBQ/vhiMMjWJy2JG6o61Iki+4WKu+ccge2iFGxevM9lVQ==", + "pubkey": "02917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0", + "signing_scheme": "secp256k1_ecdsa" +} diff --git a/examples/oracle_demo/fake_oracle.py b/examples/oracle_demo/fake_oracle.py new file mode 100644 index 0000000..493eda6 --- /dev/null +++ b/examples/oracle_demo/fake_oracle.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""A stand-in for the Mycelia Signal price oracle, for testnet demos only. + +It signs BTC/USD price statements in Mycelia Signal's exact format (its docs/DEMO.md): +a pipe-separated canonical string, signed with secp256k1 ECDSA over sha256(canonical), +returned as the same JSON the paid API endpoint returns. The key is a local test key, +so nothing here is a real price. + +It also plays the cold key that certifies oracle keys: a BIP340 signature over the +oracle's compressed pubkey and the range of statement timestamps it may sign. The +oracle doesn't take part, so a real oracle's published key can be certified the same way. + +And it turns a statement and its certificate into the --params file for an oracle_demo +action. The covenant cannot split a string, so the canonical string is handed to it +already cut into its fields, each as a SimplicityHL literal. + +Needs the `ecdsa` package (pip install ecdsa). +""" + +import argparse +import base64 +import hashlib +import json +import sys +import time +from decimal import Decimal, InvalidOperation +from pathlib import Path + +from ecdsa import SECP256k1, SigningKey, VerifyingKey +from ecdsa.util import sigdecode_der, sigencode_der + +DOMAIN = "BTCUSD" +QUOTE = "USD" +DECIMALS = 2 +DEFAULT_SOURCES = "binance,bitstamp,coinbase,kraken,okx" +DEFAULT_METHOD = "median" + +# Bounds of the program's witness lists. A `List` holds at most N - 1 elements. +MAX_PRICE_DIGITS = 15 +MAX_SOURCES_LEN = 127 +MAX_METHOD_LEN = 15 +MAX_TIMESTAMP_DIGITS = 15 +MAX_NONCE_LEN = 31 + +N = SECP256k1.order + +# Written into every key file, so nobody mistakes one for a real key. +TEST_KEY_NOTE = "Test key made by fake_oracle.py. Testnet only: never use it for anything of value." + +CERT_TAG = "oracle-demo/oracle-cert/v1" + + +# --------------------------------------------------------------------------- +# BIP340 Schnorr, after the BIP's reference implementation. `ecdsa` has no Schnorr. +# --------------------------------------------------------------------------- + +P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +G = ( + 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, +) + + +def tagged_hash(tag, msg): + tag_hash = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(tag_hash + tag_hash + msg).digest() + + +def point_add(p1, p2): + if p1 is None: + return p2 + if p2 is None: + return p1 + if p1[0] == p2[0] and p1[1] != p2[1]: + return None + if p1 == p2: + lam = 3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P) % P + else: + lam = (p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P) % P + x3 = (lam * lam - p1[0] - p2[0]) % P + return (x3, (lam * (p1[0] - x3) - p1[1]) % P) + + +def point_mul(point, n): + result = None + for i in range(256): + if (n >> i) & 1: + result = point_add(result, point) + point = point_add(point, point) + return result + + +def lift_x(x): + if x >= P: + return None + y_sq = (pow(x, 3, P) + 7) % P + y = pow(y_sq, (P + 1) // 4, P) + if pow(y, 2, P) != y_sq: + return None + return (x, y if y % 2 == 0 else P - y) + + +def int_from(b): + return int.from_bytes(b, "big") + + +def bytes_from(n): + return n.to_bytes(32, "big") + + +def schnorr_pubkey(secret): + return bytes_from(point_mul(G, int_from(secret))[0]) + + +def schnorr_verify(msg, pubkey, sig): + point = lift_x(int_from(pubkey)) + r, s = int_from(sig[:32]), int_from(sig[32:]) + if point is None or r >= P or s >= N: + return False + e = int_from(tagged_hash("BIP0340/challenge", sig[:32] + pubkey + msg)) % N + R = point_add(point_mul(G, s), point_mul(point, N - e)) + return R is not None and R[1] % 2 == 0 and R[0] == r + + +def schnorr_sign(msg, secret): + # Zero auxiliary randomness keeps signatures, and so the test fixtures, deterministic. + d0 = int_from(secret) + point = point_mul(G, d0) + d = d0 if point[1] % 2 == 0 else N - d0 + t = bytes_from(d ^ int_from(tagged_hash("BIP0340/aux", bytes(32)))) + k0 = int_from(tagged_hash("BIP0340/nonce", t + bytes_from(point[0]) + msg)) % N + R = point_mul(G, k0) + k = k0 if R[1] % 2 == 0 else N - k0 + e = int_from(tagged_hash("BIP0340/challenge", bytes_from(R[0]) + bytes_from(point[0]) + msg)) % N + sig = bytes_from(R[0]) + bytes_from((k + e * d) % N) + assert schnorr_verify(msg, bytes_from(point[0]), sig) + return sig + + +# --------------------------------------------------------------------------- +# Certificates +# --------------------------------------------------------------------------- + + +def cert_message(oracle_pubkey, valid_from, valid_until): + body = bytes.fromhex(oracle_pubkey) + valid_from.to_bytes(8, "big") + valid_until.to_bytes(8, "big") + return tagged_hash(CERT_TAG, body) + + +def verify_cert(cert): + msg = cert_message(cert["oracle_pubkey"], cert["valid_from"], cert["valid_until"]) + return schnorr_verify(msg, bytes.fromhex(cert["cold_pubkey"]), bytes.fromhex(cert["signature"])) + + +def load_key(path): + data = json.loads(Path(path).read_text()) + return SigningKey.from_string(bytes.fromhex(data["secret"]), curve=SECP256k1) + + +def compressed_pubkey(vk): + return vk.to_string("compressed").hex() + + +def canonical_string(price, timestamp, nonce, sources, method): + return "|".join( + ["v1", "PRICE", DOMAIN, price, QUOTE, str(DECIMALS), sources, method, str(timestamp), str(nonce)] + ) + + +def format_price(raw): + try: + value = Decimal(raw) + except InvalidOperation: + sys.exit(f"not a price: {raw!r}") + if value <= 0: + sys.exit("price must be positive") + return f"{value.quantize(Decimal(1).scaleb(-DECIMALS))}" + + +def sign(sk, canonical): + digest = hashlib.sha256(canonical.encode()).digest() + sig = sk.sign_digest_deterministic(digest, hashfunc=hashlib.sha256, sigencode=sigencode_der) + r, s = sigdecode_der(sig, N) + # libsecp256k1, which the DEMO.md check uses through coincurve, rejects high-S. + if s > N // 2: + sig = sigencode_der(r, N - s, N) + return base64.b64encode(sig).decode() + + +def verify(statement): + """The check from Mycelia Signal's DEMO.md, step 4.""" + canonical = statement["canonical"] + msg_hash = hashlib.sha256(canonical.encode()).digest() + vk = VerifyingKey.from_string(bytes.fromhex(statement["pubkey"]), curve=SECP256k1) + sig = base64.b64decode(statement["signature"]) + _, s = sigdecode_der(sig, N) + if s > N // 2: + return False + try: + return vk.verify_digest(sig, msg_hash, sigdecode=sigdecode_der) + except Exception: + return False + + +def parse_canonical(canonical): + fields = canonical.split("|") + if len(fields) != 10: + sys.exit(f"expected 10 fields in the canonical string, found {len(fields)}") + version, kind, domain, price, quote, decimals, sources, method, timestamp, nonce = fields + expected = ("v1", "PRICE", DOMAIN, QUOTE, str(DECIMALS)) + if (version, kind, domain, quote, decimals) != expected: + sys.exit(f"the covenant only accepts v1|PRICE|{DOMAIN}||{QUOTE}|{DECIMALS}|…") + integer, dot, fraction = price.partition(".") + if not dot or len(fraction) != DECIMALS or not (integer + fraction).isdigit(): + sys.exit(f"price {price!r} is not .<{DECIMALS} digits>") + if not timestamp.isdigit(): + sys.exit(f"timestamp {timestamp!r} is not decimal digits") + for name, value, limit in [ + ("price", integer, MAX_PRICE_DIGITS), + ("sources", sources, MAX_SOURCES_LEN), + ("method", method, MAX_METHOD_LEN), + ("timestamp", timestamp, MAX_TIMESTAMP_DIGITS), + ("nonce", nonce, MAX_NONCE_LEN), + ]: + if len(value) > limit: + sys.exit(f"{name} is {len(value)} bytes; the covenant takes at most {limit}") + return { + "price_int": integer, + "price_frac": fraction, + "price_cents": int(integer + fraction), + "sources": sources, + "method": method, + "timestamp": int(timestamp), + "timestamp_digits": timestamp, + "nonce": nonce, + } + + +def simf_list(text): + return "list![" + ", ".join(str(b) for b in text.encode()) + "]" + + +def statement_params(statement, cert): + """The STATEMENT_* and CERT_* witness params for one signed statement.""" + if not verify(statement): + sys.exit("the statement's signature does not verify") + if not verify_cert(cert): + sys.exit("the cold key did not sign this certificate") + if cert["oracle_pubkey"] != statement["pubkey"]: + sys.exit("the certificate is for a different oracle key than the one that signed the statement") + fields = parse_canonical(statement["canonical"]) + if not cert["valid_from"] <= fields["timestamp"] <= cert["valid_until"]: + sys.exit("the statement's timestamp is outside the certificate's range") + r, s = sigdecode_der(base64.b64decode(statement["signature"]), N) + frac = fields["price_frac"].encode() + key = cert["oracle_pubkey"] + return fields, { + "STATEMENT_PRICE_INT": simf_list(fields["price_int"]), + "STATEMENT_PRICE_FRAC": f"({frac[0]}, {frac[1]})", + "STATEMENT_SOURCES": simf_list(fields["sources"]), + "STATEMENT_METHOD": simf_list(fields["method"]), + "STATEMENT_TIMESTAMP": simf_list(fields["timestamp_digits"]), + "STATEMENT_NONCE": simf_list(fields["nonce"]), + "STATEMENT_SIG": f"(0x{r:064x}, 0x{s:064x})", + "CERT_ORACLE_KEY": f"({int(key[:2], 16)}, 0x{key[2:]})", + "CERT_VALID_FROM": str(cert["valid_from"]), + "CERT_VALID_UNTIL": str(cert["valid_until"]), + "CERT_SIG": "0x" + cert["signature"], + } + + +def load_json(path): + return json.loads(Path(path).read_text()) + + +def write_json(obj, out): + text = json.dumps(obj, indent=2) + "\n" + if out: + Path(out).write_text(text) + print(f"wrote {out}", file=sys.stderr) + else: + sys.stdout.write(text) + + +def cmd_keygen(args): + if Path(args.out).exists() and not args.force: + sys.exit(f"{args.out} exists; pass --force to replace it") + sk = SigningKey.generate(curve=SECP256k1) + pubkey = compressed_pubkey(sk.get_verifying_key()) + write_json({"note": TEST_KEY_NOTE, "secret": sk.to_string().hex(), "pubkey": pubkey}, args.out) + print(f"oracle pubkey: {pubkey}; certify it with `certify`", file=sys.stderr) + + +def cmd_cold_keygen(args): + if Path(args.out).exists() and not args.force: + sys.exit(f"{args.out} exists; pass --force to replace it") + secret = SigningKey.generate(curve=SECP256k1).to_string() + pubkey = schnorr_pubkey(secret).hex() + write_json({"note": TEST_KEY_NOTE, "secret": secret.hex(), "pubkey": pubkey}, args.out) + print(f"COLD_PUBKEY={pubkey}", file=sys.stderr) + + +def cmd_certify(args): + cold = json.loads(Path(args.cold_key).read_text()) + oracle_pubkey = args.oracle_pubkey + if oracle_pubkey is None: + oracle_pubkey = compressed_pubkey(load_key(args.oracle_key).get_verifying_key()) + if len(bytes.fromhex(oracle_pubkey)) != 33 or oracle_pubkey[:2] not in ("02", "03"): + sys.exit("--oracle-pubkey must be a 33-byte compressed key") + valid_from = args.valid_from if args.valid_from is not None else int(time.time()) + valid_until = args.valid_until if args.valid_until is not None else valid_from + args.days * 86400 + if valid_until < valid_from: + sys.exit("--valid-until is before --valid-from") + msg = cert_message(oracle_pubkey, valid_from, valid_until) + cert = { + "oracle_pubkey": oracle_pubkey, + "valid_from": valid_from, + "valid_until": valid_until, + "cold_pubkey": cold["pubkey"], + "signature": schnorr_sign(msg, bytes.fromhex(cold["secret"])).hex(), + "scheme": "bip340", + "tag": CERT_TAG, + } + assert verify_cert(cert) + write_json(cert, args.out) + + +def cmd_verify_cert(args): + cert = json.loads(Path(args.cert).read_text()) + if not verify_cert(cert): + sys.exit("INVALID: the cold key did not sign this certificate") + print(f"valid: {cert['oracle_pubkey']} may sign statements from {cert['valid_from']} to {cert['valid_until']}") + + +def cmd_sign(args): + sk = load_key(args.key) + timestamp = args.timestamp if args.timestamp is not None else int(time.time()) + nonce = args.nonce if args.nonce is not None else timestamp % 1000000 + canonical = canonical_string(format_price(args.price), timestamp, nonce, args.sources, args.method) + statement = { + "domain": DOMAIN, + "canonical": canonical, + "signature": sign(sk, canonical), + "pubkey": compressed_pubkey(sk.get_verifying_key()), + "signing_scheme": "secp256k1_ecdsa", + } + assert verify(statement) + write_json(statement, args.out) + + +def cmd_verify(args): + statement = json.loads(Path(args.statement).read_text()) + if not verify(statement): + sys.exit("INVALID: the signature does not verify") + fields = parse_canonical(statement["canonical"]) + print(f"valid: {DOMAIN} {fields['price_cents'] / 100:.2f} at {fields['timestamp']}") + + +def cmd_request_params(args): + statement = load_json(args.statement) + fields, params = statement_params(statement, load_json(args.cert)) + params.update( + { + "REQUEST_PRICE": str(fields["price_cents"]), + "REQUEST_TIMESTAMP": str(fields["timestamp"]), + "REQUEST_LBTC_AMOUNT": str(args.lbtc_amount), + "REQUEST_RECIPIENT": args.recipient, + } + ) + write_json(params, args.out) + + +def cmd_challenge_params(args): + request = load_json(args.request) + statement = load_json(args.statement) + fields, params = statement_params(statement, load_json(args.cert)) + if fields["timestamp"] <= int(request["REQUEST_TIMESTAMP"]): + print("warning: this statement is not newer than the request, so the covenant will refuse it", file=sys.stderr) + params.update({k: v for k, v in request.items() if k.startswith("REQUEST_")}) + write_json(params, args.out) + + +def cmd_statement_params(args): + fields, params = statement_params(load_json(args.statement), load_json(args.cert)) + if args.claim: + params.update({"CLAIM_PRICE": str(fields["price_cents"]), "CLAIM_TIMESTAMP": str(fields["timestamp"])}) + for path in args.merge or []: + params.update({k: v for k, v in load_json(path).items() if not k.startswith(("STATEMENT_", "CERT_"))}) + write_json(params, args.out) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("keygen", help="create the test oracle key") + p.add_argument("--out", default="data/example_oracle_key.json") + p.add_argument("--force", action="store_true") + p.set_defaults(func=cmd_keygen) + + p = sub.add_parser("sign", help="sign a BTC/USD price statement") + p.add_argument("--key", default="data/example_oracle_key.json") + p.add_argument("--price", required=True, help="BTC/USD price, e.g. 84231.50") + p.add_argument("--timestamp", type=int, help="unix seconds (default: now)") + p.add_argument("--nonce", type=int) + p.add_argument("--sources", default=DEFAULT_SOURCES) + p.add_argument("--method", default=DEFAULT_METHOD) + p.add_argument("--out") + p.set_defaults(func=cmd_sign) + + p = sub.add_parser("verify", help="check a statement the way DEMO.md does") + p.add_argument("statement") + p.set_defaults(func=cmd_verify) + + p = sub.add_parser("cold-keygen", help="create the test cold key, which certifies oracle keys") + p.add_argument("--out", default="data/example_cold_key.json") + p.add_argument("--force", action="store_true") + p.set_defaults(func=cmd_cold_keygen) + + p = sub.add_parser("certify", help="certify an oracle key for a range of statement timestamps") + p.add_argument("--cold-key", default="data/example_cold_key.json") + p.add_argument("--oracle-key", default="data/example_oracle_key.json", help="key file to certify") + p.add_argument("--oracle-pubkey", help="compressed pubkey to certify, instead of --oracle-key") + p.add_argument("--valid-from", type=int, help="unix seconds (default: now)") + p.add_argument("--valid-until", type=int, help="unix seconds (default: --days after --valid-from)") + p.add_argument("--days", type=int, default=30) + p.add_argument("--out") + p.set_defaults(func=cmd_certify) + + p = sub.add_parser("verify-cert", help="check the cold key signed a certificate") + p.add_argument("cert") + p.set_defaults(func=cmd_verify_cert) + + p = sub.add_parser("request-params", help="params for RequestMint (and later Finalize)") + p.add_argument("statement") + p.add_argument("--cert", required=True, help="the certificate for the key that signed the statement") + p.add_argument("--lbtc-amount", type=int, required=True, help="L-BTC to burn, in satoshis") + p.add_argument("--recipient", required=True, help="address that receives the tUSDT (or the refund)") + p.add_argument("--out") + p.set_defaults(func=cmd_request_params) + + p = sub.add_parser("challenge-params", help="params for Challenge") + p.add_argument("request", help="the request's params file") + p.add_argument("statement", help="a newer statement whose price differs") + p.add_argument("--cert", required=True, help="the certificate for the key that signed it") + p.add_argument("--out") + p.set_defaults(func=cmd_challenge_params) + + p = sub.add_parser("statement-params", help="params for a lending_v4 liquidation action") + p.add_argument("statement") + p.add_argument("--cert", required=True, help="the certificate for the key that signed the statement") + p.add_argument("--claim", action="store_true", help="set CLAIM_PRICE and CLAIM_TIMESTAMP from it (StartLiquidation)") + p.add_argument("--merge", action="append", help="copy the other params from this file, e.g. the claim (CancelLiquidation)") + p.add_argument("--out") + p.set_defaults(func=cmd_statement_params) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/examples/oracle_demo/issue_usdt/txmanifest.json b/examples/oracle_demo/issue_usdt/txmanifest.json new file mode 100644 index 0000000..5c9f01a --- /dev/null +++ b/examples/oracle_demo/issue_usdt/txmanifest.json @@ -0,0 +1,68 @@ +{ + "$schema": "../../../schema/txmanifest.schema.json", + "$comment": "Separate from the mint service because the service's addresses depend on the asset id, and the id only exists once this transaction has picked the outpoint it is derived from: the engine fixes compile params before it resolves inputs, so one action cannot issue an asset and pay it to an address derived from it.", + "manifest_version": "0.3.1", + "protocol": "oracle-demo-issue-tusdt", + "description": "Issue tUSDT, the testnet stablecoin the oracle_demo mint service sells: a fixed supply with no reissuance token, paid to your wallet. Lock it into a treasury with ../txmanifest.json CreateTreasury.", + "chain": "liquid", + "contract_templates": { + "tusdt": { + "description": "One tUSDT asset. The instance file records its id, which CreateTreasury takes as USD_ASSET.", + "fields": { + "USD_ASSET": { "type": "liquid.asset_id" }, + "SUPPLY": { "type": "u64" } + }, + "actions": { + "IssueUSDT": { + "description": "Issue the whole tUSDT supply to your wallet and write the instance file recording its asset id.", + "intent": "issue {params.SUPPLY} base units of a new tUSDT asset", + "params": { + "SUPPLY": { + "type": "u64", + "default": "100000000000000", + "description": "tUSDT to issue, in base units. 8 decimal places, like L-BTC, so the default is 1,000,000 tUSDT." + } + }, + "inputs": [ + { + "id": "issuer_in", + "description": "Wallet L-BTC UTXO whose outpoint defines the asset id. It also pays the fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "params.SUPPLY", + "inflation_amount_sat": 0 + }, + "ui": { "label": "input that defines the tUSDT asset", "role": "issuance" } + } + ], + "outputs": [ + { + "id": "usd_out", + "description": "The tUSDT supply, to your wallet.", + "destination": "wallet", + "asset": "inputs.issuer_in.issued_asset", + "amount_sat": "params.SUPPLY", + "ui": { "label": "the new tUSDT", "role": "issuance" } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the issuing input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { "label": "change returned to you", "role": "change" } + } + ], + "create_instance": { + "fields": { + "USD_ASSET": "$inputs.issuer_in.issued_asset", + "SUPPLY": "$params.SUPPLY" + } + } + } + } + } + } +} diff --git a/examples/oracle_demo/oracle_mint.simf b/examples/oracle_demo/oracle_mint.simf new file mode 100644 index 0000000..f09a50d --- /dev/null +++ b/examples/oracle_demo/oracle_mint.simf @@ -0,0 +1,512 @@ +/* + * ORACLE MINT + * + * Burn L-BTC for tUSDT at the current BTC/USD price, where "current" is enforced by a + * challenge window rather than by the chain. + * + * A Simplicity program cannot tell whether an oracle statement is recent. So a mint takes + * two steps. A request presents a signed statement and reserves the tUSDT it is worth. + * Then, for WINDOW_BLOCKS, anyone holding a newer statement whose price differs by more + * than TOLERANCE_BPS can cancel it: the L-BTC is refunded and the tUSDT goes back to the + * treasury. Once the window has passed, anyone can finalize it: the tUSDT goes to the + * recipient and the L-BTC is burned. + * + * One program, two states, told apart by a tapdata leaf beside the program leaf: + * + * treasury tapdata(0x00) holds the tUSDT supply + * pending tapdata(0x01 || price || timestamp || lbtc_amount || recipient) + * one address per request, holding two UTXOs: the L-BTC (always input 0 + * when spent) and the reserved tUSDT (always input 1) + * + * Oracle statements are Mycelia Signal's canonical strings, ECDSA-signed over their + * sha256: + * + * v1|PRICE|BTCUSD|84231.50|USD|2|binance,bitstamp,coinbase,kraken,okx|median|1741514400|482910 + * + * The witness carries the string already split into its variable fields, and the program + * hashes it back together. Number fields must be all digits and text fields must contain + * no '|', so a signed string can only be read one way. + * + * The oracle's key is not fixed here. COLD_PUBKEY, which can be a committee's aggregate + * key, signs certificates that let an oracle key sign statements timestamped within a + * range, and each statement comes with one. A new or rotated oracle needs a new + * certificate, not a new program. + * + * A request's statement may not be dated more than MAX_FUTURE_SECONDS past the chain's + * median time, enforced through the transaction's lock time, so even a leaked oracle key + * can't sign a request that no honest statement is newer than. + */ + +// ============================================================================ +// Boolean helpers (from simplicityhl-std, lib/binary.simf) +// ============================================================================ + +fn not(bit: bool) -> bool { + ::into(jet::complement_1(::into(bit))) +} + +fn or(a: bool, b: bool) -> bool { + ::into(jet::or_1(::into(a), ::into(b))) +} + +fn and(a: bool, b: bool) -> bool { + ::into(jet::and_1(::into(a), ::into(b))) +} + +// ============================================================================ +// ECDSA (verbatim from lib/secp256k1/ecdsa.simf, a proposed addition to simplicityhl-std) +// +// Copied because this wallet compiles each program from a single file. +// ============================================================================ + +/// Check that a signature component is a canonical scalar in `[1, n - 1]`. +/// +/// The scalar jets reduce their inputs modulo `n`, so without this check `s + n` would +/// verify wherever `s` does. +fn is_signature_scalar(a: Scalar) -> bool { + and(jet::eq_256(jet::scalar_normalize(a), a), not(jet::scalar_is_zero(a))) +} + +/// Decompress a public key, rejecting an x-coordinate that is not reduced modulo `p`. +/// +/// `jet::decompress` reduces the x-coordinate itself, so without this check `x + p` would +/// decode to the same key as `x`. +fn decompress_public_key(pk: Point) -> Option { + let (_, x): (u1, Fe) = pk; + + match jet::eq_256(jet::fe_normalize(x), x) { + true => jet::decompress(pk), + false => None, + } +} + +/// Check an ECDSA signature `(r, s)` over the message digest `msg` against the compressed +/// public key `pk`, as in SEC 1 v2.0, section 4.1.4. +/// +/// Like SEC 1, and unlike Bitcoin's low-S rule, this accepts both `(r, s)` and `(r, n - s)`. +pub fn ecdsa_is_valid(pk: Point, msg: Message, sig: (Scalar, Scalar)) -> bool { + let (r, s): (Scalar, Scalar) = sig; + + match decompress_public_key(pk) { + Some(q: Ge) => { + // The scalar jets reduce `msg` modulo `n`. If `s = 0` then `w = 0`, and the range + // check below rejects the signature. + let w: Scalar = jet::scalar_invert(s); + let u1: Scalar = jet::scalar_multiply(msg, w); + let u2: Scalar = jet::scalar_multiply(r, w); + + // R = u1 * G + u2 * Q + let q: Gej = (q, 1); + let big_r: Gej = jet::linear_combination_1((u2, q), u1); + + let r_matches: bool = match jet::gej_normalize(big_r) { + Some(big_r: Ge) => { + let (x, _): (Fe, Fe) = big_r; + // x < p < 2n, so reducing once gives x mod n. + jet::eq_256(jet::scalar_normalize(x), r) + }, + None => false, + }; + + and(and(is_signature_scalar(r), is_signature_scalar(s)), r_matches) + }, + None => false, + } +} + +/// Assert that `(r, s)` is a valid ECDSA signature over the message digest `msg` for the +/// compressed public key `pk`. +pub fn ecdsa_verify(pk: Point, msg: Message, sig: (Scalar, Scalar)) { + assert!(ecdsa_is_valid(pk, msg, sig)); +} + +// ============================================================================ +// Arithmetic helpers +// ============================================================================ + +fn safe_multiply(a: u64, b: u64) -> u64 { + let (high, low): (u64, u64) = ::into(jet::multiply_64(a, b)); + assert!(jet::is_zero_64(high)); + low +} + +fn safe_add(a: u64, b: u64) -> u64 { + let (carry, sum): (bool, u64) = jet::add_64(a, b); + assert!(not(carry)); + sum +} + +fn safe_subtract(a: u64, b: u64) -> u64 { + let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); + assert!(not(borrow)); + diff +} + +/// tUSDT base units for `lbtc_amount` satoshis at `price_cents` US cents per BTC. +/// +/// Both assets have 8 decimal places, so this is `lbtc_amount * price_cents / 100`, +/// rounded down. +fn usd_for(lbtc_amount: u64, price_cents: u64) -> u64 { + let (usd, _): (u64, u64) = jet::div_mod_64(safe_multiply(lbtc_amount, price_cents), 100); + usd +} + +/// Whether `other` differs from `price` by more than TOLERANCE_BPS basis points of `price`. +fn differs_beyond_tolerance(price: u64, other: u64) -> bool { + let diff: u64 = match jet::lt_64(other, price) { + true => safe_subtract(price, other), + false => safe_subtract(other, price), + }; + jet::lt_64(safe_multiply(price, param::TOLERANCE_BPS), safe_multiply(diff, 10000)) +} + +// ============================================================================ +// Transaction helpers +// ============================================================================ + +fn get_input_script_hash(index: u32) -> u256 { + unwrap(jet::input_script_hash(index)) +} + +fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { + let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); + let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); + let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); + (asset_val, amount_val) +} + +fn ensure_input_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_input_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +fn ensure_output_script_hash_eq(index: u32, expected: u256) { + let actual: u256 = unwrap(jet::output_script_hash(index)); + assert!(jet::eq_256(actual, expected)); +} + +fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { + let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); + assert!(jet::eq_256(asset, expected_asset)); + assert!(jet::eq_64(amount, expected_amount)); +} + +/// sha256 of a bare `OP_RETURN` script, the burn output this wallet builds. +fn burn_script_hash() -> u256 { + 0x189f40034be7a199f1fa9891668ee3ab6049f82d38c68be70f596eab2e1857b7 +} + +/// Assert that `timestamp` is less than MAX_FUTURE_SECONDS past the chain's median time. +/// +/// A program can't read the time, but a transaction whose lock time is a time can only be +/// mined once the chain's median time past is beyond it. Demanding a lock time of at least +/// `timestamp - MAX_FUTURE_SECONDS` therefore bounds how far ahead of the chain a request's +/// statement can be dated. Without it, a leaked oracle key could date a request past every +/// honest statement, and nobody could challenge it until real time caught up. +fn ensure_not_from_the_future(timestamp: u64) { + let (high, timestamp): (u32, u32) = ::into(timestamp); + assert!(jet::is_zero_32(high)); + let (borrow, earliest): (bool, u32) = jet::subtract_32(timestamp, param::MAX_FUTURE_SECONDS); + assert!(not(borrow)); + jet::check_lock_time(earliest); +} + +/// Assert that WINDOW_BLOCKS have passed since this input was confirmed. +/// +/// This reads the input's own nSequence, which BIP68 enforces input by input. It avoids +/// `check_lock_distance`, which reads the largest lock across all inputs, so another +/// input's lock would satisfy it. +fn ensure_window_elapsed() { + assert!(jet::le_32(2, jet::version())); + let (flags, blocks): (u16, u16) = ::into(jet::current_sequence()); + // Bit 31 disables the lock; bit 22 would count it in 512-second units, not blocks. + assert!(jet::is_zero_16(jet::and_16(flags, 0x8040))); + assert!(jet::le_16(param::WINDOW_BLOCKS, blocks)); +} + +// ============================================================================ +// Addresses +// ============================================================================ + +fn covenant_nums_key() -> u256 { + 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 +} + +fn p2tr_script_hash(output_key: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); + jet::sha_256_ctx_8_finalize(ctx) +} + +/// The script hash of this program's address with `state_leaf` as its tapdata leaf. +fn script_hash_for_state(state_leaf: u256) -> u256 { + let tap_node: u256 = jet::build_tapbranch(jet::tapleaf_hash(), state_leaf); + p2tr_script_hash(jet::build_taptweak(covenant_nums_key(), tap_node)) +} + +fn treasury_script_hash() -> u256 { + let ctx: Ctx8 = jet::tapdata_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 0); + script_hash_for_state(jet::sha_256_ctx_8_finalize(ctx)) +} + +/// A mint request: price in US cents, oracle timestamp, L-BTC satoshis, and the sha256 of +/// the scriptPubKey that receives the tUSDT, or the refund. +type Request = (u64, u64, u64, u256); + +fn pending_script_hash(request: Request) -> u256 { + let (price, timestamp, lbtc_amount, recipient): Request = request; + let ctx: Ctx8 = jet::tapdata_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 1); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, price); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, timestamp); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, lbtc_amount); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, recipient); + script_hash_for_state(jet::sha_256_ctx_8_finalize(ctx)) +} + +// ============================================================================ +// Oracle statements +// ============================================================================ + +/// COLD_PUBKEY's word that an oracle key may sign statements timestamped from +/// `valid_from` to `valid_until`, inclusive: the key as its compressed prefix byte and +/// x-coordinate, the range, and the BIP340 signature. +type Certificate = ((u8, u256), u64, u64, Signature); + +/// A canonical string's variable fields: the price's integer digits, its two fraction +/// digits, sources, method, timestamp digits and nonce. Then the oracle's signature +/// `(r, s)`, and the certificate for its key. +type Statement = ( + List, + (u8, u8), + List, + List, + List, + List, + (Scalar, Scalar), + Certificate +); + +/// Check COLD_PUBKEY signed `cert`, and return the oracle key and the range it covers. +/// +/// The signed message is `tagged_hash("oracle-demo/oracle-cert/v1", key || from || until)` +/// with the key in its 33-byte compressed form, so no other signature by the cold key can +/// pass for a certificate. +fn verify_certificate(cert: Certificate) -> (Point, u64, u64) { + let (key, valid_from, valid_until, sig): Certificate = cert; + let (prefix, x): (u8, u256) = key; + assert!(or(jet::eq_8(prefix, 2), jet::eq_8(prefix, 3))); + + // sha256("oracle-demo/oracle-cert/v1") + let tag: u256 = 0x026a11d3eb897194ef8efa87e6d28690e25bee5a89ee37142b5ba3816dcc4b89; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, prefix); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, x); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, valid_from); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_8(ctx, valid_until); + jet::bip_0340_verify((param::COLD_PUBKEY, jet::sha_256_ctx_8_finalize(ctx)), sig); + + ((::into(jet::eq_8(prefix, 3)), x), valid_from, valid_until) +} + +/// Hash one decimal digit and fold it into the number read so far. +fn digit_step(byte: u8, acc: (Ctx8, u64)) -> (Ctx8, u64) { + let (ctx, value): (Ctx8, u64) = acc; + assert!(jet::le_8(48, byte)); + assert!(jet::le_8(byte, 57)); + let (_, digit): (bool, u8) = jet::subtract_8(byte, 48); + let value: u64 = safe_add(safe_multiply(value, 10), jet::left_pad_low_8_64(digit)); + (jet::sha_256_ctx_8_add_1(ctx, byte), value) +} + +/// Hash one byte of a text field, which may be anything but the '|' separator. +fn text_step(byte: u8, ctx: Ctx8) -> Ctx8 { + assert!(not(jet::eq_8(byte, 124))); + jet::sha_256_ctx_8_add_1(ctx, byte) +} + +fn add_separator(ctx: Ctx8) -> Ctx8 { + jet::sha_256_ctx_8_add_1(ctx, 124) +} + +/// Rebuild a statement's canonical string, check a certified oracle signed it within its +/// certificate's range, and return its price in US cents and its timestamp. +fn verify_statement(statement: Statement) -> (u64, u64) { + let (price_int, price_frac, sources, method, timestamp, nonce, sig, cert): Statement = + statement; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + // "v1|PRICE|BTCUSD|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_16(ctx, 0x76317c50524943457c4254435553447c); + let (ctx, price): (Ctx8, u64) = fold::(price_int, (ctx, 0)); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, 46); // "." + let (frac_tens, frac_units): (u8, u8) = price_frac; + let (ctx, price): (Ctx8, u64) = digit_step(frac_units, digit_step(frac_tens, (ctx, price))); + // "|USD|2|" + let ctx: Ctx8 = jet::sha_256_ctx_8_add_4(ctx, 0x7c555344); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x7c32); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(sources, ctx); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(method, ctx); + let ctx: Ctx8 = add_separator(ctx); + let (ctx, timestamp): (Ctx8, u64) = fold::(timestamp, (ctx, 0)); + let ctx: Ctx8 = add_separator(ctx); + let ctx: Ctx8 = fold::(nonce, ctx); + + let (oracle_key, valid_from, valid_until): (Point, u64, u64) = verify_certificate(cert); + assert!(jet::le_64(valid_from, timestamp)); + assert!(jet::le_64(timestamp, valid_until)); + ecdsa_verify(oracle_key, jet::sha_256_ctx_8_finalize(ctx), sig); + (price, timestamp) +} + +// ============================================================================ +// Spending paths +// ============================================================================ + +/// Request: spend the treasury (input 0) to open a pending request. +/// +/// Outputs: 0 the L-BTC to burn and 1 the tUSDT it buys, both at the request's pending +/// address; 2 the rest of the treasury, back at the treasury address. +fn request_path(statement: Statement, recipient: u256) { + assert!(jet::eq_32(jet::current_index(), 0)); + let treasury: u256 = treasury_script_hash(); + assert!(jet::eq_256(get_input_script_hash(0), treasury)); + + let (price, timestamp): (u64, u64) = verify_statement(statement); + ensure_not_from_the_future(timestamp); + let (lbtc, lbtc_amount): (u256, u64) = get_output_explicit_asset_amount(0); + assert!(jet::eq_256(lbtc, param::LBTC_ASSET)); + let pending: u256 = pending_script_hash((price, timestamp, lbtc_amount, recipient)); + let usd_amount: u64 = usd_for(lbtc_amount, price); + + ensure_output_script_hash_eq(0, pending); + ensure_output_script_hash_eq(1, pending); + ensure_output_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); + + let (usd, held): (u256, u64) = get_input_explicit_asset_amount(0); + assert!(jet::eq_256(usd, param::USD_ASSET)); + ensure_output_script_hash_eq(2, treasury); + ensure_output_asset_with_amount_eq(2, param::USD_ASSET, safe_subtract(held, usd_amount)); +} + +/// Check that inputs 0 and 1 are `request`'s L-BTC and tUSDT, run from input 0, and return +/// the tUSDT amount. +fn ensure_pending_inputs(request: Request) -> u64 { + assert!(jet::eq_32(jet::current_index(), 0)); + let (price, _, lbtc_amount, _): Request = request; + let pending: u256 = pending_script_hash(request); + assert!(jet::eq_256(get_input_script_hash(0), pending)); + assert!(jet::eq_256(get_input_script_hash(1), pending)); + + let usd_amount: u64 = usd_for(lbtc_amount, price); + ensure_input_asset_with_amount_eq(0, param::LBTC_ASSET, lbtc_amount); + ensure_input_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); + usd_amount +} + +/// Finalize: after the window, pay the tUSDT to the recipient (output 0) and burn the +/// L-BTC (output 1). +fn finalize_path(request: Request) { + let usd_amount: u64 = ensure_pending_inputs(request); + ensure_window_elapsed(); + let (_, _, lbtc_amount, recipient): Request = request; + + ensure_output_script_hash_eq(0, recipient); + ensure_output_asset_with_amount_eq(0, param::USD_ASSET, usd_amount); + ensure_output_script_hash_eq(1, burn_script_hash()); + ensure_output_asset_with_amount_eq(1, param::LBTC_ASSET, lbtc_amount); +} + +/// Challenge: with a newer statement whose price differs by more than the tolerance, +/// refund the L-BTC to the recipient (output 0) and return the tUSDT to the treasury +/// (output 1). +/// +/// Nothing stops a challenge after the window: a program can't require that a block +/// height has *not* been reached. A request stays open to challenge until it is finalized. +fn challenge_path(request: Request, statement: Statement) { + let usd_amount: u64 = ensure_pending_inputs(request); + let (price, timestamp, lbtc_amount, recipient): Request = request; + let (new_price, new_timestamp): (u64, u64) = verify_statement(statement); + assert!(jet::lt_64(timestamp, new_timestamp)); + assert!(differs_beyond_tolerance(price, new_price)); + + ensure_output_script_hash_eq(0, recipient); + ensure_output_asset_with_amount_eq(0, param::LBTC_ASSET, lbtc_amount); + ensure_output_script_hash_eq(1, treasury_script_hash()); + ensure_output_asset_with_amount_eq(1, param::USD_ASSET, usd_amount); +} + +/// Sibling: the pending tUSDT, spent at input 1 beside its L-BTC at input 0, whose finalize +/// or challenge path checks the whole transaction. +/// +/// Refused at the treasury address. Otherwise a second treasury UTXO could ride along with +/// a request as input 1, with nothing checking where its tUSDT goes. +fn sibling_path() { + assert!(jet::eq_32(jet::current_index(), 1)); + let mine: u256 = get_input_script_hash(1); + assert!(jet::eq_256(mine, get_input_script_hash(0))); + assert!(not(jet::eq_256(mine, treasury_script_hash()))); +} + +/// One word of budget padding, which must be zero. +fn padding_word(word: u256, acc: ()) -> () { + assert!(jet::eq_256(word, 0)); + acc +} + +fn main() { + // A program may not cost more than its witness's size buys, and checking a statement + // costs more than the statement's own bytes. Liquid does not relay the annex that + // would otherwise pad it, so the request and challenge paths are padded here with + // zero words. Other paths leave the list empty. + fold::(witness::BUDGET_PAD, ()); + + let request: Request = ( + witness::REQUEST_PRICE, + witness::REQUEST_TIMESTAMP, + witness::REQUEST_LBTC_AMOUNT, + witness::REQUEST_RECIPIENT + ); + let statement: Statement = ( + witness::STATEMENT_PRICE_INT, + witness::STATEMENT_PRICE_FRAC, + witness::STATEMENT_SOURCES, + witness::STATEMENT_METHOD, + witness::STATEMENT_TIMESTAMP, + witness::STATEMENT_NONCE, + witness::STATEMENT_SIG, + ( + witness::CERT_ORACLE_KEY, + witness::CERT_VALID_FROM, + witness::CERT_VALID_UNTIL, + witness::CERT_SIG + ) + ); + + match witness::PATH { + Left(open_or_close: Either<(), ()>) => match open_or_close { + Left(u: ()) => { + let (_, _, _, recipient): Request = request; + request_path(statement, recipient) + }, + Right(u: ()) => finalize_path(request), + }, + Right(cancel_or_sibling: Either<(), ()>) => match cancel_or_sibling { + Left(u: ()) => challenge_path(request, statement), + Right(u: ()) => sibling_path(), + }, + } +} diff --git a/examples/oracle_demo/txmanifest.json b/examples/oracle_demo/txmanifest.json new file mode 100644 index 0000000..be7c37e --- /dev/null +++ b/examples/oracle_demo/txmanifest.json @@ -0,0 +1,986 @@ +{ + "$schema": "../../schema/txmanifest.schema.json", + "$comment": "A demonstration of an oracle challenge window: using an oracle's price as the current price, which a Simplicity program can't check for itself. Statements use Mycelia Signal's canonical format and ECDSA signing scheme, signed here by a local test key (fake_oracle.py). The tUSDT asset is issued by the separate manifest in issue_usdt/.", + "manifest_version": "0.3.1", + "protocol": "oracle-demo-mint", + "description": "Burn L-BTC for tUSDT at the current BTC/USD price. Simplicity cannot tell whether an oracle statement is current, so a mint takes two steps. A request presents a signed price statement and reserves the tUSDT it is worth. Anyone with a newer statement whose price differs by more than a tolerance can then challenge it, refunding the L-BTC. If no one does within the window, anyone can finalize it: the tUSDT goes to the requester and the L-BTC is burned. One instance per service; requests are tracked in its state file.", + "chain": "liquid", + "requires": [ + "simplicity" + ], + "simplicity_hl": { + "version": "0.7.1" + }, + "utxo_types": { + "treasury": { + "description": "The tUSDT supply, waiting to be sold. Its tapdata leaf is the single byte 0x00.", + "script": { + "type": "simplicity", + "source": "./oracle_mint.simf", + "source_hash": "sha256:2a14736e667e50193aa059abb3d95ecd3616d7f10c4f1631591234a003ed26f6", + "compile_params": { + "COLD_PUBKEY": "COLD_PUBKEY", + "LBTC_ASSET": "LBTC_ASSET", + "USD_ASSET": "USD_ASSET", + "WINDOW_BLOCKS": "WINDOW_BLOCKS", + "TOLERANCE_BPS": "TOLERANCE_BPS", + "MAX_FUTURE_SECONDS": "MAX_FUTURE_SECONDS" + }, + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x00" + ] + } + ] + }, + "asset": "USD_ASSET" + }, + "pending_mint": { + "description": "One open mint request: the L-BTC to burn and the tUSDT reserved for it, two UTXOs at one address. The tapdata leaf commits to the request, 0x01 || price || timestamp || L-BTC amount || recipient, so every request has its own address.", + "params": { + "COLD_PUBKEY": { + "type": "pubkey", + "default": "instance.COLD_PUBKEY", + "description": "The service's cold key, which certifies oracle keys." + }, + "LBTC_ASSET": { + "type": "liquid.asset_id", + "default": "instance.LBTC_ASSET", + "description": "The asset requests burn." + }, + "USD_ASSET": { + "type": "liquid.asset_id", + "default": "instance.USD_ASSET", + "description": "The tUSDT asset id." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "default": "instance.WINDOW_BLOCKS", + "description": "The challenge window, in blocks." + }, + "TOLERANCE_BPS": { + "type": "u64", + "default": "instance.TOLERANCE_BPS", + "description": "The challenge tolerance, in basis points." + }, + "MAX_FUTURE_SECONDS": { + "type": "u32", + "default": "instance.MAX_FUTURE_SECONDS", + "description": "How far past the chain's median time a request's statement may be dated, in seconds." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price, in US cents per BTC." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's unix timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The L-BTC to burn, in satoshis." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "description": "sha256 of the scriptPubKey that receives the tUSDT, or the refund." + } + }, + "script": { + "type": "simplicity", + "source": "./oracle_mint.simf", + "source_hash": "sha256:2a14736e667e50193aa059abb3d95ecd3616d7f10c4f1631591234a003ed26f6", + "extra_leaves": [ + { + "type": "tapdata", + "payload": [ + "0x01", + { + "value": "params.REQUEST_PRICE", + "type": "u64", + "endian": "be" + }, + { + "value": "params.REQUEST_TIMESTAMP", + "type": "u64", + "endian": "be" + }, + { + "value": "params.REQUEST_LBTC_AMOUNT", + "type": "u64", + "endian": "be" + }, + { + "value": "params.RECIPIENT_SPK_HASH", + "type": "bytes32" + } + ] + } + ] + } + } + }, + "contract_templates": { + "oracle_mint": { + "description": "One mint service: a treasury of tUSDT, the cold key that certifies oracle keys, and the challenge window's length and tolerance, and how far ahead a statement may be dated. All six fields are compile params of oracle_mint.simf, so they fix every address the service uses. The oracle keys are not among them: a new or rotated oracle needs only a new certificate.", + "fields": { + "COLD_PUBKEY": { + "type": "pubkey" + }, + "LBTC_ASSET": { + "type": "liquid.asset_id" + }, + "USD_ASSET": { + "type": "liquid.asset_id" + }, + "WINDOW_BLOCKS": { + "type": "u16" + }, + "TOLERANCE_BPS": { + "type": "u64" + }, + "MAX_FUTURE_SECONDS": { + "type": "u32" + } + }, + "actions": { + "CreateTreasury": { + "description": "Start the service: lock tUSDT into the treasury and write the instance file. Issue the tUSDT first with issue_usdt/txmanifest.json.", + "intent": "lock {params.SUPPLY} of {params.USD_ASSET:symbol} into a mint service treasury", + "params": { + "USD_ASSET": { + "type": "liquid.asset_id", + "description": "The tUSDT asset id, from the instance file issue_usdt wrote." + }, + "SUPPLY": { + "type": "u64", + "description": "tUSDT base units to lock in the treasury (8 decimal places)." + }, + "COLD_PUBKEY": { + "type": "pubkey", + "description": "x-only BIP340 key that signs oracle certificates. fake_oracle.py cold-keygen prints it. In practice, a committee's aggregate key (MuSig2 or FROST)." + }, + "LBTC_ASSET": { + "type": "liquid.asset_id", + "default": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", + "description": "The asset requests burn. The default is Liquid testnet L-BTC." + }, + "WINDOW_BLOCKS": { + "type": "u16", + "default": "5", + "description": "Blocks a request must wait before it can be finalized. Liquid makes a block a minute, so 5 is about 5 minutes." + }, + "TOLERANCE_BPS": { + "type": "u64", + "default": "50", + "description": "How far a newer statement's price may differ from a request's, in basis points of the request's price, before it can cancel the request. 50 is 0.5%." + }, + "MAX_FUTURE_SECONDS": { + "type": "u32", + "default": "300", + "description": "How far past the chain's median time a request's statement may be dated, in seconds. Liquid's median time runs about 5-6 minutes behind the clock, so 300 lets a fresh statement be used within about a minute, while a forged one can't be dated past the present." + } + }, + "inputs": [ + { + "id": "usd_in", + "description": "Wallet UTXO holding the tUSDT.", + "utxo_source": "wallet", + "asset": "params.USD_ASSET", + "amount_sat": { + "min_amount": "params.SUPPLY" + }, + "ui": { + "label": "the tUSDT to sell", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "treasury_out", + "description": "The treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "params.USD_ASSET", + "amount_sat": "params.SUPPLY", + "ui": { + "label": "tUSDT locked in the treasury", + "role": "covenant" + } + }, + { + "id": "usd_change", + "description": "tUSDT change back to your wallet.", + "destination": "change", + "asset": "params.USD_ASSET", + "optional": true, + "ui": { + "label": "tUSDT returned to you", + "role": "change" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ], + "create_instance": { + "fields": { + "COLD_PUBKEY": "$params.COLD_PUBKEY", + "LBTC_ASSET": "$params.LBTC_ASSET", + "USD_ASSET": "$params.USD_ASSET", + "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", + "TOLERANCE_BPS": "$params.TOLERANCE_BPS", + "MAX_FUTURE_SECONDS": "$params.MAX_FUTURE_SECONDS" + } + } + }, + "RequestMint": { + "$comment": "Outputs 0-2 are the ones the treasury checks, in this order. The lock time is what proves the statement isn't dated more than MAX_FUTURE_SECONDS ahead of the chain; until the chain's median time passes it, the node refuses the transaction as non-final. Generate the params with fake_oracle.py request-params, which takes the statement and the certificate for the key that signed it.", + "description": "Step 1 of a mint: present a signed price statement, lock the L-BTC to burn and reserve the tUSDT it buys at that price. Opens the challenge window.", + "intent": "request {params.USD_AMOUNT} tUSDT for {params.REQUEST_LBTC_AMOUNT} L-BTC at {params.REQUEST_PRICE} cents per BTC", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The oracle's signature, as (r, s)." + }, + "CERT_ORACLE_KEY": { + "type": "string", + "description": "The key that signed the statement, from its certificate, as (prefix, x-coordinate)." + }, + "CERT_VALID_FROM": { + "type": "u64", + "description": "The certificate's first valid statement timestamp." + }, + "CERT_VALID_UNTIL": { + "type": "u64", + "description": "The certificate's last valid statement timestamp." + }, + "CERT_SIG": { + "type": "bytes", + "description": "The cold key's 64-byte BIP340 signature on the certificate, 0x-prefixed." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The statement's price in US cents. Must match the statement." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The statement's timestamp. Must match the statement." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "L-BTC to burn, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "Address that receives the tUSDT, or the refund if the request is challenged." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "lock_time": { + "time": "params.REQUEST_TIMESTAMP - instance.MAX_FUTURE_SECONDS" + }, + "inputs": [ + { + "id": "treasury_in", + "description": "The treasury, input 0. It checks the statement and the three outputs after it.", + "utxo_source": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Left(Left(()))", + "description": "The request path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "CERT_ORACLE_KEY": { + "type": "simplicityhl", + "simplicity_type": "(u8, u256)", + "value": "params.CERT_ORACLE_KEY" + }, + "CERT_VALID_FROM": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_FROM" + }, + "CERT_VALID_UNTIL": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_UNTIL" + }, + "CERT_SIG": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.CERT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the tUSDT treasury", + "role": "covenant" + } + }, + { + "id": "lbtc_in", + "description": "Wallet L-BTC to burn.", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { + "min_amount": "params.REQUEST_LBTC_AMOUNT" + }, + "ui": { + "label": "the L-BTC you are burning", + "role": "payment" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "pending_lbtc_out", + "description": "Output 0: the L-BTC, held at the request's pending address until the request is finalized or challenged.", + "destination": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "ui": { + "label": "L-BTC held until the request settles", + "role": "covenant" + } + }, + { + "id": "pending_usd_out", + "description": "Output 1: the tUSDT reserved for this request, at the same address.", + "destination": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "ui": { + "label": "tUSDT reserved for the request", + "role": "covenant" + } + }, + { + "id": "treasury_change_out", + "description": "Output 2: the rest of the treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "amount_sat": "treasury_in.amount_sat - params.USD_AMOUNT", + "ui": { + "label": "tUSDT left in the treasury", + "role": "covenant" + } + }, + { + "id": "lbtc_change", + "description": "L-BTC change from the burn and fee inputs.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "L-BTC change returned to you", + "role": "change" + } + } + ] + }, + "Finalize": { + "$comment": "Use the request's params file. Anyone can run it: the covenant fixes where both outputs go.", + "description": "Step 2 of a mint, once the window has passed: pay the reserved tUSDT to the recipient and burn the L-BTC.", + "intent": "finalize a request: pay {params.USD_AMOUNT} tUSDT to {params.REQUEST_RECIPIENT} and burn {params.REQUEST_LBTC_AMOUNT} L-BTC", + "params": { + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price in US cents." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The request's L-BTC, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "The request's recipient address." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "inputs": [ + { + "id": "pending_lbtc_in", + "description": "The request's L-BTC, input 0. Its relative timelock is the challenge window, and it checks the whole transaction.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "sequence": { + "relative_blocks": "instance.WINDOW_BLOCKS" + }, + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Left(Right(()))", + "description": "The finalize path." + }, + "REQUEST_PRICE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_PRICE" + }, + "REQUEST_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_TIMESTAMP" + }, + "REQUEST_LBTC_AMOUNT": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_LBTC_AMOUNT" + }, + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's L-BTC", + "role": "covenant" + } + }, + { + "id": "pending_usd_in", + "description": "The request's reserved tUSDT, input 1.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Right(()))", + "description": "The sibling path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's reserved tUSDT", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "usd_out", + "description": "Output 0: the tUSDT, to the recipient. Explicit, because the covenant reads its amount.", + "destination": "params.REQUEST_RECIPIENT", + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "confidential": false, + "ui": { + "label": "tUSDT paid to the recipient", + "role": "settlement" + } + }, + { + "id": "lbtc_burn", + "description": "Output 1: the L-BTC, burned.", + "destination": { + "type": "burn" + }, + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "ui": { + "label": "L-BTC burned", + "role": "burn" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + }, + "Challenge": { + "$comment": "Generate the params with fake_oracle.py challenge-params. Anyone can run it, with a statement from any certified oracle key.", + "description": "Cancel a request with a newer statement whose price differs from the request's by more than the tolerance. Refunds the L-BTC to the recipient and returns the tUSDT to the treasury.", + "intent": "challenge a request at {params.REQUEST_PRICE} cents per BTC: refund {params.REQUEST_LBTC_AMOUNT} L-BTC to {params.REQUEST_RECIPIENT}", + "params": { + "STATEMENT_PRICE_INT": { + "type": "string", + "description": "The newer statement's price, integer digits, as a SimplicityHL list of bytes." + }, + "STATEMENT_PRICE_FRAC": { + "type": "string", + "description": "The newer statement's price, the two fraction digits, as a pair of bytes." + }, + "STATEMENT_SOURCES": { + "type": "string", + "description": "The newer statement's sources field, as a list of bytes." + }, + "STATEMENT_METHOD": { + "type": "string", + "description": "The newer statement's method field, as a list of bytes." + }, + "STATEMENT_TIMESTAMP": { + "type": "string", + "description": "The newer statement's timestamp digits, as a list of bytes." + }, + "STATEMENT_NONCE": { + "type": "string", + "description": "The newer statement's nonce field, as a list of bytes." + }, + "STATEMENT_SIG": { + "type": "string", + "description": "The oracle's signature on the newer statement, as (r, s)." + }, + "CERT_ORACLE_KEY": { + "type": "string", + "description": "The key that signed the newer statement, from its certificate, as (prefix, x-coordinate)." + }, + "CERT_VALID_FROM": { + "type": "u64", + "description": "The certificate's first valid statement timestamp." + }, + "CERT_VALID_UNTIL": { + "type": "u64", + "description": "The certificate's last valid statement timestamp." + }, + "CERT_SIG": { + "type": "bytes", + "description": "The cold key's 64-byte BIP340 signature on the certificate, 0x-prefixed." + }, + "REQUEST_PRICE": { + "type": "u64", + "description": "The request's price in US cents." + }, + "REQUEST_TIMESTAMP": { + "type": "u64", + "description": "The request statement's timestamp." + }, + "REQUEST_LBTC_AMOUNT": { + "type": "u64", + "description": "The request's L-BTC, in satoshis." + }, + "REQUEST_RECIPIENT": { + "type": "string", + "description": "The request's recipient address." + }, + "RECIPIENT_SPK_HASH": { + "type": "bytes32", + "compute": { + "type": "script_hash", + "address": "params.REQUEST_RECIPIENT" + } + }, + "USD_AMOUNT": { + "type": "u64", + "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" + } + }, + "inputs": [ + { + "id": "pending_lbtc_in", + "description": "The request's L-BTC, input 0. It checks the newer statement and the whole transaction.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "lbtc", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Left(()))", + "description": "The challenge path." + }, + "REQUEST_PRICE": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_PRICE" + }, + "REQUEST_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_TIMESTAMP" + }, + "REQUEST_LBTC_AMOUNT": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.REQUEST_LBTC_AMOUNT" + }, + "REQUEST_RECIPIENT": { + "type": "simplicityhl", + "simplicity_type": "u256", + "value": "params.RECIPIENT_SPK_HASH" + }, + "STATEMENT_PRICE_INT": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_PRICE_INT" + }, + "STATEMENT_PRICE_FRAC": { + "type": "simplicityhl", + "simplicity_type": "(u8, u8)", + "value": "params.STATEMENT_PRICE_FRAC" + }, + "STATEMENT_SOURCES": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_SOURCES" + }, + "STATEMENT_METHOD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_METHOD" + }, + "STATEMENT_TIMESTAMP": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_TIMESTAMP" + }, + "STATEMENT_NONCE": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "params.STATEMENT_NONCE" + }, + "STATEMENT_SIG": { + "type": "simplicityhl", + "simplicity_type": "(u256, u256)", + "value": "params.STATEMENT_SIG" + }, + "CERT_ORACLE_KEY": { + "type": "simplicityhl", + "simplicity_type": "(u8, u256)", + "value": "params.CERT_ORACLE_KEY" + }, + "CERT_VALID_FROM": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_FROM" + }, + "CERT_VALID_UNTIL": { + "type": "simplicityhl", + "simplicity_type": "u64", + "value": "params.CERT_VALID_UNTIL" + }, + "CERT_SIG": { + "type": "simplicityhl", + "simplicity_type": "[u8; 64]", + "value": "params.CERT_SIG" + }, + "BUDGET_PAD": { + "type": "simplicityhl", + "simplicity_type": "List", + "value": "list![0, 0, 0, 0, 0, 0, 0, 0]", + "description": "Zero words that buy execution budget for checking the statement. If a broadcast fails with \"Program's execution cost could exceed budget\", add more." + } + }, + "ui": { + "label": "the request's L-BTC", + "role": "covenant" + } + }, + { + "id": "pending_usd_in", + "description": "The request's reserved tUSDT, input 1.", + "utxo_source": { + "utxo_type": "pending_mint", + "args": { + "REQUEST_PRICE": "params.REQUEST_PRICE", + "REQUEST_TIMESTAMP": "params.REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT": "params.REQUEST_LBTC_AMOUNT", + "RECIPIENT_SPK_HASH": "params.RECIPIENT_SPK_HASH" + } + }, + "asset": "instance.USD_ASSET", + "witnesses": { + "PATH": { + "type": "simplicityhl", + "simplicity_type": "Either, Either<(), ()>>", + "value": "Right(Right(()))", + "description": "The sibling path." + }, + "REQUEST_PRICE": "unused", + "REQUEST_TIMESTAMP": "unused", + "REQUEST_LBTC_AMOUNT": "unused", + "REQUEST_RECIPIENT": "unused", + "STATEMENT_PRICE_INT": "unused", + "STATEMENT_PRICE_FRAC": "unused", + "STATEMENT_SOURCES": "unused", + "STATEMENT_METHOD": "unused", + "STATEMENT_TIMESTAMP": "unused", + "STATEMENT_NONCE": "unused", + "STATEMENT_SIG": "unused", + "CERT_ORACLE_KEY": "unused", + "CERT_VALID_FROM": "unused", + "CERT_VALID_UNTIL": "unused", + "CERT_SIG": "unused", + "BUDGET_PAD": "unused" + }, + "ui": { + "label": "the request's reserved tUSDT", + "role": "covenant" + } + }, + { + "id": "fee_input", + "description": "Wallet L-BTC UTXO covering the network fee.", + "utxo_source": "wallet", + "asset": "lbtc", + "ui": { + "label": "input used for paying fees", + "role": "fee" + } + } + ], + "outputs": [ + { + "id": "lbtc_refund", + "description": "Output 0: the L-BTC, refunded to the recipient. Explicit, because the covenant reads its amount.", + "destination": "params.REQUEST_RECIPIENT", + "asset": "lbtc", + "amount_sat": "params.REQUEST_LBTC_AMOUNT", + "confidential": false, + "ui": { + "label": "L-BTC refunded to the requester", + "role": "refund" + } + }, + { + "id": "usd_to_treasury", + "description": "Output 1: the reserved tUSDT, back to the treasury.", + "destination": { + "utxo_type": "treasury" + }, + "asset": "instance.USD_ASSET", + "amount_sat": "params.USD_AMOUNT", + "ui": { + "label": "tUSDT returned to the treasury", + "role": "covenant" + } + }, + { + "id": "fee_change", + "description": "L-BTC change from the fee input.", + "destination": "change", + "asset": "lbtc", + "optional": true, + "ui": { + "label": "fee change returned to you", + "role": "change" + } + } + ] + } + } + } + } +} diff --git a/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index ddba907..90b7be8 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -51,6 +51,17 @@ "null" ] }, + "lock_time": { + "anyOf": [ + { + "$ref": "#/definitions/LockTime" + }, + { + "type": "null" + } + ], + "description": "The transaction's `nLockTime`: the earliest block height, or time, at which it can be mined. This is what the `check_lock_height` / `check_lock_time` Simplicity jets read. See [`LockTime`].\n\nOmitted → zero, no absolute timelock." + }, "on_post_broadcast": { "anyOf": [ { @@ -375,6 +386,41 @@ ], "type": "object" }, + "LockTime": { + "description": "An action's absolute timelock, `{\"height\": }` or `{\"time\": }`.\n\nBoth set the same `nLockTime` field, which consensus reads as a height below 500,000,000 and as unix seconds from there up, so the two are separate keys rather than one number whose meaning depends on its size. A lock time is ignored when every input is final, so the engine gives each input that sets no `sequence` of its own `0xfffffffe`.", + "oneOf": [ + { + "additionalProperties": false, + "description": "The transaction can be mined in a block at least this high. Below 500,000,000.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "height": true + }, + "required": [ + "height" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The transaction can be mined once the chain's median time past is beyond this unix time, in seconds. At least 500,000,000. On a chain with one-minute blocks, median time past runs about six minutes behind the clock.", + "properties": { + "$comment": { + "description": "Documentation only; ignored by the engine.", + "type": "string" + }, + "time": true + }, + "required": [ + "time" + ], + "type": "object" + } + ] + }, "Output": { "additionalProperties": false, "properties": { diff --git a/txmanifest_lib/src/assembly.rs b/txmanifest_lib/src/assembly.rs index 36cdb7b..151d64a 100644 --- a/txmanifest_lib/src/assembly.rs +++ b/txmanifest_lib/src/assembly.rs @@ -632,6 +632,7 @@ mod tests { fee_rate: 1.0, policy_asset: bitcoin_policy_asset(), change_assets: Default::default(), + lock_time: None, }; let narrowed = crate::psbt_builder::from_pset_request(&req, None).expect("narrows"); let crate::psbt_builder::PsbtInput::Wallet { @@ -668,6 +669,7 @@ mod tests { fee_rate: 1.0, policy_asset: c.policy_asset(), change_assets: Default::default(), + lock_time: None, }; assert!(crate::psbt_builder::from_pset_request(&req, None).is_ok()); diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 862f884..eeafd9b 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -815,7 +815,21 @@ pub fn finalize_covenant_input( let cb_bytes = control_block.serialize(); // Simplicity tapscript witness: [witness_bits, program_bytes, cmr_script, control_block] - pset_input.final_script_witness = Some(vec![witness, prog, cmr_bytes, cb_bytes]); + let stack = vec![witness, prog, cmr_bytes, cb_bytes]; + + // The node refuses a program that may cost more than the budget its witness buys (the + // serialized stack plus 50 weight units). Elements can pad with an annex, but Liquid + // does not relay one (`bad-witness-nonstandard`), so the padding has to be a witness + // the program itself reads. Say so here rather than as a broadcast error. + let cost = satisfied.redeem().bounds().cost; + if let Some(short) = cost.get_padding_size(&stack) { + anyhow::bail!( + "Program's execution cost exceeds its budget: the witness is {short} bytes short. \ + Pad it with a witness the program reads." + ); + } + + pset_input.final_script_witness = Some(stack); Ok(()) } diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index a2cba50..975dd7b 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -68,6 +68,39 @@ fn encode_sequence(spec: &serde_json::Value, ctx: &ExecutionContext) -> Result Result { + use crate::manifest::LockTime; + match spec { + LockTime::Height(v) => { + let height = eval::eval_amount(v, ctx).context("evaluating lock_time.height")?; + if height >= LOCKTIME_THRESHOLD { + anyhow::bail!( + "lock_time.height {height} is not below {LOCKTIME_THRESHOLD}, so consensus \ + would read it as a time" + ); + } + Ok(height as u32) + } + LockTime::Time(v) => { + let time = eval::eval_amount(v, ctx).context("evaluating lock_time.time")?; + if time < LOCKTIME_THRESHOLD { + anyhow::bail!( + "lock_time.time {time} is below {LOCKTIME_THRESHOLD}, so consensus would \ + read it as a block height" + ); + } + u32::try_from(time) + .map_err(|_| anyhow::anyhow!("lock_time.time {time} does not fit in 32 bits")) + } + } +} + /// Resolve `inp.sequence` (if present) to a raw nSequence, warning loudly if it would /// silently disable the relative timelock the user asked for. fn resolve_input_sequence(inp: &Input, ctx: &ExecutionContext) -> Result> { @@ -2092,8 +2125,24 @@ pub fn run( } } + let lock_time = match action.lock_time.as_ref() { + None => Ok(None), + Some(spec) => encode_lock_time(spec, &ctx).map(Some), + }; + let lock_time_ok = match &lock_time { + Ok(Some(lt)) => { + println!(" · nLockTime = {lt}"); + true + } + Ok(None) => true, + Err(e) => { + println!(" {} {e:#}", style("[error]").red()); + false + } + }; + // ---- Build PSET ---- - if collect_inputs_ok && collect_outputs_ok { + if collect_inputs_ok && collect_outputs_ok && lock_time_ok { // Change is permitted per ASSET: by an explicit `destination: "change"` // output (collected while resolving outputs, above), or by the action's // `allow_change` setting, which covers surpluses that cannot be predicted — @@ -2120,6 +2169,7 @@ pub fn run( fee_rate: fee_rate as f32, policy_asset: actx.policy_asset(), change_assets: change_assets.clone(), + lock_time: lock_time.ok().flatten(), }; // Resolve the `fee` keyword: estimate the fee from the current (fee=0) @@ -4997,6 +5047,25 @@ mod tests { use crate::manifest::{ComputeSpec, InstanceCreate}; use std::collections::BTreeMap; + /// `nLockTime` means a height below 500,000,000 and a time from there up, so each key + /// refuses a value consensus would read as the other. + #[test] + fn a_lock_time_must_be_the_kind_its_key_says() { + use crate::manifest::LockTime; + use serde_json::json; + + let mut ctx = ExecutionContext::new(); + ctx.set_param("T", "1800000000"); + let time = |v| encode_lock_time(&LockTime::Time(v), &ctx); + let height = |v| encode_lock_time(&LockTime::Height(v), &ctx); + + assert_eq!(time(json!("params.T - 300")).unwrap(), 1_799_999_700); + assert_eq!(height(json!(2_670_000)).unwrap(), 2_670_000); + assert!(height(json!("params.T")).is_err()); + assert!(time(json!(2_670_000)).is_err()); + assert!(time(json!(5_000_000_000u64)).is_err()); + } + /// `confidential: false` must beat a confidential address's own blinding key. /// /// The covenant case that forces it: a payout check does `unwrap_right` on diff --git a/txmanifest_lib/src/manifest.rs b/txmanifest_lib/src/manifest.rs index fd85472..0016810 100644 --- a/txmanifest_lib/src/manifest.rs +++ b/txmanifest_lib/src/manifest.rs @@ -610,6 +610,24 @@ impl AllowChange { } } +/// An action's absolute timelock, `{"height": }` or `{"time": }`. +/// +/// Both set the same `nLockTime` field, which consensus reads as a height below +/// 500,000,000 and as unix seconds from there up, so the two are separate keys rather than +/// one number whose meaning depends on its size. A lock time is ignored when every input +/// is final, so the engine gives each input that sets no `sequence` of its own +/// `0xfffffffe`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum LockTime { + /// The transaction can be mined in a block at least this high. Below 500,000,000. + Height(serde_json::Value), + /// The transaction can be mined once the chain's median time past is beyond this + /// unix time, in seconds. At least 500,000,000. On a chain with one-minute blocks, + /// median time past runs about six minutes behind the clock. + Time(serde_json::Value), +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Action { @@ -639,6 +657,12 @@ pub struct Action { pub allow_change: AllowChange, /// Runtime action parameters (Spec §5). Prompted, or set by hooks. pub params: Option>, + /// The transaction's `nLockTime`: the earliest block height, or time, at which it can + /// be mined. This is what the `check_lock_height` / `check_lock_time` Simplicity jets + /// read. See [`LockTime`]. + /// + /// Omitted → zero, no absolute timelock. + pub lock_time: Option, pub inputs: Option>, pub outputs: Option>, /// Method-level hook: runs after inputs are resolved, before PSET is built. diff --git a/txmanifest_lib/src/psbt_builder.rs b/txmanifest_lib/src/psbt_builder.rs index cc5983b..1a4f984 100644 --- a/txmanifest_lib/src/psbt_builder.rs +++ b/txmanifest_lib/src/psbt_builder.rs @@ -729,6 +729,7 @@ mod tests { fee_rate: 2.0, policy_asset: policy(), change_assets: std::collections::HashSet::from([policy()]), + lock_time: None, } } @@ -762,6 +763,24 @@ mod tests { assert_eq!(psbt.change_script, Some(p2tr(7))); } + /// A lock time crosses to Bitcoin, and makes an input that asked for no sequence + /// non-final there too. + #[test] + fn narrowing_carries_the_lock_time() { + let mut pset = covenant_pset_request(policy()); + pset.lock_time = Some(900_000); + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + assert_eq!(psbt.lock_time, Some(900_000)); + assert_eq!(psbt.inputs[0].sequence().to_consensus_u32(), 144); + + let crate::pset_builder::PsetInput::Covenant { sequence, .. } = &mut pset.inputs[0] else { + panic!("the request has one covenant input"); + }; + *sequence = None; + let psbt = from_pset_request(&pset, Some(p2tr(7))).expect("narrows"); + assert_eq!(psbt.inputs[0].sequence().to_consensus_u32(), 0xffff_fffe); + } + /// Change is emitted only where the action declared it, matching the Elements rule /// that an undeclared surplus is an error rather than an invented output. #[test] @@ -1159,7 +1178,7 @@ pub fn from_pset_request( value: Amount::from_sat(utxo.unblinded.value), script_pubkey: convert_script(&utxo.script_pubkey), }, - sequence: *sequence, + sequence: pset.input_sequence(*sequence), }); } EIn::Covenant { @@ -1186,7 +1205,7 @@ pub fn from_pset_request( outpoint: convert_outpoint(*outpoint), script_pubkey: convert_script(script_pubkey), amount: *amount, - sequence: *sequence, + sequence: pset.input_sequence(*sequence), }); } } @@ -1225,9 +1244,7 @@ pub fn from_pset_request( // A change output is emitted only where the action declared one, which on Bitcoin // means the policy asset appeared in `change_assets`. change_script: change_script.filter(|_| pset.change_assets.contains(&policy)), - // The Elements request has no transaction-level locktime; absolute timelocks reach - // a covenant through its own `check_lock_height` rather than through the builder. - lock_time: None, + lock_time: pset.lock_time, }) } diff --git a/txmanifest_lib/src/pset_builder.rs b/txmanifest_lib/src/pset_builder.rs index adb7eee..8508871 100644 --- a/txmanifest_lib/src/pset_builder.rs +++ b/txmanifest_lib/src/pset_builder.rs @@ -8,8 +8,9 @@ use lwk_wollet::{ hashes::{sha256, Hash as _}, pset::{Input, Output, PartiallySignedTransaction}, secp256k1_zkp::{RangeProof, SecretKey, SurjectionProof, Tweak}, - AssetId, BlindAssetProofs, BlindValueProofs, ContractHash, OutPoint, RangeProofMessage, - Script, Sequence, SurjectionInput, TxOut, TxOutSecrets, TxOutWitness, Txid, + AssetId, BlindAssetProofs, BlindValueProofs, ContractHash, LockTime, OutPoint, + RangeProofMessage, Script, Sequence, SurjectionInput, TxOut, TxOutSecrets, TxOutWitness, + Txid, }, ElementsNetwork, WalletTxOut, Wollet, EC, }; @@ -106,6 +107,23 @@ pub struct BuildPsetRequest { /// declared change output is an error rather than a silently-invented output: the /// alternative is a transaction that moves value the manifest never mentioned. pub change_assets: std::collections::HashSet, + /// Transaction-level `nLockTime`. `None` leaves it at zero (no absolute timelock). + pub lock_time: Option, +} + +/// `nSequence` for an input that enables a lock time without a relative one. +const SEQUENCE_ENABLE_LOCKTIME: u32 = 0xffff_fffe; + +impl BuildPsetRequest { + /// The `nSequence` an input gets, given the one it asked for. + /// + /// A lock time is ignored when every input is at the maximum sequence, so an input + /// that asks for none gets [`SEQUENCE_ENABLE_LOCKTIME`] whenever the transaction + /// has a lock time. Shared with the Bitcoin builder through `from_pset_request`, so + /// both chains apply the same rule. + pub fn input_sequence(&self, requested: Option) -> Option { + requested.or(self.lock_time.map(|_| SEQUENCE_ENABLE_LOCKTIME)) + } } pub struct IssuanceResult { @@ -534,11 +552,14 @@ fn build_inner( Vec, )> { let mut pset = PartiallySignedTransaction::new_v2(); + pset.global.tx_data.fallback_locktime = req.lock_time.map(LockTime::from_consensus); let mut inp_txout_sec: HashMap = HashMap::new(); let mut issuances: Vec = Vec::new(); let mut total_lbtc_in: u64 = 0; - // Track non-LBTC wallet input amounts for automatic change output generation. - let mut wallet_asset_in: HashMap = HashMap::new(); + // Non-LBTC amounts in, from every input, for change and for the balance check. Covenant + // inputs count too: an explicit wallet UTXO arrives as one, and leaving it out once + // dropped a tUSDT change output, so the node refused the transaction. + let mut asset_in: HashMap = HashMap::new(); // Add inputs for pset_input in &req.inputs { @@ -550,7 +571,7 @@ fn build_inner( sequence, } => { let idx = add_wallet_input(&mut pset, &mut inp_txout_sec, wollet, secp, rng, utxo)?; - apply_sequence(&mut pset, idx, *sequence); + apply_sequence(&mut pset, idx, req.input_sequence(*sequence)); if let Some(iso) = issuance { apply_new_issuance(&mut pset, idx, iso)?; let (asset_id, token_id) = pset.inputs()[idx].issuance_ids(); @@ -575,8 +596,7 @@ fn build_inner( if utxo.unblinded.asset == req.policy_asset { total_lbtc_in += utxo.unblinded.value; } else { - *wallet_asset_in.entry(utxo.unblinded.asset).or_default() += - utxo.unblinded.value; + *asset_in.entry(utxo.unblinded.asset).or_default() += utxo.unblinded.value; } } PsetInput::Covenant { @@ -599,7 +619,7 @@ fn build_inner( *amount, *blinding, )?; - apply_sequence(&mut pset, idx, *sequence); + apply_sequence(&mut pset, idx, req.input_sequence(*sequence)); if let Some(iso) = issuance { // A covenant input may carry either a NEW issuance (e.g. an issuance-factory // covenant minting a fresh NFT from its own outpoint) or a reissuance. @@ -627,6 +647,8 @@ fn build_inner( } if *asset == req.policy_asset { total_lbtc_in += amount; + } else { + *asset_in.entry(*asset).or_default() += amount; } } } @@ -685,7 +707,7 @@ fn build_inner( )); } - // Non-LBTC change outputs: for any wallet-input asset where the input exceeds the outputs. + // Non-LBTC change outputs: for any asset where the inputs exceed the outputs. let total_non_lbtc_out: HashMap = req .outputs .iter() @@ -694,7 +716,7 @@ fn build_inner( *m.entry(o.asset).or_default() += o.amount; m }); - for (asset, in_amt) in &wallet_asset_in { + for (asset, in_amt) in &asset_in { let out_amt = total_non_lbtc_out.get(asset).copied().unwrap_or(0); if *in_amt > out_amt && !req.change_assets.contains(asset) { // Only the fee may be an output the manifest did not declare. Inventing a @@ -1696,6 +1718,92 @@ mod abf_reuse_tests { } } +#[cfg(test)] +mod change_tests { + use super::*; + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn wollet() -> Wollet { + let w = crate::wallet::WalletFile { + network: "testnet".to_string(), + mnemonic: MNEMONIC.to_string(), + }; + Wollet::new( + ElementsNetwork::LiquidTestnet, + lwk_wollet::NoPersist::new(), + crate::wallet::descriptor(&w).unwrap(), + ) + .unwrap() + } + + /// Spend 100,000 L-BTC and 1,000 of another asset, all explicit, the way an explicit + /// wallet UTXO reaches the builder, and pay 400 of the other asset out. + fn request(change_assets: &[AssetId]) -> BuildPsetRequest { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let other = AssetId::from_slice(&[0xaa; 32]).unwrap(); + let p2tr = Script::from([&[0x51, 0x20][..], &[7; 32]].concat()); + let input = |n: u8, asset, amount| PsetInput::Covenant { + input_id: format!("in{n}"), + outpoint: OutPoint::new(Txid::from_byte_array([n; 32]), 0), + script_pubkey: p2tr.clone(), + asset, + amount, + issuance: None, + sequence: None, + blinding: None, + }; + BuildPsetRequest { + inputs: vec![input(1, policy, 100_000), input(2, other, 1_000)], + outputs: vec![PsetOutputSpec { + script_pubkey: p2tr.clone(), + amount: 400, + asset: other, + blinding_key: None, + blinding: None, + }], + fee_rate: 0.1, + policy_asset: policy, + change_assets: change_assets.iter().copied().collect(), + lock_time: None, + } + } + + #[test] + fn an_explicit_input_in_another_asset_gets_its_change() { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let other = AssetId::from_slice(&[0xaa; 32]).unwrap(); + let built = build_pset( + &wollet(), + ElementsNetwork::LiquidTestnet, + &request(&[policy, other]), + ) + .expect("builds"); + let change: u64 = built + .pset + .outputs() + .iter() + .filter(|o| o.asset == Some(other)) + .map(|o| o.amount.unwrap()) + .sum(); + assert_eq!(change, 1_000, "400 paid out and 600 back as change"); + } + + #[test] + fn an_explicit_input_with_nowhere_for_its_surplus_is_an_error() { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let err = build_pset( + &wollet(), + ElementsNetwork::LiquidTestnet, + &request(&[policy]), + ) + .err() + .expect("600 of the other asset has nowhere to go"); + assert!(err.to_string().contains("does not balance"), "{err}"); + } +} + #[cfg(test)] mod lbtc_balance_tests { use super::resolve_lbtc_balance; @@ -1799,3 +1907,85 @@ mod lbtc_balance_tests { assert_eq!(fee, 0); } } + +#[cfg(test)] +mod lock_time_tests { + use super::*; + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn wollet() -> Wollet { + let w = crate::wallet::WalletFile { + network: "testnet".to_string(), + mnemonic: MNEMONIC.to_string(), + }; + Wollet::new( + ElementsNetwork::LiquidTestnet, + lwk_wollet::NoPersist::new(), + crate::wallet::descriptor(&w).unwrap(), + ) + .unwrap() + } + + /// Spend one explicit covenant UTXO to one explicit output, change to the wallet. + fn request(lock_time: Option, sequence: Option) -> BuildPsetRequest { + let policy = ElementsNetwork::LiquidTestnet.policy_asset(); + let p2tr = Script::from([&[0x51, 0x20][..], &[7; 32]].concat()); + BuildPsetRequest { + inputs: vec![PsetInput::Covenant { + input_id: "cov".to_string(), + outpoint: OutPoint::new(Txid::from_byte_array([3; 32]), 0), + script_pubkey: p2tr.clone(), + asset: policy, + amount: 100_000, + issuance: None, + sequence, + blinding: None, + }], + outputs: vec![PsetOutputSpec { + script_pubkey: p2tr, + amount: 90_000, + asset: policy, + blinding_key: None, + blinding: None, + }], + fee_rate: 0.1, + policy_asset: policy, + change_assets: std::collections::HashSet::from([policy]), + lock_time, + } + } + + fn build(req: &BuildPsetRequest) -> lwk_wollet::elements::Transaction { + build_pset(&wollet(), ElementsNetwork::LiquidTestnet, req) + .expect("builds") + .pset + .extract_tx() + .expect("extracts") + } + + #[test] + fn a_lock_time_reaches_the_transaction_and_makes_its_inputs_non_final() { + let tx = build(&request(Some(1_800_000_000), None)); + assert_eq!(tx.lock_time.to_consensus_u32(), 1_800_000_000); + // At the maximum sequence the lock time would be ignored. + assert_eq!( + tx.input[0].sequence.to_consensus_u32(), + SEQUENCE_ENABLE_LOCKTIME + ); + } + + #[test] + fn an_input_keeps_the_sequence_it_asked_for() { + let tx = build(&request(Some(1_800_000_000), Some(5))); + assert_eq!(tx.input[0].sequence.to_consensus_u32(), 5); + } + + #[test] + fn without_a_lock_time_nothing_changes() { + let tx = build(&request(None, None)); + assert_eq!(tx.lock_time.to_consensus_u32(), 0); + assert_eq!(tx.input[0].sequence, Sequence::MAX); + } +} diff --git a/txmanifest_lib/tests/bitcoin_path.rs b/txmanifest_lib/tests/bitcoin_path.rs index d44364b..86b66e5 100644 --- a/txmanifest_lib/tests/bitcoin_path.rs +++ b/txmanifest_lib/tests/bitcoin_path.rs @@ -86,6 +86,7 @@ fn a_scanned_utxo_becomes_a_signed_transaction() { fee_rate: 2.0, policy_asset: bitcoin_policy_asset(), change_assets: std::collections::HashSet::from([bitcoin_policy_asset()]), + lock_time: None, }; // 3. Narrow to Bitcoin. @@ -244,6 +245,7 @@ fn a_covenant_input_is_refused_rather_than_left_unsigned() { fee_rate: 1.0, policy_asset: bitcoin_policy_asset(), change_assets: std::collections::HashSet::from([bitcoin_policy_asset()]), + lock_time: None, }; // It narrows and builds — a covenant output is a perfectly ordinary P2TR input at this @@ -339,6 +341,7 @@ fn the_fee_keyword_is_estimable_on_bitcoin() { fee_rate: 2.0, policy_asset: bitcoin_policy_asset(), change_assets: Default::default(), + lock_time: None, }; let psbt_req = psbt_builder::from_pset_request(&req, None).expect("narrows"); diff --git a/txmanifest_lib/tests/examples_parse.rs b/txmanifest_lib/tests/examples_parse.rs index 51590a8..ac108c2 100644 --- a/txmanifest_lib/tests/examples_parse.rs +++ b/txmanifest_lib/tests/examples_parse.rs @@ -15,19 +15,35 @@ fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") } -/// Every `examples/*/txmanifest.json`, sorted for stable failure output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable failure output. An example may ship a second manifest in a subdirectory, as +/// `oracle_demo/issue_usdt` does, since `run` writes state files beside each manifest. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(examples_dir()) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = example_dirs(&examples_dir()) + .into_iter() + .flat_map(|dir| { + let mut dirs = example_dirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +/// The subdirectories of `dir`. +fn example_dirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + #[test] fn every_example_manifest_parses() { let manifests = example_manifests(); diff --git a/txmanifest_lib/tests/lending_v4.rs b/txmanifest_lib/tests/lending_v4.rs new file mode 100644 index 0000000..e3c1dc7 --- /dev/null +++ b/txmanifest_lib/tests/lending_v4.rs @@ -0,0 +1,727 @@ +//! Runs the oracle-liquidation paths of `examples/lending_v4/lending.simf` on the +//! BitMachine, against transactions built here, with statements signed by +//! `examples/oracle_demo`'s example oracle key and certified by its example cold key. +//! +//! The loan: 3,000 sat of L-BTC against 1.5 tUSDT at 1%, a debt of 151,500,000 base units. +//! At an 80% loan-to-value it may be liquidated below $63,125.00. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use lwk_wollet::elements::confidential::{Asset, Nonce, Value}; +use lwk_wollet::elements::hashes::{sha256, Hash, HashEngine}; +use lwk_wollet::elements::secp256k1_zkp::{Keypair, Message, PublicKey, Secp256k1, SecretKey}; +use lwk_wollet::elements::{ + AssetId, AssetIssuance, BlockHash, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, + TxInWitness, TxOut, TxOutWitness, Txid, +}; +use lwk_wollet::ElementsNetwork; +use serde_json::json; +use tx_manifest_lib::chain::ChainFamily; +use tx_manifest_lib::covenant; +use tx_manifest_lib::programs::ProgramSource; + +const LBTC: &str = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +/// Stand-ins for tUSDT and the loan's two NFTs. +const USD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const BORROWER_NFT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const LENDER_NFT: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +const COLLATERAL: u64 = 3_000; +const PRINCIPAL: u64 = 150_000_000; +const DEBT: u64 = 151_500_000; +const PROTOCOL_FEE: u64 = 150_000; +const WINDOW_BLOCKS: u32 = 5; +const MAX_FUTURE_SECONDS: u32 = 300; +const ENABLE_LOCKTIME: u32 = 0xffff_fffe; +const NOW: u64 = 1_792_660_000; +const SOURCES: &str = "binance,bitstamp,coinbase,kraken,okx"; +const BUDGET_PAD: &str = "list![0, 0, 0, 0, 0, 0, 0, 0]"; + +fn examples() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn simf_list(text: &str) -> String { + let bytes: Vec = text.bytes().map(|b| b.to_string()).collect(); + format!("list![{}]", bytes.join(", ")) +} + +fn secret(file: &str) -> SecretKey { + let raw: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(examples().join("oracle_demo/data").join(file)).unwrap(), + ) + .unwrap(); + SecretKey::from_str(raw["secret"].as_str().unwrap()).unwrap() +} + +fn oracle_key() -> SecretKey { + secret("example_oracle_key.json") +} + +fn cold_key() -> SecretKey { + secret("example_cold_key.json") +} + +/// Scripts whose hashes stand in for the loan's covenant hashes, so outputs can pay them. +fn p2tr(n: u8) -> Script { + Script::from([&[0x51, 0x20][..], &[n; 32]].concat()) +} + +fn script_hash(script: &Script) -> String { + hex(&sha256::Hash::hash(script.as_bytes()).to_byte_array()) +} + +const LENDER_VAULT: u8 = 0x31; +const FINALIZED_LENDER_VAULT: u8 = 0x32; +const PROTOCOL_FEE_VAULT: u8 = 0x33; +const FINALIZED_PROTOCOL_FEE_VAULT: u8 = 0x34; + +/// A certified statement split into the witnesses the program reads. +struct Statement { + price_cents: u64, + timestamp: u64, + witnesses: Vec<(&'static str, String)>, +} + +/// The example oracle signs a BTC/USD statement at `price` and `timestamp`, and the example +/// cold key certifies it for a day either side. +fn statement(price: &str, timestamp: u64) -> Statement { + let canonical = format!("v1|PRICE|BTCUSD|{price}|USD|2|{SOURCES}|median|{timestamp}|1"); + let secp = Secp256k1::new(); + let digest = sha256::Hash::hash(canonical.as_bytes()).to_byte_array(); + let sig = secp + .sign_ecdsa(&Message::from_digest_slice(&digest).unwrap(), &oracle_key()) + .serialize_compact(); + + let oracle_pubkey = PublicKey::from_secret_key(&secp, &oracle_key()).serialize(); + let (valid_from, valid_until) = (timestamp - 86_400, timestamp + 86_400); + let tag = sha256::Hash::hash(b"oracle-demo/oracle-cert/v1"); + let mut engine = sha256::Hash::engine(); + engine.input(tag.as_ref()); + engine.input(tag.as_ref()); + engine.input(&oracle_pubkey); + engine.input(&valid_from.to_be_bytes()); + engine.input(&valid_until.to_be_bytes()); + let cert_msg = Message::from_digest_slice(sha256::Hash::from_engine(engine).as_ref()).unwrap(); + let cert_sig = + secp.sign_schnorr_no_aux_rand(&cert_msg, &Keypair::from_secret_key(&secp, &cold_key())); + + let (integer, fraction) = price.split_once('.').unwrap(); + let frac = fraction.as_bytes(); + Statement { + price_cents: format!("{integer}{fraction}").parse().unwrap(), + timestamp, + witnesses: vec![ + ("STATEMENT_PRICE_INT", simf_list(integer)), + ( + "STATEMENT_PRICE_FRAC", + format!("({}, {})", frac[0], frac[1]), + ), + ("STATEMENT_SOURCES", simf_list(SOURCES)), + ("STATEMENT_METHOD", simf_list("median")), + ("STATEMENT_TIMESTAMP", simf_list(×tamp.to_string())), + ("STATEMENT_NONCE", simf_list("1")), + ( + "STATEMENT_SIG", + format!("(0x{}, 0x{})", hex(&sig[..32]), hex(&sig[32..])), + ), + ( + "CERT_ORACLE_KEY", + format!("({}, 0x{})", oracle_pubkey[0], hex(&oracle_pubkey[1..])), + ), + ("CERT_VALID_FROM", valid_from.to_string()), + ("CERT_VALID_UNTIL", valid_until.to_string()), + ("CERT_SIG", format!("0x{}", hex(cert_sig.as_ref()))), + ], + } +} + +const STATEMENT_WITNESSES: [&str; 11] = [ + "STATEMENT_PRICE_INT", + "STATEMENT_PRICE_FRAC", + "STATEMENT_SOURCES", + "STATEMENT_METHOD", + "STATEMENT_TIMESTAMP", + "STATEMENT_NONCE", + "STATEMENT_SIG", + "CERT_ORACLE_KEY", + "CERT_VALID_FROM", + "CERT_VALID_UNTIL", + "CERT_SIG", +]; + +/// Witnesses for one input: `PATH` (lending_v3's paths) or `LIQUIDATION`, and a statement +/// for the paths that check one. +fn witnesses( + path: Option<&str>, + liquidation: Option<&str>, + st: Option<&Statement>, +) -> serde_json::Value { + let value = |v: &str| json!({ "type": "simplicityhl", "value": v }); + let mut map = serde_json::Map::new(); + map.insert("PATH".into(), path.map_or(json!("unused"), value)); + map.insert( + "LIQUIDATION".into(), + liquidation.map_or(json!("unused"), |l| value(&format!("Some({l})"))), + ); + for name in STATEMENT_WITNESSES { + let w = st + .and_then(|s| s.witnesses.iter().find(|(n, _)| *n == name)) + .map_or(json!("unused"), |(_, v)| value(v)); + map.insert(name.into(), w); + } + map.insert( + "BUDGET_PAD".into(), + st.map_or(json!("unused"), |_| value(BUDGET_PAD)), + ); + serde_json::Value::Object(map) +} + +/// The loan, compiled once per test. +struct Loan { + program: ProgramSource, + params: HashMap, + hints: HashMap, +} + +impl Loan { + fn new() -> Self { + let secp = Secp256k1::new(); + let (cold_pubkey, _) = cold_key().x_only_public_key(&secp); + let mut params = HashMap::new(); + let mut hints = HashMap::new(); + let mut set = |name: &str, value: String, hint: &str| { + params.insert(name.to_string(), value); + hints.insert(name.to_string(), hint.to_string()); + }; + set("COLLATERAL_ASSET_ID", LBTC.into(), "liquid.asset_id"); + set("PRINCIPAL_ASSET_ID", USD.into(), "liquid.asset_id"); + set( + "BORROWER_NFT_ASSET_ID", + BORROWER_NFT.into(), + "liquid.asset_id", + ); + set("LENDER_NFT_ASSET_ID", LENDER_NFT.into(), "liquid.asset_id"); + set("COLLATERAL_AMOUNT", COLLATERAL.to_string(), "u64"); + set("PRINCIPAL_AMOUNT", PRINCIPAL.to_string(), "u64"); + set("PRINCIPAL_INTEREST_RATE", "100".into(), "u64"); + set("LOAN_EXPIRATION_TIME", "3000000".into(), "u32"); + set( + "LENDER_VAULT_COV_HASH", + script_hash(&p2tr(LENDER_VAULT)), + "bytes32", + ); + set( + "FINALIZED_LENDER_VAULT_COV_HASH", + script_hash(&p2tr(FINALIZED_LENDER_VAULT)), + "bytes32", + ); + set( + "PROTOCOL_FEE_VAULT_COV_HASH", + script_hash(&p2tr(PROTOCOL_FEE_VAULT)), + "bytes32", + ); + set( + "FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH", + script_hash(&p2tr(FINALIZED_PROTOCOL_FEE_VAULT)), + "bytes32", + ); + set( + "PRINCIPAL_OUTPUT_SCRIPT_HASH", + script_hash(&p2tr(0x35)), + "bytes32", + ); + set("COLD_PUBKEY", hex(&cold_pubkey.serialize()), "pubkey"); + set("MAX_FUTURE_SECONDS", MAX_FUTURE_SECONDS.to_string(), "u32"); + set("LIQUIDATION_LTV_BPS", "8000".into(), "u64"); + set("WINDOW_BLOCKS", WINDOW_BLOCKS.to_string(), "u16"); + Self { + program: ProgramSource::read_unpinned(examples().join("lending_v4/lending.simf")) + .unwrap(), + params, + hints, + } + } + + /// Slot 0 for the active state, and for a pending liquidation of `(timestamp, price)`. + fn active_slot() -> Vec { + let mut slot = vec![0; 32]; + slot[31] = 1; + slot + } + + fn pending_slot(claim: (u64, u64)) -> Vec { + let mut slot = Vec::new(); + slot.extend(claim.0.to_be_bytes()); + slot.extend(claim.1.to_be_bytes()); + slot.extend(0u64.to_be_bytes()); + slot.extend(2u64.to_be_bytes()); + slot + } + + fn leaves(slot0: Vec) -> [Vec; 2] { + let mut debt = vec![0; 24]; + debt.extend(DEBT.to_be_bytes()); + [slot0, debt] + } + + fn script(&self, slot0: Vec) -> Script { + covenant::compute_covenant_address( + &self.program, + &self.params, + &self.hints, + &Self::leaves(slot0), + ElementsNetwork::LiquidTestnet, + true, + ) + .unwrap() + .script_pubkey() + } + + fn run( + &self, + slot0: Vec, + witnesses: serde_json::Value, + tx: &Transaction, + utxos: &[TxOut], + index: u32, + ) -> anyhow::Result<()> { + covenant::dry_run_covenant( + &self.program, + &self.params, + &self.hints, + &Self::leaves(slot0), + Some(&witnesses), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + false, + true, + ) + } + + /// Finalize, which also checks the program's cost against its witness's budget. + fn finalize( + &self, + slot0: Vec, + witnesses: serde_json::Value, + tx: &Transaction, + utxos: &[TxOut], + index: u32, + ) -> anyhow::Result<()> { + let mut input = lwk_wollet::elements::pset::Input::default(); + covenant::finalize_covenant_input( + &self.program, + &self.params, + &self.hints, + &Self::leaves(slot0), + Some(&witnesses), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + &mut input, + true, + ) + } +} + +/// Assert that the program ran and refused the spend, rather than failing before it ran. +fn assert_rejected(result: anyhow::Result<()>) { + let err = format!("{:#}", result.expect_err("the spend was accepted")); + assert!( + err.contains("satisfaction failed") || err.contains("execution failed"), + "failed before the program ran: {err}" + ); +} + +fn txout(script: &Script, asset: &str, amount: u64) -> TxOut { + TxOut { + asset: Asset::Explicit(AssetId::from_str(asset).unwrap()), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: script.clone(), + witness: TxOutWitness::default(), + } +} + +fn wallet() -> Script { + Script::from(Vec::from_iter([0x00, 0x14].into_iter().chain([0x77; 20]))) +} + +fn burn(asset: &str, amount: u64) -> TxOut { + txout(&Script::from(vec![0x6a]), asset, amount) +} + +fn fee() -> TxOut { + TxOut::new_fee(500, AssetId::from_str(LBTC).unwrap()) +} + +fn build_tx(sequences: &[u32], lock_time: u32, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::from_consensus(lock_time), + input: sequences + .iter() + .enumerate() + .map(|(n, &sequence)| TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([n as u8 + 1; 32]), 0), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence(sequence), + asset_issuance: AssetIssuance::default(), + witness: TxInWitness::default(), + }) + .collect(), + output: outputs, + } +} + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +fn claim_of(st: &Statement) -> (u64, u64) { + (st.timestamp, st.price_cents) +} + +/// A start transaction for `st`: the active loan and the lender NFT in, the pending loan +/// at `claim` and the NFT out, locked at the earliest time the statement allows. +fn start_tx(loan: &Loan, st: &Statement, claim: (u64, u64)) -> (Transaction, Vec) { + let tx = build_tx( + &[ENABLE_LOCKTIME; 3], + (st.timestamp - MAX_FUTURE_SECONDS as u64) as u32, + vec![ + txout(&loan.script(Loan::pending_slot(claim)), LBTC, COLLATERAL), + txout(&wallet(), LENDER_NFT, 1), + txout(&wallet(), LBTC, 9_500), + fee(), + ], + ); + let utxos = vec![ + txout(&loan.script(Loan::active_slot()), LBTC, COLLATERAL), + txout(&wallet(), LENDER_NFT, 1), + txout(&wallet(), LBTC, 10_000), + ]; + (tx, utxos) +} + +fn start(loan: &Loan, st: &Statement, tx: &Transaction, utxos: &[TxOut]) -> anyhow::Result<()> { + let step = format!("Left(Left({DEBT}))"); + loan.run( + Loan::active_slot(), + witnesses(None, Some(&step), Some(st)), + tx, + utxos, + 0, + ) +} + +#[test] +fn a_lender_starts_a_liquidation_at_a_crashed_price() { + let loan = Loan::new(); + let st = statement("60000.00", NOW); + let (tx, utxos) = start_tx(&loan, &st, claim_of(&st)); + start(&loan, &st, &tx, &utxos).unwrap(); +} + +#[test] +fn a_liquidation_starts_only_below_the_threshold() { + // 3,000 sat at $63,125.00 is worth 189,375,000, and 80% of that is exactly the debt. + let loan = Loan::new(); + for (price, liquidatable) in [("63124.99", true), ("63125.00", false), ("84231.50", false)] { + let st = statement(price, NOW); + let (tx, utxos) = start_tx(&loan, &st, claim_of(&st)); + let result = start(&loan, &st, &tx, &utxos); + if liquidatable { + result.unwrap(); + } else { + assert_rejected(result); + } + } +} + +#[test] +fn a_liquidation_needs_the_lender_nft_shown_and_kept() { + let loan = Loan::new(); + let st = statement("60000.00", NOW); + + let (tx, mut utxos) = start_tx(&loan, &st, claim_of(&st)); + utxos[1] = txout(&wallet(), BORROWER_NFT, 1); + assert_rejected(start(&loan, &st, &tx, &utxos)); + + let (mut tx, utxos) = start_tx(&loan, &st, claim_of(&st)); + tx.output[1] = txout(&wallet(), LBTC, 1); + assert_rejected(start(&loan, &st, &tx, &utxos)); +} + +#[test] +fn a_liquidation_records_the_claim_it_was_started_with() { + let loan = Loan::new(); + let st = statement("60000.00", NOW); + let (tx, utxos) = start_tx(&loan, &st, (st.timestamp, st.price_cents + 1)); + assert_rejected(start(&loan, &st, &tx, &utxos)); +} + +#[test] +fn a_liquidation_statement_cannot_be_dated_ahead_of_its_lock_time() { + let loan = Loan::new(); + let st = statement("60000.00", NOW); + let (mut tx, utxos) = start_tx(&loan, &st, claim_of(&st)); + tx.lock_time = LockTime::from_consensus(tx.lock_time.to_consensus_u32() - 1); + assert_rejected(start(&loan, &st, &tx, &utxos)); +} + +// --------------------------------------------------------------------------- +// Cancel, finish, repay +// --------------------------------------------------------------------------- + +/// The claim a liquidation was started with: a crash to $60,000 at NOW. +fn pending_claim() -> (u64, u64) { + (NOW, 6_000_000) +} + +fn claim_step(kind: &str) -> String { + let (ts, price) = pending_claim(); + format!("{kind}(({DEBT}, ({ts}, {price})))") +} + +fn cancel(loan: &Loan, newer: &Statement, active_output: bool) -> anyhow::Result<()> { + let to = match active_output { + true => loan.script(Loan::active_slot()), + false => wallet(), + }; + let tx = build_tx( + &[0xffff_ffff, 0xffff_ffff], + 0, + vec![ + txout(&to, LBTC, COLLATERAL), + txout(&wallet(), LBTC, 9_500), + fee(), + ], + ); + let utxos = vec![ + txout( + &loan.script(Loan::pending_slot(pending_claim())), + LBTC, + COLLATERAL, + ), + txout(&wallet(), LBTC, 10_000), + ]; + let step = format!("Left({})", claim_step("Right")); + loan.run( + Loan::pending_slot(pending_claim()), + witnesses(None, Some(&step), Some(newer)), + &tx, + &utxos, + 0, + ) +} + +#[test] +fn a_newer_price_at_which_the_loan_is_fine_cancels_the_liquidation() { + let loan = Loan::new(); + cancel(&loan, &statement("84231.50", NOW + 60), true).unwrap(); +} + +#[test] +fn a_liquidation_is_not_cancelled_by_a_price_that_still_justifies_it() { + let loan = Loan::new(); + assert_rejected(cancel(&loan, &statement("61000.00", NOW + 60), true)); +} + +#[test] +fn a_liquidation_is_not_cancelled_by_an_older_statement() { + let loan = Loan::new(); + assert_rejected(cancel(&loan, &statement("84231.50", NOW - 60), true)); + assert_rejected(cancel(&loan, &statement("84231.50", NOW), true)); +} + +#[test] +fn a_cancel_returns_the_loan_to_active() { + let loan = Loan::new(); + assert_rejected(cancel(&loan, &statement("84231.50", NOW + 60), false)); +} + +fn finish_tx(loan: &Loan, sequence: u32, burned: TxOut) -> (Transaction, Vec) { + let tx = build_tx( + &[sequence, 0xffff_ffff, 0xffff_ffff], + 0, + vec![ + burned, + txout(&wallet(), LBTC, COLLATERAL), + txout(&wallet(), LBTC, 9_500), + fee(), + ], + ); + let utxos = vec![ + txout( + &loan.script(Loan::pending_slot(pending_claim())), + LBTC, + COLLATERAL, + ), + txout(&wallet(), LENDER_NFT, 1), + txout(&wallet(), LBTC, 10_000), + ]; + (tx, utxos) +} + +fn finish(loan: &Loan, tx: &Transaction, utxos: &[TxOut]) -> anyhow::Result<()> { + let step = format!("Right({})", claim_step("Left")); + loan.run( + Loan::pending_slot(pending_claim()), + witnesses(None, Some(&step), None), + tx, + utxos, + 0, + ) +} + +#[test] +fn the_lender_takes_the_collateral_after_the_window() { + let loan = Loan::new(); + let (tx, utxos) = finish_tx(&loan, WINDOW_BLOCKS, burn(LENDER_NFT, 1)); + finish(&loan, &tx, &utxos).unwrap(); +} + +#[test] +fn a_liquidation_waits_for_the_window() { + let loan = Loan::new(); + let (tx, utxos) = finish_tx(&loan, WINDOW_BLOCKS - 1, burn(LENDER_NFT, 1)); + assert_rejected(finish(&loan, &tx, &utxos)); +} + +#[test] +fn finishing_burns_the_lender_nft() { + let loan = Loan::new(); + let (tx, utxos) = finish_tx(&loan, WINDOW_BLOCKS, txout(&wallet(), LENDER_NFT, 1)); + assert_rejected(finish(&loan, &tx, &utxos)); +} + +/// A full repayment: the borrower NFT (input 0) burned, the loan (input 1), the repayment +/// (input 2); the lender's and the protocol's shares to their finalized vaults. +fn repay_tx(loan_script: Script) -> (Transaction, Vec) { + let tx = build_tx( + &[0xffff_ffff; 4], + 0, + vec![ + burn(BORROWER_NFT, 1), + txout(&p2tr(FINALIZED_LENDER_VAULT), USD, DEBT - PROTOCOL_FEE), + txout(&p2tr(FINALIZED_PROTOCOL_FEE_VAULT), USD, PROTOCOL_FEE), + txout(&wallet(), LBTC, COLLATERAL), + txout(&wallet(), LBTC, 9_500), + fee(), + ], + ); + let utxos = vec![ + txout(&wallet(), BORROWER_NFT, 1), + txout(&loan_script, LBTC, COLLATERAL), + txout(&wallet(), USD, DEBT), + txout(&wallet(), LBTC, 10_000), + ]; + (tx, utxos) +} + +#[test] +fn the_borrower_can_repay_while_a_liquidation_is_pending() { + let loan = Loan::new(); + let pending = Loan::pending_slot(pending_claim()); + let (tx, utxos) = repay_tx(loan.script(pending.clone())); + let step = format!("Right({})", claim_step("Right")); + loan.run( + pending.clone(), + witnesses(None, Some(&step), None), + &tx, + &utxos, + 1, + ) + .unwrap(); + + // lending_v3's repayment path still works on an active loan, and only there. + let full_repayment = format!("Right(Left(Right({DEBT})))"); + let (tx, utxos) = repay_tx(loan.script(Loan::active_slot())); + loan.run( + Loan::active_slot(), + witnesses(Some(&full_repayment), None, None), + &tx, + &utxos, + 1, + ) + .unwrap(); + let (tx, utxos) = repay_tx(loan.script(pending.clone())); + assert_rejected(loan.run( + pending, + witnesses(Some(&full_repayment), None, None), + &tx, + &utxos, + 1, + )); +} + +#[test] +fn every_liquidation_path_fits_its_budget() { + let loan = Loan::new(); + + let st = statement("60000.00", NOW); + let (tx, utxos) = start_tx(&loan, &st, claim_of(&st)); + let step = format!("Left(Left({DEBT}))"); + loan.finalize( + Loan::active_slot(), + witnesses(None, Some(&step), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); + + let newer = statement("84231.50", NOW + 60); + let tx = build_tx( + &[0xffff_ffff, 0xffff_ffff], + 0, + vec![ + txout(&loan.script(Loan::active_slot()), LBTC, COLLATERAL), + txout(&wallet(), LBTC, 9_500), + fee(), + ], + ); + let utxos = vec![ + txout( + &loan.script(Loan::pending_slot(pending_claim())), + LBTC, + COLLATERAL, + ), + txout(&wallet(), LBTC, 10_000), + ]; + let step = format!("Left({})", claim_step("Right")); + loan.finalize( + Loan::pending_slot(pending_claim()), + witnesses(None, Some(&step), Some(&newer)), + &tx, + &utxos, + 0, + ) + .unwrap(); + + let (tx, utxos) = finish_tx(&loan, WINDOW_BLOCKS, burn(LENDER_NFT, 1)); + let step = format!("Right({})", claim_step("Left")); + loan.finalize( + Loan::pending_slot(pending_claim()), + witnesses(None, Some(&step), None), + &tx, + &utxos, + 0, + ) + .unwrap(); +} diff --git a/txmanifest_lib/tests/oracle_demo.rs b/txmanifest_lib/tests/oracle_demo.rs new file mode 100644 index 0000000..b95854e --- /dev/null +++ b/txmanifest_lib/tests/oracle_demo.rs @@ -0,0 +1,1026 @@ +//! Runs every spending path of `examples/oracle_demo/oracle_mint.simf` on the BitMachine, +//! against transactions built here, with the fake oracle's signed statements from +//! `examples/oracle_demo/data/`. +//! +//! Each path has a passing case and the failures that matter for the challenge window: +//! a forged or stale statement, a challenge within tolerance, a finalize before the +//! window, and a second treasury UTXO riding along with a request. And for the cold-key +//! certificates: a statement outside its certificate's range, a certificate from the +//! wrong key or for another oracle, and a challenge from an oracle certified later. And a +//! request whose lock time doesn't bound its statement's timestamp. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use base64::Engine as _; +use lwk_wollet::elements::confidential::{Asset, Nonce, Value}; +use lwk_wollet::elements::hashes::{sha256, Hash, HashEngine}; +use lwk_wollet::elements::secp256k1_zkp::{ + ecdsa, Keypair, Message, PublicKey, Secp256k1, SecretKey, +}; +use lwk_wollet::elements::{ + AssetId, AssetIssuance, BlockHash, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, + TxInWitness, TxOut, TxOutWitness, Txid, +}; +use lwk_wollet::ElementsNetwork; +use serde_json::json; +use tx_manifest_lib::chain::ChainFamily; +use tx_manifest_lib::covenant; +use tx_manifest_lib::programs::ProgramSource; + +const LBTC: &str = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +/// A stand-in for the tUSDT asset id. +const USD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const WINDOW_BLOCKS: u32 = 5; +const SUPPLY: u64 = 1_000_000_000_000_000; +const LBTC_AMOUNT: u64 = 100_000; +const MAX_FUTURE_SECONDS: u32 = 300; +/// The `nSequence` the wallet gives an input when the transaction has a lock time. +const ENABLE_LOCKTIME: u32 = 0xffff_fffe; + +fn example_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/oracle_demo") +} + +fn program() -> ProgramSource { + ProgramSource::read_unpinned(example_dir().join("oracle_mint.simf")).unwrap() +} + +/// A statement split into the witnesses the program reads, its certificate's among them. +struct Statement { + price_cents: u64, + timestamp: u64, + witnesses: BTreeMap<&'static str, String>, +} + +impl Statement { + /// This statement, presented with `cert` in place of any certificate it had. + fn with_cert(mut self, cert: &Cert) -> Self { + self.witnesses.extend(cert.witnesses()); + self + } +} + +/// A certificate: the cold key's BIP340 signature saying `oracle_key` may sign statements +/// timestamped from `valid_from` to `valid_until`. +struct Cert { + oracle_key: [u8; 33], + valid_from: u64, + valid_until: u64, + sig: [u8; 64], +} + +impl Cert { + fn witnesses(&self) -> [(&'static str, String); 4] { + [ + ( + "CERT_ORACLE_KEY", + format!("({}, 0x{})", self.oracle_key[0], hex(&self.oracle_key[1..])), + ), + ("CERT_VALID_FROM", self.valid_from.to_string()), + ("CERT_VALID_UNTIL", self.valid_until.to_string()), + ("CERT_SIG", format!("0x{}", hex(&self.sig))), + ] + } +} + +const SOURCES: &str = "binance,bitstamp,coinbase,kraken,okx"; + +fn simf_list(text: &str) -> String { + let bytes: Vec = text.bytes().map(|b| b.to_string()).collect(); + format!("list![{}]", bytes.join(", ")) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()) + .collect() +} + +fn read_json(name: &str) -> serde_json::Value { + serde_json::from_str(&std::fs::read_to_string(example_dir().join(name)).unwrap()).unwrap() +} + +fn secret(file: &str) -> SecretKey { + SecretKey::from_str(read_json(file)["secret"].as_str().unwrap()).unwrap() +} + +/// The fake oracle's key, which signed every statement in `data/`. +fn oracle_key() -> SecretKey { + secret("data/example_oracle_key.json") +} + +/// The service's cold key, which certifies oracle keys. +fn cold_key() -> SecretKey { + secret("data/example_cold_key.json") +} + +/// A second oracle, to rotate to. Any key will do. +fn second_oracle_key() -> SecretKey { + SecretKey::from_slice(&[0x42; 32]).unwrap() +} + +/// `cold` certifies `oracle` for statements timestamped `valid_from..=valid_until`. +fn certify(cold: &SecretKey, oracle: &SecretKey, valid_from: u64, valid_until: u64) -> Cert { + let secp = Secp256k1::new(); + let oracle_key = PublicKey::from_secret_key(&secp, oracle).serialize(); + let tag = sha256::Hash::hash(b"oracle-demo/oracle-cert/v1"); + let mut engine = sha256::Hash::engine(); + engine.input(tag.as_ref()); + engine.input(tag.as_ref()); + engine.input(&oracle_key); + engine.input(&valid_from.to_be_bytes()); + engine.input(&valid_until.to_be_bytes()); + let msg = Message::from_digest_slice(sha256::Hash::from_engine(engine).as_ref()).unwrap(); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &Keypair::from_secret_key(&secp, cold)); + Cert { + oracle_key, + valid_from, + valid_until, + sig: *sig.as_ref(), + } +} + +/// The certificate fake_oracle.py made for the oracle key, `data/cert_oracle.json`. +/// Statements from `data/` carry it, so the program checks the script's BIP340 signer. +fn fixture_cert() -> Cert { + let raw = read_json("data/cert_oracle.json"); + let bytes = |field: &str| unhex(raw[field].as_str().unwrap()); + Cert { + oracle_key: bytes("oracle_pubkey").try_into().unwrap(), + valid_from: raw["valid_from"].as_u64().unwrap(), + valid_until: raw["valid_until"].as_u64().unwrap(), + sig: bytes("signature").try_into().unwrap(), + } +} + +/// A statement's witnesses, given the variable fields as the witness splits them. It has +/// no certificate until [`Statement::with_cert`]. +fn split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, + compact_sig: [u8; 64], +) -> Statement { + let (integer, fraction) = price.split_once('.').unwrap(); + let frac = fraction.as_bytes(); + let witnesses = BTreeMap::from([ + ("STATEMENT_PRICE_INT", simf_list(integer)), + ( + "STATEMENT_PRICE_FRAC", + format!("({}, {})", frac[0], frac[1]), + ), + ("STATEMENT_SOURCES", simf_list(sources)), + ("STATEMENT_METHOD", simf_list(method)), + ("STATEMENT_TIMESTAMP", simf_list(timestamp)), + ("STATEMENT_NONCE", simf_list(nonce)), + ( + "STATEMENT_SIG", + format!( + "(0x{}, 0x{})", + hex(&compact_sig[..32]), + hex(&compact_sig[32..]) + ), + ), + ]); + Statement { + price_cents: format!("{integer}{fraction}").parse().unwrap(), + timestamp: timestamp.parse().unwrap(), + witnesses, + } +} + +/// A statement the fake oracle signed, from `data/statement_.json`, with the +/// fixture certificate. +fn statement(name: &str) -> Statement { + let raw = read_json(&format!("data/statement_{name}.json")); + let canonical = raw["canonical"].as_str().unwrap(); + let fields: Vec<&str> = canonical.split('|').collect(); + assert_eq!(fields.len(), 10, "{canonical}"); + + let der = base64::engine::general_purpose::STANDARD + .decode(raw["signature"].as_str().unwrap()) + .unwrap(); + let compact = ecdsa::Signature::from_der(&der) + .unwrap() + .serialize_compact(); + split( + fields[3], fields[6], fields[7], fields[8], fields[9], compact, + ) + .with_cert(&fixture_cert()) +} + +/// `oracle` signs `v1|PRICE|BTCUSD||USD|2||||`, +/// split into exactly those fields. No certificate yet. +fn signed_by( + oracle: &SecretKey, + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, +) -> Statement { + let canonical = format!("v1|PRICE|BTCUSD|{price}|USD|2|{sources}|{method}|{timestamp}|{nonce}"); + let digest = sha256::Hash::hash(canonical.as_bytes()).to_byte_array(); + let secp = Secp256k1::new(); + let sig = secp.sign_ecdsa(&Message::from_digest_slice(&digest).unwrap(), oracle); + split( + price, + sources, + method, + timestamp, + nonce, + sig.serialize_compact(), + ) +} + +/// The fake oracle signs a string split into these fields, certified for any timestamp. +fn signed_split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, +) -> Statement { + signed_by(&oracle_key(), price, sources, method, timestamp, nonce).with_cert(&certify( + &cold_key(), + &oracle_key(), + 0, + u64::MAX, + )) +} + +const STATEMENT_WITNESSES: [&str; 11] = [ + "STATEMENT_PRICE_INT", + "STATEMENT_PRICE_FRAC", + "STATEMENT_SOURCES", + "STATEMENT_METHOD", + "STATEMENT_TIMESTAMP", + "STATEMENT_NONCE", + "STATEMENT_SIG", + "CERT_ORACLE_KEY", + "CERT_VALID_FROM", + "CERT_VALID_UNTIL", + "CERT_SIG", +]; + +/// One mint service, compiled once per test. +struct Service { + program: ProgramSource, + params: HashMap, + hints: HashMap, +} + +impl Service { + fn new() -> Self { + let secp = Secp256k1::new(); + let (cold_pubkey, _) = cold_key().x_only_public_key(&secp); + let params = HashMap::from([ + ("COLD_PUBKEY".into(), hex(&cold_pubkey.serialize())), + ("LBTC_ASSET".into(), LBTC.into()), + ("USD_ASSET".into(), USD.into()), + ("WINDOW_BLOCKS".into(), WINDOW_BLOCKS.to_string()), + ("TOLERANCE_BPS".into(), "50".into()), + ("MAX_FUTURE_SECONDS".into(), MAX_FUTURE_SECONDS.to_string()), + ]); + let hints = HashMap::from([ + ("COLD_PUBKEY".into(), "pubkey".into()), + ("LBTC_ASSET".into(), "liquid.asset_id".into()), + ("USD_ASSET".into(), "liquid.asset_id".into()), + ("WINDOW_BLOCKS".into(), "u16".into()), + ("TOLERANCE_BPS".into(), "u64".into()), + ("MAX_FUTURE_SECONDS".into(), "u32".into()), + ]); + Self { + program: program(), + params, + hints, + } + } + + fn script(&self, leaf: &[u8]) -> Script { + covenant::compute_covenant_address( + &self.program, + &self.params, + &self.hints, + &[leaf.to_vec()], + ElementsNetwork::LiquidTestnet, + false, + ) + .unwrap() + .script_pubkey() + } + + fn treasury(&self) -> (Script, Vec) { + (self.script(&[0]), vec![0]) + } + + fn pending(&self, req: &Request) -> (Script, Vec) { + let mut leaf = vec![1]; + leaf.extend(req.price.to_be_bytes()); + leaf.extend(req.timestamp.to_be_bytes()); + leaf.extend(req.lbtc_amount.to_be_bytes()); + leaf.extend(sha256::Hash::hash(req.recipient.as_bytes()).to_byte_array()); + (self.script(&leaf), leaf) + } + + /// Dry-run input `index` of `tx`, which spends `utxos`, as the covenant with `leaf`. + fn run( + &self, + leaf: &[u8], + witnesses: serde_json::Value, + tx: &Transaction, + utxos: &[TxOut], + index: u32, + ) -> anyhow::Result<()> { + covenant::dry_run_covenant( + &self.program, + &self.params, + &self.hints, + &[leaf.to_vec()], + Some(&witnesses), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + false, + false, + ) + } +} + +#[derive(Clone)] +struct Request { + price: u64, + timestamp: u64, + lbtc_amount: u64, + recipient: Script, +} + +impl Request { + fn from_statement(st: &Statement) -> Self { + Self { + price: st.price_cents, + timestamp: st.timestamp, + lbtc_amount: LBTC_AMOUNT, + recipient: Script::from(Vec::from_iter([0x00, 0x14].into_iter().chain([0x42; 20]))), + } + } + + fn usd_amount(&self) -> u64 { + self.lbtc_amount * self.price / 100 + } + + fn recipient_hash(&self) -> String { + hex(&sha256::Hash::hash(self.recipient.as_bytes()).to_byte_array()) + } +} + +fn witness(value: &str) -> serde_json::Value { + json!({ "type": "simplicityhl", "value": value }) +} + +/// The witness map for one input: `path`, the request's fields or "unused", and the +/// statement's or "unused", padded when there is a statement. +fn witnesses(path: &str, req: Option<&Request>, st: Option<&Statement>) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert("PATH".into(), witness(path)); + let fields = req.map(|r| { + [ + r.price.to_string(), + r.timestamp.to_string(), + r.lbtc_amount.to_string(), + r.recipient_hash(), + ] + }); + for (i, name) in [ + "REQUEST_PRICE", + "REQUEST_TIMESTAMP", + "REQUEST_LBTC_AMOUNT", + "REQUEST_RECIPIENT", + ] + .iter() + .enumerate() + { + let value = match &fields { + Some(f) => witness(&f[i]), + None => json!("unused"), + }; + map.insert((*name).into(), value); + } + for name in STATEMENT_WITNESSES { + let value = match st { + Some(s) => witness(&s.witnesses[name]), + None => json!("unused"), + }; + map.insert(name.into(), value); + } + // The paths that check a statement are the ones that need padding. + let pad = match st { + Some(_) => witness(BUDGET_PAD), + None => json!("unused"), + }; + map.insert("BUDGET_PAD".into(), pad); + serde_json::Value::Object(map) +} + +/// Assert that the program ran and refused the spend, rather than failing before it ran. +fn assert_rejected(result: anyhow::Result<()>) { + let err = format!("{:#}", result.expect_err("the spend was accepted")); + assert!( + err.contains("satisfaction failed") || err.contains("execution failed"), + "failed before the program ran: {err}" + ); +} + +fn asset(id: &str) -> AssetId { + AssetId::from_str(id).unwrap() +} + +fn txout(script: &Script, asset_id: &str, amount: u64) -> TxOut { + TxOut { + asset: Asset::Explicit(asset(asset_id)), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: script.clone(), + witness: TxOutWitness::default(), + } +} + +fn fee(amount: u64) -> TxOut { + TxOut::new_fee(amount, asset(LBTC)) +} + +fn burn(amount: u64) -> TxOut { + txout(&Script::from(vec![0x6a]), LBTC, amount) +} + +fn wallet_script() -> Script { + Script::from(Vec::from_iter([0x00, 0x14].into_iter().chain([0x77; 20]))) +} + +fn txin(n: u8, sequence: u32) -> TxIn { + TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([n; 32]), 0), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence(sequence), + asset_issuance: AssetIssuance::default(), + witness: TxInWitness::default(), + } +} + +fn tx(inputs: Vec, outputs: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +/// A request transaction, its spent outputs, and the treasury leaf. Its lock time is the +/// earliest the statement allows, `timestamp - MAX_FUTURE_SECONDS`, as the manifest sets. +fn request_tx(svc: &Service, req: &Request, usd_amount: u64) -> (Transaction, Vec, Vec) { + let (treasury, treasury_leaf) = svc.treasury(); + let (pending, _) = svc.pending(req); + let mut tx = tx( + vec![txin(1, ENABLE_LOCKTIME), txin(2, ENABLE_LOCKTIME)], + vec![ + txout(&pending, LBTC, req.lbtc_amount), + txout(&pending, USD, usd_amount), + txout(&treasury, USD, SUPPLY - usd_amount), + txout(&wallet_script(), LBTC, 400_000 - req.lbtc_amount - 500), + fee(500), + ], + ); + tx.lock_time = LockTime::from_consensus(req.timestamp as u32 - MAX_FUTURE_SECONDS); + let utxos = vec![ + txout(&treasury, USD, SUPPLY), + txout(&wallet_script(), LBTC, 400_000), + ]; + (tx, utxos, treasury_leaf) +} + +#[test] +fn request_opens_a_pending_mint() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn request_refuses_more_tusdt_than_the_price_buys() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount() + 1); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn request_refuses_a_forged_price() { + let svc = Service::new(); + let mut st = statement("request"); + // 94231.50 under the signature on 84231.50. + st.witnesses + .insert("STATEMENT_PRICE_INT", simf_list("94231")); + st.price_cents = 9_423_150; + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn request_refuses_a_separator_inside_a_text_field() { + // A signed string with two fields more than the format has. Read with the first + // three fields after the decimals squeezed into "sources", it hashes to the same + // bytes, so the signature verifies, and the nonce would read as a far-future + // timestamp. Only the '|' check stands in the way. + let svc = Service::new(); + let st = signed_split( + "84231.50", + "okx|median|1741514400", + "482910", + "4000000000", + "0", + ); + let req = Request::from_statement(&st); + assert_eq!(req.timestamp, 4_000_000_000); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); + + // The same split without the separators is accepted, so it is the '|' that fails. + let st = signed_split( + "84231.50", + "okx,median,1741514400", + "482910", + "4000000000", + "0", + ); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn request_refuses_a_pending_address_for_another_price() { + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (mut tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + let (other, _) = svc.pending(&Request { + price: req.price + 1, + ..req.clone() + }); + tx.output[0].script_pubkey = other.clone(); + tx.output[1].script_pubkey = other; + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); +} + +#[test] +fn a_second_treasury_utxo_cannot_ride_along() { + // Both treasury UTXOs hold the supply, so input 0's checks on outputs 0-2 are met for + // either. Input 1 would be free to pay its tUSDT anywhere if any path let it through. + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let (treasury, leaf) = svc.treasury(); + let (mut tx, mut utxos, _) = request_tx(&svc, &req, req.usd_amount()); + utxos[1] = txout(&treasury, USD, SUPPLY); + tx.output[3] = txout(&wallet_script(), USD, SUPPLY); + + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); + for path in [ + "Left(Left(()))", + "Left(Right(()))", + "Right(Left(()))", + "Right(Right(()))", + ] { + assert_rejected(svc.run( + &leaf, + witnesses(path, Some(&req), Some(&st)), + &tx, + &utxos, + 1, + )); + } +} + +// --------------------------------------------------------------------------- +// Finalize and challenge +// --------------------------------------------------------------------------- + +/// Spend a pending request's two UTXOs, plus a wallet fee input. +fn settle_tx( + svc: &Service, + req: &Request, + sequence: u32, + outputs: Vec, +) -> (Transaction, Vec, Vec) { + let (pending, leaf) = svc.pending(req); + let tx = tx( + vec![ + txin(3, sequence), + txin(4, 0xffff_ffff), + txin(5, 0xffff_ffff), + ], + outputs, + ); + let utxos = vec![ + txout(&pending, LBTC, req.lbtc_amount), + txout(&pending, USD, req.usd_amount()), + txout(&wallet_script(), LBTC, 10_000), + ]; + (tx, utxos, leaf) +} + +fn finalize_outputs(req: &Request) -> Vec { + vec![ + txout(&req.recipient, USD, req.usd_amount()), + burn(req.lbtc_amount), + txout(&wallet_script(), LBTC, 9_500), + fee(500), + ] +} + +fn finalize(svc: &Service, req: &Request, sequence: u32) -> anyhow::Result<()> { + let (tx, utxos, leaf) = settle_tx(svc, req, sequence, finalize_outputs(req)); + svc.run( + &leaf, + witnesses("Left(Right(()))", Some(req), None), + &tx, + &utxos, + 0, + )?; + svc.run( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) +} + +#[test] +fn finalize_after_the_window_pays_out_and_burns() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + finalize(&svc, &req, WINDOW_BLOCKS).unwrap(); +} + +#[test] +fn finalize_waits_for_the_window() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(finalize(&svc, &req, WINDOW_BLOCKS - 1)); + // Relative lock disabled, and a time-based lock of the same number. + assert_rejected(finalize(&svc, &req, 0x8000_0000 | WINDOW_BLOCKS)); + assert_rejected(finalize(&svc, &req, 0x0040_0000 | WINDOW_BLOCKS)); +} + +#[test] +fn finalize_pays_only_the_recipient() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + let mut outputs = finalize_outputs(&req); + outputs[0].script_pubkey = wallet_script(); + let (tx, utxos, leaf) = settle_tx(&svc, &req, WINDOW_BLOCKS, outputs); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Right(()))", Some(&req), None), + &tx, + &utxos, + 0, + )); +} + +fn challenge(svc: &Service, req: &Request, newer: &str) -> anyhow::Result<()> { + challenge_with(svc, req, &statement(newer)) +} + +fn challenge_with(svc: &Service, req: &Request, st: &Statement) -> anyhow::Result<()> { + let (treasury, _) = svc.treasury(); + let outputs = vec![ + txout(&req.recipient, LBTC, req.lbtc_amount), + txout(&treasury, USD, req.usd_amount()), + txout(&wallet_script(), LBTC, 9_500), + fee(500), + ]; + let (tx, utxos, leaf) = settle_tx(svc, req, 0xffff_ffff, outputs); + svc.run( + &leaf, + witnesses("Right(Left(()))", Some(req), Some(st)), + &tx, + &utxos, + 0, + )?; + svc.run( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) +} + +#[test] +fn challenge_with_a_newer_price_beyond_tolerance_refunds() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + // Both directions: the request's price was stale whichever way it moved. + challenge(&svc, &req, "rose").unwrap(); + challenge(&svc, &req, "fell").unwrap(); +} + +#[test] +fn challenge_within_tolerance_is_refused() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(challenge(&svc, &req, "close")); +} + +#[test] +fn challenge_with_an_older_statement_is_refused() { + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + assert_rejected(challenge(&svc, &req, "older")); + assert_rejected(challenge(&svc, &req, "request")); +} + +#[test] +fn request_refuses_a_statement_dated_ahead_of_its_lock_time() { + // Each of these lets the transaction be mined while the statement's timestamp is more + // than MAX_FUTURE_SECONDS past the chain's median time, or doesn't lock at all. + let svc = Service::new(); + let st = statement("request"); + let req = Request::from_statement(&st); + let refused = |edit: &dyn Fn(&mut Transaction)| { + let (mut tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + edit(&mut tx); + assert_rejected(svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + )); + }; + let earliest = req.timestamp as u32 - MAX_FUTURE_SECONDS; + + refused(&|tx| tx.lock_time = LockTime::from_consensus(earliest - 1)); + refused(&|tx| tx.lock_time = LockTime::ZERO); + // A height, which the chain compares against blocks, not time. + refused(&|tx| tx.lock_time = LockTime::from_consensus(2_670_000)); + // A lock time is ignored when every input is final. + refused(&|tx| { + for input in &mut tx.input { + input.sequence = Sequence::MAX; + } + }); +} + +// --------------------------------------------------------------------------- +// Certificates +// --------------------------------------------------------------------------- + +/// A request at `st`'s price, checked by the treasury. +fn request(svc: &Service, st: &Statement) -> anyhow::Result<()> { + let req = Request::from_statement(st); + let (tx, utxos, leaf) = request_tx(svc, &req, req.usd_amount()); + svc.run( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(st)), + &tx, + &utxos, + 0, + ) +} + +#[test] +fn a_certificate_bounds_the_timestamps_its_key_may_sign() { + let svc = Service::new(); + let ts = statement("request").timestamp; + let cert = |from, until| certify(&cold_key(), &oracle_key(), from, until); + + // Both ends are inclusive. + request(&svc, &statement("request").with_cert(&cert(ts, ts))).unwrap(); + assert_rejected(request( + &svc, + &statement("request").with_cert(&cert(ts + 1, ts + 100)), + )); + assert_rejected(request( + &svc, + &statement("request").with_cert(&cert(ts - 100, ts - 1)), + )); +} + +#[test] +fn a_certificate_must_come_from_the_cold_key() { + let svc = Service::new(); + let impostor = SecretKey::from_slice(&[0x13; 32]).unwrap(); + let cert = certify(&impostor, &oracle_key(), 0, u64::MAX); + assert_rejected(request(&svc, &statement("request").with_cert(&cert))); +} + +#[test] +fn a_certificate_only_covers_its_own_oracle_key() { + // A genuine certificate for the second oracle, with a statement the first one signed. + let svc = Service::new(); + let cert = certify(&cold_key(), &second_oracle_key(), 0, u64::MAX); + assert_rejected(request(&svc, &statement("request").with_cert(&cert))); +} + +#[test] +fn a_certificate_cannot_be_stretched() { + let svc = Service::new(); + let ts = statement("request").timestamp; + let mut cert = certify(&cold_key(), &oracle_key(), ts - 100, ts - 1); + cert.valid_until = ts; + assert_rejected(request(&svc, &statement("request").with_cert(&cert))); +} + +#[test] +fn a_newly_certified_oracle_can_challenge_with_no_new_program() { + // The first oracle signed the request. A second, certified afterwards, cancels it. + let svc = Service::new(); + let req = Request::from_statement(&statement("request")); + let newer = signed_by( + &second_oracle_key(), + "85231.50", + SOURCES, + "median", + "1741514700", + "1", + ) + .with_cert(&certify( + &cold_key(), + &second_oracle_key(), + 1_741_514_600, + 1_741_514_600 + 30 * 86_400, + )); + challenge_with(&svc, &req, &newer).unwrap(); +} + +/// Zero words of budget padding, as the manifest's RequestMint and Challenge supply them. +const BUDGET_PAD: &str = "list![0, 0, 0, 0, 0, 0, 0, 0]"; + +#[test] +fn every_path_fits_its_budget() { + // Finalizing checks the program's cost against the budget its witness buys, the check + // the node makes. Unpadded, the request path fits by about 5 weight units at the time + // of writing, so it carries BUDGET_PAD for margin; so does the challenge path. + let svc = Service::new(); + let finalize = + |leaf: &[u8], w: serde_json::Value, tx: &Transaction, utxos: &[TxOut], index: u32| { + let mut input = lwk_wollet::elements::pset::Input::default(); + covenant::finalize_covenant_input( + &svc.program, + &svc.params, + &svc.hints, + &[leaf.to_vec()], + Some(&w), + None, + Arc::new(tx.clone()), + utxos, + index, + BlockHash::all_zeros(), + ChainFamily::Elements, + &mut input, + false, + ) + }; + + let st = statement("request"); + let req = Request::from_statement(&st); + let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); + finalize( + &leaf, + witnesses("Left(Left(()))", Some(&req), Some(&st)), + &tx, + &utxos, + 0, + ) + .unwrap(); + + let (tx, utxos, leaf) = settle_tx(&svc, &req, WINDOW_BLOCKS, finalize_outputs(&req)); + finalize( + &leaf, + witnesses("Left(Right(()))", Some(&req), None), + &tx, + &utxos, + 0, + ) + .unwrap(); + finalize( + &leaf, + witnesses("Right(Right(()))", None, None), + &tx, + &utxos, + 1, + ) + .unwrap(); + + let newer = statement("rose"); + let (treasury, _) = svc.treasury(); + let outputs = vec![ + txout(&req.recipient, LBTC, req.lbtc_amount), + txout(&treasury, USD, req.usd_amount()), + fee(500), + ]; + let (tx, utxos, leaf) = settle_tx(&svc, &req, 0xffff_ffff, outputs); + finalize( + &leaf, + witnesses("Right(Left(()))", Some(&req), Some(&newer)), + &tx, + &utxos, + 0, + ) + .unwrap(); +} + +#[test] +fn the_testnet_treasury_address_is_unchanged() { + // The treasury from the README's testnet run. A change to oracle_mint.simf that moves + // its CMR moves this address, and strands that run's tUSDT. + let mut svc = Service::new(); + svc.params.insert( + "USD_ASSET".into(), + "625690ab2153a80490ea5c3b1a654f929510e0863049f4f9c24da75fb35faade".into(), + ); + let address = covenant::compute_covenant_address( + &svc.program, + &svc.params, + &svc.hints, + &[vec![0]], + ElementsNetwork::LiquidTestnet, + false, + ) + .unwrap(); + assert_eq!( + address.to_string(), + "tex1p5nmq7g24yfsyv3u770vuuwfqxtth0jxysq5j9dr2pm3ndjrw9ldq09umnk" + ); +} diff --git a/txmanifest_lib/tests/schema.rs b/txmanifest_lib/tests/schema.rs index df37c5c..66dfcfb 100644 --- a/txmanifest_lib/tests/schema.rs +++ b/txmanifest_lib/tests/schema.rs @@ -20,19 +20,33 @@ fn checked_in_schema_path() -> PathBuf { workspace_root().join(SCHEMA_PATH) } -/// Every `examples/*/txmanifest.json`, sorted for stable output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable output. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(workspace_root().join("examples")) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = subdirs(&workspace_root().join("examples")) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + #[test] fn checked_in_schema_matches_the_model() { let path = checked_in_schema_path(); diff --git a/txmanifest_lib/tests/ui_coverage.rs b/txmanifest_lib/tests/ui_coverage.rs index fa2be19..4b13e06 100644 --- a/txmanifest_lib/tests/ui_coverage.rs +++ b/txmanifest_lib/tests/ui_coverage.rs @@ -33,17 +33,23 @@ fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") } -/// `(example_name, parsed manifest)` for every example, sorted by name. +/// `(example_name, parsed manifest)` for every example, sorted by name. A manifest one +/// level down is named by its path, e.g. `oracle_demo/issue_usdt`. fn examples() -> Vec<(String, Manifest)> { - let mut found: Vec<(String, Manifest)> = std::fs::read_dir(examples_dir()) - .expect("examples/ should exist") - .filter_map(|entry| { - let dir = entry.ok()?.path(); + let root = examples_dir(); + let mut found: Vec<(String, Manifest)> = subdirs(&root) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs + }) + .filter_map(|dir| { let path = dir.join("txmanifest.json"); if !path.is_file() { return None; } - let name = dir.file_name()?.to_string_lossy().to_string(); + let name = dir.strip_prefix(&root).ok()?.to_string_lossy().to_string(); let raw = std::fs::read_to_string(&path).ok()?; let manifest = Manifest::from_json_str(&raw) .unwrap_or_else(|e| panic!("{name}/txmanifest.json should parse: {e}")); @@ -54,6 +60,16 @@ fn examples() -> Vec<(String, Manifest)> { found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + /// Every action in a manifest, standalone or template method, keyed by a dot-path. fn all_actions(manifest: &Manifest) -> BTreeMap { let mut out: BTreeMap = manifest diff --git a/txmanifest_wallet/tests/cli_parses_examples.rs b/txmanifest_wallet/tests/cli_parses_examples.rs index fb217d8..1f97299 100644 --- a/txmanifest_wallet/tests/cli_parses_examples.rs +++ b/txmanifest_wallet/tests/cli_parses_examples.rs @@ -22,19 +22,33 @@ fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("..") } -/// Every `examples/*/txmanifest.json`, sorted for stable failure output. +/// Every `txmanifest.json` in an example's directory or one level below it, sorted for +/// stable failure output. fn example_manifests() -> Vec { - let mut found: Vec = std::fs::read_dir(workspace_root().join("examples")) - .expect("examples/ should exist") - .filter_map(|entry| { - let path = entry.ok()?.path().join("txmanifest.json"); - path.is_file().then_some(path) + let mut found: Vec = subdirs(&workspace_root().join("examples")) + .into_iter() + .flat_map(|dir| { + let mut dirs = subdirs(&dir); + dirs.push(dir); + dirs }) + .map(|dir| dir.join("txmanifest.json")) + .filter(|path| path.is_file()) .collect(); found.sort(); found } +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("examples/ should be readable") + .filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then_some(path) + }) + .collect() +} + fn run_cli(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_tx-manifest-wallet")) .args(args)