diff --git a/src/lib/extras/js.rs b/src/lib/extras/js.rs index 3fe521c..a749bc2 100644 --- a/src/lib/extras/js.rs +++ b/src/lib/extras/js.rs @@ -387,7 +387,7 @@ impl WASMMarloweStateMachine { } #[wasm_bindgen] - pub fn apply_input_deposit_for_role(&mut self,from_role:&str,to_role:&str,token_name:&str,currency_symbol:&str,quantity:u64) { + pub fn apply_input_deposit_for_role(&mut self,from_role:&str,to_role:&str,token_name:&str,currency_symbol:&str,quantity:i64) { let asset = Token { currency_symbol: currency_symbol.into(), token_name: token_name.into() @@ -399,7 +399,7 @@ impl WASMMarloweStateMachine { } #[wasm_bindgen] - pub fn apply_input_deposit_for_addr(&mut self,from_bech32_addr:&str,to_bech32_addr:&str,token_name:&str,currency_symbol:&str,quantity:u64) { + pub fn apply_input_deposit_for_addr(&mut self,from_bech32_addr:&str,to_bech32_addr:&str,token_name:&str,currency_symbol:&str,quantity:i64) { let asset = Token { currency_symbol: currency_symbol.into(), token_name: token_name.into() diff --git a/src/lib/semantics.rs b/src/lib/semantics.rs index 0bdcd8b..4b98df7 100644 --- a/src/lib/semantics.rs +++ b/src/lib/semantics.rs @@ -20,7 +20,7 @@ pub enum InputType { Deposit { who_is_expected_to_pay:Party , expected_asset_type: Token, - expected_amount: u64, + expected_amount: i64, expected_target_account:crate::types::marlowe::AccountId, continuation: Contract }, @@ -120,7 +120,7 @@ pub trait ContractSemantics { fn apply_input_choice(&self,applied_choice_name:&str, applied_choice_owner:Party, applied_chosen_value: i64) -> Result; - fn apply_input_deposit(&self,from:Party, asset: Token, quantity: u64, to:crate::types::marlowe::AccountId) -> + fn apply_input_deposit(&self,from:Party, asset: Token, quantity: i64, to:crate::types::marlowe::AccountId) -> Result; fn apply_input_action(&self,action:crate::types::marlowe::InputAction) -> @@ -390,8 +390,9 @@ impl ContractSemantics for ContractInstance { match action { InputAction::Deposit { into_account, input_from_party, of_tokens, that_deposits } => { + match (into_account,input_from_party,of_tokens) { - (Some(a), Some(b), Some(c)) => match self.apply_input_deposit(b, c, that_deposits, AccountId::Role { role_token: "nisse".to_owned() }) { + (Some(_a), Some(b), Some(c)) => match self.apply_input_deposit(b, c, that_deposits, AccountId::Role { role_token: "nisse".to_owned() }) { Ok(v) => Ok(v), Err(e) => Err(ActionApplicationError::InvalidDeposit(e)), }, @@ -419,7 +420,7 @@ impl ContractSemantics for ContractInstance { let result = self.step(true); match result { Ok(r) => match r.0.process() { - Ok((a,b)) => Ok(a), + Ok((a,_b)) => Ok(a), Err(e) => Err(ActionApplicationError::Unknown( format!("Applied notify successfully but failed to invoke process() (step 2).. {:?}",e) )), @@ -485,7 +486,7 @@ impl ContractSemantics for ContractInstance { } - fn apply_input_deposit(&self,from:Party, asset: Token, quantity: u64, to:crate::types::marlowe::AccountId) -> Result { + fn apply_input_deposit(&self,from:Party, asset: Token, quantity: i64, to:crate::types::marlowe::AccountId) -> Result { let (mut new_instance,machine_state) = match self.process() { Ok((a, b)) => (a,b), @@ -510,30 +511,43 @@ impl ContractSemantics for ContractInstance { continuation } => { + // When a contract expects negative deposit amounts, we accept the redeemer matching that exact negative value, + // but we treat the applied input value as zero, thus it will not actually affect the value of any account. + let clamped_quantity = 0.max(quantity) as u64; if who_is_expected_to_pay == &from && &asset == expected_asset_type && &quantity == expected_amount && expected_target_account == &to { - // Add or update amount for the party that is depositing value to their account. - if let Some(existing_amount) = new_instance.state.accounts.get_mut(&(from.clone(),asset.clone())) { - *existing_amount = *existing_amount + quantity; + let mut old_amount : u64 = 0; + let new_amount : u64; + + // Add or update amount for the target account. + if let Some(existing_amount) = new_instance.state.accounts.get_mut(&(expected_target_account.clone(),asset.clone())) { + old_amount = *existing_amount; + new_amount = *existing_amount + clamped_quantity; + *existing_amount = *existing_amount + clamped_quantity; } else { + new_amount = clamped_quantity; new_instance.state.accounts.insert( - (from.clone(),asset.clone()), - quantity + (expected_target_account.clone(),asset.clone()), + clamped_quantity ); } new_instance.contract = continuation.clone(); new_instance.applied.push(AppliedInput::Deposit( - from, + from.clone(), expected_target_account.clone(), expected_asset_type.clone(), - quantity + clamped_quantity )); - new_instance.logs.push(format!("Deposit was successfully applied: '{x:?}' has been applied.")); + if clamped_quantity as i64 > quantity { + new_instance.logs.push(format!("Deposit was successfully applied: '{x:?}' has been applied. {from}. Because this was a negative deposit, it counted as zero - so the target account {expected_target_account} did not change at all but stays at {old_amount} {expected_asset_type}")); + } else { + new_instance.logs.push(format!("Deposit was successfully applied: '{x:?}' has been applied. {from} has added {clamped_quantity} of {expected_asset_type} to the account of {expected_target_account} which now contains {new_amount} {expected_asset_type} (before this, it contained only {old_amount})")); + } return Ok(new_instance) } } @@ -729,14 +743,10 @@ impl ContractSemantics for ContractInstance { match expected_amount { Ok(v) => { - if v < 0 { - return Err(format!("Expected amount turned out negative.. This is most likely a bug in the marlowe_lang crate. {:?} {:?}", self.state ,depo)) - } - expected_inputs.push(InputType::Deposit { who_is_expected_to_pay: from.clone(), expected_asset_type: tok.clone(), - expected_amount: v as u64, + expected_amount: v, expected_target_account: to.clone(), continuation: *(continuation.clone()) }) @@ -822,3 +832,6 @@ impl ContractSemantics for ContractInstance { new_instance } } + + + diff --git a/src/lib/tests/core.rs b/src/lib/tests/core.rs index cce1fc8..a45f603 100644 --- a/src/lib/tests/core.rs +++ b/src/lib/tests/core.rs @@ -407,9 +407,9 @@ fn can_find_uninitialized_inputs() -> Result<(),String> { } if found_inputs.len() != (result.uninitialized_const_params.len() + result.uninitialized_time_params.len()) { - println!("parser found these consts: {:?}",result.uninitialized_const_params); - println!("parser found these times: {:?}",result.uninitialized_time_params); - println!("contract impl found these: {:?}",found_inputs); + // println!("parser found these consts: {:?}",result.uninitialized_const_params); + // println!("parser found these times: {:?}",result.uninitialized_time_params); + // println!("contract impl found these: {:?}",found_inputs); return Err(format!("PARSE AND STRUCT IMPL DIFF!")) } @@ -519,7 +519,7 @@ pub fn marlowe_strict_conversion() -> Result<(),String> { pub fn basic_marlowe_strict_example_code() -> Result<(),String> { use crate::types::marlowe_strict::*; - use crate::serialization::*; + // use crate::serialization::*; let p1 = Party::role("P1"); let p2 = Party::role("P2"); @@ -549,11 +549,11 @@ pub fn basic_marlowe_strict_example_code() -> Result<(),String> { timeout_continuation: Contract::Close.into() }; - let serializable_contract : crate::types::marlowe::Contract = contract.try_into()?; + let _serializable_contract : crate::types::marlowe::Contract = contract.try_into()?; - println!("{}",marlowe::serialize(serializable_contract.clone())); - println!("{}",json::serialize(serializable_contract.clone())?); - println!("{}",cborhex::serialize(serializable_contract.clone())?); + // println!("{}",marlowe::serialize(serializable_contract.clone())); + // println!("{}",json::serialize(serializable_contract.clone())?); + // println!("{}",cborhex::serialize(serializable_contract.clone())?); Ok(()) @@ -615,8 +615,8 @@ fn marlowe_strict_with_iter_example() { timeout: chrono::Utc::now().checked_add_days(Days::new(1)).unwrap().timestamp_millis(), timeout_continuation: Contract::Close.into() }; - let serialized = marlowe::serialize_strict(contract).unwrap(); - println!("{}",parsing::fmt::fmt(&serialized)) + let _serialized = marlowe::serialize_strict(contract).unwrap(); + // println!("{}",parsing::fmt::fmt(&serialized)) } diff --git a/src/lib/tests/semantics.rs b/src/lib/tests/semantics.rs index 751246b..25e2fa1 100644 --- a/src/lib/tests/semantics.rs +++ b/src/lib/tests/semantics.rs @@ -113,7 +113,7 @@ fn basic_semantics_test() { from_account: Some(Party::Role { role_token: "NISSE".into() }), to: Some(Payee::Party(Some(Party::Role {role_token: "NISSE".into()}))), token: Some(Token::ada()), - pay: Some(Value::ConstantValue(42)), + pay: Some(Value::ConstantValue(44)), then: Some(Contract::Close.boxed()) }.boxed())) }) @@ -146,11 +146,13 @@ fn basic_semantics_test() { timeout_continuation: Some(Contract::Close.boxed()) }; - + let machine = - ContractInstance::new(&wait_for_choice_contract,Some("AA".to_string())) - .process().expect("should be able to process"); - + ContractInstance::new( + &wait_for_choice_contract, + Some("AA".to_string()) + ).with_account_role("NISSE", &Token::ada(), 2) + .process().expect("should be able to process"); let machine_of_first_kind = machine.0.apply_input_choice( "nisses_choice".into(), @@ -160,6 +162,8 @@ fn basic_semantics_test() { .expect("nisse should be able to apply his choice.") .process().expect("should be able to process after applying the choice"); + let acc = machine.0.state.accounts.get_key_value(&(Party::Role { role_token: "NISSE".into()}, Token::ada())).expect("should find nisses ada acc"); + assert!(acc.1 == &2); let machine_of_second_kind = machine.0.apply_input_choice( "nisses_choice".into(), @@ -179,14 +183,318 @@ fn basic_semantics_test() { ).expect("nisse should be able to apply input deposit").process().unwrap(); match machine_of_first_kind.1 { - MachineState::Closed => {}, + MachineState::Closed => { + let accs = machine_of_first_kind.0.as_datum().state.accounts; + let v = accs.get_key_value(&(Party::Role { role_token: "NISSE".into()}, Token::ada())).expect("should find nisses ada acc"); + // for x in machine_of_first_kind.0.logs { + // println!("{}",x) + // } + assert!(v.1 == &2) + }, _ => panic!("The first machine was not closed even though we selected the number 4. This is bad.") } match machine_of_second_kind.1 { - MachineState::Closed => {}, + MachineState::Closed => { + let accs = machine_of_second_kind.0.as_datum().state.accounts; + let v = accs.get_key_value(&(Party::Role { role_token: "NISSE".into()}, Token::ada())).expect("should find nisses ada acc"); + // for x in machine_of_second_kind.0.logs { + // println!("{}",x) + // } + assert!(v.1 == &0) + }, _ => panic!("The first machine was not closed even though we selected the number 3 and 3 again... This is bad.") } } + +#[test] +fn basic_semantics_test_cannot_pay_more_than_available_funds() { + + let iffy = Contract::If { + x_if: Some(Observation::AndObs { both: Some(Box::new(Observation::True)), and:Some(Box::new(Observation::True)) }), + then: Some( + Contract::Let { + x_let: ValueId::Name("KALLE".into()), + be: Some(Box::new(Value::ConstantValue(42))), + then: Some(Contract::If { + x_if: Some( + Observation::ChoseSomething( + Some( + ChoiceId { + choice_name: "nisses_choice".into(), + choice_owner: Some( + Party::Role { role_token: "NISSE".into() } + ) + } + ) + ) + ), + then: Some(Contract::Assert { assert: Some(Observation::ChoseSomething( + Some( + ChoiceId { + choice_name: "nisses_choice".into(), + choice_owner: Some( + Party::Role { role_token: "NISSE".into() } + ) + } + ) + )), then: Some(Contract::Close.boxed()) }.boxed()), + x_else: Some(Contract::Assert { assert: Some(Observation::ChoseSomething( + Some( + ChoiceId { + choice_name: "nisses_choice".into(), + choice_owner: Some( + Party::Role { role_token: "NISSE".into() } + ) + } + ) + )), then: Some(Contract::Close.boxed()) }.boxed()) + }.boxed()) + }.boxed()), + x_else: Some(Contract::If { + x_if: Some(Observation::True), + then: Some(Contract::Close.boxed()), + x_else: Some(Contract::Close.boxed()) + }.boxed()) + }; + + let wait_for_choice_contract = Contract::When { + when: vec![ + Some(Case { + case:Some(Action::Choice { + for_choice: Some(ChoiceId { + choice_name: "nisses_choice".into(), + choice_owner: Some(Party::Role { role_token: "NISSE".into() }) + }), + choose_between: vec![Some(Bound(1,5))] + }), + then: Some(PossiblyMerkleizedContract::Raw( + Contract::If { + x_if: Some( + Observation::ValueEQ { + value: Some(Box::new(Value::ChoiceValue(Some(ChoiceId { + choice_name: "nisses_choice".into(), + choice_owner: Some(Party::Role { role_token: "NISSE".into() }) + })))), + equal_to: Some(Box::new(Value::ConstantValue(4))) + } + ), + then: Some(iffy.boxed()), + x_else: Some(Contract::When { + when: vec![ + Some(Case { + case:Some(Action::Choice { + for_choice: Some(ChoiceId { + choice_name: "nisses_second_choice".into(), + choice_owner: Some(Party::Role { role_token: "NISSE".into() }) + }), + choose_between: vec![Some(Bound(1,5))] + }), + then: Some(PossiblyMerkleizedContract::Raw( + Contract::If { + x_if: Some( + Observation::ChoseSomething( + Some( + ChoiceId { + choice_name: "nisses_second_choice".into(), + choice_owner: Some( + Party::Role { role_token: "NISSE".into() } + ) + } + ) + ) + ), + then: Some(Contract::When { + when: vec![ + Some(Case { + case: Some(Action::Deposit { + into_account: Some(Party::Role { role_token: "NISSE".into() }), + party: Some(Party::Role { role_token: "NISSE".into() }), + of_token: Some(Token::ada()), + deposits: Some(Value::ConstantValue(42)) + }), + then: Some(PossiblyMerkleizedContract::Raw(Contract::Pay { + from_account: Some(Party::Role { role_token: "NISSE".into() }), + to: Some(Payee::Party(Some(Party::Role {role_token: "NISSE".into()}))), + token: Some(Token::ada()), + pay: Some(Value::ConstantValue(45)), + then: Some(Contract::Close.boxed()) + }.boxed())) + }) + ], + timeout: Some(Timeout::TimeConstant( + (ContractInstance::get_current_time()+(2000*60*60)).try_into().unwrap() + )), + timeout_continuation: Some(Contract::Close.boxed()) + }.boxed()) , + x_else: Some(Contract::Close.boxed()) + }.boxed() + )) + }) + ], + timeout: Some(Timeout::TimeConstant((ContractInstance::get_current_time()+(1000*60*60)).try_into().unwrap())), + timeout_continuation: Some(Contract::Close.boxed()) + }.boxed()) + }.boxed() + )) + }) + ], + + timeout: { + let t : Result = (ContractInstance::get_current_time()+(1000*60*60)).try_into(); + match t { + Ok(v) => Some(Timeout::TimeConstant(v)), + Err(_) => panic!("Failed to get current time."), + } + }, + timeout_continuation: Some(Contract::Close.boxed()) + }; + + + let machine = + ContractInstance::new( + &wait_for_choice_contract, + Some("AA".to_string()) + ).with_account_role("NISSE", &Token::ada(), 2) + .process().expect("should be able to process"); + + let acc = machine.0.state.accounts.get_key_value(&(Party::Role { role_token: "NISSE".into()}, Token::ada())).expect("should find nisses ada acc"); + assert!(acc.1 == &2); + + let machine_of_second_kind = + machine.0.apply_input_choice( + "nisses_choice".into(), + Party::Role { role_token: "NISSE".into() }, + 3 + ) + .expect("nisse should be able to apply nisses choice").process().expect("should be able to process").0.apply_input_choice( + "nisses_second_choice".into(), + Party::Role { role_token: "NISSE".into() }, + 3 + ).expect("nisse should be able to apply a second choice.").process().expect("should be able to process").0.apply_input_deposit( + Party::Role { role_token: "NISSE".into() }, + Token::ada(), + 42, + AccountId::Role { role_token: "NISSE".into() } + + ).expect("nisse should be able to apply input deposit").process(); + + + match machine_of_second_kind { + Ok(_) => panic!("It should not have been possible to make this payment because nisse does not have the funds for it"), + Err(e) => match &e { + crate::semantics::ProcessError::Generic(generic_error) => { + if !generic_error.contains("45") || !generic_error.contains("44") { + panic!("expected an error mentioning the relevant quantities") + } + }, + _ => panic!("Unexpected error: {e:?}"), + }, + } + + +} + + + + + +#[test] +fn negative_deposits_are_treated_as_zero() { + let test_account = Some(Party::role("test")); + let test_case = Case { + case: Some(Action::Deposit { + into_account: test_account.clone(), + party: test_account.clone(), + of_token: Some(Token::ada()), + deposits: Some(Value::ConstantValue(-42)) + }), + then: Some(PossiblyMerkleizedContract::Raw(Contract::Close.boxed())) }; + + let contract = Contract::When { + when: vec![ + Some(test_case.clone()) + ], + timeout: Some(Timeout::TimeConstant(9687697276039)), + timeout_continuation: Some(Box::new(Contract::Close)) + }; + + let dsl = crate::serialization::marlowe::serialize(contract.clone()); + let json = crate::serialization::json::serialize(contract.clone()).expect("json serialize"); + + let deserialized_from_json = Contract::from_json(&json).expect("deserialize from json"); + let deserialized_from_dsl = Contract::from_dsl(&dsl, vec![]).expect("from dsl"); + + + if let Contract::When { when, timeout:_, timeout_continuation:_ } = deserialized_from_json { + let action : Case = when.first().expect("no deposit action found (json)").clone().unwrap(); + if !action.eq(&test_case) { + panic!("test case does not equal original (json)") + } + } + else { + panic!("deserialized contract does not match original (json)") + } + + if let Contract::When { when, timeout:_, timeout_continuation:_ } = deserialized_from_dsl { + let action : Case = when.first().expect("no deposit action found (dsl)").clone().unwrap(); + if !action.eq(&test_case) { + panic!("test case does not equal original (dsl)") + } + } + else { + panic!("deserialized contract does not match original (dsl)") + } + + // println!("CONTRACT: {:?}",contract); + + let machine = // 100 initial lovelace + crate::semantics::ContractInstance::new(&contract,None) + .with_account(&test_account.clone().unwrap(), &Token::ada(),100); + + let (_instance,state) = machine.process().unwrap(); + + match state { + crate::semantics::MachineState::WaitingForInput { expected, timeout } => { + assert!(timeout == 9687697276039); + assert!(expected.len() == 1); + if let crate::semantics::InputType::Deposit { + who_is_expected_to_pay:_, + expected_asset_type:_, + expected_amount, + expected_target_account:_, + continuation :_ + } = expected.first().unwrap() { + + assert!(expected_amount == &(-42)); + + let (a,b) = + machine.apply_input_deposit(test_account.clone().unwrap(), Token::ada(), -42, test_account.clone().unwrap()) + .unwrap() + .process() + .unwrap(); + + // for x in &a.logs { + // println!("--> {x}"); + // } + + match b { + crate::semantics::MachineState::Closed => { + //println!("{:?}",a); + let v = a.state.accounts.get_key_value(&(test_account.unwrap(),Token::ada())).expect("test acc should be in state"); + assert!(v.1==&100); + + } + _ => panic!("Expected contract to now be closed.") + } + + } else { + panic!("expected deposit, found {expected:?}") + } + }, + _ => panic!("expected this contract to be waiting for input, but current state is: {state:?}") + } + +} \ No newline at end of file diff --git a/src/lib/tests/serialization.rs b/src/lib/tests/serialization.rs index 6dcf1af..ff134a2 100644 --- a/src/lib/tests/serialization.rs +++ b/src/lib/tests/serialization.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::{types::marlowe::*}; +use crate::{types::marlowe::*, semantics::ContractSemantics}; #[cfg(feature="js")] use crate::extras::js::*; @@ -446,4 +446,17 @@ fn json_serialize_of_transaction_assertion_failed() { let serialized = serde_json::to_string(&failure).expect("should be able to serialize"); assert!(serialized=="\"assertion_failed\"") -} \ No newline at end of file +} + + +#[test] +fn serialize_input_deposit_with_negative_numbers() { + let b = InputAction::Deposit { + into_account: Some(Party::Role { role_token: "".into() }), + input_from_party:Some(Party::Role { role_token: "".into() }), + of_tokens: Some(Token::ada()), + that_deposits: -414 + }; + _ = crate::serialization::json::serialize(b).expect("should be able to serialize with negative deposits"); +} + diff --git a/src/lib/types/marlowe.rs b/src/lib/types/marlowe.rs index dd888ca..158c2e9 100644 --- a/src/lib/types/marlowe.rs +++ b/src/lib/types/marlowe.rs @@ -239,7 +239,7 @@ pub enum InputAction { input_from_party: Option, // 1 #[ignore_option_container] of_tokens: Option, // 2 - that_deposits: u64 // 3 + that_deposits: i64 // 3 }, Choice { // 1 #[ignore_option_container]