diff --git a/bin/drk/src/cli_util.rs b/bin/drk/src/cli_util.rs index 68539acd1..84a38ba22 100644 --- a/bin/drk/src/cli_util.rs +++ b/bin/drk/src/cli_util.rs @@ -305,7 +305,7 @@ pub fn generate_completions(shell: &str) -> Result<()> { .about("Create a transfer proposal for a DAO") .args(&vec![ name.clone(), - duration, + duration.clone(), amount, token, recipient, @@ -313,6 +313,10 @@ pub fn generate_completions(shell: &str) -> Result<()> { user_data.clone(), ]); + let propose_generic = SubCommand::with_name("propose-generic") + .about("Create a generic proposal for a DAO") + .args(&vec![name.clone(), duration, user_data.clone()]); + let proposals = SubCommand::with_name("proposals").about("List DAO proposals").args(&vec![name]); @@ -355,6 +359,7 @@ pub fn generate_completions(shell: &str) -> Result<()> { balance, mint, propose_transfer, + propose_generic, proposals, proposal, proposal_import, diff --git a/bin/drk/src/dao.rs b/bin/drk/src/dao.rs index 74462740d..d7802bd74 100644 --- a/bin/drk/src/dao.rs +++ b/bin/drk/src/dao.rs @@ -1862,6 +1862,64 @@ impl Drk { Ok(proposal_record) } + /// Create a DAO generic proposal. + pub async fn dao_propose_generic( + &self, + name: &str, + duration_blockwindows: u64, + user_data: Option, + ) -> Result { + // Fetch DAO and check its deployed + let dao = self.get_dao_by_name(name).await?; + if dao.leaf_position.is_none() || dao.tx_hash.is_none() || dao.call_index.is_none() { + return Err(Error::Custom( + "[dao_propose_transfer] DAO seems to not have been deployed yet".to_string(), + )) + } + + // Check that we have the proposer key + if dao.params.proposer_secret_key.is_none() { + return Err(Error::Custom( + "[dao_propose_transfer] We need the proposer secret key to create proposals for this DAO".to_string(), + )) + } + + // Retrieve next block height and current block time target, + // to compute their window. + let next_block_height = self.get_next_block_height().await?; + let block_target = self.get_block_target().await?; + let creation_blockwindow = blockwindow(next_block_height, block_target); + + // Create the actual proposal + let proposal = DaoProposal { + auth_calls: vec![], + creation_blockwindow, + duration_blockwindows, + user_data: user_data.unwrap_or(pallas::Base::ZERO), + dao_bulla: dao.bulla(), + blind: Blind::random(&mut OsRng), + }; + + let proposal_record = ProposalRecord { + proposal, + data: None, + leaf_position: None, + money_snapshot_tree: None, + nullifiers_smt_snapshot: None, + tx_hash: None, + call_index: None, + exec_tx_hash: None, + }; + + if let Err(e) = self.put_dao_proposal(&proposal_record).await { + return Err(Error::DatabaseError(format!( + "[dao_propose_transfer] Put DAO proposal failed: {e:?}" + ))) + } + + Ok(proposal_record) + } + /// Create a DAO transfer proposal transaction. pub async fn dao_transfer_proposal_tx(&self, proposal: &ProposalRecord) -> Result { // Check we know the plaintext data @@ -2087,6 +2145,196 @@ impl Drk { Ok(tx) } + /// Create a DAO generic proposal transaction. + pub async fn dao_generic_proposal_tx(&self, proposal: &ProposalRecord) -> Result { + // Fetch DAO and check its deployed + let Ok(dao) = self.get_dao_by_bulla(&proposal.proposal.dao_bulla).await else { + return Err(Error::Custom(format!( + "[dao_transfer_proposal_tx] DAO {} was not found", + proposal.proposal.dao_bulla + ))) + }; + if dao.leaf_position.is_none() || dao.tx_hash.is_none() || dao.call_index.is_none() { + return Err(Error::Custom( + "[dao_transfer_proposal_tx] DAO seems to not have been deployed yet".to_string(), + )) + } + + // Check that we have the proposer key + if dao.params.proposer_secret_key.is_none() { + return Err(Error::Custom( + "[dao_transfer_proposal_tx] We need the proposer secret key to create proposals for this DAO".to_string(), + )) + } + + // Fetch our own governance OwnCoins to see what our balance is + let gov_owncoins = self.get_token_coins(&dao.params.dao.gov_token_id).await?; + if gov_owncoins.is_empty() { + return Err(Error::Custom(format!( + "[dao_transfer_proposal_tx] Did not find any governance {} coins in wallet", + dao.params.dao.gov_token_id + ))) + } + + // Find which governance coins we can use + let mut total_value = 0; + let mut gov_owncoins_to_use = vec![]; + for gov_owncoin in gov_owncoins { + if total_value >= dao.params.dao.proposer_limit { + break + } + + total_value += gov_owncoin.note.value; + gov_owncoins_to_use.push(gov_owncoin); + } + + // Check our governance coins balance is sufficient + if total_value < dao.params.dao.proposer_limit { + return Err(Error::Custom(format!( + "[dao_transfer_proposal_tx] Not enough gov token {} balance to propose", + dao.params.dao.gov_token_id + ))) + } + + // Now we need to do a lookup for the zkas proof bincodes, and create + // the circuit objects and proving keys so we can build the transaction. + // We also do this through the RPC. First we grab the fee call from money. + let zkas_bins = self.lookup_zkas(&MONEY_CONTRACT_ID).await?; + + let Some(fee_zkbin) = zkas_bins.iter().find(|x| x.0 == MONEY_CONTRACT_ZKAS_FEE_NS_V1) + else { + return Err(Error::Custom( + "[dao_transfer_proposal_tx] Fee circuit not found".to_string(), + )) + }; + + let fee_zkbin = ZkBinary::decode(&fee_zkbin.1)?; + + let fee_circuit = ZkCircuit::new(empty_witnesses(&fee_zkbin)?, &fee_zkbin); + + // Creating Fee circuit proving key + let fee_pk = ProvingKey::build(fee_zkbin.k, &fee_circuit); + + // Now we grab the DAO bins + let zkas_bins = self.lookup_zkas(&DAO_CONTRACT_ID).await?; + + let Some(propose_burn_zkbin) = + zkas_bins.iter().find(|x| x.0 == DAO_CONTRACT_ZKAS_DAO_PROPOSE_INPUT_NS) + else { + return Err(Error::Custom( + "[dao_transfer_proposal_tx] Propose Burn circuit not found".to_string(), + )) + }; + + let Some(propose_main_zkbin) = + zkas_bins.iter().find(|x| x.0 == DAO_CONTRACT_ZKAS_DAO_PROPOSE_MAIN_NS) + else { + return Err(Error::Custom( + "[dao_transfer_proposal_tx] Propose Main circuit not found".to_string(), + )) + }; + + let propose_burn_zkbin = ZkBinary::decode(&propose_burn_zkbin.1)?; + let propose_main_zkbin = ZkBinary::decode(&propose_main_zkbin.1)?; + + let propose_burn_circuit = + ZkCircuit::new(empty_witnesses(&propose_burn_zkbin)?, &propose_burn_zkbin); + let propose_main_circuit = + ZkCircuit::new(empty_witnesses(&propose_main_zkbin)?, &propose_main_zkbin); + + // Creating DAO ProposeBurn and ProposeMain circuits proving keys + let propose_burn_pk = ProvingKey::build(propose_burn_zkbin.k, &propose_burn_circuit); + let propose_main_pk = ProvingKey::build(propose_main_zkbin.k, &propose_main_circuit); + + // Fetch our money Merkle tree + let money_merkle_tree = self.get_money_tree().await?; + + // Now we can create the proposal transaction parameters. + // We first generate the `DaoProposeStakeInput` inputs, + // using our governance OwnCoins. + let mut inputs = Vec::with_capacity(gov_owncoins_to_use.len()); + for gov_owncoin in gov_owncoins_to_use { + let input = DaoProposeStakeInput { + secret: gov_owncoin.secret, + note: gov_owncoin.note.clone(), + leaf_position: gov_owncoin.leaf_position, + merkle_path: money_merkle_tree.witness(gov_owncoin.leaf_position, 0).unwrap(), + }; + inputs.push(input); + } + + // Now create the parameters for the proposal tx + let signature_secret = SecretKey::random(&mut OsRng); + + // Fetch the daos Merkle tree to compute the DAO Merkle path and root + let (daos_tree, _) = self.get_dao_trees().await?; + let (dao_merkle_path, dao_merkle_root) = { + let root = daos_tree.root(0).unwrap(); + let leaf_pos = dao.leaf_position.unwrap(); + let dao_merkle_path = daos_tree.witness(leaf_pos, 0).unwrap(); + (dao_merkle_path, root) + }; + + // Generate the Money nullifiers Sparse Merkle Tree + let store = WalletStorage::new( + &self.wallet, + &MONEY_SMT_TABLE, + MONEY_SMT_COL_KEY, + MONEY_SMT_COL_VALUE, + ); + let money_null_smt = WalletSmt::new(store, PoseidonFp::new(), &EMPTY_NODES_FP); + + // Create the proposal call + let call = DaoProposeCall { + money_null_smt: &money_null_smt, + inputs, + proposal: proposal.proposal.clone(), + dao: dao.params.dao, + dao_leaf_position: dao.leaf_position.unwrap(), + dao_merkle_path, + dao_merkle_root, + signature_secret, + }; + + let (params, proofs) = call.make( + &dao.params.proposer_secret_key.unwrap(), + &propose_burn_zkbin, + &propose_burn_pk, + &propose_main_zkbin, + &propose_main_pk, + )?; + + // Encode the call + let mut data = vec![DaoFunction::Propose as u8]; + params.encode_async(&mut data).await?; + let call = ContractCall { contract_id: *DAO_CONTRACT_ID, data }; + + // Create the TransactionBuilder containing above call + let mut tx_builder = TransactionBuilder::new(ContractCallLeaf { call, proofs }, vec![])?; + + // We first have to execute the fee-less tx to gather its used gas, and then we feed + // it into the fee-creating function. + let mut tx = tx_builder.build()?; + let sigs = tx.create_sigs(&[signature_secret])?; + tx.signatures = vec![sigs]; + + let tree = self.get_money_tree().await?; + let (fee_call, fee_proofs, fee_secrets) = + self.append_fee_call(&tx, &tree, &fee_pk, &fee_zkbin, None).await?; + + // Append the fee call to the transaction + tx_builder.append(ContractCallLeaf { call: fee_call, proofs: fee_proofs }, vec![])?; + + // Now build the actual transaction and sign it with all necessary keys. + let mut tx = tx_builder.build()?; + let sigs = tx.create_sigs(&[signature_secret])?; + tx.signatures.push(sigs); + let sigs = tx.create_sigs(&fee_secrets)?; + tx.signatures.push(sigs); + + Ok(tx) + } + /// Vote on a DAO proposal pub async fn dao_vote( &self, @@ -2619,4 +2867,153 @@ impl Drk { Ok(tx) } + + /// Execute a DAO generic proposal. + pub async fn dao_exec_generic(&self, proposal: &ProposalRecord) -> Result { + if proposal.leaf_position.is_none() || + proposal.money_snapshot_tree.is_none() || + proposal.nullifiers_smt_snapshot.is_none() || + proposal.tx_hash.is_none() || + proposal.call_index.is_none() + { + return Err(Error::Custom( + "[dao_exec_transfer] Proposal seems to not have been deployed yet".to_string(), + )) + } + + // Check proposal is not executed + if let Some(exec_tx_hash) = proposal.exec_tx_hash { + return Err(Error::Custom(format!( + "[dao_exec_transfer] Proposal was executed on transaction: {exec_tx_hash}" + ))) + } + + // Fetch DAO and check its deployed + let Ok(dao) = self.get_dao_by_bulla(&proposal.proposal.dao_bulla).await else { + return Err(Error::Custom(format!( + "[dao_exec_transfer] DAO {} was not found", + proposal.proposal.dao_bulla + ))) + }; + if dao.leaf_position.is_none() || dao.tx_hash.is_none() || dao.call_index.is_none() { + return Err(Error::Custom( + "[dao_exec_transfer] DAO seems to not have been deployed yet".to_string(), + )) + } + + // Check that we have the exec key + if dao.params.exec_secret_key.is_none() { + return Err(Error::Custom( + "[dao_exec_transfer] We need the exec secret key to execute proposals for this DAO" + .to_string(), + )) + } + + // Check proposal is approved + let votes = self.get_dao_proposal_votes(&proposal.bulla()).await?; + let mut yes_vote_value = 0; + let mut yes_vote_blind = Blind::ZERO; + let mut all_vote_value = 0; + let mut all_vote_blind = Blind::ZERO; + for vote in votes { + if vote.vote_option { + yes_vote_value += vote.all_vote_value; + }; + yes_vote_blind += vote.yes_vote_blind; + all_vote_value += vote.all_vote_value; + all_vote_blind += vote.all_vote_blind; + } + let approval_ratio = (yes_vote_value as f64 * 100.0) / all_vote_value as f64; + if all_vote_value < dao.params.dao.quorum || + approval_ratio < + (dao.params.dao.approval_ratio_quot / dao.params.dao.approval_ratio_base) + as f64 + { + return Err(Error::Custom( + "[dao_exec_transfer] Proposal is not approved yet".to_string(), + )) + }; + + // Now we need to do a lookup for the zkas proof bincodes, and create + // the circuit objects and proving keys so we can build the transaction. + // We also do this through the RPC. First we grab the calls from money. + let zkas_bins = self.lookup_zkas(&MONEY_CONTRACT_ID).await?; + let Some(fee_zkbin) = zkas_bins.iter().find(|x| x.0 == MONEY_CONTRACT_ZKAS_FEE_NS_V1) + else { + return Err(Error::Custom("Fee circuit not found".to_string())) + }; + let fee_zkbin = ZkBinary::decode(&fee_zkbin.1)?; + let fee_circuit = ZkCircuit::new(empty_witnesses(&fee_zkbin)?, &fee_zkbin); + let fee_pk = ProvingKey::build(fee_zkbin.k, &fee_circuit); + + // Now we grab the DAO bins + let zkas_bins = self.lookup_zkas(&DAO_CONTRACT_ID).await?; + let Some(dao_exec_zkbin) = zkas_bins.iter().find(|x| x.0 == DAO_CONTRACT_ZKAS_DAO_EXEC_NS) + else { + return Err(Error::Custom("[dao_exec_transfer] DAO Exec circuit not found".to_string())) + }; + let dao_exec_zkbin = ZkBinary::decode(&dao_exec_zkbin.1)?; + let dao_exec_circuit = ZkCircuit::new(empty_witnesses(&dao_exec_zkbin)?, &dao_exec_zkbin); + let dao_exec_pk = ProvingKey::build(dao_exec_zkbin.k, &dao_exec_circuit); + + // Fetch our money Merkle tree + let tree = self.get_money_tree().await?; + + // Retrieve next block height and current block time target, + // to compute their window. + let next_block_height = self.get_next_block_height().await?; + let block_target = self.get_block_target().await?; + let current_blockwindow = blockwindow(next_block_height, block_target); + + // Create the exec call + let exec_signature_secret = SecretKey::random(&mut OsRng); + let exec_builder = DaoExecCall { + proposal: proposal.proposal.clone(), + dao: dao.params.dao.clone(), + yes_vote_value, + all_vote_value, + yes_vote_blind, + all_vote_blind, + signature_secret: exec_signature_secret, + current_blockwindow, + }; + let (exec_params, exec_proofs) = exec_builder.make( + &dao.params.exec_secret_key.unwrap(), + &None, + &dao_exec_zkbin, + &dao_exec_pk, + )?; + + // Encode the call + let mut data = vec![DaoFunction::Exec as u8]; + exec_params.encode_async(&mut data).await?; + let exec_call = ContractCall { contract_id: *DAO_CONTRACT_ID, data }; + + // Create the TransactionBuilder containing above calls + let mut tx_builder = TransactionBuilder::new( + ContractCallLeaf { call: exec_call, proofs: exec_proofs }, + vec![], + )?; + + // We first have to execute the fee-less tx to gather its used gas, and then we feed + // it into the fee-creating function. + let mut tx = tx_builder.build()?; + let exec_sigs = tx.create_sigs(&[exec_signature_secret])?; + tx.signatures = vec![exec_sigs]; + + let (fee_call, fee_proofs, fee_secrets) = + self.append_fee_call(&tx, &tree, &fee_pk, &fee_zkbin, None).await?; + + // Append the fee call to the transaction + tx_builder.append(ContractCallLeaf { call: fee_call, proofs: fee_proofs }, vec![])?; + + // Now build the actual transaction and sign it with all necessary keys. + let mut tx = tx_builder.build()?; + let sigs = tx.create_sigs(&[exec_signature_secret])?; + tx.signatures.push(sigs); + let sigs = tx.create_sigs(&fee_secrets)?; + tx.signatures.push(sigs); + + Ok(tx) + } } diff --git a/bin/drk/src/main.rs b/bin/drk/src/main.rs index ea7c86013..08361675e 100644 --- a/bin/drk/src/main.rs +++ b/bin/drk/src/main.rs @@ -342,6 +342,18 @@ enum DaoSubcmd { user_data: Option, }, + /// Create a generic proposal for a DAO + ProposeGeneric { + /// Name identifier for the DAO + name: String, + + /// Duration of the proposal, in block windows + duration: u64, + + /// Optional user data to use + user_data: Option, + }, + /// List DAO proposals Proposals { /// Name identifier for the DAO @@ -1428,6 +1440,50 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { drk.stop_rpc_client().await } + DaoSubcmd::ProposeGeneric { name, duration, user_data } => { + let drk = new_wallet( + blockchain_config.wallet_path, + blockchain_config.wallet_pass, + Some(blockchain_config.endpoint), + ex, + args.fun, + ) + .await; + + let user_data = match user_data { + Some(u) => { + let bytes: [u8; 32] = match bs58::decode(&u).into_vec()?.try_into() { + Ok(b) => b, + Err(e) => { + eprintln!("Invalid user data: {e:?}"); + exit(2); + } + }; + + match pallas::Base::from_repr(bytes).into() { + Some(v) => Some(v), + None => { + eprintln!("Invalid user data"); + exit(2); + } + } + } + None => None, + }; + + let proposal = match drk.dao_propose_generic(&name, duration, user_data).await { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to create DAO transfer proposal: {e:?}"); + exit(2); + } + }; + + println!("Generated proposal: {}", proposal.bulla()); + + drk.stop_rpc_client().await + } + DaoSubcmd::Proposals { name } => { let drk = new_wallet( blockchain_config.wallet_path, @@ -1483,7 +1539,9 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { } if mint_proposal { + // Identify proposal type by its auth calls for call in &proposal.proposal.auth_calls { + // We only support transfer right now if call.function_code == DaoFunction::AuthMoneyTransfer as u8 { let tx = match drk.dao_transfer_proposal_tx(&proposal).await { Ok(tx) => tx, @@ -1498,6 +1556,20 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { } } + // If proposal has no auth calls, we consider it a generic one + if proposal.proposal.auth_calls.is_empty() { + let tx = match drk.dao_generic_proposal_tx(&proposal).await { + Ok(tx) => tx, + Err(e) => { + eprintln!("Failed to create DAO generic proposal: {e:?}"); + exit(2); + } + }; + + println!("{}", base64::encode(&serialize_async(&tx).await)); + return drk.stop_rpc_client().await + } + eprintln!("Unsuported DAO proposal"); exit(2); } @@ -1581,7 +1653,7 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { let outcome = if table.is_empty() { println!("Votes: No votes found"); - "Rejected" + "Unknown" } else { println!("Votes:"); println!("{table}"); @@ -1751,7 +1823,9 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { .await; let proposal = drk.get_dao_proposal_by_bulla(&bulla).await?; + // Identify proposal type by its auth calls for call in &proposal.proposal.auth_calls { + // We only support transfer right now if call.function_code == DaoFunction::AuthMoneyTransfer as u8 { let tx = match drk.dao_exec_transfer(&proposal).await { Ok(tx) => tx, @@ -1766,6 +1840,20 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { } } + // If proposal has no auth calls, we consider it a generic one + if proposal.proposal.auth_calls.is_empty() { + let tx = match drk.dao_exec_generic(&proposal).await { + Ok(tx) => tx, + Err(e) => { + eprintln!("Failed to execute DAO generic proposal: {e:?}"); + exit(2); + } + }; + + println!("{}", base64::encode(&serialize_async(&tx).await)); + return drk.stop_rpc_client().await + } + eprintln!("Unsuported DAO proposal"); exit(2); }