From 84c1ec8efac95b2b30008ddc636c831f5cee24a0 Mon Sep 17 00:00:00 2001 From: Shunkichi Sato <49983831+s8sato@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:46:02 +0900 Subject: [PATCH] feat: improve multisig utility and usability BREAKING CHANGES: - (api-changes) `CanRegisterAnyTrigger` `CanUnregisterAnyTrigger` permission - (config-changes) `defaults/genesis.json` assumes `wasm_triggers[*].action.executable` is prebuilt under `wasm_samples/target/prebuilt/` Major commits: - feat: support multisig recursion - feat: introduce multisig quorum and weights - feat: add multisig subcommand to client CLI - feat: introduce multisig transaction time-to-live - feat: predefine multisig world-level trigger in genesis - feat: allow accounts in domain to register multisig accounts Signed-off-by: Shunkichi Sato <49983831+s8sato@users.noreply.github.com> --- Cargo.lock | 2 + crates/iroha/tests/integration/multisig.rs | 568 ++++++++++++++---- crates/iroha_cli/Cargo.toml | 1 + crates/iroha_cli/src/main.rs | 231 ++++++- crates/iroha_data_model/src/block.rs | 4 +- crates/iroha_executor/src/default.rs | 91 ++- crates/iroha_executor/src/permission.rs | 24 +- .../src/permission.rs | 10 + crates/iroha_genesis/Cargo.toml | 2 +- crates/iroha_genesis/src/lib.rs | 130 +++- crates/iroha_kagami/Cargo.toml | 1 + crates/iroha_kagami/src/genesis/generate.rs | 61 +- crates/iroha_schema_gen/src/lib.rs | 8 + crates/iroha_test_samples/src/lib.rs | 9 + defaults/genesis.json | 76 ++- docs/source/references/schema.json | 45 +- hooks/pre-commit.sample | 7 +- scripts/tests/instructions.json | 11 + scripts/tests/multisig.recursion.sh | 95 +++ scripts/tests/multisig.sh | 66 ++ scripts/tests/tick.json | 8 + wasm_samples/Cargo.toml | 5 +- .../src/lib.rs | 3 +- .../src/multisig.rs | 38 +- wasm_samples/multisig/src/lib.rs | 124 ---- .../Cargo.toml | 2 +- wasm_samples/multisig_accounts/build.rs | 27 + wasm_samples/multisig_accounts/src/lib.rs | 165 +++++ wasm_samples/multisig_domains/Cargo.toml | 26 + wasm_samples/multisig_domains/build.rs | 27 + wasm_samples/multisig_domains/src/lib.rs | 74 +++ wasm_samples/multisig_register/build.rs | 20 - wasm_samples/multisig_register/src/lib.rs | 97 --- .../Cargo.toml | 2 +- wasm_samples/multisig_transactions/src/lib.rs | 226 +++++++ 35 files changed, 1858 insertions(+), 428 deletions(-) create mode 100644 scripts/tests/instructions.json create mode 100644 scripts/tests/multisig.recursion.sh create mode 100644 scripts/tests/multisig.sh create mode 100644 scripts/tests/tick.json delete mode 100644 wasm_samples/multisig/src/lib.rs rename wasm_samples/{multisig_register => multisig_accounts}/Cargo.toml (95%) create mode 100644 wasm_samples/multisig_accounts/build.rs create mode 100644 wasm_samples/multisig_accounts/src/lib.rs create mode 100644 wasm_samples/multisig_domains/Cargo.toml create mode 100644 wasm_samples/multisig_domains/build.rs create mode 100644 wasm_samples/multisig_domains/src/lib.rs delete mode 100644 wasm_samples/multisig_register/build.rs delete mode 100644 wasm_samples/multisig_register/src/lib.rs rename wasm_samples/{multisig => multisig_transactions}/Cargo.toml (93%) create mode 100644 wasm_samples/multisig_transactions/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6a03b06ef07..eec375003d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2933,6 +2933,7 @@ dependencies = [ "color-eyre", "erased-serde", "error-stack", + "executor_custom_data_model", "eyre", "iroha", "iroha_config_base", @@ -3309,6 +3310,7 @@ dependencies = [ "iroha_primitives", "iroha_schema_gen", "iroha_test_samples", + "iroha_wasm_builder", "parity-scale-codec", "serde", "serde_json", diff --git a/crates/iroha/tests/integration/multisig.rs b/crates/iroha/tests/integration/multisig.rs index 1a531107dc2..e510dfd34d8 100644 --- a/crates/iroha/tests/integration/multisig.rs +++ b/crates/iroha/tests/integration/multisig.rs @@ -1,74 +1,215 @@ -use std::collections::BTreeMap; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; -use executor_custom_data_model::multisig::{MultisigArgs, MultisigRegisterArgs}; +use executor_custom_data_model::multisig::{MultisigAccountArgs, MultisigTransactionArgs}; use eyre::Result; use iroha::{ client, crypto::KeyPair, - data_model::{ - parameter::SmartContractParameter, - prelude::*, - query::{builder::SingleQueryError, trigger::FindTriggers}, - transaction::TransactionBuilder, - }, + data_model::{prelude::*, query::trigger::FindTriggers, Level}, }; -use iroha_data_model::asset::{AssetDefinition, AssetDefinitionId}; -use iroha_executor_data_model::permission::asset_definition::CanRegisterAssetDefinition; +use iroha_data_model::events::execute_trigger::ExecuteTriggerEventFilter; use iroha_test_network::*; -use iroha_test_samples::{gen_account_in, load_sample_wasm, ALICE_ID}; -use nonzero_ext::nonzero; +use iroha_test_samples::{ + gen_account_in, ALICE_ID, BOB_ID, BOB_KEYPAIR, CARPENTER_ID, CARPENTER_KEYPAIR, +}; + +#[test] +fn multisig() -> Result<()> { + multisig_base(None, 11_400) +} #[test] -#[expect(clippy::too_many_lines)] -fn mutlisig() -> Result<()> { - let (_rt, _peer, test_client) = ::new().with_port(11_400).start_with_runtime(); +fn multisig_expires() -> Result<()> { + multisig_base(Some(2), 11_405) +} + +/// # Scenario +/// +/// Proceeds from top left to bottom right. Starred operations are the responsibility of the user +/// +/// ``` +/// | world level | domain level | account level | transaction level | +/// |---------------------------|-----------------------------|---------------------------------|----------------------| +/// | given domains initializer | | | | +/// | | * creates domain | | | +/// | domains initializer | generates accounts registry | | | +/// | | | * creates signatories | | +/// | | * calls accounts registry | generates multisig account | | +/// | | accounts registry | generates transactions registry | | +/// | | | * calls transactions registry | proposes transaction | +/// | | | * calls transactions registry | approves transaction | +/// | | | transactions registry | executes transaction | +/// ``` +#[allow(clippy::cast_possible_truncation)] +fn multisig_base(transaction_ttl_secs: Option, port: u16) -> Result<()> { + const N_SIGNATORIES: usize = 5; + + let (_rt, _peer, test_client) = ::new().with_port(port).start_with_runtime(); wait_for_genesis_committed(&vec![test_client.clone()], 0); - test_client.submit_all_blocking([ - SetParameter::new(Parameter::SmartContract(SmartContractParameter::Fuel( - nonzero!(100_000_000_u64), - ))), - SetParameter::new(Parameter::Executor(SmartContractParameter::Fuel(nonzero!( - 100_000_000_u64 - )))), - ])?; - - let account_id = ALICE_ID.clone(); - let multisig_register_trigger_id = "multisig_register".parse::()?; - - let trigger = Trigger::new( - multisig_register_trigger_id.clone(), - Action::new( - load_sample_wasm("multisig_register"), - Repeats::Indefinitely, - account_id.clone(), - ExecuteTriggerEventFilter::new().for_trigger(multisig_register_trigger_id.clone()), - ), - ); - - // Register trigger which would allow multisig account creation in wonderland domain - // Access to call this trigger shouldn't be restricted - test_client.submit_blocking(Register::trigger(trigger))?; - - // Create multisig account id and destroy it's private key - let multisig_account_id = gen_account_in("wonderland").0; - - let multisig_trigger_id: TriggerId = format!( - "{}_{}_multisig_trigger", - multisig_account_id.signatory(), - multisig_account_id.domain() - ) - .parse()?; + let kingdom: DomainId = "kingdom".parse().unwrap(); + + // Assume any domain registered after genesis + let register_and_transfer_kingdom: [InstructionBox; 2] = [ + Register::domain(Domain::new(kingdom.clone())).into(), + Transfer::domain(ALICE_ID.clone(), kingdom.clone(), BOB_ID.clone()).into(), + ]; + test_client.submit_all_blocking(register_and_transfer_kingdom)?; - let signatories = core::iter::repeat_with(|| gen_account_in("wonderland")) - .take(5) + // One more block to generate a multisig accounts registry for the domain + test_client.submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string()))?; + + // Check that the multisig accounts registry has been generated + let multisig_accounts_registry_id = multisig_accounts_registry_of(&kingdom); + let _trigger = test_client + .query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(multisig_accounts_registry_id.clone())) + .execute_single() + .expect("multisig accounts registry should be generated after domain creation"); + + // Populate residents in the domain + let mut residents = core::iter::repeat_with(|| gen_account_in(&kingdom)) + .take(1 + N_SIGNATORIES) .collect::>(); + alt_client((BOB_ID.clone(), BOB_KEYPAIR.clone()), &test_client).submit_all_blocking( + residents + .keys() + .cloned() + .map(Account::new) + .map(Register::account), + )?; - let args = MultisigRegisterArgs { - account: Account::new(multisig_account_id.clone()), - signatories: signatories.keys().cloned().collect(), + // Create a multisig account ID and discard the corresponding private key + // FIXME #5022 Should not allow arbitrary IDs. Otherwise, after #4426 pre-registration account will be hijacked as a multisig account + let multisig_account_id = gen_account_in(&kingdom).0; + + let not_signatory = residents.pop_first().unwrap(); + let mut signatories = residents; + + let args = MultisigAccountArgs { + account: multisig_account_id.signatory().clone(), + signatories: signatories + .keys() + .enumerate() + .map(|(weight, id)| (id.clone(), 1 + weight as u8)) + .collect(), + // Can be met without the first signatory + quorum: (1..=N_SIGNATORIES).skip(1).sum::() as u16, + transaction_ttl_secs, }; + let register_multisig_account = + ExecuteTrigger::new(multisig_accounts_registry_id).with_args(&args); + // Any account in another domain cannot register a multisig account without special permission + let _err = alt_client( + (CARPENTER_ID.clone(), CARPENTER_KEYPAIR.clone()), + &test_client, + ) + .submit_blocking(register_multisig_account.clone()) + .expect_err("multisig account should not be registered by account of another domain"); + + // Any account in the same domain can register a multisig account without special permission + alt_client(not_signatory, &test_client) + .submit_blocking(register_multisig_account) + .expect("multisig account should be registered by account of the same domain"); + + // Check that the multisig account has been registered + test_client + .query(client::account::all()) + .filter_with(|account| account.id.eq(multisig_account_id.clone())) + .execute_single() + .expect("multisig account should be created by calling the multisig accounts registry"); + + // Check that the multisig transactions registry has been generated + let multisig_transactions_registry_id = multisig_transactions_registry_of(&multisig_account_id); + let _trigger = test_client + .query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(multisig_transactions_registry_id.clone())) + .execute_single() + .expect("multisig transactions registry should be generated along with the corresponding multisig account"); + + let key: Name = "key".parse().unwrap(); + let instructions = vec![SetKeyValue::account( + multisig_account_id.clone(), + key.clone(), + "value".parse::().unwrap(), + ) + .into()]; + let instructions_hash = HashOf::new(&instructions); + + let proposer = signatories.pop_last().unwrap(); + let approvers = signatories; + + let args = MultisigTransactionArgs::Propose(instructions); + let propose = ExecuteTrigger::new(multisig_transactions_registry_id.clone()).with_args(&args); + + alt_client(proposer, &test_client).submit_blocking(propose)?; + + // Check that the multisig transaction has not yet executed + let _err = test_client + .query_single(FindAccountMetadata::new( + multisig_account_id.clone(), + key.clone(), + )) + .expect_err("key-value shouldn't be set without enough approvals"); + + // Allow time to elapse to test the expiration + if let Some(s) = transaction_ttl_secs { + std::thread::sleep(Duration::from_secs(s.into())) + }; + test_client.submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string()))?; + + // All but the first signatory approve the multisig transaction + for approver in approvers.into_iter().skip(1) { + let args = MultisigTransactionArgs::Approve(instructions_hash); + let approve = + ExecuteTrigger::new(multisig_transactions_registry_id.clone()).with_args(&args); + + alt_client(approver, &test_client).submit_blocking(approve)?; + } + // Check that the multisig transaction has executed + let res = test_client.query_single(FindAccountMetadata::new( + multisig_account_id.clone(), + key.clone(), + )); + + if transaction_ttl_secs.is_some() { + let _err = res.expect_err("key-value shouldn't be set despite enough approvals"); + } else { + res.expect("key-value should be set with enough approvals"); + } + + Ok(()) +} + +/// # Scenario +/// +/// ``` +/// 012345 <--- root multisig account +/// / \ +/// / 12345 +/// / / \ +/// / 12 345 +/// / / \ / | \ +/// 0 1 2 3 4 5 <--- personal signatories +/// ``` +#[test] +#[allow(clippy::similar_names, clippy::too_many_lines)] +fn multisig_recursion() -> Result<()> { + let (_rt, _peer, test_client) = ::new().with_port(11_410).start_with_runtime(); + wait_for_genesis_committed(&vec![test_client.clone()], 0); + + let wonderland = "wonderland"; + + let ms_accounts_registry_id = multisig_accounts_registry_of(&wonderland.parse().unwrap()); + + // Populate signatories in the domain + let signatories = core::iter::repeat_with(|| gen_account_in(wonderland)) + .take(6) + .collect::>(); test_client.submit_all_blocking( signatories .keys() @@ -77,76 +218,275 @@ fn mutlisig() -> Result<()> { .map(Register::account), )?; - let call_trigger = ExecuteTrigger::new(multisig_register_trigger_id).with_args(&args); - test_client.submit_blocking(call_trigger)?; + // Recursively register multisig accounts from personal signatories to the root one + let mut sigs = signatories.clone(); + let sigs_345 = sigs.split_off(signatories.keys().nth(3).unwrap()); + let sigs_12 = sigs.split_off(signatories.keys().nth(1).unwrap()); + let mut sigs_0 = sigs; - // Check that multisig account exist - test_client - .submit_blocking(Grant::account_permission( - CanRegisterAssetDefinition { - domain: "wonderland".parse().unwrap(), - }, - multisig_account_id.clone(), + let register_ms_accounts = |sigs_list: Vec>| { + sigs_list + .into_iter() + .map(|sigs| { + let ms_account_id = gen_account_in(wonderland).0; + let args = MultisigAccountArgs { + account: ms_account_id.signatory().clone(), + signatories: sigs.iter().copied().map(|id| (id.clone(), 1)).collect(), + quorum: sigs.len().try_into().unwrap(), + transaction_ttl_secs: None, + }; + let register_ms_account = + ExecuteTrigger::new(ms_accounts_registry_id.clone()).with_args(&args); + + test_client + .submit_blocking(register_ms_account) + .expect("multisig account should be registered by account of the same domain"); + + ms_account_id + }) + .collect::>() + }; + + let sigs_list: Vec> = [&sigs_12, &sigs_345] + .into_iter() + .map(|sigs| sigs.keys().collect()) + .collect(); + let msas = register_ms_accounts(sigs_list); + let msa_12 = msas[0].clone(); + let msa_345 = msas[1].clone(); + + let sigs_list = vec![vec![&msa_12, &msa_345]]; + let msas = register_ms_accounts(sigs_list); + let msa_12345 = msas[0].clone(); + + let sig_0 = sigs_0.keys().next().unwrap().clone(); + let sigs_list = vec![vec![&sig_0, &msa_12345]]; + let msas = register_ms_accounts(sigs_list); + // The root multisig account with 6 personal signatories under its umbrella + let msa_012345 = msas[0].clone(); + + // One of personal signatories proposes a multisig transaction + let key: Name = "key".parse().unwrap(); + let instructions = vec![SetKeyValue::account( + msa_012345.clone(), + key.clone(), + "value".parse::().unwrap(), + ) + .into()]; + let instructions_hash = HashOf::new(&instructions); + + let proposer = sigs_0.pop_last().unwrap(); + let ms_transactions_registry_id = multisig_transactions_registry_of(&msa_012345); + let args = MultisigTransactionArgs::Propose(instructions); + let propose = ExecuteTrigger::new(ms_transactions_registry_id.clone()).with_args(&args); + + alt_client(proposer, &test_client).submit_blocking(propose)?; + + // Ticks as many times as the multisig recursion + (0..2).for_each(|_| { + test_client + .submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string())) + .unwrap(); + }); + + // Check that the entire authentication policy has been deployed down to one of the leaf registries + let approval_hash_to_12345 = { + let approval_hash_to_012345 = { + let registry_id = multisig_transactions_registry_of(&msa_012345); + let args = MultisigTransactionArgs::Approve(instructions_hash); + let approve: InstructionBox = ExecuteTrigger::new(registry_id.clone()) + .with_args(&args) + .into(); + + HashOf::new(&vec![approve]) + }; + let registry_id = multisig_transactions_registry_of(&msa_12345); + let args = MultisigTransactionArgs::Approve(approval_hash_to_012345); + let approve: InstructionBox = ExecuteTrigger::new(registry_id.clone()) + .with_args(&args) + .into(); + + HashOf::new(&vec![approve]) + }; + + let approvals_at_12: BTreeSet = test_client + .query_single(FindTriggerMetadata::new( + multisig_transactions_registry_of(&msa_12), + format!("proposals/{approval_hash_to_12345}/approvals") + .parse() + .unwrap(), )) - .expect("multisig account should be created after the call to register multisig trigger"); + .expect("leaf approvals should be initialized by the root proposal") + .try_into_any() + .unwrap(); - // Check that multisig trigger exist - let trigger = test_client + assert!(1 == approvals_at_12.len() && approvals_at_12.contains(&msa_12345)); + + // Check that the multisig transaction has not yet executed + let _err = test_client + .query_single(FindAccountMetadata::new(msa_012345.clone(), key.clone())) + .expect_err("key-value shouldn't be set without enough approvals"); + + // All the rest signatories approve the multisig transaction + let approve_for_each = |approvers: BTreeMap, + instructions_hash: HashOf>, + ms_account: &AccountId| { + for approver in approvers { + let registry_id = multisig_transactions_registry_of(ms_account); + let args = MultisigTransactionArgs::Approve(instructions_hash); + let approve = ExecuteTrigger::new(registry_id.clone()).with_args(&args); + + alt_client(approver, &test_client) + .submit_blocking(approve) + .expect("should successfully approve the proposal"); + } + }; + + approve_for_each(sigs_12, approval_hash_to_12345, &msa_12); + approve_for_each(sigs_345, approval_hash_to_12345, &msa_345); + + // Let the intermediate registry (12345) collect approvals and approve the original proposal + test_client.submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string()))?; + + // Let the root registry (012345) collect approvals and execute the original proposal + test_client.submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string()))?; + + // Check that the multisig transaction has executed + test_client + .query_single(FindAccountMetadata::new(msa_012345.clone(), key.clone())) + .expect("key-value should be set with enough approvals"); + + Ok(()) +} + +#[test] +fn persistent_domain_level_authority() -> Result<()> { + let (_rt, _peer, test_client) = ::new().with_port(11_415).start_with_runtime(); + wait_for_genesis_committed(&vec![test_client.clone()], 0); + + let wonderland: DomainId = "wonderland".parse().unwrap(); + + let ms_accounts_registry_id = multisig_accounts_registry_of(&wonderland); + + // Domain owner changes from Alice to Bob + test_client.submit_blocking(Transfer::domain( + ALICE_ID.clone(), + wonderland, + BOB_ID.clone(), + ))?; + + // One block gap to follow the domain owner change + test_client.submit_blocking(Log::new(Level::DEBUG, "Just ticking time".to_string()))?; + + // Bob is the authority of the wonderland multisig accounts registry + let ms_accounts_registry = test_client .query(FindTriggers::new()) - .filter_with(|trigger| trigger.id.eq(multisig_trigger_id.clone())) + .filter_with(|trigger| trigger.id.eq(ms_accounts_registry_id.clone())) .execute_single() - .expect("multisig trigger should be created after the call to register multisig trigger"); + .expect("multisig accounts registry should survive before and after a domain owner change"); - assert_eq!(trigger.id(), &multisig_trigger_id); + assert!(*ms_accounts_registry.action().authority() == BOB_ID.clone()); - let asset_definition_id = "asset_definition_controlled_by_multisig#wonderland" - .parse::() - .unwrap(); - let isi = - vec![ - Register::asset_definition(AssetDefinition::numeric(asset_definition_id.clone())) - .into(), - ]; - let isi_hash = HashOf::new(&isi); - - let mut signatories_iter = signatories.into_iter(); - - if let Some((signatory, key_pair)) = signatories_iter.next() { - let args = MultisigArgs::Instructions(isi); - let call_trigger = ExecuteTrigger::new(multisig_trigger_id.clone()).with_args(&args); - test_client.submit_transaction_blocking( - &TransactionBuilder::new(test_client.chain.clone(), signatory) - .with_instructions([call_trigger]) - .sign(key_pair.private_key()), - )?; + Ok(()) +} + +#[test] +fn reserved_names() { + let (_rt, _peer, test_client) = ::new().with_port(11_420).start_with_runtime(); + wait_for_genesis_committed(&vec![test_client.clone()], 0); + + let account_in_another_domain = gen_account_in("garden_of_live_flowers").0; + + { + let reserved_prefix = "multisig_accounts_"; + let register = { + let id: TriggerId = format!("{reserved_prefix}{}", account_in_another_domain.domain()) + .parse() + .unwrap(); + let action = Action::new( + Vec::::new(), + Repeats::Indefinitely, + ALICE_ID.clone(), + ExecuteTriggerEventFilter::new(), + ); + Register::trigger(Trigger::new(id, action)) + }; + let _err = test_client.submit_blocking(register).expect_err( + "trigger with this name shouldn't be registered by anyone other than multisig system", + ); } - // Check that asset definition isn't created yet - let err = test_client - .query(client::asset::all_definitions()) - .filter_with(|asset_definition| asset_definition.id.eq(asset_definition_id.clone())) - .execute_single() - .expect_err("asset definition shouldn't be created before enough votes are collected"); - assert!(matches!(err, SingleQueryError::ExpectedOneGotNone)); - - for (signatory, key_pair) in signatories_iter { - let args = MultisigArgs::Vote(isi_hash); - let call_trigger = ExecuteTrigger::new(multisig_trigger_id.clone()).with_args(&args); - test_client.submit_transaction_blocking( - &TransactionBuilder::new(test_client.chain.clone(), signatory) - .with_instructions([call_trigger]) - .sign(key_pair.private_key()), - )?; + { + let reserved_prefix = "multisig_transactions_"; + let register = { + let id: TriggerId = format!( + "{reserved_prefix}{}_{}", + account_in_another_domain.signatory(), + account_in_another_domain.domain() + ) + .parse() + .unwrap(); + let action = Action::new( + Vec::::new(), + Repeats::Indefinitely, + ALICE_ID.clone(), + ExecuteTriggerEventFilter::new(), + ); + Register::trigger(Trigger::new(id, action)) + }; + let _err = test_client.submit_blocking(register).expect_err( + "trigger with this name shouldn't be registered by anyone other than domain owner", + ); } - // Check that new asset definition was created and multisig account is owner - let asset_definition = test_client - .query(client::asset::all_definitions()) - .filter_with(|asset_definition| asset_definition.id.eq(asset_definition_id.clone())) - .execute_single() - .expect("asset definition should be created after enough votes are collected"); + { + let reserved_prefix = "multisig_signatory_"; + let register = { + let id: RoleId = format!( + "{reserved_prefix}{}_{}", + account_in_another_domain.signatory(), + account_in_another_domain.domain() + ) + .parse() + .unwrap(); + Register::role(Role::new(id, ALICE_ID.clone())) + }; + let _err = test_client.submit_blocking(register).expect_err( + "role with this name shouldn't be registered by anyone other than domain owner", + ); + } +} - assert_eq!(asset_definition.owned_by(), &multisig_account_id); +fn alt_client(signatory: (AccountId, KeyPair), base_client: &client::Client) -> client::Client { + client::Client { + account: signatory.0, + key_pair: signatory.1, + ..base_client.clone() + } +} - Ok(()) +fn multisig_accounts_registry_of(domain: &DomainId) -> TriggerId { + format!("multisig_accounts_{domain}",).parse().unwrap() +} + +fn multisig_transactions_registry_of(multisig_account: &AccountId) -> TriggerId { + format!( + "multisig_transactions_{}_{}", + multisig_account.signatory(), + multisig_account.domain() + ) + .parse() + .unwrap() +} + +#[allow(dead_code)] +fn debug_mst_registry(msa: &AccountId, client: &client::Client) { + let mst_registry = client + .query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(multisig_transactions_registry_of(msa))) + .execute_single() + .unwrap(); + let mst_metadata = mst_registry.action().metadata(); + + iroha_logger::error!(%msa, ?mst_metadata); } diff --git a/crates/iroha_cli/Cargo.toml b/crates/iroha_cli/Cargo.toml index 019db3bcd9b..f7d6cc1ebaa 100644 --- a/crates/iroha_cli/Cargo.toml +++ b/crates/iroha_cli/Cargo.toml @@ -30,6 +30,7 @@ path = "src/main.rs" iroha = { workspace = true } iroha_primitives = { workspace = true } iroha_config_base = { workspace = true } +executor_custom_data_model = { version = "=2.0.0-rc.1.0", path = "../../wasm_samples/executor_custom_data_model" } thiserror = { workspace = true } error-stack = { workspace = true, features = ["eyre"] } diff --git a/crates/iroha_cli/src/main.rs b/crates/iroha_cli/src/main.rs index 5c06ab8d6b5..f257873a19c 100644 --- a/crates/iroha_cli/src/main.rs +++ b/crates/iroha_cli/src/main.rs @@ -110,6 +110,9 @@ enum Subcommand { Blocks(blocks::Args), /// The subcommand related to multi-instructions as Json or Json5 Json(json::Args), + /// The subcommand related to multisig accounts and transactions + #[clap(subcommand)] + Multisig(multisig::Args), } /// Context inside which command is executed @@ -165,7 +168,7 @@ macro_rules! match_all { impl RunArgs for Subcommand { fn run(self, context: &mut dyn RunContext) -> Result<()> { use Subcommand::*; - match_all!((self, context), { Domain, Account, Asset, Peer, Events, Wasm, Blocks, Json }) + match_all!((self, context), { Domain, Account, Asset, Peer, Events, Wasm, Blocks, Json, Multisig }) } } @@ -1197,6 +1200,232 @@ mod json { } } } + +mod multisig { + use std::io::{BufReader, Read as _}; + + use executor_custom_data_model::multisig::{MultisigAccountArgs, MultisigTransactionArgs}; + + use super::*; + + /// Arguments for multisig subcommand + #[derive(Debug, clap::Subcommand)] + pub enum Args { + /// Register a multisig account + Register(Register), + /// Propose a multisig transaction + Propose(Propose), + /// Approve a multisig transaction + Approve(Approve), + /// List pending multisig transactions relevant to you + #[clap(subcommand)] + List(List), + } + + impl RunArgs for Args { + fn run(self, context: &mut dyn RunContext) -> Result<()> { + match_all!((self, context), { Args::Register, Args::Propose, Args::Approve, Args::List }) + } + } + /// Args to register a multisig account + #[derive(Debug, clap::Args)] + pub struct Register { + /// ID of the multisig account to be registered + #[arg(short, long)] + pub account: AccountId, + /// Signatories of the multisig account + #[arg(short, long, num_args(2..))] + pub signatories: Vec, + /// Relative weights of responsibility of respective signatories + #[arg(short, long, num_args(2..))] + pub weights: Vec, + /// Threshold of total weight at which the multisig is considered authenticated + #[arg(short, long)] + pub quorum: u16, + /// Time-to-live of multisig transactions made by the multisig account + #[arg(short, long)] + pub transaction_ttl_secs: Option, + } + + impl RunArgs for Register { + fn run(self, context: &mut dyn RunContext) -> Result<()> { + let Self { + account, + signatories, + weights, + quorum, + transaction_ttl_secs, + } = self; + if signatories.len() != weights.len() { + return Err(eyre!("signatories and weights must be equal in length")); + } + let registry_id: TriggerId = format!("multisig_accounts_{}", account.domain()) + .parse() + .unwrap(); + let account = account.signatory.clone(); + let signatories = signatories.into_iter().zip(weights).collect(); + let args = MultisigAccountArgs { + account, + signatories, + quorum, + transaction_ttl_secs, + }; + let register_multisig_account = + iroha::data_model::isi::ExecuteTrigger::new(registry_id).with_args(&args); + + submit([register_multisig_account], Metadata::default(), context) + .wrap_err("Failed to register multisig account") + } + } + + /// Args to propose a multisig transaction + #[derive(Debug, clap::Args)] + pub struct Propose { + /// Multisig authority of the multisig transaction + #[arg(short, long)] + pub account: AccountId, + } + + impl RunArgs for Propose { + fn run(self, context: &mut dyn RunContext) -> Result<()> { + let Self { account } = self; + let registry_id: TriggerId = format!( + "multisig_transactions_{}_{}", + account.signatory(), + account.domain() + ) + .parse() + .unwrap(); + let instructions: Vec = { + let mut reader = BufReader::new(stdin()); + let mut raw_content = Vec::new(); + reader.read_to_end(&mut raw_content)?; + let string_content = String::from_utf8(raw_content)?; + json5::from_str(&string_content)? + }; + let instructions_hash = HashOf::new(&instructions); + println!("{instructions_hash}"); + let args = MultisigTransactionArgs::Propose(instructions); + let propose_multisig_transaction = + iroha::data_model::isi::ExecuteTrigger::new(registry_id).with_args(&args); + + submit([propose_multisig_transaction], Metadata::default(), context) + .wrap_err("Failed to propose transaction") + } + } + + /// Args to approve a multisig transaction + #[derive(Debug, clap::Args)] + pub struct Approve { + /// Multisig authority of the multisig transaction + #[arg(short, long)] + pub account: AccountId, + /// Instructions to approve + #[arg(short, long)] + pub instructions_hash: iroha::crypto::Hash, + } + + impl RunArgs for Approve { + fn run(self, context: &mut dyn RunContext) -> Result<()> { + let Self { + account, + instructions_hash, + } = self; + let registry_id: TriggerId = format!( + "multisig_transactions_{}_{}", + account.signatory(), + account.domain() + ) + .parse() + .unwrap(); + let instructions_hash = HashOf::from_untyped_unchecked(instructions_hash); + let args = MultisigTransactionArgs::Approve(instructions_hash); + let approve_multisig_transaction = + iroha::data_model::isi::ExecuteTrigger::new(registry_id).with_args(&args); + + submit([approve_multisig_transaction], Metadata::default(), context) + .wrap_err("Failed to approve transaction") + } + } + + /// List pending multisig transactions relevant to you + #[derive(clap::Subcommand, Debug, Clone)] + pub enum List { + /// All pending multisig transactions relevant to you + All, + } + + impl RunArgs for List { + fn run(self, context: &mut dyn RunContext) -> Result<()> { + let client = context.client_from_config(); + let me = client.account.clone(); + + trace_back_from(me, &client, context) + } + } + + /// Recursively trace back to the root multisig account + fn trace_back_from( + account: AccountId, + client: &Client, + context: &mut dyn RunContext, + ) -> Result<()> { + let Ok(multisig_roles) = client + .query(FindRolesByAccountId::new(account)) + .filter_with(|role_id| role_id.name.starts_with("multisig_signatory_")) + .execute_all() + else { + return Ok(()); + }; + + for role_id in multisig_roles { + let super_account: AccountId = role_id + .name + .as_ref() + .strip_prefix("multisig_signatory_") + .unwrap() + .replace('_', "@") + .parse() + .unwrap(); + + trace_back_from(super_account, client, context)?; + + let transactions_registry_id: TriggerId = role_id + .name + .as_ref() + .replace("signatory", "transactions") + .parse() + .unwrap(); + + context.print_data(&transactions_registry_id)?; + + let transactions_registry = client + .query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(transactions_registry_id.clone())) + .execute_single()?; + let proposal_kvs = transactions_registry + .action() + .metadata() + .iter() + .filter(|kv| kv.0.as_ref().starts_with("proposals")); + + proposal_kvs.fold("", |acc, (k, v)| { + let mut path = k.as_ref().split('/'); + let hash = path.nth(1).unwrap(); + + if acc != hash { + context.print_data(&hash).unwrap(); + } + path.for_each(|seg| context.print_data(&seg).unwrap()); + context.print_data(&v).unwrap(); + + hash + }); + } + + Ok(()) + } +} #[cfg(test)] mod tests { use super::*; diff --git a/crates/iroha_data_model/src/block.rs b/crates/iroha_data_model/src/block.rs index 13b006888c8..e3fddb5da5c 100644 --- a/crates/iroha_data_model/src/block.rs +++ b/crates/iroha_data_model/src/block.rs @@ -459,9 +459,9 @@ mod candidate { ); }; - if transactions.len() > 4 { + if transactions.len() > 5 { return Err( - "Genesis block must have 1 to 4 transactions (executor upgrade, initial topology, parameters, other isi)", + "Genesis block must have 1 to 5 transactions (executor upgrade, initial topology, parameters, other instructions, trigger registrations)", ); } diff --git a/crates/iroha_executor/src/default.rs b/crates/iroha_executor/src/default.rs index 178c3b619e5..8a38a71d75f 100644 --- a/crates/iroha_executor/src/default.rs +++ b/crates/iroha_executor/src/default.rs @@ -370,7 +370,9 @@ pub mod domain { AnyPermission::CanRegisterTrigger(permission) => { permission.authority.domain() == domain_id } - AnyPermission::CanUnregisterTrigger(_) + AnyPermission::CanRegisterAnyTrigger(_) + | AnyPermission::CanUnregisterAnyTrigger(_) + | AnyPermission::CanUnregisterTrigger(_) | AnyPermission::CanExecuteTrigger(_) | AnyPermission::CanModifyTrigger(_) | AnyPermission::CanModifyTriggerMetadata(_) @@ -535,7 +537,9 @@ pub mod account { AnyPermission::CanBurnAsset(permission) => permission.asset.account() == account_id, AnyPermission::CanTransferAsset(permission) => permission.asset.account() == account_id, AnyPermission::CanRegisterTrigger(permission) => permission.authority == *account_id, - AnyPermission::CanUnregisterTrigger(_) + AnyPermission::CanRegisterAnyTrigger(_) + | AnyPermission::CanUnregisterAnyTrigger(_) + | AnyPermission::CanUnregisterTrigger(_) | AnyPermission::CanExecuteTrigger(_) | AnyPermission::CanModifyTrigger(_) | AnyPermission::CanModifyTriggerMetadata(_) @@ -773,6 +777,8 @@ pub mod asset_definition { AnyPermission::CanUnregisterAccount(_) | AnyPermission::CanRegisterAsset(_) | AnyPermission::CanModifyAccountMetadata(_) + | AnyPermission::CanRegisterAnyTrigger(_) + | AnyPermission::CanUnregisterAnyTrigger(_) | AnyPermission::CanRegisterTrigger(_) | AnyPermission::CanUnregisterTrigger(_) | AnyPermission::CanExecuteTrigger(_) @@ -1094,7 +1100,7 @@ pub mod parameter { } pub mod role { - use iroha_executor_data_model::permission::role::CanManageRoles; + use iroha_executor_data_model::permission::{role::CanManageRoles, trigger::CanExecuteTrigger}; use iroha_smart_contract::data_model::role::Role; use super::*; @@ -1166,6 +1172,37 @@ pub mod role { let role = isi.object(); let mut new_role = Role::new(role.id().clone(), role.grant_to().clone()); + // Exception for multisig roles + let mut is_multisig_role = false; + if let Some(tail) = role + .id() + .name() + .as_ref() + .strip_prefix("multisig_signatory_") + { + let Ok(account_id) = tail.replace('_', "@").parse::() else { + deny!(executor, "Violates multisig role format") + }; + if crate::permission::domain::is_domain_owner(account_id.domain(), authority) + .unwrap_or_default() + { + // Bind this role to this permission here, regardless of the given contains + let permission = CanExecuteTrigger { + trigger: format!( + "multisig_transactions_{}_{}", + account_id.signatory(), + account_id.domain() + ) + .parse() + .unwrap(), + }; + new_role = new_role.add_permission(permission); + is_multisig_role = true; + } else { + deny!(executor, "Can't register multisig role") + } + } + for permission in role.inner().permissions() { iroha_smart_contract::debug!(&format!("Checking `{permission:?}`")); @@ -1190,7 +1227,7 @@ pub mod role { } let isi = Register::role(new_role); - if is_genesis(executor) || CanManageRoles.is_owned_by(authority) { + if is_genesis(executor) || CanManageRoles.is_owned_by(authority) || is_multisig_role { let grant_role = Grant::account_role(role.id().clone(), role.grant_to().clone()); if let Err(err) = isi.execute() { @@ -1253,8 +1290,8 @@ pub mod role { pub mod trigger { use iroha_executor_data_model::permission::trigger::{ - CanExecuteTrigger, CanModifyTrigger, CanModifyTriggerMetadata, CanRegisterTrigger, - CanUnregisterTrigger, + CanExecuteTrigger, CanModifyTrigger, CanModifyTriggerMetadata, CanRegisterAnyTrigger, + CanRegisterTrigger, CanUnregisterAnyTrigger, CanUnregisterTrigger, }; use iroha_smart_contract::data_model::trigger::Trigger; @@ -1270,6 +1307,28 @@ pub mod trigger { ) { let trigger = isi.object(); + let trigger_name = trigger.id().name().as_ref(); + let naming_is_ok = if let Some(tail) = trigger_name.strip_prefix("multisig_accounts_") { + let multisig_system: AccountId = + // iroha_test_samples::MULTISIG_SYSTEM_ID + "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890@system" + .parse() + .unwrap(); + tail.parse::().is_ok() + && (is_genesis(executor) || *authority == multisig_system) + } else if let Some(tail) = trigger_name.strip_prefix("multisig_transactions_") { + tail.replace('_', "@") + .parse::() + .ok() + .and_then(|account_id| is_domain_owner(account_id.domain(), authority).ok()) + .unwrap_or_default() + } else { + true + }; + if !naming_is_ok { + deny!(executor, "Violates trigger naming restrictions"); + } + if is_genesis(executor) || { match is_domain_owner(trigger.action().authority().domain(), authority) { @@ -1283,6 +1342,7 @@ pub mod trigger { }; can_register_user_trigger_token.is_owned_by(authority) } + || CanRegisterAnyTrigger.is_owned_by(authority) { execute!(executor, isi) } @@ -1307,6 +1367,7 @@ pub mod trigger { }; can_unregister_user_trigger_token.is_owned_by(authority) } + || CanUnregisterAnyTrigger.is_owned_by(authority) { use iroha_smart_contract::ExecuteOnHost as _; @@ -1411,6 +1472,20 @@ pub mod trigger { if can_execute_trigger_token.is_owned_by(authority) { execute!(executor, isi); } + // Any account in domain can call multisig accounts registry to register any multisig account in the domain + // TODO Restrict access to the multisig signatories? + // TODO Impose proposal and approval process? + if trigger_id + .name() + .as_ref() + .strip_prefix("multisig_accounts_") + .and_then(|s| s.parse::().ok()) + .map_or(false, |registry_domain| { + *authority.domain() == registry_domain + }) + { + execute!(executor, isi); + } deny!(executor, "Can't execute trigger owned by another account"); } @@ -1482,7 +1557,9 @@ pub mod trigger { AnyPermission::CanModifyTriggerMetadata(permission) => { &permission.trigger == trigger_id } - AnyPermission::CanRegisterTrigger(_) + AnyPermission::CanRegisterAnyTrigger(_) + | AnyPermission::CanUnregisterAnyTrigger(_) + | AnyPermission::CanRegisterTrigger(_) | AnyPermission::CanManagePeers(_) | AnyPermission::CanRegisterDomain(_) | AnyPermission::CanUnregisterDomain(_) diff --git a/crates/iroha_executor/src/permission.rs b/crates/iroha_executor/src/permission.rs index ded0a1094b2..0f3b6fd31db 100644 --- a/crates/iroha_executor/src/permission.rs +++ b/crates/iroha_executor/src/permission.rs @@ -113,6 +113,8 @@ declare_permissions! { iroha_executor_data_model::permission::parameter::{CanSetParameters}, iroha_executor_data_model::permission::role::{CanManageRoles}, + iroha_executor_data_model::permission::trigger::{CanRegisterAnyTrigger}, + iroha_executor_data_model::permission::trigger::{CanUnregisterAnyTrigger}, iroha_executor_data_model::permission::trigger::{CanRegisterTrigger}, iroha_executor_data_model::permission::trigger::{CanUnregisterTrigger}, iroha_executor_data_model::permission::trigger::{CanModifyTrigger}, @@ -630,8 +632,8 @@ pub mod account { pub mod trigger { //! Module with pass conditions for trigger related tokens use iroha_executor_data_model::permission::trigger::{ - CanExecuteTrigger, CanModifyTrigger, CanModifyTriggerMetadata, CanRegisterTrigger, - CanUnregisterTrigger, + CanExecuteTrigger, CanModifyTrigger, CanModifyTriggerMetadata, CanRegisterAnyTrigger, + CanRegisterTrigger, CanUnregisterAnyTrigger, CanUnregisterTrigger, }; use super::*; @@ -691,6 +693,24 @@ pub mod trigger { } } + impl ValidateGrantRevoke for CanRegisterAnyTrigger { + fn validate_grant(&self, authority: &AccountId, block_height: u64) -> Result { + OnlyGenesis::from(self).validate(authority, block_height) + } + fn validate_revoke(&self, authority: &AccountId, block_height: u64) -> Result { + OnlyGenesis::from(self).validate(authority, block_height) + } + } + + impl ValidateGrantRevoke for CanUnregisterAnyTrigger { + fn validate_grant(&self, authority: &AccountId, block_height: u64) -> Result { + OnlyGenesis::from(self).validate(authority, block_height) + } + fn validate_revoke(&self, authority: &AccountId, block_height: u64) -> Result { + OnlyGenesis::from(self).validate(authority, block_height) + } + } + impl ValidateGrantRevoke for CanRegisterTrigger { fn validate_grant(&self, authority: &AccountId, block_height: u64) -> Result { super::account::Owner::from(self).validate(authority, block_height) diff --git a/crates/iroha_executor_data_model/src/permission.rs b/crates/iroha_executor_data_model/src/permission.rs index 3614807bdfd..f692db6df65 100644 --- a/crates/iroha_executor_data_model/src/permission.rs +++ b/crates/iroha_executor_data_model/src/permission.rs @@ -178,6 +178,16 @@ pub mod asset { pub mod trigger { use super::*; + permission! { + #[derive(Copy)] + pub struct CanRegisterAnyTrigger; + } + + permission! { + #[derive(Copy)] + pub struct CanUnregisterAnyTrigger; + } + permission! { pub struct CanRegisterTrigger { pub authority: AccountId, diff --git a/crates/iroha_genesis/Cargo.toml b/crates/iroha_genesis/Cargo.toml index b7085ee7ec4..954b1f5908e 100644 --- a/crates/iroha_genesis/Cargo.toml +++ b/crates/iroha_genesis/Cargo.toml @@ -14,6 +14,7 @@ workspace = true iroha_crypto = { workspace = true } iroha_schema = { workspace = true } iroha_data_model = { workspace = true, features = ["http"] } +iroha_test_samples = { workspace = true } derive_more = { workspace = true, features = ["deref"] } serde = { workspace = true, features = ["derive"] } @@ -24,4 +25,3 @@ parity-scale-codec = { workspace = true } [dev-dependencies] iroha_crypto = { workspace = true, features = ["rand"] } -iroha_test_samples = { workspace = true } diff --git a/crates/iroha_genesis/src/lib.rs b/crates/iroha_genesis/src/lib.rs index 1bd448168a5..98a5273046b 100644 --- a/crates/iroha_genesis/src/lib.rs +++ b/crates/iroha_genesis/src/lib.rs @@ -8,12 +8,14 @@ use std::{ sync::LazyLock, }; +use derive_more::Constructor; use eyre::{eyre, Result, WrapErr}; use iroha_crypto::{KeyPair, PublicKey}; use iroha_data_model::{ block::SignedBlock, isi::Instruction, parameter::Parameter, peer::Peer, prelude::*, }; use iroha_schema::IntoSchema; +use iroha_test_samples::load_sample_wasm; use parity_scale_codec::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -39,12 +41,13 @@ pub struct RawGenesisTransaction { chain: ChainId, /// Path to the [`Executor`] file executor: ExecutorPath, + /// Initial topology + topology: Vec, /// Parameters #[serde(skip_serializing_if = "Option::is_none")] parameters: Option, instructions: Vec, - /// Initial topology - topology: Vec, + wasm_triggers: Vec, } /// Path to [`Executor`] file @@ -108,31 +111,34 @@ impl RawGenesisTransaction { .parameters .map_or(Vec::new(), |parameters| parameters.parameters().collect()); let genesis = build_and_sign_genesis( - self.instructions, - executor, self.chain, - genesis_key_pair, + executor, self.topology, parameters, + self.instructions, + self.wasm_triggers, + genesis_key_pair, ); Ok(genesis) } } fn build_and_sign_genesis( - instructions: Vec, - executor: Executor, chain_id: ChainId, - genesis_key_pair: &KeyPair, + executor: Executor, topology: Vec, parameters: Vec, + instructions: Vec, + wasm_triggers: Vec, + genesis_key_pair: &KeyPair, ) -> GenesisBlock { let transactions = build_transactions( - instructions, + chain_id, executor, - parameters, topology, - chain_id, + parameters, + instructions, + wasm_triggers, genesis_key_pair, ); let block = SignedBlock::genesis(transactions, genesis_key_pair.private_key()); @@ -140,11 +146,12 @@ fn build_and_sign_genesis( } fn build_transactions( - instructions: Vec, + chain_id: ChainId, executor: Executor, - parameters: Vec, topology: Vec, - chain_id: ChainId, + parameters: Vec, + instructions: Vec, + wasm_triggers: Vec, genesis_key_pair: &KeyPair, ) -> Vec { let upgrade_isi = Upgrade::new(executor).into(); @@ -177,9 +184,23 @@ fn build_transactions( transactions.push(parameters); } if !instructions.is_empty() { - let transaction_instructions = build_transaction(instructions, chain_id, genesis_key_pair); + let transaction_instructions = + build_transaction(instructions, chain_id.clone(), genesis_key_pair); transactions.push(transaction_instructions); } + if !wasm_triggers.is_empty() { + let register_wasm_triggers = build_transaction( + wasm_triggers + .into_iter() + .map(Into::into) + .map(Register::trigger) + .map(InstructionBox::from) + .collect(), + chain_id, + genesis_key_pair, + ); + transactions.push(register_wasm_triggers); + } transactions } @@ -210,8 +231,9 @@ fn get_executor(file: &Path) -> Result { #[must_use] #[derive(Default)] pub struct GenesisBuilder { - instructions: Vec, parameters: Vec, + instructions: Vec, + wasm_triggers: Vec, } /// `Domain` subsection of the [`GenesisBuilder`]. Makes @@ -219,8 +241,9 @@ pub struct GenesisBuilder { /// provide a `DomainId`. #[must_use] pub struct GenesisDomainBuilder { - instructions: Vec, parameters: Vec, + instructions: Vec, + wasm_triggers: Vec, domain_id: DomainId, } @@ -242,8 +265,9 @@ impl GenesisBuilder { let new_domain = Domain::new(domain_id.clone()).with_metadata(metadata); self.instructions.push(Register::domain(new_domain).into()); GenesisDomainBuilder { - instructions: self.instructions, parameters: self.parameters, + instructions: self.instructions, + wasm_triggers: self.wasm_triggers, domain_id, } } @@ -260,6 +284,12 @@ impl GenesisBuilder { self } + /// Add wasm trigger to the end of registration list + pub fn append_wasm_trigger(mut self, wasm_trigger: GenesisWasmTrigger) -> Self { + self.wasm_triggers.push(wasm_trigger); + self + } + /// Finish building, sign, and produce a [`GenesisBlock`]. pub fn build_and_sign( self, @@ -269,12 +299,13 @@ impl GenesisBuilder { genesis_key_pair: &KeyPair, ) -> GenesisBlock { build_and_sign_genesis( - self.instructions, - executor_blob, chain_id, - genesis_key_pair, + executor_blob, topology, self.parameters, + self.instructions, + self.wasm_triggers, + genesis_key_pair, ) } @@ -286,11 +317,12 @@ impl GenesisBuilder { topology: Vec, ) -> RawGenesisTransaction { RawGenesisTransaction { - instructions: self.instructions, - executor: ExecutorPath(executor_file), - parameters: convert_parameters(self.parameters), chain: chain_id, + executor: ExecutorPath(executor_file), topology, + parameters: convert_parameters(self.parameters), + instructions: self.instructions, + wasm_triggers: self.wasm_triggers, } } } @@ -300,8 +332,9 @@ impl GenesisDomainBuilder { /// genesis block building. pub fn finish_domain(self) -> GenesisBuilder { GenesisBuilder { - instructions: self.instructions, parameters: self.parameters, + instructions: self.instructions, + wasm_triggers: self.wasm_triggers, } } @@ -356,6 +389,53 @@ impl Decode for ExecutorPath { } } +/// For readability of [`RawGenesisTransaction`], briefly denotes a trigger with wasm executable registered in genesis +#[derive(Debug, Clone, Serialize, Deserialize, IntoSchema, Encode, Decode, Constructor)] +pub struct GenesisWasmTrigger { + id: TriggerId, + action: GenesisWasmAction, +} + +/// For readability of [`GenesisWasmTrigger`], briefly denotes an action with wasm executable +#[derive(Debug, Clone, Serialize, Deserialize, IntoSchema, Encode, Decode)] +pub struct GenesisWasmAction { + /// Expected to be converted by [`iroha_test_samples::load_sample_wasm`] + executable: String, + repeats: Repeats, + authority: AccountId, + filter: EventFilterBox, +} + +impl GenesisWasmAction { + /// Construct [`GenesisWasmAction`] + pub fn new( + executable: impl ToString, + repeats: impl Into, + authority: AccountId, + filter: impl Into, + ) -> Self { + Self { + executable: executable.to_string(), + repeats: repeats.into(), + authority, + filter: filter.into(), + } + } +} + +impl From for Trigger { + fn from(src: GenesisWasmTrigger) -> Self { + Trigger::new(src.id, src.action.into()) + } +} + +impl From for Action { + fn from(src: GenesisWasmAction) -> Self { + let executable = load_sample_wasm(src.executable); + Action::new(executable, src.repeats, src.authority, src.filter) + } +} + #[cfg(test)] mod tests { use iroha_test_samples::{ALICE_KEYPAIR, BOB_KEYPAIR}; diff --git a/crates/iroha_kagami/Cargo.toml b/crates/iroha_kagami/Cargo.toml index 760f8146041..224bf162ef5 100644 --- a/crates/iroha_kagami/Cargo.toml +++ b/crates/iroha_kagami/Cargo.toml @@ -20,6 +20,7 @@ iroha_config.workspace = true iroha_schema_gen.workspace = true iroha_primitives.workspace = true iroha_genesis.workspace = true +iroha_wasm_builder.workspace = true iroha_test_samples.workspace = true clap = { workspace = true, features = ["derive"] } diff --git a/crates/iroha_kagami/src/genesis/generate.rs b/crates/iroha_kagami/src/genesis/generate.rs index 54edb6e4a7b..ac550b23353 100644 --- a/crates/iroha_kagami/src/genesis/generate.rs +++ b/crates/iroha_kagami/src/genesis/generate.rs @@ -7,10 +7,14 @@ use clap::{Parser, Subcommand}; use color_eyre::eyre::WrapErr as _; use iroha_data_model::{isi::InstructionBox, parameter::Parameters, prelude::*}; use iroha_executor_data_model::permission::{ - domain::CanRegisterDomain, parameter::CanSetParameters, + domain::CanRegisterDomain, + parameter::CanSetParameters, + trigger::{CanRegisterAnyTrigger, CanUnregisterAnyTrigger}, }; -use iroha_genesis::{GenesisBuilder, RawGenesisTransaction, GENESIS_DOMAIN_ID}; -use iroha_test_samples::{gen_account_in, ALICE_ID, BOB_ID, CARPENTER_ID}; +use iroha_genesis::{ + GenesisBuilder, GenesisWasmAction, GenesisWasmTrigger, RawGenesisTransaction, GENESIS_DOMAIN_ID, +}; +use iroha_test_samples::{gen_account_in, ALICE_ID, BOB_ID, CARPENTER_ID, MULTISIG_SYSTEM_ID}; use crate::{Outcome, RunArgs}; @@ -93,6 +97,9 @@ pub fn generate_default( meta.insert("key".parse()?, JsonString::new("value")); let mut builder = builder + .domain("system".parse()?) + .account(MULTISIG_SYSTEM_ID.signatory().clone()) + .finish_domain() .domain_with_metadata("wonderland".parse()?, meta.clone()) .account_with_metadata(ALICE_ID.signatory().clone(), meta.clone()) .account_with_metadata(BOB_ID.signatory().clone(), meta) @@ -128,31 +135,73 @@ pub fn generate_default( "wonderland".parse()?, ALICE_ID.clone(), ); + // Allow the initializer to register and replace a multisig accounts registry for any domain + let grant_multisig_system_to_register_any_trigger = + Grant::account_permission(CanRegisterAnyTrigger, MULTISIG_SYSTEM_ID.clone()); + let grant_multisig_system_to_unregister_any_trigger = + Grant::account_permission(CanUnregisterAnyTrigger, MULTISIG_SYSTEM_ID.clone()); let parameters = Parameters::default(); - let parameters = parameters.parameters(); - for parameter in parameters { + for parameter in parameters.parameters() { builder = builder.append_parameter(parameter); } - let instructions: [InstructionBox; 6] = [ + let instructions: [InstructionBox; 8] = [ mint.into(), mint_cabbage.into(), transfer_rose_ownership.into(), transfer_wonderland_ownership.into(), grant_permission_to_set_parameters.into(), grant_permission_to_register_domains.into(), + grant_multisig_system_to_register_any_trigger.into(), + grant_multisig_system_to_unregister_any_trigger.into(), ]; for isi in instructions { builder = builder.append_instruction(isi); } + // Register a trigger that reacts to domain creation (or owner changes) and registers (or replaces) a multisig accounts registry for the domain + let multisig_domains_initializer = GenesisWasmTrigger::new( + "multisig_domains".parse().unwrap(), + GenesisWasmAction::new( + "multisig_domains", + Repeats::Indefinitely, + MULTISIG_SYSTEM_ID.clone(), + DomainEventFilter::new() + .for_events(DomainEventSet::Created | DomainEventSet::OwnerChanged), + ), + ); + + // Manually register a multisig accounts registry for wonderland whose creation in genesis does not trigger the initializer + let multisig_accounts_registry_for_wonderland = { + let domain_owner = ALICE_ID.clone(); + let registry_id = "multisig_accounts_wonderland".parse::().unwrap(); + + GenesisWasmTrigger::new( + registry_id.clone(), + GenesisWasmAction::new( + "multisig_accounts", + Repeats::Indefinitely, + domain_owner, + ExecuteTriggerEventFilter::new().for_trigger(registry_id), + ), + ) + }; + + for wasm_trigger in [ + multisig_domains_initializer, + multisig_accounts_registry_for_wonderland, + ] { + builder = builder.append_wasm_trigger(wasm_trigger); + } + // Will be replaced with actual topology either in scripts/test_env.py or in iroha_swarm let topology = vec![]; let chain_id = ChainId::from("00000000-0000-0000-0000-000000000000"); let genesis = builder.build_raw(chain_id, executor_path, topology); + Ok(genesis) } diff --git a/crates/iroha_schema_gen/src/lib.rs b/crates/iroha_schema_gen/src/lib.rs index f310b40defb..e53403d1868 100644 --- a/crates/iroha_schema_gen/src/lib.rs +++ b/crates/iroha_schema_gen/src/lib.rs @@ -84,6 +84,8 @@ pub fn build_schemas() -> MetaMap { permission::asset::CanModifyAssetMetadata, permission::parameter::CanSetParameters, permission::role::CanManageRoles, + permission::trigger::CanRegisterAnyTrigger, + permission::trigger::CanUnregisterAnyTrigger, permission::trigger::CanRegisterTrigger, permission::trigger::CanExecuteTrigger, permission::trigger::CanUnregisterTrigger, @@ -623,6 +625,12 @@ mod tests { insert_into_test_map!(iroha_executor_data_model::permission::asset::CanModifyAssetMetadata); insert_into_test_map!(iroha_executor_data_model::permission::parameter::CanSetParameters); insert_into_test_map!(iroha_executor_data_model::permission::role::CanManageRoles); + insert_into_test_map!( + iroha_executor_data_model::permission::trigger::CanRegisterAnyTrigger + ); + insert_into_test_map!( + iroha_executor_data_model::permission::trigger::CanUnregisterAnyTrigger + ); insert_into_test_map!(iroha_executor_data_model::permission::trigger::CanRegisterTrigger); insert_into_test_map!(iroha_executor_data_model::permission::trigger::CanExecuteTrigger); insert_into_test_map!(iroha_executor_data_model::permission::trigger::CanUnregisterTrigger); diff --git a/crates/iroha_test_samples/src/lib.rs b/crates/iroha_test_samples/src/lib.rs index 3e2dd3ea1b4..86ad82eec36 100644 --- a/crates/iroha_test_samples/src/lib.rs +++ b/crates/iroha_test_samples/src/lib.rs @@ -59,6 +59,15 @@ declare_keypair!( "8026209AC47ABF59B356E0BD7DCBBBB4DEC080E302156A48CA907E47CB6AEA1D32719E" ); +// kagami crypto --seed "multisig" +// FIXME #5022 deny external access +declare_account_with_keypair!( + MULTISIG_SYSTEM_ID, + "system", + MULTISIG_SYSTEM_KEYPAIR, + "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890", + "8026201F9ED2B3DE45400FB344342ABABDD4FD9A9A9AFD816C30A1B7E49A16BDDF049E" +); declare_account_with_keypair!( ALICE_ID, "wonderland", diff --git a/defaults/genesis.json b/defaults/genesis.json index ad3aff497fe..5a1191e6daa 100644 --- a/defaults/genesis.json +++ b/defaults/genesis.json @@ -1,6 +1,7 @@ { "chain": "00000000-0000-0000-0000-000000000000", "executor": "./executor.wasm", + "topology": [], "parameters": { "sumeragi": { "block_time_ms": 2000, @@ -24,6 +25,23 @@ } }, "instructions": [ + { + "Register": { + "Domain": { + "id": "system", + "logo": null, + "metadata": {} + } + } + }, + { + "Register": { + "Account": { + "id": "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890@system", + "metadata": {} + } + } + }, { "Register": { "Domain": { @@ -149,7 +167,63 @@ "destination": "ed0120CE7FA46C9DCE7EA4B125E2E36BDB63EA33073E7590AC92816AE1E861B7048B03@wonderland" } } + }, + { + "Grant": { + "Permission": { + "object": { + "name": "CanRegisterAnyTrigger", + "payload": null + }, + "destination": "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890@system" + } + } + }, + { + "Grant": { + "Permission": { + "object": { + "name": "CanUnregisterAnyTrigger", + "payload": null + }, + "destination": "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890@system" + } + } } ], - "topology": [] + "wasm_triggers": [ + { + "id": "multisig_domains", + "action": { + "executable": "multisig_domains", + "repeats": "Indefinitely", + "authority": "ed01201F677E0900C2F633391310D12D155112DF65EDF9DC800D13797CEE5DAF47B890@system", + "filter": { + "Data": { + "Domain": { + "id_matcher": null, + "event_set": [ + "Created", + "OwnerChanged" + ] + } + } + } + } + }, + { + "id": "multisig_accounts_wonderland", + "action": { + "executable": "multisig_accounts", + "repeats": "Indefinitely", + "authority": "ed0120CE7FA46C9DCE7EA4B125E2E36BDB63EA33073E7590AC92816AE1E861B7048B03@wonderland", + "filter": { + "ExecuteTrigger": { + "trigger_id": "multisig_accounts_wonderland", + "authority": null + } + } + } + } + ] } diff --git a/docs/source/references/schema.json b/docs/source/references/schema.json index c010be2906f..6ef78681228 100644 --- a/docs/source/references/schema.json +++ b/docs/source/references/schema.json @@ -880,6 +880,7 @@ } ] }, + "CanRegisterAnyTrigger": null, "CanRegisterAsset": { "Struct": [ { @@ -937,6 +938,7 @@ } ] }, + "CanUnregisterAnyTrigger": null, "CanUnregisterAsset": { "Struct": [ { @@ -1994,6 +1996,38 @@ } ] }, + "GenesisWasmAction": { + "Struct": [ + { + "name": "executable", + "type": "String" + }, + { + "name": "repeats", + "type": "Repeats" + }, + { + "name": "authority", + "type": "AccountId" + }, + { + "name": "filter", + "type": "EventFilterBox" + } + ] + }, + "GenesisWasmTrigger": { + "Struct": [ + { + "name": "id", + "type": "TriggerId" + }, + { + "name": "action", + "type": "GenesisWasmAction" + } + ] + }, "Grant": { "Struct": [ { @@ -3408,6 +3442,10 @@ "name": "executor", "type": "String" }, + { + "name": "topology", + "type": "Vec" + }, { "name": "parameters", "type": "Option" @@ -3417,8 +3455,8 @@ "type": "Vec" }, { - "name": "topology", - "type": "Vec" + "name": "wasm_triggers", + "type": "Vec" } ] }, @@ -4953,6 +4991,9 @@ "Vec": { "Vec": "EventFilterBox" }, + "Vec": { + "Vec": "GenesisWasmTrigger" + }, "Vec": { "Vec": "InstructionBox" }, diff --git a/hooks/pre-commit.sample b/hooks/pre-commit.sample index 040fa2c3f79..9118715829e 100755 --- a/hooks/pre-commit.sample +++ b/hooks/pre-commit.sample @@ -3,14 +3,9 @@ set -e # format checks cargo fmt --all -- --check -cd ./wasm_samples/default_executor -cargo fmt --all -- --check -cd - cd ./wasm_samples cargo fmt --all -- --check cd - -# update the default executor -cargo run --release --bin iroha_wasm_builder -- build ./wasm_samples/default_executor --optimize --out-file ./defaults/executor.wasm # update the default genesis, assuming the transaction authority is `iroha_test_samples::SAMPLE_GENESIS_ACCOUNT_ID` cargo run --release --bin kagami -- genesis generate --executor-path-in-genesis ./executor.wasm --genesis-public-key ed01204164BF554923ECE1FD412D241036D863A6AE430476C898248B8237D77534CFC4 > ./defaults/genesis.json # update schema @@ -22,4 +17,4 @@ cargo run --release --bin iroha_swarm -- -p 4 -s Iroha -H -c ./defaults -i hyper # lints cargo lints clippy --workspace --benches --tests --examples --all-features # stage updates -git add ./defaults/executor.wasm ./defaults/genesis.json ./docs/source/references/schema.json ./defaults/docker-compose.single.yml ./defaults/docker-compose.local.yml ./defaults/docker-compose.yml +git add ./defaults/genesis.json ./docs/source/references/schema.json ./defaults/docker-compose.single.yml ./defaults/docker-compose.local.yml ./defaults/docker-compose.yml diff --git a/scripts/tests/instructions.json b/scripts/tests/instructions.json new file mode 100644 index 00000000000..5385f812f03 --- /dev/null +++ b/scripts/tests/instructions.json @@ -0,0 +1,11 @@ +[ + { + "SetKeyValue": { + "Account": { + "object": "ed01201F89368A4F322263C6F1AEF156759A83FB1AD7D93BAA66BFDFA973ACBADA462F@wonderland", + "key": "key", + "value": "congratulations" + } + } + } +] diff --git a/scripts/tests/multisig.recursion.sh b/scripts/tests/multisig.recursion.sh new file mode 100644 index 00000000000..b72c55b21b4 --- /dev/null +++ b/scripts/tests/multisig.recursion.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -ex + +# This diagram describes the state when the root multisig account is successfully authenticated in this test: +# https://github.com/hyperledger/iroha/pull/5027#discussion_r1741722664 + +cargo build +scripts/test_env.py setup +cd test + +gen_key_pair() { + ./kagami crypto -cs $1 +} + +DOMAIN="wonderland" + +gen_account_id() { + public_key=$(gen_key_pair $1 | head -n 1) + echo "$public_key@$DOMAIN" +} + +gen_signatories() { + for n in $(seq 1 $1); do + i=$((n-1)) + key_pair=($(gen_key_pair $i)) + public_key=${key_pair[0]} + private_key=${key_pair[1]} + # yield an account ID + echo "$public_key@$DOMAIN" + # generate a config + cat client.toml | sed '/domain/d' | sed '/public_key/d' | sed '/private_key/d' > client.$i.toml + echo "domain = \"$DOMAIN\"" >> client.$i.toml + echo "public_key = \"$public_key\"" >> client.$i.toml + echo "private_key = \"$private_key\"" >> client.$i.toml + done +} + +# populate signatories +N_SIGNATORIES=6 +SIGNATORIES=($(gen_signatories $N_SIGNATORIES)) +for signatory in ${SIGNATORIES[@]}; do + ./iroha account register --id $signatory +done +WEIGHTS=($(yes 1 | head -n $N_SIGNATORIES)) + +# register a multisig account, namely msa12 +MSA_12=$(gen_account_id "msa12") +SIGS_12=(${SIGNATORIES[@]:1:2}) +./iroha multisig register --account $MSA_12 --signatories ${SIGS_12[*]} --weights 1 1 --quorum 2 + +# register a multisig account, namely msa345 +MSA_345=$(gen_account_id "msa345") +SIGS_345=(${SIGNATORIES[@]:3:3}) +./iroha multisig register --account $MSA_345 --signatories ${SIGS_345[*]} --weights 1 1 1 --quorum 1 + +# register a multisig account, namely msa12345 +MSA_12345=$(gen_account_id "msa12345") +SIGS_12345=($MSA_12 $MSA_345) +./iroha multisig register --account $MSA_12345 --signatories ${SIGS_12345[*]} --weights 1 1 --quorum 1 + +# register a multisig account, namely msa012345 +MSA_012345=$(gen_account_id "msa") +SIGS_012345=(${SIGNATORIES[0]} $MSA_12345) +./iroha multisig register --account $MSA_012345 --signatories ${SIGS_012345[*]} --weights 1 1 --quorum 2 + +# propose a multisig transaction +INSTRUCTIONS="../scripts/tests/instructions.json" +propose_stdout=($(cat $INSTRUCTIONS | ./iroha --config "client.0.toml" multisig propose --account $MSA_012345)) +INSTRUCTIONS_HASH=${propose_stdout[0]} + +# ticks as many times as the multisig recursion +TICK="../scripts/tests/tick.json" +for i in $(seq 0 1); do + cat $TICK | ./iroha json transaction +done + +# check that one of the terminal signatories is involved +LIST=$(./iroha --config "client.5.toml" multisig list all) +echo "$LIST" | grep $INSTRUCTIONS_HASH + +# approve the multisig transaction +HASH_TO_12345=$(echo "$LIST" | grep -A1 "multisig_transactions" | sed 's/_/@/g' | grep -A1 $MSA_345 | tail -n 1 | tr -d '"') +./iroha --config "client.5.toml" multisig approve --account $MSA_345 --instructions-hash $HASH_TO_12345 + +# ticks as many times as the multisig recursion +for i in $(seq 0 1); do + cat $TICK | ./iroha json transaction +done + +# check that the multisig transaction is executed +./iroha account list all | grep "congratulations" +! ./iroha --config "client.5.toml" multisig list all | grep $INSTRUCTIONS_HASH + +cd - +scripts/test_env.py cleanup diff --git a/scripts/tests/multisig.sh b/scripts/tests/multisig.sh new file mode 100644 index 00000000000..483c9d96bb9 --- /dev/null +++ b/scripts/tests/multisig.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -ex + +cargo build +scripts/test_env.py setup +cd test + +gen_key_pair() { + ./kagami crypto -cs $1 +} + +DOMAIN="wonderland" + +gen_account_id() { + public_key=$(gen_key_pair $1 | head -n 1) + echo "$public_key@$DOMAIN" +} + +gen_signatories() { + for i in $(seq 1 $1); do + key_pair=($(gen_key_pair $i)) + public_key=${key_pair[0]} + private_key=${key_pair[1]} + # yield an account ID + echo "$public_key@$DOMAIN" + # generate a config + cat client.toml | sed '/domain/d' | sed '/public_key/d' | sed '/private_key/d' > client.$i.toml + echo "domain = \"$DOMAIN\"" >> client.$i.toml + echo "public_key = \"$public_key\"" >> client.$i.toml + echo "private_key = \"$private_key\"" >> client.$i.toml + done +} + +# populate signatories +N_SIGNATORIES=3 +SIGNATORIES=($(gen_signatories $N_SIGNATORIES)) +for signatory in ${SIGNATORIES[@]}; do + ./iroha account register --id $signatory +done + +# register a multisig account +MULTISIG_ACCOUNT=$(gen_account_id "msa") +WEIGHTS=($(yes 1 | head -n $N_SIGNATORIES)) # equal votes +QUORUM=$N_SIGNATORIES # unanimous +TRANSACTION_TTL_SECS=60 # 1 minute to expire +./iroha --config "client.1.toml" multisig register --account $MULTISIG_ACCOUNT --signatories ${SIGNATORIES[*]} --weights ${WEIGHTS[*]} --quorum $QUORUM --transaction-ttl-secs $TRANSACTION_TTL_SECS + +# propose a multisig transaction +INSTRUCTIONS="../scripts/tests/instructions.json" +propose_stdout=($(cat $INSTRUCTIONS | ./iroha --config "client.1.toml" multisig propose --account $MULTISIG_ACCOUNT)) +INSTRUCTIONS_HASH=${propose_stdout[0]} + +# check that 2nd signatory is involved +./iroha --config "client.2.toml" multisig list all | grep $INSTRUCTIONS_HASH + +# approve the multisig transaction +for i in $(seq 2 $N_SIGNATORIES); do + ./iroha --config "client.$i.toml" multisig approve --account $MULTISIG_ACCOUNT --instructions-hash $INSTRUCTIONS_HASH +done + +# check that the multisig transaction is executed +./iroha account list all | grep "congratulations" +! ./iroha --config "client.2.toml" multisig list all | grep $INSTRUCTIONS_HASH + +cd - +scripts/test_env.py cleanup diff --git a/scripts/tests/tick.json b/scripts/tests/tick.json new file mode 100644 index 00000000000..2d8e1cfac86 --- /dev/null +++ b/scripts/tests/tick.json @@ -0,0 +1,8 @@ +[ + { + "Log": { + "DEBUG": null, + "msg": "Just ticking time" + } + } +] diff --git a/wasm_samples/Cargo.toml b/wasm_samples/Cargo.toml index 6ebcb8fb9e5..0674332f07d 100644 --- a/wasm_samples/Cargo.toml +++ b/wasm_samples/Cargo.toml @@ -23,8 +23,9 @@ members = [ "executor_custom_data_model", "query_assets_and_save_cursor", "smart_contract_can_filter_queries", - "multisig_register", - "multisig", + "multisig_accounts", + "multisig_domains", + "multisig_transactions", ] [profile.dev] diff --git a/wasm_samples/create_nft_for_every_user_trigger/src/lib.rs b/wasm_samples/create_nft_for_every_user_trigger/src/lib.rs index 221ab7d4226..ad2ea65d234 100644 --- a/wasm_samples/create_nft_for_every_user_trigger/src/lib.rs +++ b/wasm_samples/create_nft_for_every_user_trigger/src/lib.rs @@ -21,7 +21,8 @@ fn main(_id: TriggerId, _owner: AccountId, _event: EventBox) { let accounts_cursor = query(FindAccounts).execute().dbg_unwrap(); - let bad_domain_ids: [DomainId; 2] = [ + let bad_domain_ids: [DomainId; 3] = [ + "system".parse().dbg_unwrap(), "genesis".parse().dbg_unwrap(), "garden_of_live_flowers".parse().dbg_unwrap(), ]; diff --git a/wasm_samples/executor_custom_data_model/src/multisig.rs b/wasm_samples/executor_custom_data_model/src/multisig.rs index 916fa06eaf6..59be1024f52 100644 --- a/wasm_samples/executor_custom_data_model/src/multisig.rs +++ b/wasm_samples/executor_custom_data_model/src/multisig.rs @@ -1,24 +1,32 @@ -//! Arguments to register and manage multisig account +//! Arguments attached on executing triggers for multisig accounts or transactions -use alloc::{collections::btree_set::BTreeSet, vec::Vec}; +use alloc::{collections::btree_map::BTreeMap, vec::Vec}; -use iroha_data_model::{account::NewAccount, prelude::*}; +use iroha_data_model::prelude::*; use serde::{Deserialize, Serialize}; -/// Arguments to multisig account register trigger +/// Arguments to register multisig account #[derive(Serialize, Deserialize)] -pub struct MultisigRegisterArgs { - // Account id of multisig account should be manually checked to not have corresponding private key (or having master key is ok) - pub account: NewAccount, - // List of accounts responsible for handling multisig account - pub signatories: BTreeSet, +pub struct MultisigAccountArgs { + /// Multisig account to be registered + /// WARNING: any corresponding private key allows the owner to manipulate this account as a ordinary personal account + pub account: PublicKey, + /// List of accounts and their relative weights of responsibility for the multisig + pub signatories: BTreeMap, + /// Threshold of total weight at which the multisig is considered authenticated + pub quorum: u16, + /// Multisig transaction time-to-live based on block timestamps. Defaults to [`DEFAULT_MULTISIG_TTL_SECS`] + pub transaction_ttl_secs: Option, } -/// Arguments to multisig account manager trigger +// Default multisig transaction time-to-live based on block timestamps +pub const DEFAULT_MULTISIG_TTL_SECS: u32 = 60 * 60; // 1 hour + +/// Arguments to propose or approve multisig transaction #[derive(Serialize, Deserialize)] -pub enum MultisigArgs { - /// Accept instructions proposal and initialize votes with the proposer's one - Instructions(Vec), - /// Accept vote for certain instructions - Vote(HashOf>), +pub enum MultisigTransactionArgs { + /// Propose instructions and initialize approvals with the proposer's one + Propose(Vec), + /// Approve certain instructions + Approve(HashOf>), } diff --git a/wasm_samples/multisig/src/lib.rs b/wasm_samples/multisig/src/lib.rs deleted file mode 100644 index b3c7cf95ad8..00000000000 --- a/wasm_samples/multisig/src/lib.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Trigger to control multisignature account - -#![no_std] - -extern crate alloc; -#[cfg(not(test))] -extern crate panic_halt; - -use alloc::{collections::btree_set::BTreeSet, format, vec::Vec}; - -use dlmalloc::GlobalDlmalloc; -use executor_custom_data_model::multisig::MultisigArgs; -use iroha_trigger::{debug::dbg_panic, prelude::*, smart_contract::query_single}; - -#[global_allocator] -static ALLOC: GlobalDlmalloc = GlobalDlmalloc; - -getrandom::register_custom_getrandom!(iroha_trigger::stub_getrandom); - -#[iroha_trigger::main] -fn main(id: TriggerId, _owner: AccountId, event: EventBox) { - let (args, signatory): (MultisigArgs, AccountId) = match event { - EventBox::ExecuteTrigger(event) => ( - event - .args() - .dbg_expect("trigger expect args") - .try_into_any() - .dbg_expect("failed to parse arguments"), - event.authority().clone(), - ), - _ => dbg_panic("only work as by call trigger"), - }; - - let instructions_hash = match &args { - MultisigArgs::Instructions(instructions) => HashOf::new(instructions), - MultisigArgs::Vote(instructions_hash) => *instructions_hash, - }; - let votes_metadata_key: Name = format!("{instructions_hash}/votes").parse().unwrap(); - let instructions_metadata_key: Name = - format!("{instructions_hash}/instructions").parse().unwrap(); - - let (votes, instructions) = match args { - MultisigArgs::Instructions(instructions) => { - query_single(FindTriggerMetadata::new( - id.clone(), - votes_metadata_key.clone(), - )) - .expect_err("instructions are already submitted"); - - let votes = BTreeSet::from([signatory.clone()]); - - SetKeyValue::trigger( - id.clone(), - instructions_metadata_key.clone(), - JsonString::new(&instructions), - ) - .execute() - .dbg_unwrap(); - - SetKeyValue::trigger( - id.clone(), - votes_metadata_key.clone(), - JsonString::new(&votes), - ) - .execute() - .dbg_unwrap(); - - (votes, instructions) - } - MultisigArgs::Vote(_instructions_hash) => { - let mut votes: BTreeSet = query_single(FindTriggerMetadata::new( - id.clone(), - votes_metadata_key.clone(), - )) - .dbg_expect("instructions should be submitted first") - .try_into_any() - .dbg_unwrap(); - - votes.insert(signatory.clone()); - - SetKeyValue::trigger( - id.clone(), - votes_metadata_key.clone(), - JsonString::new(&votes), - ) - .execute() - .dbg_unwrap(); - - let instructions: Vec = query_single(FindTriggerMetadata::new( - id.clone(), - instructions_metadata_key.clone(), - )) - .dbg_unwrap() - .try_into_any() - .dbg_unwrap(); - - (votes, instructions) - } - }; - - let signatories: BTreeSet = query_single(FindTriggerMetadata::new( - id.clone(), - "signatories".parse().unwrap(), - )) - .dbg_unwrap() - .try_into_any() - .dbg_unwrap(); - - // Require N of N signatures - if votes.is_superset(&signatories) { - // Cleanup votes and instructions - RemoveKeyValue::trigger(id.clone(), votes_metadata_key) - .execute() - .dbg_unwrap(); - RemoveKeyValue::trigger(id.clone(), instructions_metadata_key) - .execute() - .dbg_unwrap(); - - // Execute instructions proposal which collected enough votes - for isi in instructions { - isi.execute().dbg_unwrap(); - } - } -} diff --git a/wasm_samples/multisig_register/Cargo.toml b/wasm_samples/multisig_accounts/Cargo.toml similarity index 95% rename from wasm_samples/multisig_register/Cargo.toml rename to wasm_samples/multisig_accounts/Cargo.toml index 042a19d6e53..e7bef2b919a 100644 --- a/wasm_samples/multisig_register/Cargo.toml +++ b/wasm_samples/multisig_accounts/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "multisig_register" +name = "multisig_accounts" edition.workspace = true version.workspace = true diff --git a/wasm_samples/multisig_accounts/build.rs b/wasm_samples/multisig_accounts/build.rs new file mode 100644 index 00000000000..3939321ee95 --- /dev/null +++ b/wasm_samples/multisig_accounts/build.rs @@ -0,0 +1,27 @@ +//! Compile binary containing common logic to each multisig account for handling multisig transactions + +use std::{io::Write, path::Path}; + +const TRIGGER_DIR: &str = "../multisig_transactions"; + +fn main() -> Result<(), Box> { + for path in [ + TRIGGER_DIR, + "../../crates/iroha_data_model/src", + "../../crates/iroha_core/src/smartcontracts", + "../../crates/iroha_smart_contract/src", + ] { + println!("cargo::rerun-if-changed={path}"); + } + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let wasm = iroha_wasm_builder::Builder::new(TRIGGER_DIR) + .show_output() + .build()? + .optimize()? + .into_bytes()?; + + let mut file = std::fs::File::create(Path::new(&out_dir).join("multisig_transactions.wasm"))?; + file.write_all(&wasm)?; + Ok(()) +} diff --git a/wasm_samples/multisig_accounts/src/lib.rs b/wasm_samples/multisig_accounts/src/lib.rs new file mode 100644 index 00000000000..78e27c61816 --- /dev/null +++ b/wasm_samples/multisig_accounts/src/lib.rs @@ -0,0 +1,165 @@ +//! Trigger given per domain to control multi-signature accounts and corresponding triggers + +#![no_std] + +extern crate alloc; +#[cfg(not(test))] +extern crate panic_halt; + +use alloc::format; + +use dlmalloc::GlobalDlmalloc; +use executor_custom_data_model::multisig::{MultisigAccountArgs, DEFAULT_MULTISIG_TTL_SECS}; +use iroha_executor_data_model::permission::trigger::CanExecuteTrigger; +use iroha_trigger::{debug::dbg_panic, prelude::*, smart_contract::query}; + +#[global_allocator] +static ALLOC: GlobalDlmalloc = GlobalDlmalloc; + +getrandom::register_custom_getrandom!(iroha_trigger::stub_getrandom); + +// Binary containing common logic to each multisig account for handling multisig transactions +const WASM: &[u8] = core::include_bytes!(concat!( + core::env!("OUT_DIR"), + "/multisig_transactions.wasm" +)); + +#[iroha_trigger::main] +fn main(id: TriggerId, owner: AccountId, event: EventBox) { + let args: MultisigAccountArgs = match event { + EventBox::ExecuteTrigger(event) => event + .args() + .dbg_expect("args should be attached") + .try_into_any() + .dbg_expect("args should be for a multisig account"), + _ => dbg_panic("should be triggered by a call"), + }; + + let domain_id = id + .name() + .as_ref() + .strip_prefix("multisig_accounts_") + .and_then(|s| s.parse::().ok()) + .dbg_unwrap(); + let account_id = AccountId::new(domain_id, args.account); + + Register::account(Account::new(account_id.clone())) + .execute() + .dbg_expect("accounts registry should successfully register a multisig account"); + + let multisig_transactions_registry_id: TriggerId = format!( + "multisig_transactions_{}_{}", + account_id.signatory(), + account_id.domain() + ) + .parse() + .dbg_unwrap(); + + let executable = WasmSmartContract::from_compiled(WASM.to_vec()); + let multisig_transactions_registry = Trigger::new( + multisig_transactions_registry_id.clone(), + Action::new( + executable, + Repeats::Indefinitely, + account_id.clone(), + ExecuteTriggerEventFilter::new().for_trigger(multisig_transactions_registry_id.clone()), + ), + ); + + Register::trigger(multisig_transactions_registry) + .execute() + .dbg_expect("accounts registry should successfully register a transactions registry"); + + SetKeyValue::trigger( + multisig_transactions_registry_id.clone(), + "signatories".parse().unwrap(), + JsonString::new(&args.signatories), + ) + .execute() + .dbg_unwrap(); + + SetKeyValue::trigger( + multisig_transactions_registry_id.clone(), + "quorum".parse().unwrap(), + JsonString::new(&args.quorum), + ) + .execute() + .dbg_unwrap(); + + SetKeyValue::trigger( + multisig_transactions_registry_id.clone(), + "transaction_ttl_secs".parse().unwrap(), + JsonString::new( + &args + .transaction_ttl_secs + .unwrap_or(DEFAULT_MULTISIG_TTL_SECS), + ), + ) + .execute() + .dbg_unwrap(); + + let role_id: RoleId = format!( + "multisig_signatory_{}_{}", + account_id.signatory(), + account_id.domain() + ) + .parse() + .dbg_unwrap(); + + Register::role( + // Temporarily grant a multisig role to the trigger authority to delegate the role to the signatories + Role::new(role_id.clone(), owner.clone()), + ) + .execute() + .dbg_expect("accounts registry should successfully register a multisig role"); + + for signatory in args.signatories.keys().cloned() { + let is_multisig_again = { + let sub_role_id: RoleId = format!( + "multisig_signatory_{}_{}", + signatory.signatory(), + signatory.domain() + ) + .parse() + .dbg_unwrap(); + + query(FindRoleIds) + .filter_with(|role_id| role_id.eq(sub_role_id)) + .execute_single_opt() + .dbg_unwrap() + .is_some() + }; + + if is_multisig_again { + // Allow the transactions registry to write to the sub registry + let sub_registry_id: TriggerId = format!( + "multisig_transactions_{}_{}", + signatory.signatory(), + signatory.domain() + ) + .parse() + .dbg_unwrap(); + + Grant::account_permission( + CanExecuteTrigger { + trigger: sub_registry_id, + }, + account_id.clone(), + ) + .execute() + .dbg_expect( + "accounts registry should successfully grant permission to the multisig account", + ); + } + + Grant::account_role(role_id.clone(), signatory) + .execute() + .dbg_expect( + "accounts registry should successfully grant the multisig role to signatories", + ); + } + + Revoke::account_role(role_id.clone(), owner).execute().dbg_expect( + "accounts registry should successfully revoke the multisig role from the registry owner", + ); +} diff --git a/wasm_samples/multisig_domains/Cargo.toml b/wasm_samples/multisig_domains/Cargo.toml new file mode 100644 index 00000000000..aebe70f4d2a --- /dev/null +++ b/wasm_samples/multisig_domains/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "multisig_domains" + +edition.workspace = true +version.workspace = true +authors.workspace = true + +license.workspace = true + +[lib] +crate-type = ['cdylib'] + +[dependencies] +iroha_trigger.workspace = true +iroha_executor_data_model.workspace = true +executor_custom_data_model.workspace = true + +panic-halt.workspace = true +dlmalloc.workspace = true +getrandom.workspace = true + +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, default-features = false } + +[build-dependencies] +iroha_wasm_builder = { version = "=2.0.0-rc.1.0", path = "../../crates/iroha_wasm_builder" } diff --git a/wasm_samples/multisig_domains/build.rs b/wasm_samples/multisig_domains/build.rs new file mode 100644 index 00000000000..8538b84683c --- /dev/null +++ b/wasm_samples/multisig_domains/build.rs @@ -0,0 +1,27 @@ +//! Compile binary containing common logic to each domain for handling multisig accounts + +use std::{io::Write, path::Path}; + +const TRIGGER_DIR: &str = "../multisig_accounts"; + +fn main() -> Result<(), Box> { + for path in [ + TRIGGER_DIR, + "../../crates/iroha_data_model/src", + "../../crates/iroha_core/src/smartcontracts", + "../../crates/iroha_smart_contract/src", + ] { + println!("cargo::rerun-if-changed={path}"); + } + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let wasm = iroha_wasm_builder::Builder::new(TRIGGER_DIR) + .show_output() + .build()? + .optimize()? + .into_bytes()?; + + let mut file = std::fs::File::create(Path::new(&out_dir).join("multisig_accounts.wasm"))?; + file.write_all(&wasm)?; + Ok(()) +} diff --git a/wasm_samples/multisig_domains/src/lib.rs b/wasm_samples/multisig_domains/src/lib.rs new file mode 100644 index 00000000000..862e95ef25b --- /dev/null +++ b/wasm_samples/multisig_domains/src/lib.rs @@ -0,0 +1,74 @@ +//! Trigger of world-level authority to enable multisig functionality for domains + +#![no_std] + +extern crate alloc; +#[cfg(not(test))] +extern crate panic_halt; + +use alloc::format; + +use dlmalloc::GlobalDlmalloc; +use iroha_trigger::{debug::dbg_panic, prelude::*, smart_contract::query}; + +#[global_allocator] +static ALLOC: GlobalDlmalloc = GlobalDlmalloc; + +getrandom::register_custom_getrandom!(iroha_trigger::stub_getrandom); + +// Binary containing common logic to each domain for handling multisig accounts +const WASM: &[u8] = core::include_bytes!(concat!(core::env!("OUT_DIR"), "/multisig_accounts.wasm")); + +#[iroha_trigger::main] +fn main(_id: TriggerId, _owner: AccountId, event: EventBox) { + let (domain_id, domain_owner, owner_changed) = match event { + EventBox::Data(DataEvent::Domain(DomainEvent::Created(domain))) => { + (domain.id().clone(), domain.owned_by().clone(), false) + } + EventBox::Data(DataEvent::Domain(DomainEvent::OwnerChanged(owner_changed))) => ( + owner_changed.domain().clone(), + owner_changed.new_owner().clone(), + true, + ), + _ => dbg_panic("should be triggered only by domain created events"), + }; + + let accounts_registry_id: TriggerId = format!("multisig_accounts_{}", domain_id) + .parse() + .dbg_unwrap(); + + let accounts_registry = if owner_changed { + let existing = query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(accounts_registry_id.clone())) + .execute_single() + .dbg_expect("accounts registry should be existing"); + + Unregister::trigger(existing.id().clone()) + .execute() + .dbg_expect("accounts registry should be successfully unregistered"); + + Trigger::new( + existing.id().clone(), + Action::new( + existing.action().executable().clone(), + existing.action().repeats().clone(), + domain_owner, + existing.action().filter().clone(), + ), + ) + } else { + Trigger::new( + accounts_registry_id.clone(), + Action::new( + WasmSmartContract::from_compiled(WASM.to_vec()), + Repeats::Indefinitely, + domain_owner, + ExecuteTriggerEventFilter::new().for_trigger(accounts_registry_id.clone()), + ), + ) + }; + + Register::trigger(accounts_registry) + .execute() + .dbg_expect("accounts registry should be successfully registered"); +} diff --git a/wasm_samples/multisig_register/build.rs b/wasm_samples/multisig_register/build.rs deleted file mode 100644 index 2195810702a..00000000000 --- a/wasm_samples/multisig_register/build.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Compile trigger to handle multisig actions - -use std::{io::Write, path::Path}; - -const TRIGGER_DIR: &str = "../multisig"; - -fn main() -> Result<(), Box> { - println!("cargo::rerun-if-changed={}", TRIGGER_DIR); - - let out_dir = std::env::var("OUT_DIR").unwrap(); - let wasm = iroha_wasm_builder::Builder::new(TRIGGER_DIR) - .show_output() - .build()? - .optimize()? - .into_bytes()?; - - let mut file = std::fs::File::create(Path::new(&out_dir).join("multisig.wasm"))?; - file.write_all(&wasm)?; - Ok(()) -} diff --git a/wasm_samples/multisig_register/src/lib.rs b/wasm_samples/multisig_register/src/lib.rs deleted file mode 100644 index 97782e419db..00000000000 --- a/wasm_samples/multisig_register/src/lib.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Trigger which register multisignature account and create trigger to control it - -#![no_std] - -extern crate alloc; -#[cfg(not(test))] -extern crate panic_halt; - -use alloc::format; - -use dlmalloc::GlobalDlmalloc; -use executor_custom_data_model::multisig::MultisigRegisterArgs; -use iroha_executor_data_model::permission::trigger::CanExecuteTrigger; -use iroha_trigger::{debug::dbg_panic, prelude::*}; - -#[global_allocator] -static ALLOC: GlobalDlmalloc = GlobalDlmalloc; - -getrandom::register_custom_getrandom!(iroha_trigger::stub_getrandom); - -// Trigger wasm code for handling multisig logic -const WASM: &[u8] = core::include_bytes!(concat!(core::env!("OUT_DIR"), "/multisig.wasm")); - -#[iroha_trigger::main] -fn main(_id: TriggerId, owner: AccountId, event: EventBox) { - let args: MultisigRegisterArgs = match event { - EventBox::ExecuteTrigger(event) => event - .args() - .dbg_expect("trigger expect args") - .try_into_any() - .dbg_expect("failed to parse args"), - _ => dbg_panic("Only work as by call trigger"), - }; - - let account_id = args.account.id().clone(); - - Register::account(args.account.clone()) - .execute() - .dbg_expect("failed to register multisig account"); - - let trigger_id: TriggerId = format!( - "{}_{}_multisig_trigger", - account_id.signatory(), - account_id.domain() - ) - .parse() - .dbg_expect("failed to parse trigger id"); - - let payload = WasmSmartContract::from_compiled(WASM.to_vec()); - let trigger = Trigger::new( - trigger_id.clone(), - Action::new( - payload, - Repeats::Indefinitely, - account_id.clone(), - ExecuteTriggerEventFilter::new().for_trigger(trigger_id.clone()), - ), - ); - - Register::trigger(trigger) - .execute() - .dbg_expect("failed to register multisig trigger"); - - let role_id: RoleId = format!( - "{}_{}_signatories", - account_id.signatory(), - account_id.domain() - ) - .parse() - .dbg_expect("failed to parse role"); - - let can_execute_multisig_trigger = CanExecuteTrigger { - trigger: trigger_id.clone(), - }; - - Register::role( - // FIX: args.account.id() should be used but I can't - // execute an instruction from a different account - Role::new(role_id.clone(), owner).add_permission(can_execute_multisig_trigger), - ) - .execute() - .dbg_expect("failed to register multisig role"); - - SetKeyValue::trigger( - trigger_id, - "signatories".parse().unwrap(), - JsonString::new(&args.signatories), - ) - .execute() - .dbg_unwrap(); - - for signatory in args.signatories { - Grant::account_role(role_id.clone(), signatory) - .execute() - .dbg_expect("failed to grant multisig role to account"); - } -} diff --git a/wasm_samples/multisig/Cargo.toml b/wasm_samples/multisig_transactions/Cargo.toml similarity index 93% rename from wasm_samples/multisig/Cargo.toml rename to wasm_samples/multisig_transactions/Cargo.toml index 4c53ff63ae7..6c5ca45cb6e 100644 --- a/wasm_samples/multisig/Cargo.toml +++ b/wasm_samples/multisig_transactions/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "multisig" +name = "multisig_transactions" edition.workspace = true version.workspace = true diff --git a/wasm_samples/multisig_transactions/src/lib.rs b/wasm_samples/multisig_transactions/src/lib.rs new file mode 100644 index 00000000000..6983958aae1 --- /dev/null +++ b/wasm_samples/multisig_transactions/src/lib.rs @@ -0,0 +1,226 @@ +//! Trigger given per multi-signature account to control multi-signature transactions + +#![no_std] + +extern crate alloc; +#[cfg(not(test))] +extern crate panic_halt; + +use alloc::{ + collections::{btree_map::BTreeMap, btree_set::BTreeSet}, + format, + vec::Vec, +}; + +use dlmalloc::GlobalDlmalloc; +use executor_custom_data_model::multisig::MultisigTransactionArgs; +use iroha_trigger::{ + debug::dbg_panic, + prelude::*, + smart_contract::{query, query_single}, +}; + +#[global_allocator] +static ALLOC: GlobalDlmalloc = GlobalDlmalloc; + +getrandom::register_custom_getrandom!(iroha_trigger::stub_getrandom); + +#[iroha_trigger::main] +fn main(id: TriggerId, _owner: AccountId, event: EventBox) { + let (args, signatory): (MultisigTransactionArgs, AccountId) = match event { + EventBox::ExecuteTrigger(event) => ( + event + .args() + .dbg_expect("args should be attached") + .try_into_any() + .dbg_expect("args should be for a multisig transaction"), + event.authority().clone(), + ), + _ => dbg_panic("should be triggered by a call"), + }; + + let instructions_hash = match &args { + MultisigTransactionArgs::Propose(instructions) => HashOf::new(instructions), + MultisigTransactionArgs::Approve(instructions_hash) => *instructions_hash, + }; + let instructions_metadata_key: Name = format!("proposals/{instructions_hash}/instructions") + .parse() + .unwrap(); + let proposed_at_ms_metadata_key: Name = format!("proposals/{instructions_hash}/proposed_at_ms") + .parse() + .unwrap(); + let approvals_metadata_key: Name = format!("proposals/{instructions_hash}/approvals") + .parse() + .unwrap(); + + let signatories: BTreeMap = query_single(FindTriggerMetadata::new( + id.clone(), + "signatories".parse().unwrap(), + )) + .dbg_unwrap() + .try_into_any() + .dbg_unwrap(); + + // Recursively deploy multisig authentication down to the personal leaf signatories + for account_id in signatories.keys() { + let sub_transactions_registry_id: TriggerId = format!( + "multisig_transactions_{}_{}", + account_id.signatory(), + account_id.domain() + ) + .parse() + .unwrap(); + + if let Ok(_sub_registry) = query(FindTriggers::new()) + .filter_with(|trigger| trigger.id.eq(sub_transactions_registry_id.clone())) + .execute_single() + { + let propose_to_approve_me: InstructionBox = { + let approve_me: InstructionBox = { + let args = MultisigTransactionArgs::Approve(instructions_hash); + ExecuteTrigger::new(id.clone()).with_args(&args).into() + }; + let args = MultisigTransactionArgs::Propose([approve_me].to_vec()); + + ExecuteTrigger::new(sub_transactions_registry_id.clone()) + .with_args(&args) + .into() + }; + propose_to_approve_me + .execute() + .dbg_expect("should successfully write to sub registry"); + } + } + + let mut block_headers = query(FindBlockHeaders).execute().dbg_unwrap(); + let now_ms: u64 = block_headers + .next() + .dbg_unwrap() + .dbg_unwrap() + .creation_time() + .as_millis() + .try_into() + .dbg_unwrap(); + + let (approvals, instructions) = match args { + MultisigTransactionArgs::Propose(instructions) => { + query_single(FindTriggerMetadata::new( + id.clone(), + approvals_metadata_key.clone(), + )) + .expect_err("instructions shouldn't already be proposed"); + + let approvals = BTreeSet::from([signatory.clone()]); + + SetKeyValue::trigger( + id.clone(), + instructions_metadata_key.clone(), + JsonString::new(&instructions), + ) + .execute() + .dbg_unwrap(); + + SetKeyValue::trigger( + id.clone(), + proposed_at_ms_metadata_key.clone(), + JsonString::new(&now_ms), + ) + .execute() + .dbg_unwrap(); + + SetKeyValue::trigger( + id.clone(), + approvals_metadata_key.clone(), + JsonString::new(&approvals), + ) + .execute() + .dbg_unwrap(); + + (approvals, instructions) + } + MultisigTransactionArgs::Approve(_instructions_hash) => { + let mut approvals: BTreeSet = query_single(FindTriggerMetadata::new( + id.clone(), + approvals_metadata_key.clone(), + )) + .dbg_expect("instructions should be proposed first") + .try_into_any() + .dbg_unwrap(); + + approvals.insert(signatory.clone()); + + SetKeyValue::trigger( + id.clone(), + approvals_metadata_key.clone(), + JsonString::new(&approvals), + ) + .execute() + .dbg_unwrap(); + + let instructions: Vec = query_single(FindTriggerMetadata::new( + id.clone(), + instructions_metadata_key.clone(), + )) + .dbg_unwrap() + .try_into_any() + .dbg_unwrap(); + + (approvals, instructions) + } + }; + + let quorum: u16 = query_single(FindTriggerMetadata::new( + id.clone(), + "quorum".parse().unwrap(), + )) + .dbg_unwrap() + .try_into_any() + .dbg_unwrap(); + + let is_authenticated = quorum + <= signatories + .into_iter() + .filter(|(id, _)| approvals.contains(&id)) + .map(|(_, weight)| weight as u16) + .sum(); + + let is_expired = { + let proposed_at_ms: u64 = query_single(FindTriggerMetadata::new( + id.clone(), + proposed_at_ms_metadata_key.clone(), + )) + .dbg_unwrap() + .try_into_any() + .dbg_unwrap(); + + let transaction_ttl_secs: u32 = query_single(FindTriggerMetadata::new( + id.clone(), + "transaction_ttl_secs".parse().unwrap(), + )) + .dbg_unwrap() + .try_into_any() + .dbg_unwrap(); + + proposed_at_ms + transaction_ttl_secs as u64 * 1_000 < now_ms + }; + + if is_authenticated || is_expired { + // Cleanup approvals and instructions + RemoveKeyValue::trigger(id.clone(), approvals_metadata_key) + .execute() + .dbg_unwrap(); + RemoveKeyValue::trigger(id.clone(), proposed_at_ms_metadata_key) + .execute() + .dbg_unwrap(); + RemoveKeyValue::trigger(id.clone(), instructions_metadata_key) + .execute() + .dbg_unwrap(); + + if !is_expired { + // Execute instructions proposal which collected enough approvals + for isi in instructions { + isi.execute().dbg_unwrap(); + } + } + } +}