From bcbddb61f55d3978a242a8450712375b8673bc27 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 11:35:21 +0200 Subject: [PATCH 1/9] fix: refuse a Simplicity input whose cost exceeds its budget The node rejects a program that may cost more than its witness's size buys, with "Program's execution cost could exceed budget". run still built, signed and even exported such a transaction. Finalizing a covenant input on Elements now checks the budget, and says how many bytes short the witness is. Liquid doesn't relay the annex that would pad it (bad-witness-nonstandard), so the error says to pad with a witness the program reads. --- CHANGELOG.md | 1 + txmanifest_lib/src/covenant.rs | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83858d1..e387c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/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(()) } From 8abc9883aaf78d710d5cdf90b7b75d78d028bf7f Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 13:34:57 +0200 Subject: [PATCH 2/9] fix: give change for any input asset, not just wallet ones An explicit wallet UTXO, such as a payout a covenant made with confidential: false, reaches the builder as a covenant input, and the builder counted only the L-BTC of covenant inputs toward balance and change. Spending explicit tUSDT therefore built a transaction with no tUSDT change and no error, which the node refused: bad-txns-in-ne-out. Every input's amount now counts toward its asset's balance, so the surplus becomes change where the manifest permits it and an error where it doesn't. --- txmanifest_lib/src/pset_builder.rs | 100 +++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/txmanifest_lib/src/pset_builder.rs b/txmanifest_lib/src/pset_builder.rs index adb7eee..11d7c85 100644 --- a/txmanifest_lib/src/pset_builder.rs +++ b/txmanifest_lib/src/pset_builder.rs @@ -537,8 +537,10 @@ fn build_inner( 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 { @@ -575,8 +577,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 { @@ -627,6 +628,8 @@ fn build_inner( } if *asset == req.policy_asset { total_lbtc_in += amount; + } else { + *asset_in.entry(*asset).or_default() += amount; } } } @@ -685,7 +688,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 +697,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 +1699,91 @@ 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(), + } + } + + #[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; From 2615d93f09065ab6643148157ac5a950873d3833 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 11:47:44 +0200 Subject: [PATCH 3/9] feat: let an action set the transaction's lock time An action's lock_time, {"height": } or {"time": }, sets the transaction's nLockTime, which the check_lock_height and check_lock_time jets read. Until now it was always zero, so a covenant could only demand a lock time the wallet could never set. A height must be below 500,000,000 and a time at least that, since consensus tells them apart by size. A lock time is ignored when every input is final, so each input that asks for no sequence gets 0xfffffffe, on both chains. --- CHANGELOG.md | 2 +- docs/format-changelog.md | 6 ++ schema/txmanifest.schema.json | 46 +++++++++++ txmanifest_lib/src/assembly.rs | 2 + txmanifest_lib/src/lifecycle.rs | 71 ++++++++++++++++- txmanifest_lib/src/manifest.rs | 24 ++++++ txmanifest_lib/src/psbt_builder.rs | 27 +++++-- txmanifest_lib/src/pset_builder.rs | 110 ++++++++++++++++++++++++++- txmanifest_lib/tests/bitcoin_path.rs | 3 + 9 files changed, 280 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e387c3f..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. 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/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/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 11d7c85..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,6 +552,7 @@ 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; @@ -552,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(); @@ -600,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. @@ -1747,6 +1766,7 @@ mod change_tests { fee_rate: 0.1, policy_asset: policy, change_assets: change_assets.iter().copied().collect(), + lock_time: None, } } @@ -1887,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"); From 5ddde0e31fe1624fddc0b3d0a62003c07184278e Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 11:35:21 +0200 Subject: [PATCH 4/9] test: find example manifests one directory down An example can ship a second manifest in a subdirectory, since run writes its state files beside each manifest. The parse, validate, schema, UI-coverage and CLI checks now cover those too. --- txmanifest_lib/tests/examples_parse.rs | 28 +++++++++++++++---- txmanifest_lib/tests/schema.rs | 26 +++++++++++++---- txmanifest_lib/tests/ui_coverage.rs | 28 +++++++++++++++---- .../tests/cli_parses_examples.rs | 26 +++++++++++++---- 4 files changed, 84 insertions(+), 24 deletions(-) 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/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) From 49728575db3c3c70d6b4ee71682a84789fe4d80a Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 11:35:21 +0200 Subject: [PATCH 5/9] feat(examples): add oracle_demo, minting at the current price Burns testnet L-BTC for a new tUSDT asset at the price in a signed oracle statement. Simplicity can't tell whether a statement is current, so a mint takes two steps: a request opens a 5-block window in which anyone holding a newer statement whose price differs by more than 0.5% can cancel it, and after which anyone can finalize it. Statements use Mycelia Signal's canonical format and ECDSA scheme, signed by a local test key (fake_oracle.py), and the covenant verifies and parses them itself. issue_usdt/ issues the asset. Both paths ran on Liquid testnet; the README lists the transactions. --- examples/README.md | 3 +- examples/oracle_demo/README.md | 188 ++++ .../oracle_demo/data/example_oracle_key.json | 5 + .../oracle_demo/data/statement_close.json | 7 + examples/oracle_demo/data/statement_fell.json | 7 + .../oracle_demo/data/statement_older.json | 7 + .../oracle_demo/data/statement_request.json | 7 + examples/oracle_demo/data/statement_rose.json | 7 + examples/oracle_demo/fake_oracle.py | 260 +++++ .../oracle_demo/issue_usdt/txmanifest.json | 68 ++ examples/oracle_demo/oracle_mint.simf | 451 +++++++++ examples/oracle_demo/txmanifest.json | 898 ++++++++++++++++++ txmanifest_lib/tests/oracle_demo.rs | 793 ++++++++++++++++ 13 files changed, 2700 insertions(+), 1 deletion(-) create mode 100644 examples/oracle_demo/README.md create mode 100644 examples/oracle_demo/data/example_oracle_key.json create mode 100644 examples/oracle_demo/data/statement_close.json create mode 100644 examples/oracle_demo/data/statement_fell.json create mode 100644 examples/oracle_demo/data/statement_older.json create mode 100644 examples/oracle_demo/data/statement_request.json create mode 100644 examples/oracle_demo/data/statement_rose.json create mode 100644 examples/oracle_demo/fake_oracle.py create mode 100644 examples/oracle_demo/issue_usdt/txmanifest.json create mode 100644 examples/oracle_demo/oracle_mint.simf create mode 100644 examples/oracle_demo/txmanifest.json create mode 100644 txmanifest_lib/tests/oracle_demo.rs diff --git a/examples/README.md b/examples/README.md index b14edfa..5ed60ca 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,8 +14,9 @@ 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. 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. | -The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`) +The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`, `oracle_demo`) include full walkthroughs. diff --git a/examples/oracle_demo/README.md b/examples/oracle_demo/README.md new file mode 100644 index 0000000..9f58484 --- /dev/null +++ b/examples/oracle_demo/README.md @@ -0,0 +1,188 @@ +# 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 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. + +## 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's signature; 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 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, verifies them, and turns them into `--params` files. Needs `pip install ecdsa`. | +| [`data/`](data) | The test oracle key 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 burns 5,000 sat. `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. Issue tUSDT and start the service.** `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 the oracle key, which `fake_oracle.py keygen` printed when the key +was made. For the committed key, the prefix is `2` and the x-coordinate is +`917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0`. The window and +tolerance have defaults. + +```sh +$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 --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 + +# 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 --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 --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. + +### A run on Liquid testnet + +| Step | Transaction | +|------|-------------| +| `IssueUSDT` | [`67ed381d…a72c`](https://liquid.network/testnet/tx/67ed381d6ff6566b73d209141a68c586f56e2b8942106903a8736c9ec251a72c) | +| `CreateTreasury` | [`6043ee78…c1d1`](https://liquid.network/testnet/tx/6043ee78ab30fe90957f5a5d8112211a606f8d06e74c6b08477605e5b29ac1d1) | +| `RequestMint` at 84231.50 | [`4b3d45d9…24b7`](https://liquid.network/testnet/tx/4b3d45d955b3dd3174a305f6e178b9bd10342ba1a030720fff0c3248111824b7) | +| `Finalize`: 4.211575 tUSDT paid, 5,000 sat burned | [`69c2c404…0786`](https://liquid.network/testnet/tx/69c2c404d0c1a0e11d752d01154d1c6fdc6900cdc77ad59e0d76542316cc0786) | +| `RequestMint` at a stale 95000.00 | [`513edff5…f74b`](https://liquid.network/testnet/tx/513edff5ac5d07a42dc9e84015e5b7a33a8dbab0f602ad72072f4a2d5cfef74b) | +| `Challenge` at 84231.50: 5,000 sat refunded, 4.75 tUSDT back to the treasury | [`f6eaad84…bcfa`](https://liquid.network/testnet/tx/f6eaad84c2a4e71f871ec50fc07245bcb9a18ef5352e45aa445cba51cfb7bcfa) | + +## 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. +- **One oracle, fixed at deployment.** The key is a compile param. There is no quorum of + statements, no committee-attested key, 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. 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..b77d9bd --- /dev/null +++ b/examples/oracle_demo/fake_oracle.py @@ -0,0 +1,260 @@ +#!/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 turns a statement 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." + + +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): + """The STATEMENT_* witness params for one signed statement.""" + if not verify(statement): + sys.exit("the statement's signature does not verify") + fields = parse_canonical(statement["canonical"]) + r, s = sigdecode_der(base64.b64decode(statement["signature"]), N) + frac = fields["price_frac"].encode() + 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})", + } + + +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_PREFIX={int(pubkey[:2], 16)} ORACLE_PUBKEY_X={pubkey[2:]}", file=sys.stderr) + + +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 = json.loads(Path(args.statement).read_text()) + fields, params = statement_params(statement) + 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 = json.loads(Path(args.request).read_text()) + statement = json.loads(Path(args.statement).read_text()) + fields, params = statement_params(statement) + 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 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("request-params", help="params for RequestMint (and later Finalize)") + p.add_argument("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("--out") + p.set_defaults(func=cmd_challenge_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..f77b72b --- /dev/null +++ b/examples/oracle_demo/oracle_mint.simf @@ -0,0 +1,451 @@ +/* + * 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. + */ + +// ============================================================================ +// 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 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 +// ============================================================================ + +/// A canonical string's variable fields: the price's integer digits, its two fraction +/// digits, sources, method, timestamp digits and nonce. Then the signature `(r, s)`. +type Statement = ( + List, + (u8, u8), + List, + List, + List, + List, + (Scalar, Scalar) +); + +fn oracle_pubkey() -> Point { + let prefix: u8 = param::ORACLE_PUBKEY_PREFIX; + assert!(or(jet::eq_8(prefix, 2), jet::eq_8(prefix, 3))); + (::into(jet::eq_8(prefix, 3)), param::ORACLE_PUBKEY_X) +} + +/// 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 the oracle signed it, 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): 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); + ecdsa_verify(oracle_pubkey(), 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); + 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 + ); + + 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..ed5e27f --- /dev/null +++ b/examples/oracle_demo/txmanifest.json @@ -0,0 +1,898 @@ +{ + "$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:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "compile_params": { + "ORACLE_PUBKEY_PREFIX": "ORACLE_PUBKEY_PREFIX", + "ORACLE_PUBKEY_X": "ORACLE_PUBKEY_X", + "LBTC_ASSET": "LBTC_ASSET", + "USD_ASSET": "USD_ASSET", + "WINDOW_BLOCKS": "WINDOW_BLOCKS", + "TOLERANCE_BPS": "TOLERANCE_BPS" + }, + "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": { + "ORACLE_PUBKEY_PREFIX": { + "type": "u8", + "default": "instance.ORACLE_PUBKEY_PREFIX", + "description": "The service's oracle key prefix." + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32", + "default": "instance.ORACLE_PUBKEY_X", + "description": "The service's oracle key x-coordinate." + }, + "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." + }, + "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:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "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 oracle key it trusts, and the challenge window's length and tolerance. All six fields are compile params of oracle_mint.simf, so they fix every address the service uses.", + "fields": { + "ORACLE_PUBKEY_PREFIX": { + "type": "u8" + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32" + }, + "LBTC_ASSET": { + "type": "liquid.asset_id" + }, + "USD_ASSET": { + "type": "liquid.asset_id" + }, + "WINDOW_BLOCKS": { + "type": "u16" + }, + "TOLERANCE_BPS": { + "type": "u64" + } + }, + "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)." + }, + "ORACLE_PUBKEY_PREFIX": { + "type": "u8", + "description": "First byte of the oracle's compressed public key: 2 or 3. fake_oracle.py keygen prints it." + }, + "ORACLE_PUBKEY_X": { + "type": "bytes32", + "description": "The rest of the oracle's compressed public key: its 32-byte x-coordinate." + }, + "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%." + } + }, + "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": { + "ORACLE_PUBKEY_PREFIX": "$params.ORACLE_PUBKEY_PREFIX", + "ORACLE_PUBKEY_X": "$params.ORACLE_PUBKEY_X", + "LBTC_ASSET": "$params.LBTC_ASSET", + "USD_ASSET": "$params.USD_ASSET", + "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", + "TOLERANCE_BPS": "$params.TOLERANCE_BPS" + } + } + }, + "RequestMint": { + "$comment": "Outputs 0-2 are the ones the treasury checks, in this order. Generate the params with fake_oracle.py request-params.", + "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)." + }, + "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" + } + }, + "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" + }, + "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", + "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", + "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.", + "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)." + }, + "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" + }, + "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", + "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/txmanifest_lib/tests/oracle_demo.rs b/txmanifest_lib/tests/oracle_demo.rs new file mode 100644 index 0000000..a032d31 --- /dev/null +++ b/txmanifest_lib/tests/oracle_demo.rs @@ -0,0 +1,793 @@ +//! 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. + +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}; +use lwk_wollet::elements::secp256k1_zkp::{ecdsa, 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; + +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 from `data/`, split into the witnesses the program reads. +struct Statement { + price_cents: u64, + timestamp: u64, + pubkey: String, + witnesses: BTreeMap<&'static str, String>, +} + +fn simf_list(text: &str) -> String { + let bytes: Vec = text.bytes().map(|b| b.to_string()).collect(); + format!("list![{}]", bytes.join(", ")) +} + +/// A statement's witnesses, given the variable fields as the witness splits them. +fn split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, + compact_sig: [u8; 64], + pubkey: String, +) -> 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(), + pubkey, + witnesses, + } +} + +/// A statement the fake oracle signed, from `data/statement_.json`. +fn statement(name: &str) -> Statement { + let path = example_dir().join(format!("data/statement_{name}.json")); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + 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(); + let pubkey = raw["pubkey"].as_str().unwrap().to_string(); + split( + fields[3], fields[6], fields[7], fields[8], fields[9], compact, pubkey, + ) +} + +/// Sign `v1|PRICE|BTCUSD||USD|2||||` with the +/// fake oracle's key, and split it into exactly those fields. +fn signed_split( + price: &str, + sources: &str, + method: &str, + timestamp: &str, + nonce: &str, +) -> Statement { + let key_file: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(example_dir().join("data/example_oracle_key.json")).unwrap(), + ) + .unwrap(); + let secret = SecretKey::from_str(key_file["secret"].as_str().unwrap()).unwrap(); + 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(), &secret); + let pubkey = hex(&PublicKey::from_secret_key(&secp, &secret).serialize()); + split( + price, + sources, + method, + timestamp, + nonce, + sig.serialize_compact(), + pubkey, + ) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +const STATEMENT_WITNESSES: [&str; 7] = [ + "STATEMENT_PRICE_INT", + "STATEMENT_PRICE_FRAC", + "STATEMENT_SOURCES", + "STATEMENT_METHOD", + "STATEMENT_TIMESTAMP", + "STATEMENT_NONCE", + "STATEMENT_SIG", +]; + +/// One mint service, compiled once per test. +struct Service { + program: ProgramSource, + params: HashMap, + hints: HashMap, +} + +impl Service { + fn new() -> Self { + let pubkey = statement("request").pubkey; + let params = HashMap::from([ + ( + "ORACLE_PUBKEY_PREFIX".into(), + u8::from_str_radix(&pubkey[..2], 16).unwrap().to_string(), + ), + ("ORACLE_PUBKEY_X".into(), pubkey[2..].to_string()), + ("LBTC_ASSET".into(), LBTC.into()), + ("USD_ASSET".into(), USD.into()), + ("WINDOW_BLOCKS".into(), WINDOW_BLOCKS.to_string()), + ("TOLERANCE_BPS".into(), "50".into()), + ]); + let hints = HashMap::from([ + ("ORACLE_PUBKEY_PREFIX".into(), "u8".into()), + ("ORACLE_PUBKEY_X".into(), "bytes32".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()), + ]); + 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. +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 tx = tx( + vec![txin(1, 0xffff_ffff), txin(2, 0xffff_ffff)], + 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), + ], + ); + 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", + "9999999999", + "0", + ); + let req = Request::from_statement(&st); + assert_eq!(req.timestamp, 9_999_999_999); + 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", + "9999999999", + "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<()> { + let st = statement(newer); + 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")); +} + +/// 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(), + "6c7942976c798950efe84b7ba6461ee683cb85ecd837b440860e2590538864e9".into(), + ); + let address = covenant::compute_covenant_address( + &svc.program, + &svc.params, + &svc.hints, + &[vec![0]], + ElementsNetwork::LiquidTestnet, + false, + ) + .unwrap(); + assert_eq!( + address.to_string(), + "tex1p76c0hv73s9cq8g9phf9srqds6aenngn44yjmcsdwdgzu4rarl6aqezd48m" + ); +} From d3f9495ece464863c69f4e6ddb37c35a11225dc6 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 11:56:19 +0200 Subject: [PATCH 6/9] feat(oracle_demo): certify the oracle key with a cold key The service no longer fixes an oracle key. It fixes a cold key, which signs BIP340 certificates saying an oracle key may sign statements timestamped within a range; each statement is presented with its key's certificate, and the covenant checks both signatures and the range. A new or rotated oracle needs a new certificate rather than a redeploy, and the cold key can be a MuSig2 or FROST committee without changing the program. fake_oracle.py gains cold-keygen, certify and verify-cert, with a BIP340 signer after the BIP's reference code, and the tests check its certificates against the program's own BIP340 check. Every address changes, so the README's testnet run is gone until the next one. --- examples/README.md | 2 +- examples/oracle_demo/README.md | 90 ++++-- examples/oracle_demo/data/cert_oracle.json | 9 + .../oracle_demo/data/example_cold_key.json | 5 + examples/oracle_demo/fake_oracle.py | 207 ++++++++++++- examples/oracle_demo/oracle_mint.simf | 61 +++- examples/oracle_demo/txmanifest.json | 130 ++++++-- txmanifest_lib/tests/oracle_demo.rs | 293 ++++++++++++++---- 8 files changed, 656 insertions(+), 141 deletions(-) create mode 100644 examples/oracle_demo/data/cert_oracle.json create mode 100644 examples/oracle_demo/data/example_cold_key.json diff --git a/examples/README.md b/examples/README.md index 5ed60ca..53720c4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,7 @@ 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. Issues its asset with a second manifest in `issue_usdt/`. | +| [`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. | diff --git a/examples/oracle_demo/README.md b/examples/oracle_demo/README.md index 9f58484..3430ce8 100644 --- a/examples/oracle_demo/README.md +++ b/examples/oracle_demo/README.md @@ -36,7 +36,7 @@ format, signed with secp256k1 ECDSA over the sha256 of a canonical string: 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. +"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), @@ -49,6 +49,42 @@ move a field boundary to make some other number read as the timestamp. The ECDSA 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. + ## How it works One program, [`oracle_mint.simf`](oracle_mint.simf), holds two kinds of address, told apart @@ -61,9 +97,9 @@ by a tapdata leaf beside the program leaf: | Action | Spends | Creates | The covenant checks | |--------|--------|---------|---------------------| -| `RequestMint` | treasury (input 0), wallet L-BTC | pending L-BTC, pending tUSDT, treasury change | the statement's signature; that the tUSDT is `L-BTC × price` and both outputs pay the request's pending address | +| `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 statement, whose price differs from the request's by more than `TOLERANCE_BPS` | +| `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, @@ -84,8 +120,8 @@ chosen its input. | [`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, verifies them, and turns them into `--params` files. Needs `pip install ecdsa`. | -| [`data/`](data) | The test oracle key and the signed statements the tests use. | +| [`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 @@ -99,15 +135,16 @@ TXM=tx-manifest-wallet cd examples/oracle_demo ``` -**1. Issue tUSDT and start the service.** `IssueUSDT` writes +**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 the oracle key, which `fake_oracle.py keygen` printed when the key -was made. For the committed key, the prefix is `2` and the x-coordinate is -`917e9826b0e7a61f695a5bb1a37714d1d742da025f4127c48892f3e4dbce8ba0`. The window and -tolerance have defaults. +100000000000000), and for `COLD_PUBKEY`, which `fake_oracle.py cold-keygen` printed when +the key was made: `b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3` for +the committed one. The window and tolerance 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 @@ -122,7 +159,7 @@ 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 --lbtc-amount 5000 --recipient
--out request.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 @@ -138,12 +175,12 @@ 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 --lbtc-amount 5000 --recipient
--out stale-request.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 --out challenge.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 ``` @@ -151,25 +188,20 @@ $TXM run txmanifest.json Challenge --wallet $W --params challenge.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. -### A run on Liquid testnet - -| Step | Transaction | -|------|-------------| -| `IssueUSDT` | [`67ed381d…a72c`](https://liquid.network/testnet/tx/67ed381d6ff6566b73d209141a68c586f56e2b8942106903a8736c9ec251a72c) | -| `CreateTreasury` | [`6043ee78…c1d1`](https://liquid.network/testnet/tx/6043ee78ab30fe90957f5a5d8112211a606f8d06e74c6b08477605e5b29ac1d1) | -| `RequestMint` at 84231.50 | [`4b3d45d9…24b7`](https://liquid.network/testnet/tx/4b3d45d955b3dd3174a305f6e178b9bd10342ba1a030720fff0c3248111824b7) | -| `Finalize`: 4.211575 tUSDT paid, 5,000 sat burned | [`69c2c404…0786`](https://liquid.network/testnet/tx/69c2c404d0c1a0e11d752d01154d1c6fdc6900cdc77ad59e0d76542316cc0786) | -| `RequestMint` at a stale 95000.00 | [`513edff5…f74b`](https://liquid.network/testnet/tx/513edff5ac5d07a42dc9e84015e5b7a33a8dbab0f602ad72072f4a2d5cfef74b) | -| `Challenge` at 84231.50: 5,000 sat refunded, 4.75 tUSDT back to the treasury | [`f6eaad84…bcfa`](https://liquid.network/testnet/tx/f6eaad84c2a4e71f871ec50fc07245bcb9a18ef5352e45aa445cba51cfb7bcfa) | - ## 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. -- **One oracle, fixed at deployment.** The key is a compile param. There is no quorum of - statements, no committee-attested key, and no reward for a challenger. +- **A leaked oracle key can date statements in the future.** Its certificate lets it sign + any timestamp up to `valid_until`, and a request at a timestamp no honest statement has + reached yet can't be challenged until one does, by which time it may be finalized. + Short certificates narrow this; they don't close it. +- **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 @@ -185,4 +217,8 @@ the request's, fails the covenant's dry run and is never broadcast. 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. +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. 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/fake_oracle.py b/examples/oracle_demo/fake_oracle.py index b77d9bd..e108ff1 100644 --- a/examples/oracle_demo/fake_oracle.py +++ b/examples/oracle_demo/fake_oracle.py @@ -6,9 +6,13 @@ 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 turns a statement 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. +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). """ @@ -43,6 +47,110 @@ # 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()) @@ -132,13 +240,20 @@ def simf_list(text): return "list![" + ", ".join(str(b) for b in text.encode()) + "]" -def statement_params(statement): - """The STATEMENT_* witness params for one signed statement.""" +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]})", @@ -147,9 +262,17 @@ def statement_params(statement): "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: @@ -165,7 +288,48 @@ def cmd_keygen(args): 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_PREFIX={int(pubkey[:2], 16)} ORACLE_PUBKEY_X={pubkey[2:]}", file=sys.stderr) + 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): @@ -193,8 +357,8 @@ def cmd_verify(args): def cmd_request_params(args): - statement = json.loads(Path(args.statement).read_text()) - fields, params = statement_params(statement) + statement = load_json(args.statement) + fields, params = statement_params(statement, load_json(args.cert)) params.update( { "REQUEST_PRICE": str(fields["price_cents"]), @@ -207,9 +371,9 @@ def cmd_request_params(args): def cmd_challenge_params(args): - request = json.loads(Path(args.request).read_text()) - statement = json.loads(Path(args.statement).read_text()) - fields, params = statement_params(statement) + 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_")}) @@ -239,8 +403,28 @@ def main(): 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") @@ -249,6 +433,7 @@ def main(): 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) diff --git a/examples/oracle_demo/oracle_mint.simf b/examples/oracle_demo/oracle_mint.simf index f77b72b..3455cdb 100644 --- a/examples/oracle_demo/oracle_mint.simf +++ b/examples/oracle_demo/oracle_mint.simf @@ -26,6 +26,11 @@ * 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. */ // ============================================================================ @@ -254,8 +259,14 @@ fn pending_script_hash(request: Request) -> u256 { // 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 signature `(r, s)`. +/// 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), @@ -263,13 +274,32 @@ type Statement = ( List, List, List, - (Scalar, Scalar) + (Scalar, Scalar), + Certificate ); -fn oracle_pubkey() -> Point { - let prefix: u8 = param::ORACLE_PUBKEY_PREFIX; +/// 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))); - (::into(jet::eq_8(prefix, 3)), param::ORACLE_PUBKEY_X) + + // 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. @@ -292,10 +322,11 @@ fn add_separator(ctx: Ctx8) -> Ctx8 { jet::sha_256_ctx_8_add_1(ctx, 124) } -/// Rebuild a statement's canonical string, check the oracle signed it, and return its -/// price in US cents and its timestamp. +/// 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): Statement = statement; + 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); @@ -314,7 +345,11 @@ fn verify_statement(statement: Statement) -> (u64, u64) { let (ctx, timestamp): (Ctx8, u64) = fold::(timestamp, (ctx, 0)); let ctx: Ctx8 = add_separator(ctx); let ctx: Ctx8 = fold::(nonce, ctx); - ecdsa_verify(oracle_pubkey(), jet::sha_256_ctx_8_finalize(ctx), sig); + + 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) } @@ -432,7 +467,13 @@ fn main() { witness::STATEMENT_METHOD, witness::STATEMENT_TIMESTAMP, witness::STATEMENT_NONCE, - witness::STATEMENT_SIG + witness::STATEMENT_SIG, + ( + witness::CERT_ORACLE_KEY, + witness::CERT_VALID_FROM, + witness::CERT_VALID_UNTIL, + witness::CERT_SIG + ) ); match witness::PATH { diff --git a/examples/oracle_demo/txmanifest.json b/examples/oracle_demo/txmanifest.json index ed5e27f..9443406 100644 --- a/examples/oracle_demo/txmanifest.json +++ b/examples/oracle_demo/txmanifest.json @@ -17,10 +17,9 @@ "script": { "type": "simplicity", "source": "./oracle_mint.simf", - "source_hash": "sha256:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "source_hash": "sha256:5604ae17d418a3c8fb87de90df1ceb0e077febe9ae69d4a17a110f05d65b9377", "compile_params": { - "ORACLE_PUBKEY_PREFIX": "ORACLE_PUBKEY_PREFIX", - "ORACLE_PUBKEY_X": "ORACLE_PUBKEY_X", + "COLD_PUBKEY": "COLD_PUBKEY", "LBTC_ASSET": "LBTC_ASSET", "USD_ASSET": "USD_ASSET", "WINDOW_BLOCKS": "WINDOW_BLOCKS", @@ -40,15 +39,10 @@ "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": { - "ORACLE_PUBKEY_PREFIX": { - "type": "u8", - "default": "instance.ORACLE_PUBKEY_PREFIX", - "description": "The service's oracle key prefix." - }, - "ORACLE_PUBKEY_X": { - "type": "bytes32", - "default": "instance.ORACLE_PUBKEY_X", - "description": "The service's oracle key x-coordinate." + "COLD_PUBKEY": { + "type": "pubkey", + "default": "instance.COLD_PUBKEY", + "description": "The service's cold key, which certifies oracle keys." }, "LBTC_ASSET": { "type": "liquid.asset_id", @@ -90,7 +84,7 @@ "script": { "type": "simplicity", "source": "./oracle_mint.simf", - "source_hash": "sha256:60835c51a0818685319ad98e58969f04140e1926ba8624e1e02fcf2159c8d3ef", + "source_hash": "sha256:5604ae17d418a3c8fb87de90df1ceb0e077febe9ae69d4a17a110f05d65b9377", "extra_leaves": [ { "type": "tapdata", @@ -123,13 +117,10 @@ }, "contract_templates": { "oracle_mint": { - "description": "One mint service: a treasury of tUSDT, the oracle key it trusts, and the challenge window's length and tolerance. All six fields are compile params of oracle_mint.simf, so they fix every address the service uses.", + "description": "One mint service: a treasury of tUSDT, the cold key that certifies oracle keys, and the challenge window's length and tolerance. All five 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": { - "ORACLE_PUBKEY_PREFIX": { - "type": "u8" - }, - "ORACLE_PUBKEY_X": { - "type": "bytes32" + "COLD_PUBKEY": { + "type": "pubkey" }, "LBTC_ASSET": { "type": "liquid.asset_id" @@ -157,13 +148,9 @@ "type": "u64", "description": "tUSDT base units to lock in the treasury (8 decimal places)." }, - "ORACLE_PUBKEY_PREFIX": { - "type": "u8", - "description": "First byte of the oracle's compressed public key: 2 or 3. fake_oracle.py keygen prints it." - }, - "ORACLE_PUBKEY_X": { - "type": "bytes32", - "description": "The rest of the oracle's compressed public key: its 32-byte x-coordinate." + "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", @@ -246,8 +233,7 @@ ], "create_instance": { "fields": { - "ORACLE_PUBKEY_PREFIX": "$params.ORACLE_PUBKEY_PREFIX", - "ORACLE_PUBKEY_X": "$params.ORACLE_PUBKEY_X", + "COLD_PUBKEY": "$params.COLD_PUBKEY", "LBTC_ASSET": "$params.LBTC_ASSET", "USD_ASSET": "$params.USD_ASSET", "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", @@ -256,7 +242,7 @@ } }, "RequestMint": { - "$comment": "Outputs 0-2 are the ones the treasury checks, in this order. Generate the params with fake_oracle.py request-params.", + "$comment": "Outputs 0-2 are the ones the treasury checks, in this order. 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": { @@ -288,6 +274,22 @@ "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." @@ -374,6 +376,26 @@ "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", @@ -560,6 +582,10 @@ "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": { @@ -598,6 +624,10 @@ "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": { @@ -656,7 +686,7 @@ ] }, "Challenge": { - "$comment": "Generate the params with fake_oracle.py challenge-params. Anyone can run it.", + "$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": { @@ -688,6 +718,22 @@ "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." @@ -792,6 +838,26 @@ "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", @@ -835,6 +901,10 @@ "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": { diff --git a/txmanifest_lib/tests/oracle_demo.rs b/txmanifest_lib/tests/oracle_demo.rs index a032d31..b61caee 100644 --- a/txmanifest_lib/tests/oracle_demo.rs +++ b/txmanifest_lib/tests/oracle_demo.rs @@ -4,7 +4,9 @@ //! //! 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. +//! 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. use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; @@ -13,8 +15,10 @@ use std::sync::Arc; use base64::Engine as _; use lwk_wollet::elements::confidential::{Asset, Nonce, Value}; -use lwk_wollet::elements::hashes::{sha256, Hash}; -use lwk_wollet::elements::secp256k1_zkp::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; +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, @@ -40,20 +44,121 @@ fn program() -> ProgramSource { ProgramSource::read_unpinned(example_dir().join("oracle_mint.simf")).unwrap() } -/// A statement from `data/`, split into the witnesses the program reads. +/// A statement split into the witnesses the program reads, its certificate's among them. struct Statement { price_cents: u64, timestamp: u64, - pubkey: String, 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(", ")) } -/// A statement's witnesses, given the variable fields as the witness splits them. +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, @@ -61,7 +166,6 @@ fn split( timestamp: &str, nonce: &str, compact_sig: [u8; 64], - pubkey: String, ) -> Statement { let (integer, fraction) = price.split_once('.').unwrap(); let frac = fraction.as_bytes(); @@ -87,16 +191,14 @@ fn split( Statement { price_cents: format!("{integer}{fraction}").parse().unwrap(), timestamp: timestamp.parse().unwrap(), - pubkey, witnesses, } } -/// A statement the fake oracle signed, from `data/statement_.json`. +/// A statement the fake oracle signed, from `data/statement_.json`, with the +/// fixture certificate. fn statement(name: &str) -> Statement { - let path = example_dir().join(format!("data/statement_{name}.json")); - let raw: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + 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}"); @@ -107,31 +209,26 @@ fn statement(name: &str) -> Statement { let compact = ecdsa::Signature::from_der(&der) .unwrap() .serialize_compact(); - let pubkey = raw["pubkey"].as_str().unwrap().to_string(); split( - fields[3], fields[6], fields[7], fields[8], fields[9], compact, pubkey, + fields[3], fields[6], fields[7], fields[8], fields[9], compact, ) + .with_cert(&fixture_cert()) } -/// Sign `v1|PRICE|BTCUSD||USD|2||||` with the -/// fake oracle's key, and split it into exactly those fields. -fn signed_split( +/// `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 key_file: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(example_dir().join("data/example_oracle_key.json")).unwrap(), - ) - .unwrap(); - let secret = SecretKey::from_str(key_file["secret"].as_str().unwrap()).unwrap(); 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(), &secret); - let pubkey = hex(&PublicKey::from_secret_key(&secp, &secret).serialize()); + let sig = secp.sign_ecdsa(&Message::from_digest_slice(&digest).unwrap(), oracle); split( price, sources, @@ -139,15 +236,26 @@ fn signed_split( timestamp, nonce, sig.serialize_compact(), - pubkey, ) } -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() +/// 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; 7] = [ +const STATEMENT_WITNESSES: [&str; 11] = [ "STATEMENT_PRICE_INT", "STATEMENT_PRICE_FRAC", "STATEMENT_SOURCES", @@ -155,6 +263,10 @@ const STATEMENT_WITNESSES: [&str; 7] = [ "STATEMENT_TIMESTAMP", "STATEMENT_NONCE", "STATEMENT_SIG", + "CERT_ORACLE_KEY", + "CERT_VALID_FROM", + "CERT_VALID_UNTIL", + "CERT_SIG", ]; /// One mint service, compiled once per test. @@ -166,21 +278,17 @@ struct Service { impl Service { fn new() -> Self { - let pubkey = statement("request").pubkey; + let secp = Secp256k1::new(); + let (cold_pubkey, _) = cold_key().x_only_public_key(&secp); let params = HashMap::from([ - ( - "ORACLE_PUBKEY_PREFIX".into(), - u8::from_str_radix(&pubkey[..2], 16).unwrap().to_string(), - ), - ("ORACLE_PUBKEY_X".into(), pubkey[2..].to_string()), + ("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()), ]); let hints = HashMap::from([ - ("ORACLE_PUBKEY_PREFIX".into(), "u8".into()), - ("ORACLE_PUBKEY_X".into(), "bytes32".into()), + ("COLD_PUBKEY".into(), "pubkey".into()), ("LBTC_ASSET".into(), "liquid.asset_id".into()), ("USD_ASSET".into(), "liquid.asset_id".into()), ("WINDOW_BLOCKS".into(), "u16".into()), @@ -642,7 +750,10 @@ fn finalize_pays_only_the_recipient() { } fn challenge(svc: &Service, req: &Request, newer: &str) -> anyhow::Result<()> { - let st = statement(newer); + 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), @@ -653,7 +764,7 @@ fn challenge(svc: &Service, req: &Request, newer: &str) -> anyhow::Result<()> { let (tx, utxos, leaf) = settle_tx(svc, req, 0xffff_ffff, outputs); svc.run( &leaf, - witnesses("Right(Left(()))", Some(req), Some(&st)), + witnesses("Right(Left(()))", Some(req), Some(st)), &tx, &utxos, 0, @@ -691,6 +802,88 @@ fn challenge_with_an_older_statement_is_refused() { assert_rejected(challenge(&svc, &req, "request")); } +// --------------------------------------------------------------------------- +// 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]"; @@ -767,27 +960,3 @@ fn every_path_fits_its_budget() { ) .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(), - "6c7942976c798950efe84b7ba6461ee683cb85ecd837b440860e2590538864e9".into(), - ); - let address = covenant::compute_covenant_address( - &svc.program, - &svc.params, - &svc.hints, - &[vec![0]], - ElementsNetwork::LiquidTestnet, - false, - ) - .unwrap(); - assert_eq!( - address.to_string(), - "tex1p76c0hv73s9cq8g9phf9srqds6aenngn44yjmcsdwdgzu4rarl6aqezd48m" - ); -} From 14bd378e16e1651a22fb2b1df59dc654ac091016 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 12:03:31 +0200 Subject: [PATCH 7/9] feat(oracle_demo): refuse statements dated in the future A certificate's range alone doesn't stop a leaked oracle key: it could date a request at the end of the range, weeks ahead, where no honest statement is newer and nothing can challenge it before it finalizes. A request now sets a lock time of timestamp - MAX_FUTURE_SECONDS, which the covenant checks with check_lock_time. Consensus only mines it once the chain's median time is past that, so a statement can be dated at most MAX_FUTURE_SECONDS (default 300) ahead of the chain. On Liquid, whose median time lags about 5 minutes, an honest request is usable within a minute. Ran on Liquid testnet: an honest mint, a stale price challenged, a second oracle certified after deployment challenging the first's request, and a statement dated an hour ahead refused by the node as non-final. --- examples/oracle_demo/README.md | 69 +++++++++++++++++++++--- examples/oracle_demo/oracle_mint.simf | 20 +++++++ examples/oracle_demo/txmanifest.json | 30 ++++++++--- txmanifest_lib/tests/oracle_demo.rs | 78 ++++++++++++++++++++++++--- 4 files changed, 177 insertions(+), 20 deletions(-) diff --git a/examples/oracle_demo/README.md b/examples/oracle_demo/README.md index 3430ce8..a173b63 100644 --- a/examples/oracle_demo/README.md +++ b/examples/oracle_demo/README.md @@ -85,6 +85,24 @@ certificate. 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 @@ -126,7 +144,7 @@ chosen its input. ## 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 burns 5,000 sat. `run` asks for a fee rate and confirms before each +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 @@ -141,7 +159,7 @@ certificate for the oracle key with the cold key. `IssueUSDT` writes `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 and tolerance have defaults. +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 @@ -164,6 +182,9 @@ python3 fake_oracle.py request-params now.json --cert cert.json --lbtc-amount 50 $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 @@ -188,16 +209,48 @@ $TXM run txmanifest.json Challenge --wallet $W --params challenge.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 date statements in the future.** Its certificate lets it sign - any timestamp up to `valid_until`, and a request at a timestamp no honest statement has - reached yet can't be challenged until one does, by which time it may be finalized. - Short certificates narrow this; they don't close 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 @@ -219,6 +272,8 @@ It covers forged prices, a `|` hidden in a text field, a finalize before the win 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. The +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/oracle_mint.simf b/examples/oracle_demo/oracle_mint.simf index 3455cdb..f09a50d 100644 --- a/examples/oracle_demo/oracle_mint.simf +++ b/examples/oracle_demo/oracle_mint.simf @@ -31,6 +31,10 @@ * 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. */ // ============================================================================ @@ -200,6 +204,21 @@ 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 @@ -367,6 +386,7 @@ fn request_path(statement: Statement, recipient: u256) { 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)); diff --git a/examples/oracle_demo/txmanifest.json b/examples/oracle_demo/txmanifest.json index 9443406..be7c37e 100644 --- a/examples/oracle_demo/txmanifest.json +++ b/examples/oracle_demo/txmanifest.json @@ -17,13 +17,14 @@ "script": { "type": "simplicity", "source": "./oracle_mint.simf", - "source_hash": "sha256:5604ae17d418a3c8fb87de90df1ceb0e077febe9ae69d4a17a110f05d65b9377", + "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" + "TOLERANCE_BPS": "TOLERANCE_BPS", + "MAX_FUTURE_SECONDS": "MAX_FUTURE_SECONDS" }, "extra_leaves": [ { @@ -64,6 +65,11 @@ "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." @@ -84,7 +90,7 @@ "script": { "type": "simplicity", "source": "./oracle_mint.simf", - "source_hash": "sha256:5604ae17d418a3c8fb87de90df1ceb0e077febe9ae69d4a17a110f05d65b9377", + "source_hash": "sha256:2a14736e667e50193aa059abb3d95ecd3616d7f10c4f1631591234a003ed26f6", "extra_leaves": [ { "type": "tapdata", @@ -117,7 +123,7 @@ }, "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. All five 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.", + "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" @@ -133,6 +139,9 @@ }, "TOLERANCE_BPS": { "type": "u64" + }, + "MAX_FUTURE_SECONDS": { + "type": "u32" } }, "actions": { @@ -166,6 +175,11 @@ "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": [ @@ -237,12 +251,13 @@ "LBTC_ASSET": "$params.LBTC_ASSET", "USD_ASSET": "$params.USD_ASSET", "WINDOW_BLOCKS": "$params.WINDOW_BLOCKS", - "TOLERANCE_BPS": "$params.TOLERANCE_BPS" + "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. Generate the params with fake_oracle.py request-params, which takes the statement and the certificate for the key that signed it.", + "$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": { @@ -318,6 +333,9 @@ "compute": "params.REQUEST_LBTC_AMOUNT * params.REQUEST_PRICE / 100" } }, + "lock_time": { + "time": "params.REQUEST_TIMESTAMP - instance.MAX_FUTURE_SECONDS" + }, "inputs": [ { "id": "treasury_in", diff --git a/txmanifest_lib/tests/oracle_demo.rs b/txmanifest_lib/tests/oracle_demo.rs index b61caee..b95854e 100644 --- a/txmanifest_lib/tests/oracle_demo.rs +++ b/txmanifest_lib/tests/oracle_demo.rs @@ -6,7 +6,8 @@ //! 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. +//! 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}; @@ -35,6 +36,9 @@ const USD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 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") @@ -286,6 +290,7 @@ impl Service { ("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()), @@ -293,6 +298,7 @@ impl Service { ("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(), @@ -488,12 +494,13 @@ fn tx(inputs: Vec, outputs: Vec) -> Transaction { // Request // --------------------------------------------------------------------------- -/// A request transaction, its spent outputs, and the treasury leaf. +/// 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 tx = tx( - vec![txin(1, 0xffff_ffff), txin(2, 0xffff_ffff)], + 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), @@ -502,6 +509,7 @@ fn request_tx(svc: &Service, req: &Request, usd_amount: u64) -> (Transaction, Ve 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), @@ -570,11 +578,11 @@ fn request_refuses_a_separator_inside_a_text_field() { "84231.50", "okx|median|1741514400", "482910", - "9999999999", + "4000000000", "0", ); let req = Request::from_statement(&st); - assert_eq!(req.timestamp, 9_999_999_999); + assert_eq!(req.timestamp, 4_000_000_000); let (tx, utxos, leaf) = request_tx(&svc, &req, req.usd_amount()); assert_rejected(svc.run( &leaf, @@ -589,7 +597,7 @@ fn request_refuses_a_separator_inside_a_text_field() { "84231.50", "okx,median,1741514400", "482910", - "9999999999", + "4000000000", "0", ); let req = Request::from_statement(&st); @@ -802,6 +810,38 @@ fn challenge_with_an_older_statement_is_refused() { 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 // --------------------------------------------------------------------------- @@ -960,3 +1000,27 @@ fn every_path_fits_its_budget() { ) .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" + ); +} From de2c3ac4621537b39045933c4d5b9064554b359d Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 13:21:14 +0200 Subject: [PATCH 8/9] feat(examples): copy lending_v3 as lending_v4 --- examples/lending_v4/asset_auth.simf | 39 + examples/lending_v4/asset_auth_vault.simf | 245 ++++ examples/lending_v4/data/params.json | 12 + examples/lending_v4/issuance_factory.simf | 242 ++++ examples/lending_v4/lending.simf | 576 ++++++++ examples/lending_v4/script_auth.simf | 13 + examples/lending_v4/txmanifest.json | 1463 +++++++++++++++++++++ 7 files changed, 2590 insertions(+) create mode 100644 examples/lending_v4/asset_auth.simf create mode 100644 examples/lending_v4/asset_auth_vault.simf create mode 100644 examples/lending_v4/data/params.json create mode 100644 examples/lending_v4/issuance_factory.simf create mode 100644 examples/lending_v4/lending.simf create mode 100644 examples/lending_v4/script_auth.simf create mode 100644 examples/lending_v4/txmanifest.json 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..64aee0a --- /dev/null +++ b/examples/lending_v4/data/params.json @@ -0,0 +1,12 @@ +{ + "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" +} \ No newline at end of file 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..133704c --- /dev/null +++ b/examples/lending_v4/lending.simf @@ -0,0 +1,576 @@ +// 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) +} + +fn get_script_hash_for_storage(is_active: bool, borrower_debt: u64) -> u256 { + let is_active_slot_leaf: u256 = get_is_active_slot_leaf(is_active); + let borrower_debt_slot_leaf: u256 = get_borrower_debt_slot_leaf(borrower_debt); + + let first_tap_node: u256 = jet::build_tapbranch(jet::tapleaf_hash(), is_active_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) +} + +// 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)) +} + +fn full_repay_offer(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)); + + let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); + 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 + ); +} + +// 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 + ); +} + +fn main() { + match witness::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); + } + } + }, + } +} \ No newline at end of file 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..b41afbe --- /dev/null +++ b/examples/lending_v4/txmanifest.json @@ -0,0 +1,1463 @@ +{ + "$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, the starting point for oracle liquidation. Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", + "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:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "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" + }, + "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:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "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" + }, + "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" + }, + "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." + } + }, + "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." + } + }, + "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", + "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:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "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" + } + }, + "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(())))." + } + }, + "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(())))." + } + }, + "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." + } + }, + "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" + } + } + ] + } + } + } + } +} \ No newline at end of file From f41c3dfd773dd7fbc2416f839d4ee9b2cc027437 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Thu, 22 Oct 2026 14:17:32 +0200 Subject: [PATCH 9/9] feat(lending_v4): liquidate at the current oracle price lending_v3 liquidates only on expiry. Before then, the lender can now start a liquidation with a certified oracle statement at which the debt is more than LIQUIDATION_LTV_BPS (default 80%) of the collateral's value. The loan moves to a pending state recording the claim. Anyone with a newer statement at which it isn't liquidatable can cancel it back to active; after WINDOW_BLOCKS the lender burns the lender NFT and takes the collateral. The borrower can repay in full while it is pending. The new paths sit behind a separate LIQUIDATION witness, so lending_v3's PATH values and the offer and active addresses' derivation are unchanged. The statement, certificate and lock-time checks are oracle_demo's. Ran on Liquid testnet: a liquidation started at 60000.00 and cancelled at 84231.50, then one started at 59000.00 and finished after the window. --- examples/README.md | 5 +- examples/lending_v4/README.md | 165 +++++ examples/lending_v4/data/params.json | 8 +- examples/lending_v4/lending.simf | 398 +++++++++++- examples/lending_v4/txmanifest.json | 863 ++++++++++++++++++++++++++- examples/oracle_demo/fake_oracle.py | 17 + txmanifest_lib/tests/lending_v4.rs | 727 ++++++++++++++++++++++ 7 files changed, 2162 insertions(+), 21 deletions(-) create mode 100644 examples/lending_v4/README.md create mode 100644 txmanifest_lib/tests/lending_v4.rs diff --git a/examples/README.md b/examples/README.md index 53720c4..aaf727c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,7 @@ Roughly in order of complexity: | [`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`, `oracle_demo`) -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/data/params.json b/examples/lending_v4/data/params.json index 64aee0a..0949cbd 100644 --- a/examples/lending_v4/data/params.json +++ b/examples/lending_v4/data/params.json @@ -8,5 +8,9 @@ "LOAN_EXPIRATION_TIME": "5000000", "FACTORY_ASSET_ID": "6c7c38dbd33228a4fac33c46d38f0deb1fcf8f27e0279227d7ac868486d1fd18", "PROTOCOL_FEE_KEEPER_ASSET_ID": "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5", - "ZERO_HASH": "0000000000000000000000000000000000000000000000000000000000000000" -} \ No newline at end of file + "ZERO_HASH": "0000000000000000000000000000000000000000000000000000000000000000", + "COLD_PUBKEY": "b895fcd559365049a2602890e406a3114ffe7b482beddc09686ecd2586cfbcd3", + "MAX_FUTURE_SECONDS": "300", + "LIQUIDATION_LTV_BPS": "8000", + "WINDOW_BLOCKS": "5" +} diff --git a/examples/lending_v4/lending.simf b/examples/lending_v4/lending.simf index 133704c..92b16de 100644 --- a/examples/lending_v4/lending.simf +++ b/examples/lending_v4/lending.simf @@ -205,11 +205,11 @@ fn get_borrower_debt_slot_leaf(borrower_debt: u64) -> u256 { jet::sha_256_ctx_8_finalize(state_ctx2) } -fn get_script_hash_for_storage(is_active: bool, borrower_debt: u64) -> u256 { - let is_active_slot_leaf: u256 = get_is_active_slot_leaf(is_active); +/// 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(), is_active_slot_leaf); + 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 @@ -224,6 +224,10 @@ fn get_script_hash_for_storage(is_active: bool, borrower_debt: u64) -> u256 { 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 { @@ -406,13 +410,13 @@ fn get_full_repayment_protocol_fee_vault_indexes(start_input_index: u32, start_o (safe_add_32(start_input_index, 3), safe_add_32(start_output_index, 2)) } -fn full_repay_offer(current_debt: u64) { +/// 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)); - let current_script_hash: u256 = get_script_hash_for_storage(true, current_debt); 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); @@ -431,6 +435,10 @@ fn full_repay_offer(current_debt: u64) { ); } +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) { @@ -541,8 +549,353 @@ fn cancel_offer() { ); } -fn main() { - match witness::PATH { +// ============================================================================ +// 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: ()) => { @@ -573,4 +926,33 @@ fn main() { } }, } -} \ No newline at end of file +} + +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/txmanifest.json b/examples/lending_v4/txmanifest.json index b41afbe..0f9da6d 100644 --- a/examples/lending_v4/txmanifest.json +++ b/examples/lending_v4/txmanifest.json @@ -5,7 +5,7 @@ "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, the starting point for oracle liquidation. Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", + "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.", @@ -25,7 +25,7 @@ "script": { "type": "simplicity", "source": "./lending.simf", - "source_hash": "sha256:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "source_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", "compile_params": { "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", @@ -39,7 +39,11 @@ "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" + "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": [ { @@ -69,7 +73,7 @@ "script": { "type": "simplicity", "source": "./lending.simf", - "source_hash": "sha256:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "source_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", "compile_params": { "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", "PRINCIPAL_ASSET_ID": "PRINCIPAL_ASSET_ID", @@ -83,7 +87,11 @@ "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" + "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": [ { @@ -108,6 +116,65 @@ }, "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": { @@ -398,6 +465,22 @@ "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": { @@ -445,6 +528,26 @@ "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": { @@ -462,6 +565,10 @@ "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", @@ -641,7 +748,7 @@ "LENDING_COV_SCRIPT_HASH": { "type": "tapleaf", "simf": "./lending.simf", - "simf_hash": "sha256:f80c61629d295119a6ca6307f77d5cd5a0cd0c73e8a47a963901a08dba61b95c", + "simf_hash": "sha256:b43d93e9aa76b0bf99df17a76a7d84d310ef1378c832b8813e07678af1fa0dd5", "params": { "COLLATERAL_ASSET_ID": { "type": "liquid.asset_id", @@ -694,6 +801,22 @@ "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": [ @@ -955,7 +1078,20 @@ "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", @@ -1096,7 +1232,20 @@ "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", @@ -1342,7 +1491,20 @@ "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", @@ -1456,6 +1618,689 @@ } } ] + }, + "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" + } + } + ] } } } diff --git a/examples/oracle_demo/fake_oracle.py b/examples/oracle_demo/fake_oracle.py index e108ff1..493eda6 100644 --- a/examples/oracle_demo/fake_oracle.py +++ b/examples/oracle_demo/fake_oracle.py @@ -380,6 +380,15 @@ def cmd_challenge_params(args): 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) @@ -437,6 +446,14 @@ def main(): 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) 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(); +}