diff --git a/crates/deadcat-client/src/market_builder.rs b/crates/deadcat-client/src/market_builder.rs index 652b52f..d9dbe3f 100644 --- a/crates/deadcat-client/src/market_builder.rs +++ b/crates/deadcat-client/src/market_builder.rs @@ -8,13 +8,14 @@ use deadcat_contracts::SimplicityNetwork; use deadcat_contracts::binary_market::{ - AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketEconomics, BinaryMarketError, - BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome, CompiledBinaryMarket, - CompiledBinaryMarketError, CompiledBinaryMarketExecutionError, derived_binary_market, + AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketCoordinatorAction, + BinaryMarketEconomics, BinaryMarketError, BinaryMarketLayout, BinaryMarketLayoutError, + BinaryMarketPath, BinaryMarketResolution, BinaryMarketSlot, BinaryMarketTransition, + BinaryMarketWitness, BinaryOutcome, CompiledBinaryMarket, CompiledBinaryMarketError, + CompiledBinaryMarketExecutionError, }; #[cfg(test)] use deadcat_contracts::finalized_spend::FinalizedSimplicitySpend; -use deadcat_contracts::interpret::BinaryMarketPath; use deadcat_contracts::market_crypto::{ BinaryOutcome as OracleOutcome, derive_issuance_assets, oracle_message, }; @@ -38,7 +39,6 @@ use elements::{ }; use rand::SeedableRng as _; use rand::rngs::StdRng; -use simplex::program::WitnessTrait as _; use thiserror::Error; /// Network-known assets needed to verify a compact market recovery hint. @@ -264,14 +264,12 @@ pub struct BinaryMarketTransitionPlan { params: BinaryMarketParams, before: BinaryMarketState, applied: AppliedBinaryMarketTransition, - path: BinaryMarketPath, + layout: BinaryMarketLayout, live: BinaryMarketLiveInputs, output_templates: Vec, yes_output_factors: Option, no_output_factors: Option, - oracle_signature: [u8; 64], - tokens_burned: u64, - redeem_yes: bool, + resolution: Option, } impl BinaryMarketTransitionPlan { @@ -301,9 +299,10 @@ impl BinaryMarketTransitionPlan { let params = compiled.params(); let economics = BinaryMarketEconomics::new(params.base_payout)?; let applied = economics.apply(before, action)?; - let path = select_path(before, action, applied)?; + let layout = BinaryMarketLayout::for_transition(before, action, applied)?; + let path = layout.path(); validate_live_shape(compiled, params, before, path, &live)?; - let oracle_signature = validate_attestation(params, action, attestation)?; + let resolution = validate_attestation(params, action, attestation)?; let (tokens_burned, redeem_yes) = match action { BinaryMarketAction::Redeem { outcome, tokens } => { (tokens, outcome == BinaryOutcome::Yes) @@ -397,20 +396,23 @@ impl BinaryMarketTransitionPlan { params, before, applied, - path, + layout, live, output_templates, yes_output_factors, no_output_factors, - oracle_signature, - tokens_burned, - redeem_yes, + resolution, }) } #[must_use] pub const fn path(&self) -> BinaryMarketPath { - self.path + self.layout.path() + } + + #[must_use] + pub const fn layout(&self) -> BinaryMarketLayout { + self.layout } #[must_use] @@ -507,7 +509,7 @@ impl BinaryMarketTransitionPlan { input_base: usize, ) -> Result<(), MarketBuilderError> { if !matches!( - self.path, + self.path(), BinaryMarketPath::ActiveExpiry | BinaryMarketPath::DormantExpiry ) { return Err(MarketBuilderError::NotExpiryPath); @@ -588,7 +590,7 @@ impl BinaryMarketTransitionPlan { } } - if path_consumes_rt(self.path) { + if path_consumes_rt(self.path()) { let yes = self .live .yes_rt @@ -636,27 +638,17 @@ impl BinaryMarketTransitionPlan { let output_base_u32 = u32::try_from(output_base).map_err(|_| MarketBuilderError::IndexOverflow)?; - let input_slots = self.input_slots(); - let mut finalized = Vec::with_capacity(input_slots.len()); - for (offset, slot) in input_slots.iter().copied().enumerate() { + let action = BinaryMarketCoordinatorAction::for_layout( + self.layout, + output_base_u32, + self.resolution, + )?; + let input_roles = self.layout.input_roles(); + let mut finalized = Vec::with_capacity(input_roles.len()); + for (offset, role) in input_roles.iter().copied().enumerate() { let input_index = add_index(input_base, offset)?; - let oracle_outcome_yes = self.redeem_yes - || matches!( - self.applied.transition, - BinaryMarketTransition::Resolved { - outcome: BinaryOutcome::Yes, - .. - } - ); - let witness = derived_binary_market::BinaryMarketWitness { - path: self.path as u8, - slot: slot as u8, - output_base: output_base_u32, - oracle_outcome_yes, - oracle_signature: self.oracle_signature, - tokens_burned: self.tokens_burned, - redeem_yes: self.redeem_yes, - }; + let slot = role.slot(); + let witness = BinaryMarketWitness::new(self.layout, role, action)?; let spend = compiled.finalize(slot, pset, &witness.build_witness(), input_index, network)?; finalized.push((input_index, spend.into_witness_stack())); @@ -664,9 +656,9 @@ impl BinaryMarketTransitionPlan { for (input_index, stack) in finalized { pset.inputs_mut()[input_index].final_script_witness = Some(stack); } - for (offset, slot) in input_slots.iter().copied().enumerate() { + for (offset, role) in input_roles.iter().copied().enumerate() { let input_index = add_index(input_base, offset)?; - compiled.execute_finalized(slot, pset, input_index, network)?; + compiled.execute_finalized(role.slot(), pset, input_index, network)?; } Ok(()) } @@ -808,7 +800,7 @@ impl BinaryMarketTransitionPlan { input_base: usize, ) -> Result<(), MarketBuilderError> { if !matches!( - self.path, + self.path(), BinaryMarketPath::ActiveExpiry | BinaryMarketPath::DormantExpiry ) { return Ok(()); @@ -836,29 +828,11 @@ impl BinaryMarketTransitionPlan { } fn input_slots(&self) -> Vec { - match self.path { - BinaryMarketPath::InitialIssuance - | BinaryMarketPath::DormantResolution - | BinaryMarketPath::DormantExpiry => vec![ - BinaryMarketSlot::DormantYesRt, - BinaryMarketSlot::DormantNoRt, - ], - BinaryMarketPath::SubsequentIssuance - | BinaryMarketPath::PartialCancellation - | BinaryMarketPath::FullCancellation - | BinaryMarketPath::ActiveResolution - | BinaryMarketPath::ActiveExpiry => vec![ - BinaryMarketSlot::UnresolvedYesRt, - BinaryMarketSlot::UnresolvedNoRt, - BinaryMarketSlot::UnresolvedCollateral, - ], - BinaryMarketPath::ResolvedRedemption => vec![match self.before { - BinaryMarketState::ResolvedYes { .. } => BinaryMarketSlot::ResolvedYesCollateral, - BinaryMarketState::ResolvedNo { .. } => BinaryMarketSlot::ResolvedNoCollateral, - _ => unreachable!("path selection validates resolved state"), - }], - BinaryMarketPath::ExpiryRedemption => vec![BinaryMarketSlot::ExpiredCollateral], - } + self.layout + .input_roles() + .iter() + .map(|role| role.slot()) + .collect() } fn contract_input_indices(&self, input_base: usize) -> Result, MarketBuilderError> { @@ -1096,54 +1070,6 @@ fn append_non_rt_outputs( } } -fn select_path( - before: BinaryMarketState, - action: BinaryMarketAction, - applied: AppliedBinaryMarketTransition, -) -> Result { - Ok(match action { - BinaryMarketAction::Issue { .. } => match before { - BinaryMarketState::Trading { - outstanding_pairs: 0, - } => BinaryMarketPath::InitialIssuance, - BinaryMarketState::Trading { .. } => BinaryMarketPath::SubsequentIssuance, - _ => return Err(MarketBuilderError::UnsupportedTransition), - }, - BinaryMarketAction::Cancel { .. } => match applied.transition { - BinaryMarketTransition::Cancelled { full: true, .. } => { - BinaryMarketPath::FullCancellation - } - BinaryMarketTransition::Cancelled { full: false, .. } => { - BinaryMarketPath::PartialCancellation - } - _ => return Err(MarketBuilderError::UnsupportedTransition), - }, - BinaryMarketAction::Resolve { .. } => match before { - BinaryMarketState::Trading { - outstanding_pairs: 0, - } => BinaryMarketPath::DormantResolution, - BinaryMarketState::Trading { .. } => BinaryMarketPath::ActiveResolution, - _ => return Err(MarketBuilderError::UnsupportedTransition), - }, - BinaryMarketAction::Expire => match before { - BinaryMarketState::Trading { - outstanding_pairs: 0, - } => BinaryMarketPath::DormantExpiry, - BinaryMarketState::Trading { .. } => BinaryMarketPath::ActiveExpiry, - _ => return Err(MarketBuilderError::UnsupportedTransition), - }, - BinaryMarketAction::Redeem { .. } => match before { - BinaryMarketState::ResolvedYes { .. } | BinaryMarketState::ResolvedNo { .. } => { - BinaryMarketPath::ResolvedRedemption - } - BinaryMarketState::Expired { .. } => BinaryMarketPath::ExpiryRedemption, - BinaryMarketState::Trading { .. } => { - return Err(MarketBuilderError::UnsupportedTransition); - } - }, - }) -} - fn validate_live_shape( compiled: &CompiledBinaryMarket, params: BinaryMarketParams, @@ -1255,12 +1181,12 @@ fn validate_attestation( params: BinaryMarketParams, action: BinaryMarketAction, attestation: Option, -) -> Result<[u8; 64], MarketBuilderError> { +) -> Result, MarketBuilderError> { let BinaryMarketAction::Resolve { outcome } = action else { if attestation.is_some() { return Err(MarketBuilderError::UnexpectedOracleAttestation); } - return Ok([0; 64]); + return Ok(None); }; let attestation = attestation.ok_or(MarketBuilderError::MissingOracleAttestation)?; if attestation.outcome != outcome { @@ -1282,7 +1208,10 @@ fn validate_attestation( Secp256k1::verification_only() .verify_schnorr(&signature, &message, &public_key) .map_err(|_| MarketBuilderError::InvalidOracleAttestation)?; - Ok(attestation.signature) + Ok(Some(BinaryMarketResolution::new( + attestation.outcome, + attestation.signature, + ))) } fn validate_market_hint( @@ -1471,6 +1400,8 @@ fn compile(params: BinaryMarketParams) -> Result, slot: BinaryMarketSlot, output_base: usize, -) -> derived_binary_market::BinaryMarketWitness { - let (tokens_burned, redeem_yes) = match action { - BinaryMarketAction::Redeem { outcome, tokens } => (tokens, outcome == BinaryOutcome::Yes), - _ => (0, false), +) -> BinaryMarketWitness { + let resolution = match (action, attestation) { + (BinaryMarketAction::Resolve { outcome }, Some(attestation)) => { + Some(BinaryMarketResolution::new(outcome, attestation.signature)) + } + _ => None, }; - let oracle_outcome_yes = redeem_yes - || matches!( - action, - BinaryMarketAction::Resolve { - outcome: BinaryOutcome::Yes - } - ); - derived_binary_market::BinaryMarketWitness { - path: plan.path() as u8, - slot: slot as u8, - output_base: u32::try_from(output_base).expect("test output index fits u32"), - oracle_outcome_yes, - oracle_signature: attestation.map_or([0; 64], |value| value.signature), - tokens_burned, - redeem_yes, - } + let layout = plan.layout(); + let coordinator_action = BinaryMarketCoordinatorAction::for_layout( + layout, + u32::try_from(output_base).expect("test output index fits u32"), + resolution, + ) + .expect("action matches transition layout"); + BinaryMarketWitness::for_slot(layout, slot, coordinator_action) + .expect("slot belongs to transition layout") } fn attach_dummy_issuance(input: &mut PsetInput) { @@ -1356,14 +1351,19 @@ fn market_coordinator_rejects_adversarial_solvency_and_authorization_mutations() output_base, ); let coordinator = BinaryMarketSlot::UnresolvedYesRt; - let mut bad_signature = direct_market_witness( - &resolution_plan, - resolve_yes, - resolution_attestation, - coordinator, - output_base, - ); - bad_signature.oracle_signature[0] ^= 1; + let mut signature = resolution_attestation + .expect("resolution attestation") + .signature; + signature[0] ^= 1; + let bad_action = BinaryMarketCoordinatorAction::for_layout( + resolution_plan.layout(), + u32::try_from(output_base).expect("test output index fits u32"), + Some(BinaryMarketResolution::new(BinaryOutcome::Yes, signature)), + ) + .expect("resolution action"); + let bad_signature = + BinaryMarketWitness::for_slot(resolution_plan.layout(), coordinator, bad_action) + .expect("coordinator witness"); assert!( compiled .execute( @@ -1472,27 +1472,16 @@ fn market_coordinator_rejects_adversarial_solvency_and_authorization_mutations() input_base, output_base, ); - let resolved_slot = BinaryMarketSlot::ResolvedYesCollateral; for tokens in [0, 4] { - let mut witness = direct_market_witness( - &redemption_plan, + let mut wrong_burn = redemption.clone(); + wrong_burn.outputs_mut()[output_base + 1].amount = Some(tokens); + assert_rejected( + resolved, redeem_yes, redemption_attestation, - resolved_slot, - output_base, - ); - witness.tokens_burned = tokens; - assert!( - compiled - .execute( - resolved_slot, - &redemption, - &witness.build_witness(), - input_base, - &network, - ) - .is_err(), - "resolved redemption accepted {tokens} adversarial tokens" + &redemption_plan, + &wrong_burn, + "resolved redemption with an invalid derived burn amount", ); } @@ -1558,15 +1547,42 @@ fn market_coordinator_rejects_adversarial_solvency_and_authorization_mutations() "expiry redemption using the resolved-market payout", ); - let mut wrong_expiry_token = expired_redemption; - wrong_expiry_token.outputs_mut()[output_base + 1].asset = Some(params.yes_token_asset_id); - assert_rejected( - expired, + let mut alternate_expiry_token = expired_redemption; + alternate_expiry_token.outputs_mut()[output_base + 1].asset = Some(params.yes_token_asset_id); + let slot = BinaryMarketSlot::ExpiredCollateral; + let witness = direct_market_witness( + &expired_plan, redeem_expired_no, expired_attestation, - &expired_plan, - &wrong_expiry_token, - "expiry redemption burning the other outcome token", + slot, + output_base, + ); + compiled + .execute( + slot, + &alternate_expiry_token, + &witness.build_witness(), + input_base, + &network, + ) + .expect("expiry redemption derives and accepts either token side"); + let transaction = alternate_expiry_token + .extract_tx() + .expect("extract alternate expiry redemption"); + let expired_live = live_inputs(&compiled, expired, RtSide::A); + let interpreted = interpret_binary_market_spend_with_compiled( + &compiled, + expired, + &interpreter_live_outputs(&compiled, expired, &expired_live), + &transaction, + ) + .expect("interpret alternate expiry token side"); + assert_eq!( + interpreted.action, + BinaryMarketAction::Redeem { + outcome: BinaryOutcome::Yes, + tokens: 1, + } ); } @@ -1627,15 +1643,12 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato .into_iter() .enumerate() { - for path in (0_u8..=9).chain([u8::MAX]) { - let mut witness = - direct_market_witness(&active_plan, active_action, None, slot, output_base); - witness.path = path; - witness.output_base = u32::MAX; - witness.oracle_outcome_yes = true; - witness.oracle_signature = [0xa5; 64]; - witness.tokens_burned = u64::MAX; - witness.redeem_yes = true; + for divergent_output_base in [0, u32::MAX - 1, u32::MAX] { + let action = BinaryMarketCoordinatorAction::Cancel { + output_base: divergent_output_base, + }; + let witness = BinaryMarketWitness::for_slot(active_plan.layout(), slot, action) + .expect("follower witness"); compiled .execute( slot, @@ -1645,20 +1658,64 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato &network, ) .unwrap_or_else(|error| { - panic!("path-independent follower {slot:?} rejected path {path}: {error}") + panic!( + "action-independent follower {slot:?} rejected output base \ + {divergent_output_base}: {error}" + ) }); } } - for wrong_path in (0_u8..=9).filter(|path| *path != 2).chain([u8::MAX]) { - let mut witness = direct_market_witness( - &active_plan, - active_action, - None, + let output_base_u32 = u32::try_from(output_base).expect("test output index fits u32"); + let wrong_layouts = [ + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Issue, + None, + ) + .expect("issuance layout"), + BinaryMarketCoordinatorAction::Issue { + output_base: output_base_u32, + }, + "issue", + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Resolve, + None, + ) + .expect("resolution layout"), + BinaryMarketCoordinatorAction::Resolve { + output_base: output_base_u32, + resolution: BinaryMarketResolution::new( + BinaryOutcome::Yes, + sign_attestation(params, BinaryOutcome::Yes).signature, + ), + }, + "resolve", + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Expire, + None, + ) + .expect("expiry layout"), + BinaryMarketCoordinatorAction::Expire { + output_base: output_base_u32, + }, + "expire", + ), + ]; + for (wrong_layout, wrong_action, label) in wrong_layouts { + let witness = BinaryMarketWitness::for_slot( + wrong_layout, BinaryMarketSlot::UnresolvedYesRt, - output_base, - ); - witness.path = wrong_path; + wrong_action, + ) + .expect("wrong semantic coordinator witness"); assert!( compiled .execute( @@ -1669,14 +1726,17 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato &network, ) .is_err(), - "coordinator accepted partial cancellation under path {wrong_path}" + "coordinator accepted cancellation transaction under {label} action" ); } for (offset, slot) in active_slots.into_iter().enumerate() { - let mut witness = - direct_market_witness(&active_plan, active_action, None, slot, output_base); - witness.slot = ((slot as u8) + 1) % (BinaryMarketSlot::ALL.len() as u8); + let wrong_slot = active_slots[(offset + 1) % active_slots.len()]; + let action = BinaryMarketCoordinatorAction::Cancel { + output_base: output_base_u32, + }; + let witness = BinaryMarketWitness::for_slot(active_plan.layout(), wrong_slot, action) + .expect("wrong role still belongs to active layout"); assert!( compiled .execute( @@ -1778,19 +1838,19 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato &network, ) .expect("finalize canonical coordinator and followers"); - for (offset, slot, path, signature) in [ - (1, BinaryMarketSlot::UnresolvedNoRt, u8::MAX, [0xb6; 64]), - (2, BinaryMarketSlot::UnresolvedCollateral, 9, [0xc7; 64]), + for (offset, slot, divergent_output_base) in [ + (1, BinaryMarketSlot::UnresolvedNoRt, u32::MAX - 1), + (2, BinaryMarketSlot::UnresolvedCollateral, u32::MAX), ] { let input_index = input_base + offset; - let mut witness = - direct_market_witness(&active_plan, active_action, None, slot, output_base); - witness.path = path; - witness.output_base = u32::MAX - u32::try_from(offset).expect("small offset"); - witness.oracle_outcome_yes = offset == 1; - witness.oracle_signature = signature; - witness.tokens_burned = u64::MAX - u64::try_from(offset).expect("small offset"); - witness.redeem_yes = offset == 2; + let witness = BinaryMarketWitness::for_slot( + active_plan.layout(), + slot, + BinaryMarketCoordinatorAction::Cancel { + output_base: divergent_output_base, + }, + ) + .expect("follower witness with divergent action payload"); compiled .execute( slot, @@ -1816,6 +1876,10 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato .expect("finalize mixed follower") .into_witness_stack(); assert!((4..=5).contains(&canonical_stack.len())); + assert_eq!( + mixed_stack, canonical_stack, + "follower {slot:?} must prune ACTION from its witness program" + ); record_budget(format!("mixed-follower-{slot:?}"), &mixed_stack); mixed_witness_pset.inputs_mut()[input_index].final_script_witness = Some(mixed_stack); } @@ -1857,20 +1921,15 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato .prepare_expiry(&mut dormant_pset, input_base) .expect("prepare dormant expiry"); let dormant_follower = BinaryMarketSlot::DormantNoRt; - for path in (0_u8..=9).chain([u8::MAX]) { - let mut witness = direct_market_witness( - &dormant_plan, - dormant_action, - None, + for divergent_output_base in [0, u32::MAX - 1, u32::MAX] { + let witness = BinaryMarketWitness::for_slot( + dormant_plan.layout(), dormant_follower, - output_base, - ); - witness.path = path; - witness.output_base = u32::MAX; - witness.oracle_outcome_yes = true; - witness.oracle_signature = [0xd8; 64]; - witness.tokens_burned = u64::MAX; - witness.redeem_yes = true; + BinaryMarketCoordinatorAction::Expire { + output_base: divergent_output_base, + }, + ) + .expect("dormant follower witness with divergent action payload"); compiled .execute( dormant_follower, @@ -1880,10 +1939,18 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato &network, ) .unwrap_or_else(|error| { - panic!("dormant path-independent follower rejected path {path}: {error}") + panic!( + "ACTION-independent dormant follower rejected output base \ + {divergent_output_base}: {error}" + ) }); } + // Dormant RTs may originate at nonconsecutive outputs of one composed + // creation transaction. Only unresolved continuation groups are required + // to occupy consecutive prior vouts. + let mut composed_dormant_pset = dormant_pset.clone(); + composed_dormant_pset.inputs_mut()[input_base + 1].previous_output_index += 7; for (offset, slot) in [ BinaryMarketSlot::DormantYesRt, BinaryMarketSlot::DormantNoRt, @@ -1891,9 +1958,40 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato .into_iter() .enumerate() { - let mut witness = - direct_market_witness(&dormant_plan, dormant_action, None, slot, output_base); - witness.slot = ((slot as u8) + 1) % (BinaryMarketSlot::ALL.len() as u8); + let witness = direct_market_witness(&dormant_plan, dormant_action, None, slot, output_base); + compiled + .execute( + slot, + &composed_dormant_pset, + &witness.build_witness(), + input_base + offset, + &network, + ) + .unwrap_or_else(|error| { + panic!("{slot:?} rejected a valid nonconsecutive dormant sibling: {error}") + }); + } + + for (offset, slot) in [ + BinaryMarketSlot::DormantYesRt, + BinaryMarketSlot::DormantNoRt, + ] + .into_iter() + .enumerate() + { + let dormant_slots = [ + BinaryMarketSlot::DormantYesRt, + BinaryMarketSlot::DormantNoRt, + ]; + let wrong_slot = dormant_slots[(offset + 1) % dormant_slots.len()]; + let witness = BinaryMarketWitness::for_slot( + dormant_plan.layout(), + wrong_slot, + BinaryMarketCoordinatorAction::Expire { + output_base: u32::try_from(output_base).expect("output index fits u32"), + }, + ) + .expect("wrong role still belongs to dormant layout"); assert!( compiled .execute( @@ -1950,6 +2048,7 @@ fn every_terminal_market_slot_rejects_a_false_slot_witness() { tokens: 1, }, BinaryMarketSlot::ResolvedYesCollateral, + BinaryMarketCoordinatorRole::ResolvedNoCollateral, ), ( BinaryMarketState::ResolvedNo { @@ -1960,6 +2059,7 @@ fn every_terminal_market_slot_rejects_a_false_slot_witness() { tokens: 1, }, BinaryMarketSlot::ResolvedNoCollateral, + BinaryMarketCoordinatorRole::ResolvedYesCollateral, ), ( BinaryMarketState::Expired { @@ -1970,10 +2070,11 @@ fn every_terminal_market_slot_rejects_a_false_slot_witness() { tokens: 1, }, BinaryMarketSlot::ExpiredCollateral, + BinaryMarketCoordinatorRole::ResolvedYesCollateral, ), ]; - for (before, action, slot) in cases { - let (plan, attestation, pset) = finalized_market_fixture( + for (before, action, slot, wrong_coordinator) in cases { + let (_plan, _attestation, pset) = finalized_market_fixture( &compiled, before, action, @@ -1981,8 +2082,20 @@ fn every_terminal_market_slot_rejects_a_false_slot_witness() { input_base, output_base, ); - let mut witness = direct_market_witness(&plan, action, attestation, slot, output_base); - witness.slot = ((slot as u8) + 1) % (BinaryMarketSlot::ALL.len() as u8); + let wrong_layout = BinaryMarketLayout::for_operation( + wrong_coordinator, + BinaryMarketOperation::Redeem, + None, + ) + .expect("alternate terminal redemption layout"); + let witness = BinaryMarketWitness::for_slot( + wrong_layout, + wrong_coordinator.slot(), + BinaryMarketCoordinatorAction::Redeem { + output_base: u32::try_from(output_base).expect("output index fits u32"), + }, + ) + .expect("alternate terminal coordinator witness"); assert!( compiled .execute(slot, &pset, &witness.build_witness(), input_base, &network,) diff --git a/crates/deadcat-contracts/simplicityhl/binary_market.simf b/crates/deadcat-contracts/simplicityhl/binary_market.simf index 59f340f..8b980c0 100644 --- a/crates/deadcat-contracts/simplicityhl/binary_market.simf +++ b/crates/deadcat-contracts/simplicityhl/binary_market.simf @@ -1,7 +1,7 @@ // Deadcat binary prediction market v1. // // Multi-input states have one fixed coordinator: the YES RT input. The NO RT -// and collateral inputs dispatch by committed slot before reading PATH and only +// and collateral inputs dispatch by committed slot before reading ACTION and only // authorize a spend alongside the exact coordinator sibling. The coordinator // validates the complete transition, including issuance on every market input. // @@ -14,6 +14,23 @@ type CompressedCommitment = (bool, u256); // (odd y, x) // (side-A asset, side-B asset, side-independent one-unit value) type RtCommitments = (CompressedCommitment, CompressedCommitment, CompressedCommitment); +// Typed five-operation witness ABI. SLOT is the only other witness field. +// +// Left(Left(output_base)) Issue +// Left(Right(output_base)) Cancel +// Right(Left((output_base, outcome_yes, sig))) Resolve +// Right(Right(Left(output_base))) Expire +// Right(Right(Right(output_base))) Redeem +// +// State refinements are not witness choices: SLOT distinguishes initial from +// subsequent issuance, dormant from active resolution/expiry, and resolved +// from expired redemption. Transaction outputs distinguish partial from full +// cancellation/redemption and authenticate burn quantity and token side. +type MarketAction = Either< + Either, + Either<(u32, bool, Signature), Either> +>; + // ----------------------------------------------------------------------------- // Boolean and arithmetic helpers // ----------------------------------------------------------------------------- @@ -73,28 +90,6 @@ fn collateral_for_pairs(pairs: u64) -> u64 { safe_multiply(pairs, collateral_per_pair()) } -fn validate_parameters() { - // The recovery layout represents BASE_PAYOUT by a four-bit table index. - let allowed_0: bool = or(jet::eq_64(param::BASE_PAYOUT, 100), jet::eq_64(param::BASE_PAYOUT, 200)); - let allowed_1: bool = or(jet::eq_64(param::BASE_PAYOUT, 500), jet::eq_64(param::BASE_PAYOUT, 1000)); - let allowed_2: bool = or(jet::eq_64(param::BASE_PAYOUT, 2000), jet::eq_64(param::BASE_PAYOUT, 5000)); - let allowed_3: bool = or(jet::eq_64(param::BASE_PAYOUT, 10000), jet::eq_64(param::BASE_PAYOUT, 20000)); - let allowed_4: bool = or(jet::eq_64(param::BASE_PAYOUT, 50000), jet::eq_64(param::BASE_PAYOUT, 100000)); - let allowed_5: bool = or(jet::eq_64(param::BASE_PAYOUT, 200000), jet::eq_64(param::BASE_PAYOUT, 500000)); - let allowed_6: bool = or(jet::eq_64(param::BASE_PAYOUT, 1000000), jet::eq_64(param::BASE_PAYOUT, 2000000)); - let allowed_7: bool = or(jet::eq_64(param::BASE_PAYOUT, 5000000), jet::eq_64(param::BASE_PAYOUT, 10000000)); - let allowed_01: bool = or(allowed_0, allowed_1); - let allowed_23: bool = or(allowed_2, allowed_3); - let allowed_45: bool = or(allowed_4, allowed_5); - let allowed_67: bool = or(allowed_6, allowed_7); - assert!(or(or(allowed_01, allowed_23), or(allowed_45, allowed_67))); - - assert!(jet::le_32(1, param::EXPIRY_HEIGHT)); - assert!(jet::lt_32(param::EXPIRY_HEIGHT, 500000000)); - let cp: u64 = collateral_per_pair(); - ensure_nonzero(cp); -} - // ----------------------------------------------------------------------------- // Transaction inspection // ----------------------------------------------------------------------------- @@ -198,9 +193,24 @@ fn ensure_burn_script(index: u32) { ensure_output_script(index, burn_script_hash()); } -fn ensure_explicit_burn(index: u32, expected_asset: u256, expected_amount: u64) { - ensure_output_explicit(index, expected_asset, expected_amount); +fn explicit_burn_amount(index: u32, expected_asset: u256) -> u64 { + let (asset, amount): (u256, u64) = get_output_explicit(index); + assert!(jet::eq_256(asset, expected_asset)); + ensure_nonzero(amount); ensure_burn_script(index); + amount +} + +fn market_token_burn(index: u32) -> (bool, u64) { + let (asset, amount): (u256, u64) = get_output_explicit(index); + let burns_yes: bool = jet::eq_256(asset, param::YES_TOKEN_ASSET_ID); + match burns_yes { + true => assert!(jet::eq_256(asset, param::YES_TOKEN_ASSET_ID)), + false => assert!(jet::eq_256(asset, param::NO_TOKEN_ASSET_ID)), + }; + ensure_nonzero(amount); + ensure_burn_script(index); + (burns_yes, amount) } fn ensure_current_index(expected: u32) { @@ -255,19 +265,48 @@ fn script_for_slot(slot: u8) -> u256 { compute_p2tr_script_hash(output_key) } -fn ensure_dormant_group(yes_index: u32, no_index: u32) { - ensure_input_script(yes_index, script_for_slot(0)); +// The current input's slot script is authenticated once in main. These helpers +// authenticate only its siblings before any coordinator reads ACTION. +fn ensure_dormant_coordinator_group(yes_index: u32, no_index: u32) { ensure_input_script(no_index, script_for_slot(1)); ensure_same_previous_transaction_2(yes_index, no_index); } -fn ensure_unresolved_group(yes_index: u32, no_index: u32, collateral_index: u32) { - ensure_input_script(yes_index, script_for_slot(2)); +fn ensure_dormant_follower_group(yes_index: u32, no_index: u32) { + ensure_input_script(yes_index, script_for_slot(0)); + ensure_same_previous_transaction_2(yes_index, no_index); +} + +fn ensure_unresolved_coordinator_group( + yes_index: u32, + no_index: u32, + collateral_index: u32, +) { ensure_input_script(no_index, script_for_slot(3)); ensure_input_script(collateral_index, script_for_slot(4)); ensure_same_previous_transaction_3(yes_index, no_index, collateral_index); } +fn ensure_unresolved_no_follower_group( + yes_index: u32, + no_index: u32, + collateral_index: u32, +) { + ensure_input_script(yes_index, script_for_slot(2)); + ensure_input_script(collateral_index, script_for_slot(4)); + ensure_same_previous_transaction_3(yes_index, no_index, collateral_index); +} + +fn ensure_unresolved_collateral_follower_group( + yes_index: u32, + no_index: u32, + collateral_index: u32, +) { + ensure_input_script(yes_index, script_for_slot(2)); + ensure_input_script(no_index, script_for_slot(3)); + ensure_same_previous_transaction_3(yes_index, no_index, collateral_index); +} + // ----------------------------------------------------------------------------- // A/B confidential one-unit RT commitments // ----------------------------------------------------------------------------- @@ -410,7 +449,6 @@ fn initial_issuance( let yes_out: u32 = output_base; let no_out: u32 = safe_add_32(output_base, 1); let collateral_out: u32 = safe_add_32(output_base, 2); - ensure_dormant_group(yes_in, no_in); ensure_current_index(yes_in); let yes_is_a: bool = ensure_rt_continuation(yes_in, yes_out, yes_rt_commitments(), 2); @@ -437,7 +475,6 @@ fn subsequent_issuance( let yes_out: u32 = output_base; let no_out: u32 = safe_add_32(output_base, 1); let collateral_out: u32 = safe_add_32(output_base, 2); - ensure_unresolved_group(yes_in, no_in, collateral_in); ensure_current_index(yes_in); let yes_is_a: bool = ensure_rt_continuation(yes_in, yes_out, yes_rt_commitments(), 2); @@ -457,66 +494,54 @@ fn subsequent_issuance( ensure_output_script(collateral_out, script_for_slot(4)); } -fn partial_cancellation( - input_base: u32, - output_base: u32, -) { +fn cancellation(input_base: u32, output_base: u32) { let yes_in: u32 = input_base; let no_in: u32 = safe_add_32(input_base, 1); let collateral_in: u32 = safe_add_32(input_base, 2); let yes_out: u32 = output_base; let no_out: u32 = safe_add_32(output_base, 1); - let collateral_out: u32 = safe_add_32(output_base, 2); - let yes_burn: u32 = safe_add_32(output_base, 3); - let no_burn: u32 = safe_add_32(output_base, 4); - ensure_unresolved_group(yes_in, no_in, collateral_in); - ensure_no_issuance(yes_in); - ensure_no_issuance(no_in); - ensure_no_issuance(collateral_in); - - ensure_current_index(yes_in); - let (yes_asset, pairs): (u256, u64) = get_output_explicit(yes_burn); - assert!(jet::eq_256(yes_asset, param::YES_TOKEN_ASSET_ID)); - ensure_nonzero(pairs); - ensure_explicit_burn(yes_burn, param::YES_TOKEN_ASSET_ID, pairs); - ensure_explicit_burn(no_burn, param::NO_TOKEN_ASSET_ID, pairs); - let yes_is_a: bool = ensure_rt_continuation(yes_in, yes_out, yes_rt_commitments(), 2); - let no_is_a: bool = ensure_rt_continuation(no_in, no_out, no_rt_commitments(), 3); - ensure_same_rt_side(yes_is_a, no_is_a); - let old_collateral: u64 = ensure_input_explicit(collateral_in, param::COLLATERAL_ASSET_ID); - let remaining: u64 = safe_subtract(old_collateral, collateral_for_pairs(pairs)); - ensure_nonzero(remaining); - ensure_output_explicit(collateral_out, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script(collateral_out, script_for_slot(4)); -} + let third_out: u32 = safe_add_32(output_base, 2); + let (third_asset, third_amount): (u256, u64) = get_output_explicit(third_out); + let partial: bool = jet::eq_256(third_asset, param::COLLATERAL_ASSET_ID); + let yes_burn: u32 = match partial { + true => safe_add_32(output_base, 3), + false => third_out, + }; + let no_burn: u32 = safe_add_32(yes_burn, 1); + let yes_output_slot: u8 = match partial { + true => 2, + false => 0, + }; + let no_output_slot: u8 = match partial { + true => 3, + false => 1, + }; -fn full_cancellation( - input_base: u32, - output_base: u32, -) { - let yes_in: u32 = input_base; - let no_in: u32 = safe_add_32(input_base, 1); - let collateral_in: u32 = safe_add_32(input_base, 2); - let yes_out: u32 = output_base; - let no_out: u32 = safe_add_32(output_base, 1); - let yes_burn: u32 = safe_add_32(output_base, 2); - let no_burn: u32 = safe_add_32(output_base, 3); - ensure_unresolved_group(yes_in, no_in, collateral_in); ensure_no_issuance(yes_in); ensure_no_issuance(no_in); ensure_no_issuance(collateral_in); - ensure_current_index(yes_in); - let (yes_asset, pairs): (u256, u64) = get_output_explicit(yes_burn); - assert!(jet::eq_256(yes_asset, param::YES_TOKEN_ASSET_ID)); - ensure_nonzero(pairs); - ensure_explicit_burn(yes_burn, param::YES_TOKEN_ASSET_ID, pairs); - ensure_explicit_burn(no_burn, param::NO_TOKEN_ASSET_ID, pairs); - let collateral: u64 = ensure_input_explicit(collateral_in, param::COLLATERAL_ASSET_ID); - assert!(jet::eq_64(collateral, collateral_for_pairs(pairs))); - let yes_is_a: bool = ensure_rt_continuation(yes_in, yes_out, yes_rt_commitments(), 0); - let no_is_a: bool = ensure_rt_continuation(no_in, no_out, no_rt_commitments(), 1); + let pairs: u64 = explicit_burn_amount(yes_burn, param::YES_TOKEN_ASSET_ID); + let no_pairs: u64 = explicit_burn_amount(no_burn, param::NO_TOKEN_ASSET_ID); + assert!(jet::eq_64(pairs, no_pairs)); + let yes_is_a: bool = ensure_rt_continuation( + yes_in, yes_out, yes_rt_commitments(), yes_output_slot, + ); + let no_is_a: bool = ensure_rt_continuation( + no_in, no_out, no_rt_commitments(), no_output_slot, + ); ensure_same_rt_side(yes_is_a, no_is_a); + let collateral: u64 = ensure_input_explicit(collateral_in, param::COLLATERAL_ASSET_ID); + let remaining: u64 = safe_subtract(collateral, collateral_for_pairs(pairs)); + + match partial { + true => { + ensure_nonzero(remaining); + assert!(jet::eq_64(third_amount, remaining)); + ensure_output_script(third_out, script_for_slot(4)); + }, + false => assert!(jet::is_zero_64(remaining)), + }; } fn active_resolution( @@ -531,7 +556,6 @@ fn active_resolution( let yes_burn: u32 = output_base; let no_burn: u32 = safe_add_32(output_base, 1); let collateral_out: u32 = safe_add_32(output_base, 2); - ensure_unresolved_group(yes_in, no_in, collateral_in); ensure_no_issuance(yes_in); ensure_no_issuance(no_in); ensure_no_issuance(collateral_in); @@ -560,7 +584,6 @@ fn dormant_resolution( let no_in: u32 = safe_add_32(input_base, 1); let yes_burn: u32 = output_base; let no_burn: u32 = safe_add_32(output_base, 1); - ensure_dormant_group(yes_in, no_in); ensure_no_issuance(yes_in); ensure_no_issuance(no_in); @@ -581,7 +604,6 @@ fn active_expiry( let yes_burn: u32 = output_base; let no_burn: u32 = safe_add_32(output_base, 1); let collateral_out: u32 = safe_add_32(output_base, 2); - ensure_unresolved_group(yes_in, no_in, collateral_in); ensure_no_issuance(yes_in); ensure_no_issuance(no_in); ensure_no_issuance(collateral_in); @@ -604,7 +626,6 @@ fn dormant_expiry( let no_in: u32 = safe_add_32(input_base, 1); let yes_burn: u32 = output_base; let no_burn: u32 = safe_add_32(output_base, 1); - ensure_dormant_group(yes_in, no_in); ensure_no_issuance(yes_in); ensure_no_issuance(no_in); jet::check_lock_height(param::EXPIRY_HEIGHT); @@ -615,59 +636,60 @@ fn dormant_expiry( ensure_same_rt_side(yes_is_a, no_is_a); } -fn resolved_redemption(slot: u8, input_base: u32, output_base: u32, tokens_burned: u64) { +fn resolved_redemption(slot: u8, input_base: u32, output_base: u32) { let resolved_yes: bool = jet::eq_8(slot, 5); let resolved_no: bool = jet::eq_8(slot, 6); assert!(or(resolved_yes, resolved_no)); ensure_current_index(input_base); ensure_no_issuance(input_base); - ensure_nonzero(tokens_burned); + let (first_asset, first_amount): (u256, u64) = get_output_explicit(output_base); + let partial: bool = jet::eq_256(first_asset, param::COLLATERAL_ASSET_ID); + let burn_index: u32 = match partial { + true => safe_add_32(output_base, 1), + false => output_base, + }; + let (burns_yes, tokens_burned): (bool, u64) = market_token_burn(burn_index); + assert!(jet::eq_1(::into(burns_yes), ::into(resolved_yes))); let collateral: u64 = ensure_input_explicit(input_base, param::COLLATERAL_ASSET_ID); let payout: u64 = collateral_for_pairs(tokens_burned); let remaining: u64 = safe_subtract(collateral, payout); - let winner: u256 = match resolved_yes { - true => param::YES_TOKEN_ASSET_ID, - false => param::NO_TOKEN_ASSET_ID, - }; - match jet::is_zero_64(remaining) { - true => ensure_explicit_burn(output_base, winner, tokens_burned), - false => { - ensure_output_explicit(output_base, param::COLLATERAL_ASSET_ID, remaining); + match partial { + true => { + ensure_nonzero(remaining); + assert!(jet::eq_64(first_amount, remaining)); ensure_output_script(output_base, script_for_slot(slot)); - ensure_explicit_burn(safe_add_32(output_base, 1), winner, tokens_burned); }, + false => assert!(jet::is_zero_64(remaining)), }; } -fn expiry_redemption( - slot: u8, - input_base: u32, - output_base: u32, - tokens_burned: u64, - redeem_yes: bool, -) { +fn expiry_redemption(slot: u8, input_base: u32, output_base: u32) { assert!(jet::eq_8(slot, 7)); ensure_current_index(input_base); ensure_no_issuance(input_base); - ensure_nonzero(tokens_burned); + let (first_asset, first_amount): (u256, u64) = get_output_explicit(output_base); + let partial: bool = jet::eq_256(first_asset, param::COLLATERAL_ASSET_ID); + let burn_index: u32 = match partial { + true => safe_add_32(output_base, 1), + false => output_base, + }; + // Both token sides pay equally after expiry; market_token_burn still + // derives and authenticates the side rather than accepting a witness bit. + let (burns_yes, tokens_burned): (bool, u64) = market_token_burn(burn_index); let collateral: u64 = ensure_input_explicit(input_base, param::COLLATERAL_ASSET_ID); let payout: u64 = safe_multiply(tokens_burned, param::BASE_PAYOUT); let remaining: u64 = safe_subtract(collateral, payout); - let token: u256 = match redeem_yes { - true => param::YES_TOKEN_ASSET_ID, - false => param::NO_TOKEN_ASSET_ID, - }; - match jet::is_zero_64(remaining) { - true => ensure_explicit_burn(output_base, token, tokens_burned), - false => { - ensure_output_explicit(output_base, param::COLLATERAL_ASSET_ID, remaining); + match partial { + true => { + ensure_nonzero(remaining); + assert!(jet::eq_64(first_amount, remaining)); ensure_output_script(output_base, script_for_slot(7)); - ensure_explicit_burn(safe_add_32(output_base, 1), token, tokens_burned); }, + false => assert!(jet::is_zero_64(remaining)), }; } @@ -678,21 +700,21 @@ fn expiry_redemption( fn dormant_no_follower() { let no_in: u32 = jet::current_index(); let yes_in: u32 = safe_subtract_32(no_in, 1); - ensure_dormant_group(yes_in, no_in); + ensure_dormant_follower_group(yes_in, no_in); } fn unresolved_no_follower() { let no_in: u32 = jet::current_index(); let yes_in: u32 = safe_subtract_32(no_in, 1); let collateral_in: u32 = safe_add_32(yes_in, 2); - ensure_unresolved_group(yes_in, no_in, collateral_in); + ensure_unresolved_no_follower_group(yes_in, no_in, collateral_in); } fn unresolved_collateral_follower() { let collateral_in: u32 = jet::current_index(); let yes_in: u32 = safe_subtract_32(collateral_in, 2); let no_in: u32 = safe_add_32(yes_in, 1); - ensure_unresolved_group(yes_in, no_in, collateral_in); + ensure_unresolved_collateral_follower_group(yes_in, no_in, collateral_in); } // ----------------------------------------------------------------------------- @@ -700,97 +722,97 @@ fn unresolved_collateral_follower() { // ----------------------------------------------------------------------------- fn main() { - validate_parameters(); - - let path: u8 = witness::PATH; let slot: u8 = witness::SLOT; - let output_base: u32 = witness::OUTPUT_BASE; + let current: u32 = jet::current_index(); // This proves that SLOT is the hidden TapData word committed by the UTXO - // executing this program. Role dispatch happens before path dispatch so - // follower inputs cannot select transition-specific validation. - ensure_input_script(jet::current_index(), script_for_slot(slot)); - - let oracle_outcome_yes: bool = witness::ORACLE_OUTCOME_YES; - let oracle_signature: Signature = witness::ORACLE_SIGNATURE; - let tokens_burned: u64 = witness::TOKENS_BURNED; - let redeem_yes: bool = witness::REDEEM_YES; + // executing this program. Follower roles authenticate their complete group + // and return without ever consulting ACTION. + ensure_input_script(current, script_for_slot(slot)); + + match jet::eq_8(slot, 1) { + true => dormant_no_follower(), + false => match jet::eq_8(slot, 3) { + true => unresolved_no_follower(), + false => match jet::eq_8(slot, 4) { + true => unresolved_collateral_follower(), + false => { + // Authenticate coordinator siblings before reading ACTION. + // Slots 5, 6, and 7 are single-input terminal states. + match jet::eq_8(slot, 0) { + true => ensure_dormant_coordinator_group( + current, + safe_add_32(current, 1), + ), + false => match jet::eq_8(slot, 2) { + true => ensure_unresolved_coordinator_group( + current, + safe_add_32(current, 1), + safe_add_32(current, 2), + ), + false => assert!(or( + or(jet::eq_8(slot, 5), jet::eq_8(slot, 6)), + jet::eq_8(slot, 7), + )), + }, + }; - // Dormant YES is the two-input state's fixed coordinator. - match jet::eq_8(slot, 0) { - true => { - let input_base: u32 = jet::current_index(); - match jet::eq_8(path, 0) { - true => initial_issuance(input_base, output_base), - false => match jet::eq_8(path, 5) { - true => dormant_resolution( - input_base, output_base, oracle_outcome_yes, oracle_signature, - ), - false => { - assert!(jet::eq_8(path, 7)); - dormant_expiry(input_base, output_base); - }, - }, - }; - }, - false => match jet::eq_8(slot, 1) { - // Dormant NO is a path-independent follower. - true => dormant_no_follower(), - false => match jet::eq_8(slot, 2) { - // Unresolved YES is the three-input state's fixed coordinator. - true => { - let input_base: u32 = jet::current_index(); - match jet::eq_8(path, 1) { - true => subsequent_issuance(input_base, output_base), - false => match jet::eq_8(path, 2) { - true => partial_cancellation(input_base, output_base), - false => match jet::eq_8(path, 3) { - true => full_cancellation(input_base, output_base), - false => match jet::eq_8(path, 4) { - true => active_resolution( - input_base, - output_base, - oracle_outcome_yes, - oracle_signature, - ), - false => { - assert!(jet::eq_8(path, 6)); - active_expiry(input_base, output_base); - }, + let action: MarketAction = witness::ACTION; + match action { + Left(issue_or_cancel: Either) => match issue_or_cancel { + Left(output_base: u32) => match jet::eq_8(slot, 0) { + true => initial_issuance(current, output_base), + false => { + assert!(jet::eq_8(slot, 2)); + subsequent_issuance(current, output_base); }, }, + Right(output_base: u32) => { + assert!(jet::eq_8(slot, 2)); + cancellation(current, output_base); + }, }, - }; - }, - false => match jet::eq_8(slot, 3) { - // Unresolved NO is a path-independent follower. - true => unresolved_no_follower(), - false => match jet::eq_8(slot, 4) { - // Unresolved collateral is a path-independent follower. - true => unresolved_collateral_follower(), - false => match or(jet::eq_8(slot, 5), jet::eq_8(slot, 6)) { - true => { - assert!(jet::eq_8(path, 8)); - resolved_redemption( - slot, - jet::current_index(), - output_base, - tokens_burned, - ); + Right(resolve_or_terminal: Either<(u32, bool, Signature), Either>) => match resolve_or_terminal { + Left(resolve: (u32, bool, Signature)) => { + let (output_base, outcome_yes, signature): + (u32, bool, Signature) = resolve; + match jet::eq_8(slot, 0) { + true => dormant_resolution( + current, output_base, outcome_yes, signature, + ), + false => { + assert!(jet::eq_8(slot, 2)); + active_resolution( + current, output_base, outcome_yes, signature, + ); + }, + }; }, - false => { - assert!(jet::eq_8(slot, 7)); - assert!(jet::eq_8(path, 9)); - expiry_redemption( - slot, - jet::current_index(), - output_base, - tokens_burned, - redeem_yes, - ); + Right(expire_or_redeem: Either) => { + match expire_or_redeem { + Left(output_base: u32) => match jet::eq_8(slot, 0) { + true => dormant_expiry(current, output_base), + false => { + assert!(jet::eq_8(slot, 2)); + active_expiry(current, output_base); + }, + }, + Right(output_base: u32) => match or( + jet::eq_8(slot, 5), + jet::eq_8(slot, 6), + ) { + true => resolved_redemption( + slot, current, output_base, + ), + false => { + assert!(jet::eq_8(slot, 7)); + expiry_redemption(slot, current, output_base); + }, + }, + }; }, }, - }, + }; }, }, }, diff --git a/crates/deadcat-contracts/src/binary_market.rs b/crates/deadcat-contracts/src/binary_market.rs index a654205..4a6fea8 100644 --- a/crates/deadcat-contracts/src/binary_market.rs +++ b/crates/deadcat-contracts/src/binary_market.rs @@ -1,18 +1,21 @@ //! Pure binary-market economics and materialized-state transitions. //! -//! This module deliberately does not inspect transactions. A future interpreter -//! validates a covenant spend and converts it into a [`BinaryMarketAction`]; -//! this state machine then applies the same checked arithmetic used by builders -//! and the node's materialized view. +//! This module deliberately does not inspect transactions. The confirmed-spend +//! interpreter validates a covenant spend and converts it into a +//! [`BinaryMarketAction`]; this state machine then applies the same checked +//! arithmetic used by builders and the node's materialized view. use crate::recovery::BASE_PAYOUTS; pub use deadcat_types::{BinaryMarketParams, BinaryMarketState}; +use simplex::either::Either; +use simplex::program::WitnessTrait as _; +use simplex::simplicityhl::WitnessValues; use thiserror::Error; mod compiled; pub use crate::artifacts::binary_market::BinaryMarketProgram; -pub use crate::artifacts::binary_market::derived_binary_market; +use crate::artifacts::binary_market::derived_binary_market; pub use compiled::{ CompiledBinaryMarket, CompiledBinaryMarketError, CompiledBinaryMarketExecutionError, CompiledBinaryMarketSlot, @@ -47,6 +50,12 @@ impl BinaryMarketSlot { Self::ExpiredCollateral, ]; + /// Stable numeric tag committed by the v1 storage layout and witness ABI. + #[must_use] + pub const fn tag(self) -> u8 { + self as u8 + } + /// The canonical storage word: 30 zero bytes, version, then slot tag. #[must_use] pub const fn storage_word(self) -> [u8; 32] { @@ -63,7 +72,15 @@ impl BinaryMarketSlot { if word[30] != BINARY_MARKET_STORAGE_VERSION { return Err(BinaryMarketSlotError::UnsupportedVersion(word[30])); } - match word[31] { + Self::try_from(word[31]) + } +} + +impl TryFrom for BinaryMarketSlot { + type Error = BinaryMarketSlotError; + + fn try_from(tag: u8) -> Result { + match tag { 0 => Ok(Self::DormantYesRt), 1 => Ok(Self::DormantNoRt), 2 => Ok(Self::UnresolvedYesRt), @@ -77,6 +94,12 @@ impl BinaryMarketSlot { } } +impl From for u8 { + fn from(slot: BinaryMarketSlot) -> Self { + slot.tag() + } +} + #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum BinaryMarketSlotError { #[error("market storage word has nonzero reserved bytes")] @@ -87,6 +110,90 @@ pub enum BinaryMarketSlotError { UnknownSlot(u8), } +/// The ten stable v1 transaction shapes recognized by the market protocol. +/// +/// Paths are derived from the current coordinator role and semantic operation; +/// they are not part of the new Simplicity witness ABI. The numeric tags remain +/// stable for indexing and compatibility with existing materialized history. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum BinaryMarketPath { + InitialIssuance = 0, + SubsequentIssuance = 1, + PartialCancellation = 2, + FullCancellation = 3, + ActiveResolution = 4, + DormantResolution = 5, + ActiveExpiry = 6, + DormantExpiry = 7, + ResolvedRedemption = 8, + ExpiryRedemption = 9, +} + +impl BinaryMarketPath { + pub const ALL: [Self; 10] = [ + Self::InitialIssuance, + Self::SubsequentIssuance, + Self::PartialCancellation, + Self::FullCancellation, + Self::ActiveResolution, + Self::DormantResolution, + Self::ActiveExpiry, + Self::DormantExpiry, + Self::ResolvedRedemption, + Self::ExpiryRedemption, + ]; + + /// Stable numeric tag used by the v1 domain model. + #[must_use] + pub const fn tag(self) -> u8 { + self as u8 + } + + #[must_use] + pub const fn operation(self) -> BinaryMarketOperation { + match self { + Self::InitialIssuance | Self::SubsequentIssuance => BinaryMarketOperation::Issue, + Self::PartialCancellation | Self::FullCancellation => BinaryMarketOperation::Cancel, + Self::ActiveResolution | Self::DormantResolution => BinaryMarketOperation::Resolve, + Self::ActiveExpiry | Self::DormantExpiry => BinaryMarketOperation::Expire, + Self::ResolvedRedemption | Self::ExpiryRedemption => BinaryMarketOperation::Redeem, + } + } +} + +impl TryFrom for BinaryMarketPath { + type Error = BinaryMarketPathError; + + fn try_from(tag: u8) -> Result { + match tag { + 0 => Ok(Self::InitialIssuance), + 1 => Ok(Self::SubsequentIssuance), + 2 => Ok(Self::PartialCancellation), + 3 => Ok(Self::FullCancellation), + 4 => Ok(Self::ActiveResolution), + 5 => Ok(Self::DormantResolution), + 6 => Ok(Self::ActiveExpiry), + 7 => Ok(Self::DormantExpiry), + 8 => Ok(Self::ResolvedRedemption), + 9 => Ok(Self::ExpiryRedemption), + tag => Err(BinaryMarketPathError::UnknownTag(tag)), + } + } +} + +impl From for u8 { + fn from(path: BinaryMarketPath) -> Self { + path.tag() + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum BinaryMarketPathError { + #[error("unknown binary-market path tag {0}")] + UnknownTag(u8), +} + /// YES or NO in a binary market. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum BinaryOutcome { @@ -174,6 +281,19 @@ pub enum BinaryMarketTransition { }, } +impl BinaryMarketTransition { + #[must_use] + pub const fn operation(self) -> BinaryMarketOperation { + match self { + Self::Issued { .. } => BinaryMarketOperation::Issue, + Self::Cancelled { .. } => BinaryMarketOperation::Cancel, + Self::Resolved { .. } => BinaryMarketOperation::Resolve, + Self::Expired { .. } => BinaryMarketOperation::Expire, + Self::Redeemed { .. } => BinaryMarketOperation::Redeem, + } + } +} + /// Old state, new state, and the exact collateral effect of one operation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AppliedBinaryMarketTransition { @@ -182,6 +302,587 @@ pub struct AppliedBinaryMarketTransition { pub transition: BinaryMarketTransition, } +/// The input role that validates a transition for all inputs in its state. +/// +/// Trading transitions always coordinate through the YES RT. Terminal states +/// contain only one covenant input, so their collateral slot is the coordinator. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BinaryMarketCoordinatorRole { + DormantYesRt, + UnresolvedYesRt, + ResolvedYesCollateral, + ResolvedNoCollateral, + ExpiredCollateral, +} + +impl BinaryMarketCoordinatorRole { + #[must_use] + pub const fn slot(self) -> BinaryMarketSlot { + match self { + Self::DormantYesRt => BinaryMarketSlot::DormantYesRt, + Self::UnresolvedYesRt => BinaryMarketSlot::UnresolvedYesRt, + Self::ResolvedYesCollateral => BinaryMarketSlot::ResolvedYesCollateral, + Self::ResolvedNoCollateral => BinaryMarketSlot::ResolvedNoCollateral, + Self::ExpiredCollateral => BinaryMarketSlot::ExpiredCollateral, + } + } + + #[must_use] + pub const fn for_state(state: BinaryMarketState) -> Self { + match state { + BinaryMarketState::Trading { + outstanding_pairs: 0, + } => Self::DormantYesRt, + BinaryMarketState::Trading { .. } => Self::UnresolvedYesRt, + BinaryMarketState::ResolvedYes { .. } => Self::ResolvedYesCollateral, + BinaryMarketState::ResolvedNo { .. } => Self::ResolvedNoCollateral, + BinaryMarketState::Expired { .. } => Self::ExpiredCollateral, + } + } +} + +impl TryFrom for BinaryMarketCoordinatorRole { + type Error = BinaryMarketLayoutError; + + fn try_from(slot: BinaryMarketSlot) -> Result { + match slot { + BinaryMarketSlot::DormantYesRt => Ok(Self::DormantYesRt), + BinaryMarketSlot::UnresolvedYesRt => Ok(Self::UnresolvedYesRt), + BinaryMarketSlot::ResolvedYesCollateral => Ok(Self::ResolvedYesCollateral), + BinaryMarketSlot::ResolvedNoCollateral => Ok(Self::ResolvedNoCollateral), + BinaryMarketSlot::ExpiredCollateral => Ok(Self::ExpiredCollateral), + slot => Err(BinaryMarketLayoutError::FollowerCannotCoordinate { slot }), + } + } +} + +/// Exact positional role of one covenant input in a market transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BinaryMarketInputRole { + DormantYesCoordinator, + DormantNoFollower, + UnresolvedYesCoordinator, + UnresolvedNoFollower, + UnresolvedCollateralFollower, + ResolvedYesCoordinator, + ResolvedNoCoordinator, + ExpiredCoordinator, +} + +impl BinaryMarketInputRole { + #[must_use] + pub const fn slot(self) -> BinaryMarketSlot { + match self { + Self::DormantYesCoordinator => BinaryMarketSlot::DormantYesRt, + Self::DormantNoFollower => BinaryMarketSlot::DormantNoRt, + Self::UnresolvedYesCoordinator => BinaryMarketSlot::UnresolvedYesRt, + Self::UnresolvedNoFollower => BinaryMarketSlot::UnresolvedNoRt, + Self::UnresolvedCollateralFollower => BinaryMarketSlot::UnresolvedCollateral, + Self::ResolvedYesCoordinator => BinaryMarketSlot::ResolvedYesCollateral, + Self::ResolvedNoCoordinator => BinaryMarketSlot::ResolvedNoCollateral, + Self::ExpiredCoordinator => BinaryMarketSlot::ExpiredCollateral, + } + } + + #[must_use] + pub const fn coordinator_role(self) -> BinaryMarketCoordinatorRole { + match self { + Self::DormantYesCoordinator | Self::DormantNoFollower => { + BinaryMarketCoordinatorRole::DormantYesRt + } + Self::UnresolvedYesCoordinator + | Self::UnresolvedNoFollower + | Self::UnresolvedCollateralFollower => BinaryMarketCoordinatorRole::UnresolvedYesRt, + Self::ResolvedYesCoordinator => BinaryMarketCoordinatorRole::ResolvedYesCollateral, + Self::ResolvedNoCoordinator => BinaryMarketCoordinatorRole::ResolvedNoCollateral, + Self::ExpiredCoordinator => BinaryMarketCoordinatorRole::ExpiredCollateral, + } + } + + #[must_use] + pub const fn is_coordinator(self) -> bool { + matches!( + self, + Self::DormantYesCoordinator + | Self::UnresolvedYesCoordinator + | Self::ResolvedYesCoordinator + | Self::ResolvedNoCoordinator + | Self::ExpiredCoordinator + ) + } + + #[must_use] + pub const fn is_follower(self) -> bool { + !self.is_coordinator() + } +} + +impl TryFrom for BinaryMarketInputRole { + type Error = BinaryMarketSlotError; + + fn try_from(tag: u8) -> Result { + BinaryMarketSlot::try_from(tag).map(Self::from) + } +} + +impl From for u8 { + fn from(role: BinaryMarketInputRole) -> Self { + role.slot().tag() + } +} + +impl From for BinaryMarketSlot { + fn from(role: BinaryMarketInputRole) -> Self { + role.slot() + } +} + +impl From for BinaryMarketSlot { + fn from(role: BinaryMarketCoordinatorRole) -> Self { + role.slot() + } +} + +impl From for u8 { + fn from(role: BinaryMarketCoordinatorRole) -> Self { + role.slot().tag() + } +} + +impl From for BinaryMarketCoordinatorRole { + fn from(role: BinaryMarketInputRole) -> Self { + role.coordinator_role() + } +} + +impl BinaryMarketCoordinatorRole { + #[must_use] + pub const fn input_role(self) -> BinaryMarketInputRole { + match self { + Self::DormantYesRt => BinaryMarketInputRole::DormantYesCoordinator, + Self::UnresolvedYesRt => BinaryMarketInputRole::UnresolvedYesCoordinator, + Self::ResolvedYesCollateral => BinaryMarketInputRole::ResolvedYesCoordinator, + Self::ResolvedNoCollateral => BinaryMarketInputRole::ResolvedNoCoordinator, + Self::ExpiredCollateral => BinaryMarketInputRole::ExpiredCoordinator, + } + } +} + +impl From for BinaryMarketInputRole { + fn from(slot: BinaryMarketSlot) -> Self { + match slot { + BinaryMarketSlot::DormantYesRt => Self::DormantYesCoordinator, + BinaryMarketSlot::DormantNoRt => Self::DormantNoFollower, + BinaryMarketSlot::UnresolvedYesRt => Self::UnresolvedYesCoordinator, + BinaryMarketSlot::UnresolvedNoRt => Self::UnresolvedNoFollower, + BinaryMarketSlot::UnresolvedCollateral => Self::UnresolvedCollateralFollower, + BinaryMarketSlot::ResolvedYesCollateral => Self::ResolvedYesCoordinator, + BinaryMarketSlot::ResolvedNoCollateral => Self::ResolvedNoCoordinator, + BinaryMarketSlot::ExpiredCollateral => Self::ExpiredCoordinator, + } + } +} + +const DORMANT_INPUT_ROLES: [BinaryMarketInputRole; 2] = [ + BinaryMarketInputRole::DormantYesCoordinator, + BinaryMarketInputRole::DormantNoFollower, +]; +const UNRESOLVED_INPUT_ROLES: [BinaryMarketInputRole; 3] = [ + BinaryMarketInputRole::UnresolvedYesCoordinator, + BinaryMarketInputRole::UnresolvedNoFollower, + BinaryMarketInputRole::UnresolvedCollateralFollower, +]; +const RESOLVED_YES_INPUT_ROLES: [BinaryMarketInputRole; 1] = + [BinaryMarketInputRole::ResolvedYesCoordinator]; +const RESOLVED_NO_INPUT_ROLES: [BinaryMarketInputRole; 1] = + [BinaryMarketInputRole::ResolvedNoCoordinator]; +const EXPIRED_INPUT_ROLES: [BinaryMarketInputRole; 1] = [BinaryMarketInputRole::ExpiredCoordinator]; + +/// Checked mapping between semantic operations, protocol paths, and input roles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BinaryMarketLayout { + path: BinaryMarketPath, + coordinator: BinaryMarketCoordinatorRole, +} + +impl BinaryMarketLayout { + /// Derive the protocol path from the current coordinator and operation. + /// Cancellation is the one operation whose partial/full shape must first be + /// inferred from transaction outputs. + pub fn for_operation( + coordinator: BinaryMarketCoordinatorRole, + operation: BinaryMarketOperation, + full_cancellation: Option, + ) -> Result { + if operation == BinaryMarketOperation::Cancel && full_cancellation.is_none() { + return Err(BinaryMarketLayoutError::MissingCancellationShape); + } + if operation != BinaryMarketOperation::Cancel && full_cancellation.is_some() { + return Err(BinaryMarketLayoutError::UnexpectedCancellationShape { operation }); + } + + let path = match (coordinator, operation, full_cancellation) { + (BinaryMarketCoordinatorRole::DormantYesRt, BinaryMarketOperation::Issue, None) => { + BinaryMarketPath::InitialIssuance + } + (BinaryMarketCoordinatorRole::UnresolvedYesRt, BinaryMarketOperation::Issue, None) => { + BinaryMarketPath::SubsequentIssuance + } + ( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Cancel, + Some(false), + ) => BinaryMarketPath::PartialCancellation, + ( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Cancel, + Some(true), + ) => BinaryMarketPath::FullCancellation, + ( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Resolve, + None, + ) => BinaryMarketPath::ActiveResolution, + (BinaryMarketCoordinatorRole::DormantYesRt, BinaryMarketOperation::Resolve, None) => { + BinaryMarketPath::DormantResolution + } + (BinaryMarketCoordinatorRole::UnresolvedYesRt, BinaryMarketOperation::Expire, None) => { + BinaryMarketPath::ActiveExpiry + } + (BinaryMarketCoordinatorRole::DormantYesRt, BinaryMarketOperation::Expire, None) => { + BinaryMarketPath::DormantExpiry + } + ( + BinaryMarketCoordinatorRole::ResolvedYesCollateral + | BinaryMarketCoordinatorRole::ResolvedNoCollateral, + BinaryMarketOperation::Redeem, + None, + ) => BinaryMarketPath::ResolvedRedemption, + ( + BinaryMarketCoordinatorRole::ExpiredCollateral, + BinaryMarketOperation::Redeem, + None, + ) => BinaryMarketPath::ExpiryRedemption, + _ => { + return Err(BinaryMarketLayoutError::InvalidCoordinatorOperation { + coordinator, + operation, + }); + } + }; + Ok(Self { path, coordinator }) + } + + /// Derive and validate a layout from an economics transition. + pub fn for_transition( + before: BinaryMarketState, + action: BinaryMarketAction, + applied: AppliedBinaryMarketTransition, + ) -> Result { + if applied.old_state != before { + return Err(BinaryMarketLayoutError::OldStateMismatch); + } + if !transition_matches_action(applied.transition, action) { + return Err(BinaryMarketLayoutError::TransitionActionMismatch); + } + let full_cancellation = match applied.transition { + BinaryMarketTransition::Cancelled { full, .. } => Some(full), + _ => None, + }; + Self::for_operation( + BinaryMarketCoordinatorRole::for_state(before), + action.kind(), + full_cancellation, + ) + } + + #[must_use] + pub const fn path(self) -> BinaryMarketPath { + self.path + } + + #[must_use] + pub const fn operation(self) -> BinaryMarketOperation { + self.path.operation() + } + + #[must_use] + pub const fn coordinator_role(self) -> BinaryMarketCoordinatorRole { + self.coordinator + } + + #[must_use] + pub fn input_roles(self) -> &'static [BinaryMarketInputRole] { + match self.coordinator { + BinaryMarketCoordinatorRole::DormantYesRt => &DORMANT_INPUT_ROLES, + BinaryMarketCoordinatorRole::UnresolvedYesRt => &UNRESOLVED_INPUT_ROLES, + BinaryMarketCoordinatorRole::ResolvedYesCollateral => &RESOLVED_YES_INPUT_ROLES, + BinaryMarketCoordinatorRole::ResolvedNoCollateral => &RESOLVED_NO_INPUT_ROLES, + BinaryMarketCoordinatorRole::ExpiredCollateral => &EXPIRED_INPUT_ROLES, + } + } + + /// Resolve a slot to its exact input role and reject slots outside this layout. + pub fn input_role( + self, + slot: BinaryMarketSlot, + ) -> Result { + let role = BinaryMarketInputRole::from(slot); + if self.input_roles().contains(&role) { + Ok(role) + } else { + Err(BinaryMarketLayoutError::InputRoleNotInLayout { + path: self.path, + role, + }) + } + } +} + +fn transition_matches_action( + transition: BinaryMarketTransition, + action: BinaryMarketAction, +) -> bool { + match (transition, action) { + ( + BinaryMarketTransition::Issued { pairs: left, .. }, + BinaryMarketAction::Issue { pairs }, + ) + | ( + BinaryMarketTransition::Cancelled { pairs: left, .. }, + BinaryMarketAction::Cancel { pairs }, + ) => left == pairs, + ( + BinaryMarketTransition::Resolved { outcome: left, .. }, + BinaryMarketAction::Resolve { outcome }, + ) => left == outcome, + (BinaryMarketTransition::Expired { .. }, BinaryMarketAction::Expire) => true, + ( + BinaryMarketTransition::Redeemed { + outcome: left_outcome, + tokens: left_tokens, + .. + }, + BinaryMarketAction::Redeem { outcome, tokens }, + ) => left_outcome == outcome && left_tokens == tokens, + _ => false, + } +} + +/// Oracle data carried only by a resolution coordinator action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BinaryMarketResolution { + outcome: BinaryOutcome, + signature: [u8; 64], +} + +impl BinaryMarketResolution { + #[must_use] + pub const fn new(outcome: BinaryOutcome, signature: [u8; 64]) -> Self { + Self { outcome, signature } + } + + #[must_use] + pub const fn outcome(self) -> BinaryOutcome { + self.outcome + } + + #[must_use] + pub const fn signature(self) -> [u8; 64] { + self.signature + } +} + +/// Semantic coordinator action shared verbatim by every input witness. +/// +/// Quantities and the expiry-redemption token side are derived from burn +/// outputs by the covenant, so the witness carries only the output window and +/// resolution authorization. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BinaryMarketCoordinatorAction { + Issue { + output_base: u32, + }, + Cancel { + output_base: u32, + }, + Resolve { + output_base: u32, + resolution: BinaryMarketResolution, + }, + Expire { + output_base: u32, + }, + Redeem { + output_base: u32, + }, +} + +impl BinaryMarketCoordinatorAction { + /// Construct the unique action variant allowed by `layout`. + pub fn for_layout( + layout: BinaryMarketLayout, + output_base: u32, + resolution: Option, + ) -> Result { + match (layout.operation(), resolution) { + (BinaryMarketOperation::Issue, None) => Ok(Self::Issue { output_base }), + (BinaryMarketOperation::Cancel, None) => Ok(Self::Cancel { output_base }), + (BinaryMarketOperation::Resolve, Some(resolution)) => Ok(Self::Resolve { + output_base, + resolution, + }), + (BinaryMarketOperation::Expire, None) => Ok(Self::Expire { output_base }), + (BinaryMarketOperation::Redeem, None) => Ok(Self::Redeem { output_base }), + (BinaryMarketOperation::Resolve, None) => { + Err(BinaryMarketLayoutError::MissingResolutionAuthorization) + } + (operation, Some(_)) => { + Err(BinaryMarketLayoutError::UnexpectedResolutionAuthorization { operation }) + } + } + } + + #[must_use] + pub const fn operation(self) -> BinaryMarketOperation { + match self { + Self::Issue { .. } => BinaryMarketOperation::Issue, + Self::Cancel { .. } => BinaryMarketOperation::Cancel, + Self::Resolve { .. } => BinaryMarketOperation::Resolve, + Self::Expire { .. } => BinaryMarketOperation::Expire, + Self::Redeem { .. } => BinaryMarketOperation::Redeem, + } + } + + #[must_use] + pub const fn output_base(self) -> u32 { + match self { + Self::Issue { output_base } + | Self::Cancel { output_base } + | Self::Resolve { output_base, .. } + | Self::Expire { output_base } + | Self::Redeem { output_base } => output_base, + } + } + + fn generated(self) -> GeneratedBinaryMarketAction { + match self { + Self::Issue { output_base } => Either::Left(Either::Left(output_base)), + Self::Cancel { output_base } => Either::Left(Either::Right(output_base)), + Self::Resolve { + output_base, + resolution, + } => Either::Right(Either::Left(( + output_base, + resolution.outcome == BinaryOutcome::Yes, + resolution.signature, + ))), + Self::Expire { output_base } => Either::Right(Either::Right(Either::Left(output_base))), + Self::Redeem { output_base } => { + Either::Right(Either::Right(Either::Right(output_base))) + } + } + } +} + +type GeneratedBinaryMarketAction = + Either, Either<(u32, bool, [u8; 64]), Either>>; + +/// Checked facade for the generated Simplicity witness representation. +/// +/// The raw generated witness and its nested `Either` encoding intentionally do +/// not escape this module. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BinaryMarketWitness { + slot: BinaryMarketSlot, + action: BinaryMarketCoordinatorAction, +} + +impl BinaryMarketWitness { + pub fn new( + layout: BinaryMarketLayout, + input_role: BinaryMarketInputRole, + action: BinaryMarketCoordinatorAction, + ) -> Result { + if !layout.input_roles().contains(&input_role) { + return Err(BinaryMarketLayoutError::InputRoleNotInLayout { + path: layout.path, + role: input_role, + }); + } + if action.operation() != layout.operation() { + return Err(BinaryMarketLayoutError::ActionLayoutMismatch { + path: layout.path, + operation: action.operation(), + }); + } + Ok(Self { + slot: input_role.slot(), + action, + }) + } + + pub fn for_slot( + layout: BinaryMarketLayout, + slot: BinaryMarketSlot, + action: BinaryMarketCoordinatorAction, + ) -> Result { + Self::new(layout, layout.input_role(slot)?, action) + } + + #[must_use] + pub const fn slot(self) -> BinaryMarketSlot { + self.slot + } + + #[must_use] + pub const fn action(self) -> BinaryMarketCoordinatorAction { + self.action + } + + #[must_use] + pub fn build_witness(&self) -> WitnessValues { + derived_binary_market::BinaryMarketWitness { + slot: self.slot.tag(), + action: self.action.generated(), + } + .build_witness() + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum BinaryMarketLayoutError { + #[error("market follower slot {slot:?} cannot coordinate a transition")] + FollowerCannotCoordinate { slot: BinaryMarketSlot }, + #[error("a cancellation layout requires its full/partial transaction shape")] + MissingCancellationShape, + #[error("{operation:?} cannot carry a cancellation transaction shape")] + UnexpectedCancellationShape { operation: BinaryMarketOperation }, + #[error("{coordinator:?} cannot coordinate {operation:?}")] + InvalidCoordinatorOperation { + coordinator: BinaryMarketCoordinatorRole, + operation: BinaryMarketOperation, + }, + #[error("the applied transition old state does not match the supplied current state")] + OldStateMismatch, + #[error("the applied transition does not match the supplied semantic action")] + TransitionActionMismatch, + #[error("resolution requires an authorization payload")] + MissingResolutionAuthorization, + #[error("{operation:?} cannot carry resolution authorization")] + UnexpectedResolutionAuthorization { operation: BinaryMarketOperation }, + #[error("input role {role:?} is not present in {path:?}")] + InputRoleNotInLayout { + path: BinaryMarketPath, + role: BinaryMarketInputRole, + }, + #[error("{path:?} cannot encode a {operation:?} coordinator action")] + ActionLayoutMismatch { + path: BinaryMarketPath, + operation: BinaryMarketOperation, + }, +} + /// Canonical binary-market payout arithmetic. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BinaryMarketEconomics { @@ -595,6 +1296,336 @@ mod tests { ); } + #[test] + fn path_tags_are_stable_checked_and_round_trip() { + for (tag, path) in BinaryMarketPath::ALL.into_iter().enumerate() { + let tag = u8::try_from(tag).expect("ten tags"); + assert_eq!(path.tag(), tag); + assert_eq!(u8::from(path), tag); + assert_eq!(BinaryMarketPath::try_from(tag), Ok(path)); + } + assert_eq!( + BinaryMarketPath::try_from(10), + Err(BinaryMarketPathError::UnknownTag(10)) + ); + assert_eq!( + BinaryMarketPath::try_from(u8::MAX), + Err(BinaryMarketPathError::UnknownTag(u8::MAX)) + ); + } + + #[test] + fn every_transition_derives_its_path_coordinator_and_exact_input_roles() { + let market = economics(); + let cases = [ + ( + trading(0), + BinaryMarketAction::Issue { pairs: 1 }, + BinaryMarketPath::InitialIssuance, + BinaryMarketCoordinatorRole::DormantYesRt, + &[ + BinaryMarketSlot::DormantYesRt, + BinaryMarketSlot::DormantNoRt, + ][..], + ), + ( + trading(2), + BinaryMarketAction::Issue { pairs: 1 }, + BinaryMarketPath::SubsequentIssuance, + BinaryMarketCoordinatorRole::UnresolvedYesRt, + &[ + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketSlot::UnresolvedNoRt, + BinaryMarketSlot::UnresolvedCollateral, + ][..], + ), + ( + trading(2), + BinaryMarketAction::Cancel { pairs: 1 }, + BinaryMarketPath::PartialCancellation, + BinaryMarketCoordinatorRole::UnresolvedYesRt, + &[ + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketSlot::UnresolvedNoRt, + BinaryMarketSlot::UnresolvedCollateral, + ][..], + ), + ( + trading(2), + BinaryMarketAction::Cancel { pairs: 2 }, + BinaryMarketPath::FullCancellation, + BinaryMarketCoordinatorRole::UnresolvedYesRt, + &[ + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketSlot::UnresolvedNoRt, + BinaryMarketSlot::UnresolvedCollateral, + ][..], + ), + ( + trading(2), + BinaryMarketAction::Resolve { + outcome: BinaryOutcome::Yes, + }, + BinaryMarketPath::ActiveResolution, + BinaryMarketCoordinatorRole::UnresolvedYesRt, + &[ + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketSlot::UnresolvedNoRt, + BinaryMarketSlot::UnresolvedCollateral, + ][..], + ), + ( + trading(0), + BinaryMarketAction::Resolve { + outcome: BinaryOutcome::No, + }, + BinaryMarketPath::DormantResolution, + BinaryMarketCoordinatorRole::DormantYesRt, + &[ + BinaryMarketSlot::DormantYesRt, + BinaryMarketSlot::DormantNoRt, + ][..], + ), + ( + trading(2), + BinaryMarketAction::Expire, + BinaryMarketPath::ActiveExpiry, + BinaryMarketCoordinatorRole::UnresolvedYesRt, + &[ + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketSlot::UnresolvedNoRt, + BinaryMarketSlot::UnresolvedCollateral, + ][..], + ), + ( + trading(0), + BinaryMarketAction::Expire, + BinaryMarketPath::DormantExpiry, + BinaryMarketCoordinatorRole::DormantYesRt, + &[ + BinaryMarketSlot::DormantYesRt, + BinaryMarketSlot::DormantNoRt, + ][..], + ), + ( + resolved_yes(2 * CP), + BinaryMarketAction::Redeem { + outcome: BinaryOutcome::Yes, + tokens: 1, + }, + BinaryMarketPath::ResolvedRedemption, + BinaryMarketCoordinatorRole::ResolvedYesCollateral, + &[BinaryMarketSlot::ResolvedYesCollateral][..], + ), + ( + resolved_no(2 * CP), + BinaryMarketAction::Redeem { + outcome: BinaryOutcome::No, + tokens: 1, + }, + BinaryMarketPath::ResolvedRedemption, + BinaryMarketCoordinatorRole::ResolvedNoCollateral, + &[BinaryMarketSlot::ResolvedNoCollateral][..], + ), + ( + expired(2 * BASE), + BinaryMarketAction::Redeem { + outcome: BinaryOutcome::No, + tokens: 1, + }, + BinaryMarketPath::ExpiryRedemption, + BinaryMarketCoordinatorRole::ExpiredCollateral, + &[BinaryMarketSlot::ExpiredCollateral][..], + ), + ]; + + for (before, action, expected_path, expected_coordinator, expected_slots) in cases { + let applied = market.apply(before, action).expect("valid transition"); + let layout = BinaryMarketLayout::for_transition(before, action, applied) + .expect("valid transition layout"); + assert_eq!(layout.path(), expected_path); + assert_eq!(layout.operation(), action.kind()); + assert_eq!(layout.coordinator_role(), expected_coordinator); + + let roles = layout.input_roles(); + assert!(roles[0].is_coordinator()); + assert!(roles[1..].iter().all(|role| role.is_follower())); + assert!( + roles + .iter() + .all(|role| role.coordinator_role() == expected_coordinator) + ); + assert_eq!( + roles.iter().map(|role| role.slot()).collect::>(), + expected_slots + ); + for role in roles { + assert_eq!(layout.input_role(role.slot()), Ok(*role)); + } + } + } + + #[test] + fn layouts_reject_ambiguous_or_inconsistent_semantics() { + assert_eq!( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Cancel, + None, + ), + Err(BinaryMarketLayoutError::MissingCancellationShape) + ); + assert_eq!( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Issue, + Some(false), + ), + Err(BinaryMarketLayoutError::UnexpectedCancellationShape { + operation: BinaryMarketOperation::Issue, + }) + ); + assert_eq!( + BinaryMarketCoordinatorRole::try_from(BinaryMarketSlot::DormantNoRt), + Err(BinaryMarketLayoutError::FollowerCannotCoordinate { + slot: BinaryMarketSlot::DormantNoRt, + }) + ); + + let action = BinaryMarketAction::Issue { pairs: 1 }; + let mut applied = economics().apply(trading(0), action).expect("issuance"); + applied.old_state = trading(1); + assert_eq!( + BinaryMarketLayout::for_transition(trading(0), action, applied), + Err(BinaryMarketLayoutError::OldStateMismatch) + ); + } + + #[test] + fn checked_witness_facade_encodes_only_slot_and_nested_action() { + let resolution = BinaryMarketResolution::new(BinaryOutcome::Yes, [0x55; 64]); + let cases: [( + BinaryMarketLayout, + BinaryMarketCoordinatorAction, + GeneratedBinaryMarketAction, + ); 5] = [ + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Issue, + None, + ) + .expect("issue layout"), + BinaryMarketCoordinatorAction::Issue { output_base: 11 }, + Either::Left(Either::Left(11)), + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Cancel, + Some(false), + ) + .expect("cancel layout"), + BinaryMarketCoordinatorAction::Cancel { output_base: 12 }, + Either::Left(Either::Right(12)), + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Resolve, + None, + ) + .expect("resolve layout"), + BinaryMarketCoordinatorAction::Resolve { + output_base: 13, + resolution, + }, + Either::Right(Either::Left((13, true, [0x55; 64]))), + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Expire, + None, + ) + .expect("expire layout"), + BinaryMarketCoordinatorAction::Expire { output_base: 14 }, + Either::Right(Either::Right(Either::Left(14))), + ), + ( + BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::ExpiredCollateral, + BinaryMarketOperation::Redeem, + None, + ) + .expect("redeem layout"), + BinaryMarketCoordinatorAction::Redeem { output_base: 15 }, + Either::Right(Either::Right(Either::Right(15))), + ), + ]; + + for (layout, action, expected_action) in cases { + for role in layout.input_roles() { + let witness = BinaryMarketWitness::new(layout, *role, action).expect("witness"); + let raw = derived_binary_market::BinaryMarketWitness::from_witness( + &witness.build_witness(), + ) + .expect("generated witness round trip"); + assert_eq!(raw.slot, role.slot().tag()); + assert_eq!(raw.action, expected_action); + } + } + } + + #[test] + fn checked_witness_facade_rejects_wrong_roles_actions_and_authorization() { + let layout = BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Issue, + None, + ) + .expect("issue layout"); + assert_eq!( + layout.input_role(BinaryMarketSlot::UnresolvedYesRt), + Err(BinaryMarketLayoutError::InputRoleNotInLayout { + path: BinaryMarketPath::InitialIssuance, + role: BinaryMarketInputRole::UnresolvedYesCoordinator, + }) + ); + assert_eq!( + BinaryMarketWitness::new( + layout, + BinaryMarketInputRole::DormantYesCoordinator, + BinaryMarketCoordinatorAction::Cancel { output_base: 0 }, + ), + Err(BinaryMarketLayoutError::ActionLayoutMismatch { + path: BinaryMarketPath::InitialIssuance, + operation: BinaryMarketOperation::Cancel, + }) + ); + + let resolve_layout = BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Resolve, + None, + ) + .expect("resolve layout"); + assert_eq!( + BinaryMarketCoordinatorAction::for_layout(resolve_layout, 0, None), + Err(BinaryMarketLayoutError::MissingResolutionAuthorization) + ); + assert_eq!( + BinaryMarketCoordinatorAction::for_layout( + layout, + 0, + Some(BinaryMarketResolution::new(BinaryOutcome::No, [0; 64])), + ), + Err(BinaryMarketLayoutError::UnexpectedResolutionAuthorization { + operation: BinaryMarketOperation::Issue, + }) + ); + } + #[test] fn accepts_every_v1_denomination_and_derives_pair_collateral() { for base_payout in BASE_PAYOUTS { diff --git a/crates/deadcat-contracts/src/binary_market/compiled.rs b/crates/deadcat-contracts/src/binary_market/compiled.rs index 46b6e12..a508a76 100644 --- a/crates/deadcat-contracts/src/binary_market/compiled.rs +++ b/crates/deadcat-contracts/src/binary_market/compiled.rs @@ -1014,9 +1014,9 @@ mod tests { assert_eq!( first.cmr(), [ - 0xeb, 0xbd, 0x8f, 0x30, 0x01, 0x14, 0x11, 0x20, 0xed, 0xb0, 0x88, 0x0c, 0x8e, 0x14, - 0xf4, 0x0d, 0x20, 0x54, 0x01, 0x81, 0x16, 0x62, 0x76, 0x24, 0xfc, 0x31, 0xc1, 0xbc, - 0xf7, 0x3a, 0xf4, 0x73, + 0xe8, 0x91, 0x2f, 0x8e, 0x5d, 0xeb, 0x3c, 0x04, 0xba, 0x47, 0xea, 0xac, 0xac, 0xc8, + 0xd1, 0x94, 0xae, 0x04, 0x73, 0xe3, 0x5c, 0xee, 0x9e, 0x17, 0x1b, 0x8a, 0x71, 0xe3, + 0x51, 0x3a, 0xbc, 0xa0, ] ); diff --git a/crates/deadcat-contracts/src/interpret.rs b/crates/deadcat-contracts/src/interpret.rs index 4e66355..079156f 100644 --- a/crates/deadcat-contracts/src/interpret.rs +++ b/crates/deadcat-contracts/src/interpret.rs @@ -13,7 +13,7 @@ mod binary_market; pub use binary_market::{ BinaryMarketContinuation, BinaryMarketInterpretation, BinaryMarketLiveOutputs, - BinaryMarketPath, interpret_binary_market_spend, interpret_binary_market_spend_with_compiled, + interpret_binary_market_spend, interpret_binary_market_spend_with_compiled, }; /// A tracked covenant output with the previous output data needed to interpret @@ -39,12 +39,12 @@ pub enum InterpretError { CmrMismatch, #[error("required decoded witness value is missing: {0}")] MissingWitness(&'static str), - #[error("decoded witness admits more than one transaction interpretation")] - AmbiguousInterpretation, #[error("transaction contradicts its decoded covenant witness: {0}")] Inconsistent(&'static str), #[error("binary-market economics rejected the spend: {0}")] BinaryEconomics(#[from] crate::binary_market::BinaryMarketError), + #[error("binary-market layout rejected the spend: {0}")] + BinaryLayout(#[from] crate::binary_market::BinaryMarketLayoutError), #[error("binary-market compilation failed: {0}")] BinaryCompilation(#[from] crate::binary_market::CompiledBinaryMarketError), #[error("transaction index does not fit the v1 u32 witness domain")] @@ -54,10 +54,9 @@ pub enum InterpretError { /// A decoded finalized Simplicity script-path witness. /// /// `values` contains witness values in deterministic post-order; source-level -/// names are not present in the serialized Simplicity program. Optimizer -/// sharing may merge equal same-typed values, so contract-specific -/// interpreters use typed membership plus transaction validation rather than -/// assuming a fixed positional ABI. +/// names are not present in the serialized Simplicity program. Contract-specific +/// interpreters must therefore identify values by their complete structural +/// types and decode the source-level witness ABI exactly. #[derive(Clone)] pub struct DecodedSimplicityWitness { finalized_spend: FinalizedSimplicitySpend, @@ -84,47 +83,6 @@ impl DecodedSimplicityWitness { pub fn values(&self) -> &[Value] { &self.values } - - #[must_use] - pub fn u8_values(&self) -> Vec { - unique_words(&self.values, 1) - .into_iter() - .map(|bytes| bytes[0]) - .collect() - } - - #[must_use] - pub fn u32_values(&self) -> Vec { - unique_words(&self.values, 4) - .into_iter() - .map(|bytes| u32::from_be_bytes(bytes.try_into().expect("four bytes"))) - .collect() - } - - #[must_use] - pub fn u64_values(&self) -> Vec { - unique_words(&self.values, 8) - .into_iter() - .map(|bytes| u64::from_be_bytes(bytes.try_into().expect("eight bytes"))) - .collect() - } - - #[must_use] - pub fn bool_values(&self) -> Vec { - let mut output = Vec::new(); - for value in &self.values { - let bits: Vec = value.iter_compact().collect(); - if bits.len() == 1 && !output.contains(&bits[0]) { - output.push(bits[0]); - } - } - output - } - - #[must_use] - pub fn bytes_values(&self, length: usize) -> Vec> { - unique_words(&self.values, length) - } } /// Decode the four-element smplx script-path stack @@ -151,30 +109,6 @@ pub fn decode_simplicity_witness( }) } -fn value_bytes(value: &Value, length: usize) -> Option> { - let bits: Vec = value.iter_compact().collect(); - if bits.len() != length.checked_mul(8)? { - return None; - } - let mut output = vec![0_u8; length]; - for (index, bit) in bits.into_iter().enumerate() { - if bit { - output[index / 8] |= 1 << (7 - index % 8); - } - } - Some(output) -} - -fn unique_words(values: &[Value], length: usize) -> Vec> { - let mut output = Vec::new(); - for bytes in values.iter().filter_map(|value| value_bytes(value, length)) { - if !output.contains(&bytes) { - output.push(bytes); - } - } - output -} - fn locate_input( transaction: &elements::Transaction, outpoint: OutPoint, diff --git a/crates/deadcat-contracts/src/interpret/binary_market.rs b/crates/deadcat-contracts/src/interpret/binary_market.rs index 32149ee..f4ae7fd 100644 --- a/crates/deadcat-contracts/src/interpret/binary_market.rs +++ b/crates/deadcat-contracts/src/interpret/binary_market.rs @@ -2,52 +2,22 @@ use deadcat_types::{BinaryMarketParams, BinaryMarketState}; use elements::confidential::{Asset, Value}; use elements::secp256k1_zkp::{Message, Secp256k1, Tweak, XOnlyPublicKey, schnorr::Signature}; use elements::{AssetId, OutPoint, Transaction, TxOut}; +use simplex::simplicityhl::simplicity::types::Final; +use simplex::simplicityhl::simplicity::{Value as SimplicityValue, ValueRef}; use super::{ DecodedSimplicityWitness, InterpretError, TrackedContractOutput, decode_simplicity_witness, locate_input, output_at, }; use crate::binary_market::{ - AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketEconomics, BinaryMarketSlot, - CompiledBinaryMarket, + AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketCoordinatorAction, + BinaryMarketCoordinatorRole, BinaryMarketEconomics, BinaryMarketLayout, BinaryMarketPath, + BinaryMarketResolution, BinaryMarketSlot, CompiledBinaryMarket, }; use crate::binary_market::{BinaryMarketTransition, BinaryOutcome}; use crate::market_crypto::{BinaryOutcome as OracleOutcome, oracle_message}; use crate::rt::{RtFactors, RtLeg, RtSide, commitments, factors, infer_side}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -pub enum BinaryMarketPath { - InitialIssuance = 0, - SubsequentIssuance = 1, - PartialCancellation = 2, - FullCancellation = 3, - ActiveResolution = 4, - DormantResolution = 5, - ActiveExpiry = 6, - DormantExpiry = 7, - ResolvedRedemption = 8, - ExpiryRedemption = 9, -} - -impl BinaryMarketPath { - fn from_tag(tag: u8) -> Option { - Some(match tag { - 0 => Self::InitialIssuance, - 1 => Self::SubsequentIssuance, - 2 => Self::PartialCancellation, - 3 => Self::FullCancellation, - 4 => Self::ActiveResolution, - 5 => Self::DormantResolution, - 6 => Self::ActiveExpiry, - 7 => Self::DormantExpiry, - 8 => Self::ResolvedRedemption, - 9 => Self::ExpiryRedemption, - _ => return None, - }) - } -} - #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BinaryMarketLiveOutputs { pub yes_rt: Option, @@ -131,86 +101,59 @@ pub fn interpret_binary_market_spend_with_compiled( if decoded.cmr() != compiled.cmr() { return Err(InterpretError::CmrMismatch); } - let expected_slot = primary_slot(before); + let coordinator = BinaryMarketCoordinatorRole::for_state(before); + let expected_slot = coordinator.slot(); if decoded.control_block() != compiled.slot(expected_slot).control_block() { return Err(InterpretError::Inconsistent( "market control block mismatch", )); } - if !decoded.u8_values().contains(&(expected_slot as u8)) { - return Err(InterpretError::MissingWitness("SLOT")); - } + let operation = decode_market_witness(&decoded, expected_slot)?; + let output_base = operation.output_base(); let live_rt_sides = infer_live_rt_sides(params, before, live)?; - - let u8_values = decoded.u8_values(); - let paths: Vec = u8_values - .iter() - .copied() - .filter_map(BinaryMarketPath::from_tag) - .filter(|path| path_allowed(*path, before)) - .filter(|path| { - u8_values - .iter() - .all(|value| *value == expected_slot as u8 || *value == *path as u8) - }) - .collect(); - if paths.is_empty() { - return Err(InterpretError::MissingWitness("PATH")); - } - let output_bases = decoded.u32_values(); - match output_bases.len() { - 0 => return Err(InterpretError::MissingWitness("OUTPUT_BASE")), - 1 => {} - _ => return Err(InterpretError::AmbiguousInterpretation), - } - - let economics = BinaryMarketEconomics::new(params.base_payout)?; - let mut interpretations = Vec::new(); - for path in paths { - let outcomes = candidate_outcomes(path, params, &decoded, transaction)?; - let token_amounts = if matches!( - path, - BinaryMarketPath::ResolvedRedemption | BinaryMarketPath::ExpiryRedemption - ) { - decoded.u64_values() - } else { - vec![0] - }; - for output_base in output_bases.iter().copied() { - for outcome_yes in outcomes.iter().copied() { - for tokens in token_amounts.iter().copied() { - if let Ok(interpretation) = interpret_candidate( - params, - economics, - compiled, - before, - live, - live_rt_sides, - transaction, - path, - input_base, - output_base, - outcome_yes, - tokens, - ) && !interpretations.contains(&interpretation) - { - interpretations.push(interpretation); - } - } + let full_cancellation = match operation { + BinaryMarketCoordinatorAction::Cancel { .. } => { + Some(cancellation_is_full(params, transaction, output_base)?) + } + _ => None, + }; + let layout = BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::try_from(expected_slot)?, + operation.operation(), + full_cancellation, + )?; + let (outcome_yes, tokens) = match operation { + BinaryMarketCoordinatorAction::Resolve { resolution, .. } => { + let outcome_yes = resolution.outcome() == BinaryOutcome::Yes; + if !verify_oracle_signature(params, outcome_yes, &resolution.signature()) { + return Err(InterpretError::Inconsistent("invalid oracle signature")); } + (outcome_yes, 0) } - } - match interpretations.len() { - 0 => Err(InterpretError::Inconsistent( - "no decoded market path matches the transaction", - )), - 1 => Ok(interpretations.pop().expect("one interpretation")), - _ => Err(InterpretError::AmbiguousInterpretation), - } + BinaryMarketCoordinatorAction::Redeem { .. } => { + redemption_details(params, before, transaction, output_base)? + } + _ => (false, 0), + }; + let economics = BinaryMarketEconomics::new(params.base_payout)?; + interpret_decoded_action( + params, + economics, + compiled, + before, + live, + live_rt_sides, + transaction, + layout, + input_base, + output_base, + outcome_yes, + tokens, + ) } #[allow(clippy::too_many_arguments)] -fn interpret_candidate( +fn interpret_decoded_action( params: BinaryMarketParams, economics: BinaryMarketEconomics, compiled: &CompiledBinaryMarket, @@ -218,13 +161,14 @@ fn interpret_candidate( live: &BinaryMarketLiveOutputs, live_rt_sides: BinaryMarketRtSides, transaction: &Transaction, - path: BinaryMarketPath, + layout: BinaryMarketLayout, input_base: u32, output_base: u32, outcome_yes: bool, tokens: u64, ) -> Result { - let spent = verify_input_group(path, before, live, transaction, input_base)?; + let path = layout.path(); + let spent = verify_input_group(layout, before, live, transaction, input_base)?; let action = match path { BinaryMarketPath::InitialIssuance | BinaryMarketPath::SubsequentIssuance => { let yes_side = live_rt_sides @@ -319,7 +263,7 @@ fn interpret_candidate( params, compiled, transaction, - path, + layout, before, live, live_rt_sides, @@ -345,7 +289,7 @@ fn verify_outputs( params: BinaryMarketParams, compiled: &CompiledBinaryMarket, transaction: &Transaction, - path: BinaryMarketPath, + layout: BinaryMarketLayout, before: BinaryMarketState, live: &BinaryMarketLiveOutputs, live_rt_sides: BinaryMarketRtSides, @@ -353,6 +297,7 @@ fn verify_outputs( output_base: u32, tokens: u64, ) -> Result, InterpretError> { + let path = layout.path(); let mut output = Vec::new(); let yes_continuation = opposite_side_factors(RtLeg::Yes, live.yes_rt.as_ref(), live_rt_sides.yes)?; @@ -591,16 +536,39 @@ fn verify_outputs( } fn verify_input_group( - path: BinaryMarketPath, + layout: BinaryMarketLayout, before: BinaryMarketState, live: &BinaryMarketLiveOutputs, transaction: &Transaction, input_base: u32, ) -> Result, InterpretError> { - match path { - BinaryMarketPath::InitialIssuance - | BinaryMarketPath::DormantResolution - | BinaryMarketPath::DormantExpiry => { + let path = layout.path(); + let mut spent = Vec::with_capacity(layout.input_roles().len()); + for (offset, role) in layout.input_roles().iter().copied().enumerate() { + let tracked = match role.slot() { + BinaryMarketSlot::DormantYesRt | BinaryMarketSlot::UnresolvedYesRt => { + live.yes_rt.as_ref() + } + BinaryMarketSlot::DormantNoRt | BinaryMarketSlot::UnresolvedNoRt => live.no_rt.as_ref(), + BinaryMarketSlot::UnresolvedCollateral + | BinaryMarketSlot::ResolvedYesCollateral + | BinaryMarketSlot::ResolvedNoCollateral + | BinaryMarketSlot::ExpiredCollateral => live.collateral.as_ref(), + } + .ok_or(InterpretError::InvalidTrackedOutput( + "missing input role output", + ))?; + let offset = u32::try_from(offset).map_err(|_| InterpretError::IndexOverflow)?; + check_input( + transaction, + add_index(input_base, offset)?, + tracked.outpoint, + )?; + spent.push(tracked.outpoint); + } + + match layout.coordinator_role() { + BinaryMarketCoordinatorRole::DormantYesRt => { if before != (BinaryMarketState::Trading { outstanding_pairs: 0, @@ -616,8 +584,6 @@ fn verify_input_group( .no_rt .as_ref() .ok_or(InterpretError::InvalidTrackedOutput("missing NO RT"))?; - check_input(transaction, input_base, yes.outpoint)?; - check_input(transaction, add_index(input_base, 1)?, no.outpoint)?; if yes.outpoint.txid != no.outpoint.txid { return Err(InterpretError::Inconsistent( "dormant siblings have different txids", @@ -631,13 +597,9 @@ fn verify_input_group( "issuance on dormant terminal path", )); } - Ok(vec![yes.outpoint, no.outpoint]) + Ok(spent) } - BinaryMarketPath::SubsequentIssuance - | BinaryMarketPath::PartialCancellation - | BinaryMarketPath::FullCancellation - | BinaryMarketPath::ActiveResolution - | BinaryMarketPath::ActiveExpiry => { + BinaryMarketCoordinatorRole::UnresolvedYesRt => { if !matches!(before, BinaryMarketState::Trading { outstanding_pairs } if outstanding_pairs > 0) { return Err(InterpretError::Inconsistent("active input state")); @@ -654,9 +616,6 @@ fn verify_input_group( .collateral .as_ref() .ok_or(InterpretError::InvalidTrackedOutput("missing collateral"))?; - check_input(transaction, input_base, yes.outpoint)?; - check_input(transaction, add_index(input_base, 1)?, no.outpoint)?; - check_input(transaction, add_index(input_base, 2)?, collateral.outpoint)?; if no.outpoint.txid != yes.outpoint.txid || collateral.outpoint.txid != yes.outpoint.txid || no.outpoint.vout @@ -696,18 +655,15 @@ fn verify_input_group( "collateral input carries issuance", )); } - Ok(vec![yes.outpoint, no.outpoint, collateral.outpoint]) + Ok(spent) } - BinaryMarketPath::ResolvedRedemption | BinaryMarketPath::ExpiryRedemption => { - let collateral = live - .collateral - .as_ref() - .ok_or(InterpretError::InvalidTrackedOutput("missing collateral"))?; - check_input(transaction, input_base, collateral.outpoint)?; + BinaryMarketCoordinatorRole::ResolvedYesCollateral + | BinaryMarketCoordinatorRole::ResolvedNoCollateral + | BinaryMarketCoordinatorRole::ExpiredCollateral => { if transaction.input[input_base as usize].has_issuance() { return Err(InterpretError::Inconsistent("issuance on redemption")); } - Ok(vec![collateral.outpoint]) + Ok(spent) } } } @@ -885,36 +841,249 @@ fn opposite_side_factors( } } -fn candidate_outcomes( - path: BinaryMarketPath, - params: BinaryMarketParams, +fn decode_market_witness( decoded: &DecodedSimplicityWitness, - transaction: &Transaction, -) -> Result, InterpretError> { - match path { - BinaryMarketPath::ActiveResolution | BinaryMarketPath::DormantResolution => { - let mut output = Vec::new(); - for outcome in decoded.bool_values() { - if decoded - .bytes_values(64) - .iter() - .any(|signature| verify_oracle_signature(params, outcome, signature)) - { - output.push(outcome); - } + expected_slot: BinaryMarketSlot, +) -> Result { + decode_market_witness_values(decoded.values(), expected_slot) +} + +fn decode_market_witness_values( + values: &[SimplicityValue], + expected_slot: BinaryMarketSlot, +) -> Result { + let slot_type = Final::u8(); + let action_types = market_action_types(); + let mut slot = None; + let mut action = None; + + for value in values { + if value.ty() == slot_type.as_ref() { + if slot.replace(decode_u8(value.as_ref())?).is_some() { + return Err(InterpretError::Inconsistent("duplicate SLOT witness")); + } + } else if action_types + .iter() + .any(|action_type| value.ty() == action_type.as_ref()) + { + if action.replace(decode_market_action(value)?).is_some() { + return Err(InterpretError::Inconsistent("duplicate ACTION witness")); } - Ok(output) + } else { + return Err(InterpretError::Inconsistent( + "unexpected binary-market witness value type", + )); } - BinaryMarketPath::ExpiryRedemption => Ok(decoded.bool_values()), - BinaryMarketPath::ActiveExpiry | BinaryMarketPath::DormantExpiry => { - if transaction.lock_time.to_consensus_u32() < params.expiry_height { - Ok(Vec::new()) + } + + let slot = slot.ok_or(InterpretError::MissingWitness("SLOT"))?; + if slot != expected_slot as u8 { + return Err(InterpretError::Inconsistent( + "SLOT does not match authenticated market slot", + )); + } + action.ok_or(InterpretError::MissingWitness("ACTION")) +} + +fn resolve_action_payload_type() -> std::sync::Arc { + Final::product(Final::u32(), Final::product(Final::u1(), Final::u512())) +} + +fn market_action_types() -> [std::sync::Arc; 5] { + [ + Final::sum(Final::sum(Final::u32(), Final::unit()), Final::unit()), + Final::sum(Final::sum(Final::unit(), Final::u32()), Final::unit()), + Final::sum( + Final::unit(), + Final::sum(resolve_action_payload_type(), Final::unit()), + ), + Final::sum( + Final::unit(), + Final::sum(Final::unit(), Final::sum(Final::u32(), Final::unit())), + ), + Final::sum( + Final::unit(), + Final::sum(Final::unit(), Final::sum(Final::unit(), Final::u32())), + ), + ] +} + +fn decode_market_action( + value: &SimplicityValue, +) -> Result { + if !market_action_types() + .iter() + .any(|action_type| value.ty() == action_type.as_ref()) + { + return Err(InterpretError::Inconsistent( + "ACTION has the wrong structural type", + )); + } + + let root = value.as_ref(); + if let Some(issue_or_cancel) = root.as_left() { + if let Some(issue) = issue_or_cancel.as_left() { + return Ok(BinaryMarketCoordinatorAction::Issue { + output_base: decode_u32(issue)?, + }); + } + let cancel = issue_or_cancel + .as_right() + .ok_or(InterpretError::Inconsistent("malformed ACTION sum branch"))?; + return Ok(BinaryMarketCoordinatorAction::Cancel { + output_base: decode_u32(cancel)?, + }); + } + + let resolve_or_terminal = root + .as_right() + .ok_or(InterpretError::Inconsistent("malformed ACTION sum branch"))?; + if let Some(resolve) = resolve_or_terminal.as_left() { + let (output_base, outcome_and_signature) = resolve.as_product().ok_or( + InterpretError::Inconsistent("malformed Resolve ACTION payload"), + )?; + let (outcome_yes, signature) = + outcome_and_signature + .as_product() + .ok_or(InterpretError::Inconsistent( + "malformed Resolve ACTION payload", + ))?; + let outcome = if decode_bool(outcome_yes)? { + BinaryOutcome::Yes + } else { + BinaryOutcome::No + }; + return Ok(BinaryMarketCoordinatorAction::Resolve { + output_base: decode_u32(output_base)?, + resolution: BinaryMarketResolution::new(outcome, decode_signature(signature)?), + }); + } + + let expire_or_redeem = resolve_or_terminal + .as_right() + .ok_or(InterpretError::Inconsistent("malformed ACTION sum branch"))?; + if let Some(expire) = expire_or_redeem.as_left() { + return Ok(BinaryMarketCoordinatorAction::Expire { + output_base: decode_u32(expire)?, + }); + } + let redeem = expire_or_redeem + .as_right() + .ok_or(InterpretError::Inconsistent("malformed ACTION sum branch"))?; + Ok(BinaryMarketCoordinatorAction::Redeem { + output_base: decode_u32(redeem)?, + }) +} + +fn decode_u8(value: ValueRef<'_>) -> Result { + word_bytes(value) + .map(u8::from_be_bytes) + .ok_or(InterpretError::Inconsistent("malformed u8 witness value")) +} + +fn decode_u32(value: ValueRef<'_>) -> Result { + word_bytes(value) + .map(u32::from_be_bytes) + .ok_or(InterpretError::Inconsistent("malformed u32 witness value")) +} + +fn decode_bool(value: ValueRef<'_>) -> Result { + let word = value + .to_word() + .filter(|word| word.n() == 0) + .ok_or(InterpretError::Inconsistent("malformed bool witness value"))?; + word.iter() + .next() + .ok_or(InterpretError::Inconsistent("malformed bool witness value")) +} + +fn decode_signature(value: ValueRef<'_>) -> Result<[u8; 64], InterpretError> { + word_bytes(value).ok_or(InterpretError::Inconsistent( + "malformed Signature witness value", + )) +} + +fn word_bytes(value: ValueRef<'_>) -> Option<[u8; N]> { + let word = value.to_word()?; + if word.len() != N.checked_mul(8)? { + return None; + } + let mut output = [0_u8; N]; + for (index, bit) in word.iter().enumerate() { + if bit { + output[index / 8] |= 1 << (7 - index % 8); + } + } + Some(output) +} + +fn cancellation_is_full( + params: BinaryMarketParams, + transaction: &Transaction, + output_base: u32, +) -> Result { + let discriminator_index = add_index(output_base, 2)?; + let discriminator = output_at(transaction, discriminator_index)?; + let Some((asset, _)) = explicit_asset_value(discriminator) else { + return Err(InterpretError::Inconsistent( + "cancellation discriminator output not explicit", + )); + }; + if asset == params.collateral_asset_id { + Ok(false) + } else { + token_burn_amount(transaction, discriminator_index, params.yes_token_asset_id)?; + Ok(true) + } +} + +fn redemption_details( + params: BinaryMarketParams, + before: BinaryMarketState, + transaction: &Transaction, + output_base: u32, +) -> Result<(bool, u64), InterpretError> { + let first_output = output_at(transaction, output_base)?; + let Some((first_asset, _)) = explicit_asset_value(first_output) else { + return Err(InterpretError::Inconsistent( + "redemption discriminator output not explicit", + )); + }; + let burn_index = if first_asset == params.collateral_asset_id { + add_index(output_base, 1)? + } else { + output_base + }; + + let outcome_yes = match before { + BinaryMarketState::ResolvedYes { .. } => true, + BinaryMarketState::ResolvedNo { .. } => false, + BinaryMarketState::Expired { .. } => { + let burn = output_at(transaction, burn_index)?; + let Some((asset, _)) = explicit_asset_value(burn) else { + return Err(InterpretError::Inconsistent("redemption burn not explicit")); + }; + if asset == params.yes_token_asset_id { + true + } else if asset == params.no_token_asset_id { + false } else { - Ok(vec![false]) + return Err(InterpretError::Inconsistent( + "redemption burn has unknown token asset", + )); } } - _ => Ok(vec![false]), - } + BinaryMarketState::Trading { .. } => { + return Err(InterpretError::Inconsistent("redemption phase")); + } + }; + let burn_asset = if outcome_yes { + params.yes_token_asset_id + } else { + params.no_token_asset_id + }; + let tokens = token_burn_amount(transaction, burn_index, burn_asset)?; + Ok((outcome_yes, tokens)) } fn verify_oracle_signature(params: BinaryMarketParams, outcome_yes: bool, bytes: &[u8]) -> bool { @@ -939,43 +1108,6 @@ fn verify_oracle_signature(params: BinaryMarketParams, outcome_yes: bool, bytes: .is_ok() } -fn path_allowed(path: BinaryMarketPath, state: BinaryMarketState) -> bool { - match state { - BinaryMarketState::Trading { - outstanding_pairs: 0, - } => matches!( - path, - BinaryMarketPath::InitialIssuance - | BinaryMarketPath::DormantResolution - | BinaryMarketPath::DormantExpiry - ), - BinaryMarketState::Trading { .. } => matches!( - path, - BinaryMarketPath::SubsequentIssuance - | BinaryMarketPath::PartialCancellation - | BinaryMarketPath::FullCancellation - | BinaryMarketPath::ActiveResolution - | BinaryMarketPath::ActiveExpiry - ), - BinaryMarketState::ResolvedYes { .. } | BinaryMarketState::ResolvedNo { .. } => { - path == BinaryMarketPath::ResolvedRedemption - } - BinaryMarketState::Expired { .. } => path == BinaryMarketPath::ExpiryRedemption, - } -} - -fn primary_slot(state: BinaryMarketState) -> BinaryMarketSlot { - match state { - BinaryMarketState::Trading { - outstanding_pairs: 0, - } => BinaryMarketSlot::DormantYesRt, - BinaryMarketState::Trading { .. } => BinaryMarketSlot::UnresolvedYesRt, - BinaryMarketState::ResolvedYes { .. } => BinaryMarketSlot::ResolvedYesCollateral, - BinaryMarketState::ResolvedNo { .. } => BinaryMarketSlot::ResolvedNoCollateral, - BinaryMarketState::Expired { .. } => BinaryMarketSlot::ExpiredCollateral, - } -} - fn issuance_amount( transaction: &Transaction, index: u32, @@ -1234,3 +1366,160 @@ fn add_index(index: u32, offset: u32) -> Result { .checked_add(offset) .ok_or(InterpretError::IndexOverflow) } + +#[cfg(test)] +mod tests { + use super::*; + + fn encode_action(action: BinaryMarketCoordinatorAction) -> SimplicityValue { + match action { + BinaryMarketCoordinatorAction::Issue { output_base } => SimplicityValue::left( + SimplicityValue::left(SimplicityValue::u32(output_base), Final::unit()), + Final::unit(), + ), + BinaryMarketCoordinatorAction::Cancel { output_base } => SimplicityValue::left( + SimplicityValue::right(Final::unit(), SimplicityValue::u32(output_base)), + Final::unit(), + ), + BinaryMarketCoordinatorAction::Resolve { + output_base, + resolution, + } => { + let outcome = u8::from(resolution.outcome() == BinaryOutcome::Yes); + let payload = SimplicityValue::product( + SimplicityValue::u32(output_base), + SimplicityValue::product( + SimplicityValue::u1(outcome), + SimplicityValue::u512(resolution.signature()), + ), + ); + SimplicityValue::right(Final::unit(), SimplicityValue::left(payload, Final::unit())) + } + BinaryMarketCoordinatorAction::Expire { output_base } => SimplicityValue::right( + Final::unit(), + SimplicityValue::right( + Final::unit(), + SimplicityValue::left(SimplicityValue::u32(output_base), Final::unit()), + ), + ), + BinaryMarketCoordinatorAction::Redeem { output_base } => SimplicityValue::right( + Final::unit(), + SimplicityValue::right( + Final::unit(), + SimplicityValue::right(Final::unit(), SimplicityValue::u32(output_base)), + ), + ), + } + } + + #[test] + fn exact_action_decoder_accepts_all_five_finalized_sum_branches() { + let signature = [0xa5; 64]; + let actions = [ + BinaryMarketCoordinatorAction::Issue { output_base: 11 }, + BinaryMarketCoordinatorAction::Cancel { output_base: 12 }, + BinaryMarketCoordinatorAction::Resolve { + output_base: 13, + resolution: BinaryMarketResolution::new(BinaryOutcome::Yes, signature), + }, + BinaryMarketCoordinatorAction::Expire { output_base: 14 }, + BinaryMarketCoordinatorAction::Redeem { output_base: 15 }, + ]; + + for action in actions { + assert_eq!( + decode_market_action(&encode_action(action)).unwrap(), + action + ); + } + } + + #[test] + fn exact_witness_decoder_requires_one_slot_and_one_action() { + let action = BinaryMarketCoordinatorAction::Expire { output_base: 7 }; + let valid = [ + SimplicityValue::u8(BinaryMarketSlot::DormantYesRt.tag()), + encode_action(action), + ]; + assert_eq!( + decode_market_witness_values(&valid, BinaryMarketSlot::DormantYesRt).unwrap(), + action + ); + + assert!(matches!( + decode_market_witness_values(&valid[..1], BinaryMarketSlot::DormantYesRt), + Err(InterpretError::MissingWitness("ACTION")) + )); + assert!(matches!( + decode_market_witness_values(&valid[1..], BinaryMarketSlot::DormantYesRt), + Err(InterpretError::MissingWitness("SLOT")) + )); + assert!(matches!( + decode_market_witness_values(&valid, BinaryMarketSlot::UnresolvedYesRt), + Err(InterpretError::Inconsistent(_)) + )); + + let duplicate_slot = [ + SimplicityValue::u8(BinaryMarketSlot::DormantYesRt.tag()), + SimplicityValue::u8(BinaryMarketSlot::DormantYesRt.tag()), + encode_action(action), + ]; + assert!(matches!( + decode_market_witness_values(&duplicate_slot, BinaryMarketSlot::DormantYesRt), + Err(InterpretError::Inconsistent("duplicate SLOT witness")) + )); + + let duplicate_action = [ + SimplicityValue::u8(BinaryMarketSlot::DormantYesRt.tag()), + encode_action(action), + encode_action(action), + ]; + assert!(matches!( + decode_market_witness_values(&duplicate_action, BinaryMarketSlot::DormantYesRt), + Err(InterpretError::Inconsistent("duplicate ACTION witness")) + )); + } + + #[test] + fn exact_action_decoder_rejects_near_miss_structural_types() { + let left_associated_resolve = SimplicityValue::product( + SimplicityValue::product(SimplicityValue::u32(3), SimplicityValue::u1(1)), + SimplicityValue::u512([0x42; 64]), + ); + let malformed = SimplicityValue::right( + Final::unit(), + SimplicityValue::left(left_associated_resolve, Final::unit()), + ); + assert!(matches!( + decode_market_action(&malformed), + Err(InterpretError::Inconsistent( + "ACTION has the wrong structural type" + )) + )); + + let unpruned_source_action = SimplicityValue::left( + SimplicityValue::left(SimplicityValue::u32(3), Final::u32()), + Final::sum( + resolve_action_payload_type(), + Final::sum(Final::u32(), Final::u32()), + ), + ); + assert!(matches!( + decode_market_action(&unpruned_source_action), + Err(InterpretError::Inconsistent( + "ACTION has the wrong structural type" + )) + )); + + let unexpected_leaf = [ + SimplicityValue::u8(BinaryMarketSlot::DormantYesRt.tag()), + SimplicityValue::u32(3), + ]; + assert!(matches!( + decode_market_witness_values(&unexpected_leaf, BinaryMarketSlot::DormantYesRt), + Err(InterpretError::Inconsistent( + "unexpected binary-market witness value type" + )) + )); + } +} diff --git a/crates/deadcat-contracts/tests/covenant_execution.rs b/crates/deadcat-contracts/tests/covenant_execution.rs index ec9bfa3..f2f28e6 100644 --- a/crates/deadcat-contracts/tests/covenant_execution.rs +++ b/crates/deadcat-contracts/tests/covenant_execution.rs @@ -6,7 +6,8 @@ mod support; use deadcat_contracts::binary_market::{ - BinaryMarketSlot, CompiledBinaryMarket, derived_binary_market, + BinaryMarketCoordinatorAction, BinaryMarketCoordinatorRole, BinaryMarketLayout, + BinaryMarketOperation, BinaryMarketSlot, BinaryMarketWitness, CompiledBinaryMarket, }; use deadcat_contracts::market_crypto::derive_issuance_assets; use deadcat_contracts::rt::{RtFactors, RtLeg, RtSide, factors}; @@ -18,8 +19,6 @@ use elements::secp256k1_zkp::{Generator, Keypair, PedersenCommitment, Secp256k1, use elements::{ AssetId, ContractHash, LockTime, OutPoint, Script, Sequence, TxOut, TxOutWitness, Txid, }; -use simplex::program::WitnessTrait as _; - use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output}; fn oracle_key() -> [u8; 32] { @@ -151,17 +150,15 @@ fn execute_active_expiry_with_locktime( BinaryMarketSlot::UnresolvedNoRt, BinaryMarketSlot::UnresolvedCollateral, ]; + let layout = BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::UnresolvedYesRt, + BinaryMarketOperation::Expire, + None, + )?; + let action = BinaryMarketCoordinatorAction::for_layout(layout, 0, None)?; let net = network(params.collateral_asset_id); for (input_index, slot) in slots.into_iter().enumerate() { - let witness = derived_binary_market::BinaryMarketWitness { - path: 6, - slot: slot as u8, - output_base: 0, - oracle_outcome_yes: false, - oracle_signature: [0; 64], - tokens_burned: 0, - redeem_yes: false, - }; + let witness = BinaryMarketWitness::for_slot(layout, slot, action)?; compiled.execute(slot, &pset, &witness.build_witness(), input_index, &net)?; } Ok(()) @@ -261,6 +258,12 @@ fn execute_initial_issuance( ))); let network = network(params.collateral_asset_id); + let layout = BinaryMarketLayout::for_operation( + BinaryMarketCoordinatorRole::DormantYesRt, + BinaryMarketOperation::Issue, + None, + )?; + let action = BinaryMarketCoordinatorAction::for_layout(layout, 0, None)?; for (input_index, slot) in [ BinaryMarketSlot::DormantYesRt, BinaryMarketSlot::DormantNoRt, @@ -268,15 +271,7 @@ fn execute_initial_issuance( .into_iter() .enumerate() { - let witness = derived_binary_market::BinaryMarketWitness { - path: 0, - slot: slot as u8, - output_base: 0, - oracle_outcome_yes: false, - oracle_signature: [0; 64], - tokens_burned: 0, - redeem_yes: false, - }; + let witness = BinaryMarketWitness::for_slot(layout, slot, action)?; compiled.execute(slot, &pset, &witness.build_witness(), input_index, &network)?; } Ok(()) diff --git a/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt b/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt index 8390c6f..45cdccb 100644 --- a/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt +++ b/crates/deadcat-contracts/tests/fixtures/binary_market_abi.txt @@ -20,10 +20,5 @@ YES_RT_VALUE_PARITY: bool YES_RT_VALUE_X: u256 YES_TOKEN_ASSET_ID: u256 witnesses: -ORACLE_OUTCOME_YES: bool -ORACLE_SIGNATURE: [u8; 64] -OUTPUT_BASE: u32 -PATH: u8 -REDEEM_YES: bool +ACTION: Either, Either<(u32, bool, [u8; 64]), Either>> SLOT: u8 -TOKENS_BURNED: u64 diff --git a/crates/deadcat-contracts/tests/golden_vectors.rs b/crates/deadcat-contracts/tests/golden_vectors.rs index b99010e..4deae7c 100644 --- a/crates/deadcat-contracts/tests/golden_vectors.rs +++ b/crates/deadcat-contracts/tests/golden_vectors.rs @@ -76,7 +76,7 @@ fn nonuniform_contract_arguments_compile_to_stable_cmr() { let compiled = CompiledBinaryMarket::new(nonuniform_params()).expect("compile market"); assert_eq!( hex(&compiled.cmr()), - "17cc73d71216f687eaed7cd5dc05743f35eb75d907fbd7c996e2012f22caf7bf" + "2d350901b53cfeb3204f97e7708980fd62bf24914bf8e4aafb6530ce025dbb7f" ); } @@ -225,51 +225,48 @@ fn sample_binary_market_consensus_vectors_are_stable() { let compiled = CompiledBinaryMarket::new(params).expect("compile market"); assert_eq!( hex(&compiled.cmr()), - "ebbd8f3001141120edb0880c8e14f40d2054018116627624fc31c1bcf73af473" + "e8912f8e5deb3c04ba47eaacacc8d194ae0473e35cee9e171b8a71e3513abca0" ); let expected_slots = [ ( - "512062be1cab5b63c4d0eae924752dc57c6a8f23e8eebc887657c564d0ee45723c5a", - "bf50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac03491a3e42d2db13335d900b3cbadcb0d5088b4eb9073869ff309910862294069", + "51203fbf4e0e02df806affc1f0c66180a2d49b2ec03e1848a5ab326700157a7a15bd", + "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac03491a3e42d2db13335d900b3cbadcb0d5088b4eb9073869ff309910862294069", ), ( - "5120eb7d36768be461541ddba7bb46b00b116f49064f92c32076ef33e7b1cae92d9a", + "51206126a2324335d42fe5b9998579b9187b2b6af2a1ffa3e9e10042fc856ebc708c", "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac09c44e27f20b80c93313762a6f4e71fc82db38469d90f902bc1720755b61660f3", ), ( - "512055cf4f94da9baf447d4382cbd5b944e9367bd7cbe503c2f6ccb43e5f5e34e49d", - "bf50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac04bbdde171ae4fa8cfe1c7790ec1d737b4044251c647103b3e2c320a25a8b61e2", + "5120a252a8b117ebdd1bf207f98a6c2034344119d492f84d03e7052c103c1dfad71b", + "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac04bbdde171ae4fa8cfe1c7790ec1d737b4044251c647103b3e2c320a25a8b61e2", ), ( - "51208776e7b8e44901b16364fcfe5facc25e48f21e0b57293b32dc7b1675a4083c10", + "51207bea2e337f0bb4f7235a16a61b8aa2935fcbc011d423ebcda8c3f0f7d4456d35", "bf50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac013169a5cae722314cc26e3f278b4f9d087affe9b0dab437d6d8c2b28ace343d0", ), ( - "5120be476bd31a864e59f050ec170cda3f7bc92e21ecc210bd7f497a763fe8569be3", + "5120b1920be3d3c368dd49067dd3958bd1c5c79af3166ba5534d34ddf2a6e087e68a", "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac04b7b02768d8d3b9339bfd3417355db78cada99eb21f1e32873b3ab77065ee015", ), ( - "5120adb9fe40cf8562fe943d44ba2546f989ea3b152feb6a6e7b79921f425a05a17b", - "bf50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac094c17910e4ec9a08d1a445308fd4b66ee01cc818d1772a8deff59dd38b649bee", + "5120fccd0ab4351768ecf55fbf7cbe2bb65591845bdc13978e8b5832de33f87afa74", + "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac094c17910e4ec9a08d1a445308fd4b66ee01cc818d1772a8deff59dd38b649bee", ), ( - "512035289805a0c289bc61a6f9888b3ebba7f6bac9562d9ff6d2fee8a790a99876a4", - "bf50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0838594e38820be8487ed62ea7663c1d3ce2f20b3f0c7d8075b1bd3f436239d25", + "51206b247f70cbae80525455896174fb5d3129d417b224418650bd7d6e444dd8a4c7", + "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0838594e38820be8487ed62ea7663c1d3ce2f20b3f0c7d8075b1bd3f436239d25", ), ( - "5120b18a089dc84e553a30d61bd70a7b9fb5e3a5c7b8bc29c58dab73f1effe7fc5e4", + "51208e44c81c10c744ea9d5ab30469e1c1510e5a67c08c42a10db2973c9f876f91bb", "be50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac03efb3634a85ea11aa3a246775edf9406ff088af069350d5b8a4ef7a9f862ae0c", ), ]; for (slot, (script, control_block)) in BinaryMarketSlot::ALL.into_iter().zip(expected_slots) { + let actual_script = hex(compiled.slot(slot).script_pubkey().as_bytes()); + let actual_control_block = hex(&compiled.slot(slot).control_block().serialize()); + assert_eq!(actual_script, script, "{slot:?} scriptPubKey"); assert_eq!( - hex(compiled.slot(slot).script_pubkey().as_bytes()), - script, - "{slot:?} scriptPubKey" - ); - assert_eq!( - hex(&compiled.slot(slot).control_block().serialize()), - control_block, + actual_control_block, control_block, "{slot:?} control block" ); } diff --git a/crates/deadcat-contracts/tests/interpret.rs b/crates/deadcat-contracts/tests/interpret.rs index a8e9be0..cc8ae46 100644 --- a/crates/deadcat-contracts/tests/interpret.rs +++ b/crates/deadcat-contracts/tests/interpret.rs @@ -3,12 +3,13 @@ mod support; use deadcat_contracts::binary_market::{ - BinaryMarketAction, BinaryMarketSlot, CompiledBinaryMarket, derived_binary_market, + BinaryMarketAction, BinaryMarketCoordinatorAction, BinaryMarketCoordinatorRole, + BinaryMarketLayout, BinaryMarketOperation, BinaryMarketPath, BinaryMarketSlot, + BinaryMarketWitness, CompiledBinaryMarket, }; use deadcat_contracts::finalized_spend::FinalizedSimplicitySpendError; use deadcat_contracts::interpret::{ - BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, TrackedContractOutput, - interpret_binary_market_spend, + BinaryMarketLiveOutputs, InterpretError, TrackedContractOutput, interpret_binary_market_spend, }; use deadcat_contracts::rt::{RtFactors, RtLeg, RtSide, factors}; use deadcat_types::{BinaryMarketParams, BinaryMarketState}; @@ -17,8 +18,6 @@ use elements::hashes::Hash as _; use elements::pset::PartiallySignedTransaction; use elements::secp256k1_zkp::{Generator, Keypair, PedersenCommitment, Secp256k1, Tweak}; use elements::{LockTime, OutPoint, Script, Sequence, Transaction, TxOut, TxOutWitness}; -use simplex::program::WitnessTrait as _; - use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output, script}; fn key(seed: u8) -> [u8; 32] { @@ -50,6 +49,20 @@ struct BinaryScenario { transaction: Transaction, } +fn coordinator_witness( + slot: BinaryMarketSlot, + operation: BinaryMarketOperation, + full_cancellation: Option, + output_base: u32, +) -> BinaryMarketWitness { + let coordinator = BinaryMarketCoordinatorRole::try_from(slot).expect("coordinator slot"); + let layout = BinaryMarketLayout::for_operation(coordinator, operation, full_cancellation) + .expect("valid test layout"); + let action = BinaryMarketCoordinatorAction::for_layout(layout, output_base, None) + .expect("valid test action"); + BinaryMarketWitness::for_slot(layout, slot, action).expect("valid test witness") +} + fn resolved_redemption_scenario(full: bool, decoy: bool) -> BinaryScenario { let params = binary_params(); let compiled = CompiledBinaryMarket::new(params).expect("compile market"); @@ -128,15 +141,12 @@ fn resolved_redemption_scenario(full: bool, decoy: bool) -> BinaryScenario { script(0x90), ))); - let witness = derived_binary_market::BinaryMarketWitness { - path: 8, - slot: BinaryMarketSlot::ResolvedYesCollateral as u8, + let witness = coordinator_witness( + BinaryMarketSlot::ResolvedYesCollateral, + BinaryMarketOperation::Redeem, + None, output_base, - oracle_outcome_yes: false, - oracle_signature: [0; 64], - tokens_burned: tokens, - redeem_yes: false, - }; + ); let net = network(params.collateral_asset_id); let stack = compiled .finalize( @@ -362,7 +372,7 @@ fn finalized_active_expiry(side: RtSide, sequences: [Sequence; 3]) -> BinaryScen input.sequence = Some(sequence); pset.add_input(input); } - // Same-script decoy at vout 0; the decoded OUTPUT_BASE is 2. + // Same-script decoy at vout 0; the decoded action's output base is 2. pset.add_output(pset_output(explicit_txout( params.collateral_asset_id, 599, @@ -395,15 +405,12 @@ fn finalized_active_expiry(side: RtSide, sequences: [Sequence; 3]) -> BinaryScen .clone(), ))); - let witness = derived_binary_market::BinaryMarketWitness { - path: 6, - slot: BinaryMarketSlot::UnresolvedYesRt as u8, - output_base: 2, - oracle_outcome_yes: false, - oracle_signature: [0; 64], - tokens_burned: 0, - redeem_yes: false, - }; + let witness = coordinator_witness( + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketOperation::Expire, + None, + 2, + ); let net = network(params.collateral_asset_id); let stack = compiled .finalize( @@ -546,7 +553,7 @@ fn rejects_inactive_or_time_typed_active_expiry_locktime() { } #[test] -fn interprets_partial_cancellation_when_path_equals_slot_and_bases_are_shared() { +fn interprets_partial_cancellation_from_action_and_output_shape() { let params = binary_params(); let compiled = CompiledBinaryMarket::new(params).expect("compile market"); let before = BinaryMarketState::Trading { @@ -631,15 +638,12 @@ fn interprets_partial_cancellation_when_path_equals_slot_and_bases_are_shared() 200, script(0x92), ))); - let witness = derived_binary_market::BinaryMarketWitness { - path: 2, - slot: 2, - output_base: 0, - oracle_outcome_yes: false, - oracle_signature: [0; 64], - tokens_burned: 0, - redeem_yes: false, - }; + let witness = coordinator_witness( + BinaryMarketSlot::UnresolvedYesRt, + BinaryMarketOperation::Cancel, + Some(false), + 0, + ); let net = network(params.collateral_asset_id); let stack = compiled .finalize( diff --git a/crates/deadcat-node/src/interpreter.rs b/crates/deadcat-node/src/interpreter.rs index 27cd014..d0fa069 100644 --- a/crates/deadcat-node/src/interpreter.rs +++ b/crates/deadcat-node/src/interpreter.rs @@ -8,10 +8,11 @@ use std::collections::HashSet; use std::sync::Arc; -use deadcat_contracts::binary_market::{BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome}; +use deadcat_contracts::binary_market::{ + BinaryMarketPath, BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome, +}; use deadcat_contracts::interpret::{ - BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, TrackedContractOutput, - interpret_binary_market_spend, + BinaryMarketLiveOutputs, InterpretError, TrackedContractOutput, interpret_binary_market_spend, }; use deadcat_rpc::RecoveryFamily; use deadcat_types::{ContractDescriptor, ContractId, ContractSyncState, LiquidNetwork}; diff --git a/docs/acceptance/binary-market-ab-v1.md b/docs/acceptance/binary-market-ab-v1.md index 4a76b45..a40ae5f 100644 --- a/docs/acceptance/binary-market-ab-v1.md +++ b/docs/acceptance/binary-market-ab-v1.md @@ -1,7 +1,8 @@ # Binary-market A/B v1 acceptance packet - Status: Engineering candidate complete; protocol-owner approved; focused external review pending -- Prepared: 2026-07-13; strict coordinator hardening updated 2026-07-22 +- Prepared: 2026-07-13; strict coordinator hardening updated 2026-07-22; + typed-action ABI candidate updated 2026-07-31 - Protocol-owner approval: Tommy Volk; 2026-07-14 - Decision record: [ADR 0005](../adr/0005-rt-blinding-schedule.md) - Protocol specification: [Deadcat protocol v1](../protocol-v1.md) @@ -22,6 +23,13 @@ dormant transition or three-input unresolved transition, while each follower proves it belongs to that exact coordinator group. No compatibility path or migration is required because the superseded candidate was never deployed. +The still-undeployed v1 covenant was subsequently simplified to a typed +five-operation `ACTION` sum plus authenticated `SLOT`. Path variants, burn +quantity, redemption side, and dummy oracle fields are derived from the slot, +semantic action, and mandatory transaction outputs. The exact branch-pruned +decoder and current identity are recorded in the +[PR3 witness conformance note](../binary-market-witness-conformance-pr3.md). + ADR 0005 remains Proposed until a focused reviewer records a conclusion. Tommy Volk approved every protocol-owner choice below on 2026-07-14 against the candidate implementation commit. Reviewer checkboxes remain for the independent @@ -33,7 +41,7 @@ For the golden fixture in `deadcat-contracts/tests/golden_vectors.rs`, the parameterized binary-market CMR is: ```text -ebbd8f3001141120edb0880c8e14f40d2054018116627624fc31c1bcf73af473 +e8912f8e5deb3c04ba47eaacacc8d194ae0473e35cee9e171b8a71e3513abca0 ``` The v1 consensus scalars are big-endian 32-byte integers: @@ -50,7 +58,10 @@ The golden test also freezes all four asset commitments, both side-independent value commitments, and all eight Taproot script/control-block pairs. A second set of independently derived commitments uses nonuniform asset IDs so an accidental Elements display-order reversal cannot pass unnoticed. -The CMR above is not universal across market parameters. +The CMR above is not universal across market parameters. The prior strict +coordinator/follower implementation compiled this fixture to +`ebbd8f3001141120edb0880c8e14f40d2054018116627624fc31c1bcf73af473`; +that value remains historical evidence rather than the current candidate. ## Engineering evidence @@ -63,6 +74,7 @@ The CMR above is not universal across market parameters. | Mandatory synchronized flip/burn | Complete | SimplicityHL and Rust checks plus A→B, B→A, same-side, mixed-side, wrong-role, malformed-commitment, and terminal-burn tests | | Input-side reissuance nonce | Complete | Covenant/builder/interpreter adversarial tests and live A/B issuances inspect the exact nonce | | Every lifecycle shape | Complete | All 18 builder/BitMachine/interpreter shapes execute; every RT-consuming shape executes on both A and B, every sibling, with sufficient Simplicity budget; direct mutation tests cover solvency, issuance authority, terminal burns, oracle/expiry, redemption arithmetic, wrong slots, and follower source-witness independence | +| Typed semantic action ABI | Complete | Golden source ABI contains only `SLOT` and the five-branch `ACTION`; checked Rust facades prevent invalid role/action layouts; exact finalized decoding rejects missing, duplicate, unexpected, or near-miss structural values without width scans or candidate search | | Elements consensus and policy | Complete | Three full-contract chains, 15 market transactions plus one setup-funding transaction confirmed; every valid stage through `testmempoolaccept`, broadcast, mining, and confirmation; strict rerun adds a divergent-follower-stack transaction and rejects follower stacks transplanted into the coordinator input | | Confidential proofs | Complete | Creation/continuation/burn rangeproofs and complete-domain surjection proofs accepted live; missing and parseable-corrupt proofs rejected | | Golden integration identity | Complete | Constants, six fixture commitments, six independently derived nonuniform-ID commitments, CMR, eight scripts, and eight control blocks are literal regression vectors | @@ -71,8 +83,8 @@ The CMR above is not universal across market parameters. | Composition/orchestration | Complete for v1 gate | Live confidential wallet input/change composition and deterministic synthetic two-market atomic indexing; the mandatory multi-contract liquidregtest gate mines and atomically indexes a real market-plus-two-orders transaction | | Full-market before/after measurements | Captured with provenance limitation | A/B reporter is reproducible; exact rolling rows are a preserved capture from temporary baseline instrumentation whose patch was not committed; the strict live rerun passed locally but its per-transaction raw JSON is not committed; isolated rolling/A-B study remains reproducible | | Rolling retirement | Complete | No rolling implementation, compatibility mode, study crate, or schema migration remains in the candidate tree | -| Clean pinned-Nix CI | Passed on 2026-07-13; strict local rerun on 2026-07-22 | `nix develop path:.#default --command just ci`; rerun after freezing nonuniform commitment vectors; `nix develop .#default --command just ci-checks` validates the strict contract review update | -| Independent implementation review | Complete for A/B vectors and pre-strict CMR; current strict CMR is regression-tested | Plain-integer scalar arithmetic, direct C libsecp256k1-zkp commitments, and isolated pinned-compiler CMR reproduction all matched on 2026-07-13; the strict CMR and eight scripts/control blocks are covered by committed golden vectors | +| Clean pinned-Nix CI | Passed on 2026-07-13, 2026-07-22, and typed-action candidate on 2026-07-31 | `nix develop .#default --command just ci-checks` passes formatting, strict Clippy, 211 workspace tests, doc tests, and WASM; `just regtest-market-ab` passes the production-shaped live lifecycle | +| Independent implementation review | Complete for A/B vectors and pre-strict CMR; current typed-action identity is regression-tested | Plain-integer scalar arithmetic, direct C libsecp256k1-zkp commitments, and isolated pinned-compiler CMR reproduction all matched on 2026-07-13; current CMRs and all eight scripts/control blocks are covered by committed golden vectors | | Focused external human review | Pending | Reviewer record below | | Protocol-owner approval | Complete | Tommy Volk; 2026-07-14; candidate implementation commit below | @@ -104,7 +116,7 @@ nix develop path:.#default --command cargo test --locked \ | nixpkgs revision | `50ab793786d9de88ee30ec4e4c24fb4236fc2674` | | rustc | `1.94.1 (e408947bf 2026-03-25)` | | Cargo | `1.94.0 (29ea6fb6a 2026-03-24)` | -| Simplex CLI / smplx crates | `0.0.6` | +| Simplex CLI / smplx crates | `0.0.9` | | Elements Core | `23.3.3` | | Electrs | `0.4.1` | | Original expanded live record | Passed, 44.81 seconds, 2026-07-13 | @@ -124,9 +136,15 @@ confirmed setup-funding transaction, for 16 confirmed transactions total. The same production-shaped gate was rerun against the strict canonical contract on 2026-07-22: all 16 transactions confirmed, both malformed-proof variants were rejected, and a coordinator/follower pruning negative was rejected. -Transaction identifiers are intentionally treated as run-local; the deterministic -strict covenant metrics and CMR are recorded in the current measurement and -golden-vector files. +Transaction identifiers are intentionally treated as run-local. The preserved +measurement file records that strict-covenant run; the typed-action candidate's +current CMR and resource maxima are recorded in the golden vectors and PR3 +conformance note. + +The gate was rerun again against the typed-action candidate on 2026-07-31. All +16 transactions confirmed across the three lifecycle chains, both malformed +proofs were rejected, and transplanting a follower stack into the coordinator +was rejected. | Chain | Stage | Side | Height | Txid | |---|---|---|---:|---| @@ -231,7 +249,7 @@ The external reviewer should work from the recorded implementation commit: the group base from its own index, checks every required market input and the complete transition, and prohibits issuance on every market input when appropriate. -- [ ] Confirm each follower ignores path/output witnesses yet requires its +- [ ] Confirm each follower ignores `ACTION` yet requires its exact role, position, and sibling outpoints in the same coordinator group. - [ ] Confirm issuance binds the nonce to the input side for both legs. - [ ] Confirm rangeproof VBFs and the complete canonical surjection domain agree @@ -261,7 +279,7 @@ for the focused human review above. | Field | Value | |---|---| | Independent vector method/result | Python big integers reproduced all CBF/VBF arithmetic; standalone C using upstream libsecp256k1-zkp reproduced the fixture and nonuniform-asset-ID commitment sets; isolated Simplex 0.0.6 build reproduced the pre-strict CMR; all matched on 2026-07-13 | -| Automated implementation audit | No production A/B findings on 2026-07-13; no strict-contract production findings on 2026-07-22, with adversarial BitMachine/regtest coverage added | +| Automated implementation audit | No production A/B findings on 2026-07-13; no strict-contract production findings on 2026-07-22; typed-action ABI/domain/interpreter review completed on 2026-07-31 with exact-decoder, adversarial BitMachine, and live-regtest coverage | | External reviewer | `` | | Commit reviewed | `` | | Review date | `` | diff --git a/docs/adr/0005-rt-blinding-schedule.md b/docs/adr/0005-rt-blinding-schedule.md index ed11423..b25de37 100644 --- a/docs/adr/0005-rt-blinding-schedule.md +++ b/docs/adr/0005-rt-blinding-schedule.md @@ -70,6 +70,15 @@ membership in the coordinator's market group. This does not alter the A/B blinding decision recorded here. It changes the parameterized golden CMR to `ebbd8f3001141120edb0880c8e14f40d2054018116627624fc31c1bcf73af473`. +On 2026-07-31, still before deployment, redundant path, amount, redemption-side, +and dummy oracle witness fields were replaced by an authenticated slot and a +typed five-operation action sum. Transaction outputs now supply cancellation +and redemption refinements. This also leaves the A/B decision unchanged and +changes the current parameterized golden CMR to +`e8912f8e5deb3c04ba47eaacacc8d194ae0473e35cee9e171b8a71e3513abca0`. +The exact source and finalized witness shapes are recorded in the +[PR3 conformance note](../binary-market-witness-conformance-pr3.md). + ## Candidate schedule Let `n` be the secp256k1 group order. Use these fixed, valid, nonzero scalars: @@ -280,7 +289,7 @@ The candidate is integrated across the production-shaped binary-market stack: - The lowest-index market input is the coordinator. It derives the market input base from its own index, checks the selected transition, every required market input, every issuance prohibition or exact reissuance field, and all - required outputs. Followers ignore transition witnesses but require their + required outputs. Followers do not consult `ACTION` but require their exact role, position, and coordinator-group outpoint relationship. - SimplicityHL receives the two asset commitments and one value commitment for diff --git a/docs/binary-market-witness-conformance-pr3.md b/docs/binary-market-witness-conformance-pr3.md new file mode 100644 index 0000000..dcf665d --- /dev/null +++ b/docs/binary-market-witness-conformance-pr3.md @@ -0,0 +1,114 @@ +# Binary-market PR3 witness conformance + +Status: implementation conformance note for the proposed v1 binary-market +witness refactor. The normative protocol description remains in +`docs/protocol-v1.md`. + +## Typed ABI + +Coordinator spends decode exactly two source witnesses: + +```text +SLOT: u8 +ACTION: Either, + Either<(u32, bool, Signature), Either>> +``` + +The five `ACTION` branches are, in order: + +| Operation | Structural branch | Payload | +|---|---|---| +| Issue | `Left(Left(...))` | `output_base: u32` | +| Cancel | `Left(Right(...))` | `output_base: u32` | +| Resolve | `Right(Left(...))` | `(output_base: u32, outcome_yes: bool, signature: Signature)` | +| Expire | `Right(Right(Left(...)))` | `output_base: u32` | +| Redeem | `Right(Right(Right(...)))` | `output_base: u32` | + +Simplicity represents the three-element Resolve tuple as the right-associated +product `(u32, (bool, Signature))`. Redeem-program pruning replaces inactive +`ACTION` alternatives with unit while retaining the selected sum path. The five +exact finalized types are therefore: + +```text +Issue: Either, ()> +Cancel: Either, ()> +Resolve: Either<(), Either<(u32, (bool, Signature)), ()>> +Expire: Either<(), Either<(), Either>> +Redeem: Either<(), Either<(), Either<(), u32>>> +``` + +An interpreter must match one of these complete branch-pruned structural types +before decoding its value. Matching anonymous bit widths or accepting a +differently associated product is non-conforming. + +Follower roles read only `SLOT`; they authenticate their coordinator group and +do not consult `ACTION`. Confirmed-spend interpretation begins from the tracked +coordinator input, so its decoded program must contain exactly one `SLOT` and +one branch-pruned `ACTION` value of the complete types above. + +## Derived layout + +`PATH` is not a witness. The interpreter uses the shared +`BinaryMarketCoordinatorRole` and `BinaryMarketLayout` domain mapping: + +| Coordinator slot | Operation | Transaction discriminator | Legacy path | +|---|---|---|---| +| 0 | Issue | — | Initial issuance | +| 2 | Issue | — | Subsequent issuance | +| 2 | Cancel | output `base + 2` is collateral | Partial cancellation | +| 2 | Cancel | output `base + 2` is a YES burn | Full cancellation | +| 2 | Resolve | — | Active resolution | +| 0 | Resolve | — | Dormant resolution | +| 2 | Expire | — | Active expiry | +| 0 | Expire | — | Dormant expiry | +| 5 or 6 | Redeem | — | Resolved redemption | +| 7 | Redeem | — | Expiry redemption | + +Dormant YES/NO RT siblings must share a prior transaction but may occupy +nonconsecutive vouts so composed creation remains valid. Unresolved YES/NO/ +collateral siblings must share a prior transaction and occupy consecutive +vouts in that order. + +For redemption, an explicit collateral asset at `output_base` selects the +partial layout and moves the burn to `output_base + 1`; otherwise the burn is at +`output_base`. The explicit burn supplies both token quantity and, after +expiry, YES/NO side. These values are never reconstructed from spare witness +bits. + +## Fail-closed checks + +Conforming interpretation rejects missing, duplicate, unexpected, or +wrongly-typed witness values; a `SLOT` that disagrees with the authenticated +control block/state; an operation invalid for the coordinator role; and any +transaction that fails independent input, issuance, output, burn, oracle, +locktime, or economic validation. It performs one interpretation from the +decoded action rather than searching candidate paths or payload values. + +The unit conformance matrix covers all five nested `ACTION` leaves and malformed +near-miss structural types. Shared domain tests cover all ten legacy paths and +every coordinator/follower role; confirmed-transaction tests cover exact +output-base selection and independently invalid transaction shapes. + +## Candidate identity and resource bounds + +For the canonical golden parameter fixture, this refactor produces CMR +`e8912f8e5deb3c04ba47eaacacc8d194ae0473e35cee9e171b8a71e3513abca0`. +The nonuniform-asset fixture produces +`2d350901b53cfeb3204f97e7708980fd62bf24914bf8e4aafb6530ce025dbb7f`. +The golden-vector suite pins both CMRs, the source ABI, and all eight resulting +Taproot script/control-block pairs. + +The all-path, both-RT-side budget corpus records these current maxima: + +| Resource | Maximum | +|---|---:| +| Covenant cost | 3,633,302 milliweight | +| Extra cells | 72,462 | +| Extra frames | 62 | +| Finalized covenant stack | 4,339 bytes | +| Transaction size | 13,624 bytes | +| Transaction weight | 15,574 WU | +| Transaction vsize | 3,894 vB | + +CI uses rounded ceilings above these measurements and fails if a later compiler +or covenant change crosses them. diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 05636f4..eed203e 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -253,6 +253,10 @@ collateral_per_pair = cp = checked_mul(base_payout, 2) ``` Its four-bit recovery index is the zero-based position in this list. +Compiler and creation validation enforce this supported-denomination profile. +The spend covenant commits the selected value and uses checked payout +arithmetic, but does not repeat the recovery-policy membership check on every +spend. Amounts are in the smallest indivisible unit of the relevant asset. The contract accepts any Liquid collateral asset; transaction fees remain in the @@ -291,14 +295,12 @@ the contract input base from their own `current_index` and validate the complete transition. They check every sibling input, both RT legs, every constrained output, the full collateral equation, and issuance fields on every market input. -Dormant NO, unresolved NO, and unresolved collateral dispatch by their committed -slot before consulting any transition witness. They ignore `PATH`, `OUTPUT_BASE`, -oracle, and redemption fields and authorize only a co-spend with the exact -coordinator group at the required adjacent transaction-input positions. The -unresolved group must also spend consecutive prior outputs YES, NO, collateral. +Dormant NO, unresolved NO, and unresolved collateral authenticate their +committed slot and exact coordinator group without consulting `ACTION`. Consensus executes all inputs atomically, so a follower succeeds only when its -coordinator independently authorizes the complete transition. Terminal -collateral inputs validate their own redemption paths. +coordinator independently authorizes the complete transition. The unresolved +group must also spend consecutive prior outputs YES, NO, collateral. Terminal +collateral inputs authenticate their own slot and validate their redemption. The canonical node state is: @@ -339,11 +341,39 @@ Every market path enforces: - no transition creates unmatched YES/NO supply; and - checked arithmetic cannot wrap. -The interpreter derives the input base from the tracked coordinator outpoint and -uses only the coordinator's decoded path and output base. Follower transition -witness values are non-authoritative. It does not find a continuation by taking -the first output with a matching script. Decoy same-script outputs in an -otherwise valid custom transaction must not change the interpreted state. +The interpreter derives the input base from the tracked coordinator outpoint +and decodes the coordinator's exact typed action. Follower action values are +non-authoritative. It does not find a continuation by taking the first output +with a matching script. Decoy same-script outputs in an otherwise valid custom +transaction must not change the interpreted state. + +### Covenant witness ABI + +The source-level witness has two fields: + +```text +SLOT: u8 +ACTION: Either, + Either<(u32, bool, Signature), Either>> +``` + +`SLOT` is authenticated against the hidden TapData word before it can grant any +authority. `ACTION` is a five-way semantic sum whose branches are: + +| Operation | Encoding | Payload | +|---|---|---| +| Issue | `Left(Left(output_base))` | output window only | +| Cancel | `Left(Right(output_base))` | output window only | +| Resolve | `Right(Left((output_base, outcome_yes, signature)))` | output window and oracle attestation | +| Expire | `Right(Right(Left(output_base)))` | output window only | +| Redeem | `Right(Right(Right(output_base)))` | output window only | + +The authenticated slot and transaction determine the old-state variant. In +particular, the slot distinguishes initial from subsequent issuance, +dormant from active resolution/expiry, and resolved from expired redemption. +Cancellation shape is derived from its mandatory outputs. Redemption quantity, +completion, and token side are derived from the explicit burn and collateral +outputs. They are not independently witness-selected. ### Spend paths @@ -408,10 +438,11 @@ the canonical spend confirms. #### Expiry Expiry uses the same unresolved/dormant shapes as resolution. `expiry_height` -is the exact CLTV-style lock-height threshold and must satisfy -`1 <= expiry_height < 500_000_000`. The covenant requires transaction -`nLockTime >= expiry_height`; the transaction must also use a non-final input -sequence so consensus locktime is active. Because consensus requires +is the exact CLTV-style lock-height threshold. Compiler and creation validation +require `1 <= expiry_height < 500_000_000`; the covenant enforces the committed +height by requiring transaction `nLockTime >= expiry_height`. The transaction +must also use a non-final input sequence so consensus locktime is active. +Because consensus requires `nLockTime < candidate_block_height`, a transaction with locktime exactly `H` is first confirmable in block `H + 1`. Unresolved collateral moves unchanged to slot 7.