From 0997311fb7529d4ce4fda2040eada3ac19c86b44 Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Tue, 21 Jul 2026 16:27:39 -0400 Subject: [PATCH 1/2] fix: propagate P2ID note type so proposals honor private notes (#322) buildP2idTransactionRequest (TS) and build_p2id_transaction_request (Rust) hardcoded NoteType.Public, so a P2ID proposal always created a public note regardless of what the user selected. - TS client: buildP2idTransactionRequest accepts options.noteType; createP2idProposal takes { noteType } and records it in proposal metadata; the rebuild-from-metadata path parses it back so cosigners verify and execute the same note visibility. - Rust client: build_p2id_transaction_request takes NoteType; TransactionType::P2ID carries note_type (transfer() still defaults to public, transfer_with_note_type() for explicit choice); ProposalMetadata, ProposalMetadataPayload, and ExportedMetadata round-trip it. - Wire format: metadata.note_type is "public" | "private"; it is omitted for public notes so pre-existing proposals keep their exact wire shape, and absent still means public. Unknown values are rejected at parse time instead of being silently rebuilt as a public note that could never match the signed tx_summary commitment. - Server + operator client: mirror note_type in the delta-summary proposal metadata; OpenAPI specs regenerated. - Examples: demo CLI prompts for note visibility; browser example wrappers and the smoke harness pass it through. --- crates/miden-multisig-client/src/account.rs | 1 + crates/miden-multisig-client/src/execution.rs | 2 + crates/miden-multisig-client/src/export.rs | 8 ++ crates/miden-multisig-client/src/payload.rs | 56 ++++++++++- crates/miden-multisig-client/src/proposal.rs | 97 ++++++++++++++++++- .../src/transaction/builder.rs | 14 ++- .../src/transaction/payment.rs | 65 ++++++++++++- crates/server/src/delta_summary/mod.rs | 5 + docs/openapi-client.json | 7 ++ docs/openapi-dashboard.json | 7 ++ docs/openapi.json | 7 ++ .../multisig-browser/src/multisigApi.ts | 3 + .../demo/src/actions/proposal_management.rs | 25 ++++- examples/demo/src/state.rs | 2 + examples/smoke-web/src/smokeHarness.ts | 4 +- examples/web/src/lib/multisigApi.ts | 4 +- .../guardian-client/src/conversion.test.ts | 16 +++ packages/guardian-client/src/conversion.ts | 2 + packages/guardian-client/src/server-types.ts | 2 + packages/guardian-client/src/types.ts | 2 + packages/guardian-operator-client/src/http.ts | 1 + .../guardian-operator-client/src/types.ts | 2 + packages/miden-multisig-client/src/index.ts | 5 + .../src/multisig.test.ts | 87 +++++++++++++++++ .../miden-multisig-client/src/multisig.ts | 11 ++- .../src/proposal/metadata.test.ts | 45 +++++++++ .../src/proposal/metadata.ts | 11 +++ .../miden-multisig-client/src/transaction.ts | 3 + .../src/transaction/p2id.test.ts | 70 +++++++++++-- .../src/transaction/p2id.ts | 36 ++++++- .../src/types/proposal.ts | 9 ++ 31 files changed, 588 insertions(+), 21 deletions(-) diff --git a/crates/miden-multisig-client/src/account.rs b/crates/miden-multisig-client/src/account.rs index 26e91c2f..feaee466 100644 --- a/crates/miden-multisig-client/src/account.rs +++ b/crates/miden-multisig-client/src/account.rs @@ -339,6 +339,7 @@ mod tests { recipient: account_id, faucet_id: account_id, amount: 10, + note_type: miden_protocol::note::NoteType::Public, }) .expect("threshold"), 1 diff --git a/crates/miden-multisig-client/src/execution.rs b/crates/miden-multisig-client/src/execution.rs index 7b275708..e9bc4ade 100644 --- a/crates/miden-multisig-client/src/execution.rs +++ b/crates/miden-multisig-client/src/execution.rs @@ -139,6 +139,7 @@ pub async fn build_final_transaction_request( recipient, faucet_id, amount, + note_type, } => { let asset = build_transfer_asset(account, *faucet_id, *amount)?; @@ -146,6 +147,7 @@ pub async fn build_final_transaction_request( account, *recipient, vec![asset.into()], + *note_type, salt, signature_advice, ) diff --git a/crates/miden-multisig-client/src/export.rs b/crates/miden-multisig-client/src/export.rs index 2d767714..5e7f7764 100644 --- a/crates/miden-multisig-client/src/export.rs +++ b/crates/miden-multisig-client/src/export.rs @@ -86,6 +86,11 @@ pub struct ExportedMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub amount: Option, + /// P2ID note visibility, `"public"` or `"private"` (issue #322). + /// Absent => public (pre-#322 exports). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_type: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub note_ids_hex: Vec, @@ -120,6 +125,7 @@ impl ExportedProposal { recipient_hex: self.metadata.recipient_hex.clone(), faucet_id_hex: self.metadata.faucet_id_hex.clone(), amount: self.metadata.amount, + note_type: self.metadata.note_type.clone(), note_ids_hex: self.metadata.note_ids_hex.clone(), consume_notes_metadata_version: self.metadata.consume_notes_metadata_version, consume_notes_notes: self @@ -278,6 +284,7 @@ impl ExportedProposal { recipient_hex: proposal.metadata.recipient_hex.clone(), faucet_id_hex: proposal.metadata.faucet_id_hex.clone(), amount: proposal.metadata.amount, + note_type: proposal.metadata.note_type.clone(), note_ids_hex: proposal.metadata.note_ids_hex.clone(), consume_notes_metadata_version: proposal.metadata.consume_notes_metadata_version, consume_notes_notes: proposal @@ -486,6 +493,7 @@ mod tests { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: vec![], consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), diff --git a/crates/miden-multisig-client/src/payload.rs b/crates/miden-multisig-client/src/payload.rs index 222ab858..69f5c2e1 100644 --- a/crates/miden-multisig-client/src/payload.rs +++ b/crates/miden-multisig-client/src/payload.rs @@ -1,6 +1,7 @@ //! Payload types for multisig transaction proposals. use guardian_shared::{DeltaSignature, ProposalSignature, ToJson}; +use miden_protocol::note::NoteType; use miden_protocol::transaction::TransactionSummary; use serde::{Deserialize, Serialize}; @@ -33,6 +34,12 @@ pub struct ProposalMetadataPayload { #[serde(skip_serializing_if = "Option::is_none")] pub amount: Option, + /// P2ID note visibility, `"public"` or `"private"` (issue #322). Omitted + /// for public notes so the pre-#322 wire shape stays byte-identical; + /// absent => public. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub required_signatures: Option, @@ -145,19 +152,23 @@ impl ProposalPayload { self } - /// Sets the metadata for P2ID payment transfers. + /// Sets the metadata for P2ID payment transfers. `note_type` is written to + /// the wire only when it is private, so public payloads keep the legacy + /// shape (issue #322). pub fn with_payment_metadata( mut self, recipient_id: String, faucet_id: String, amount: u64, salt: String, + note_type: NoteType, ) -> Self { self.metadata = Some(ProposalMetadataPayload { proposal_type: "p2id".to_string(), recipient_id: Some(recipient_id), faucet_id: Some(faucet_id), amount: Some(amount.to_string()), + note_type: (note_type != NoteType::Public).then(|| note_type.to_string()), salt: Some(salt), ..Default::default() }); @@ -337,6 +348,7 @@ mod tests { "0xfaucet".to_string(), 1000, "0xsalt".to_string(), + NoteType::Public, ); let meta = payload.metadata.unwrap(); @@ -345,6 +357,48 @@ mod tests { assert_eq!(meta.faucet_id, Some("0xfaucet".to_string())); assert_eq!(meta.amount, Some("1000".to_string())); assert_eq!(meta.salt, Some("0xsalt".to_string())); + assert_eq!(meta.note_type, None); + } + + /// A public P2ID payload must keep the pre-#322 wire shape: no + /// `note_type` key at all. + #[test] + fn with_payment_metadata_public_omits_note_type_on_wire() { + let payload = ProposalPayload { + tx_summary: serde_json::json!({}), + signatures: vec![], + metadata: None, + } + .with_payment_metadata( + "0xrecipient".to_string(), + "0xfaucet".to_string(), + 1000, + "0xsalt".to_string(), + NoteType::Public, + ); + + let json = serde_json::to_value(payload.metadata.unwrap()).unwrap(); + assert!(json.get("note_type").is_none()); + } + + #[test] + fn with_payment_metadata_private_round_trips_note_type() { + let payload = ProposalPayload { + tx_summary: serde_json::json!({}), + signatures: vec![], + metadata: None, + } + .with_payment_metadata( + "0xrecipient".to_string(), + "0xfaucet".to_string(), + 1000, + "0xsalt".to_string(), + NoteType::Private, + ); + + let json = serde_json::to_string(&payload.metadata.unwrap()).unwrap(); + let parsed: ProposalMetadataPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.note_type, Some("private".to_string())); } #[test] diff --git a/crates/miden-multisig-client/src/proposal.rs b/crates/miden-multisig-client/src/proposal.rs index 264a998a..edefaff9 100644 --- a/crates/miden-multisig-client/src/proposal.rs +++ b/crates/miden-multisig-client/src/proposal.rs @@ -13,7 +13,7 @@ use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{ PublicKey as EcdsaPublicKey, Signature as EcdsaSignature, }; use miden_protocol::crypto::dsa::falcon512_poseidon2::Signature as Poseidon2FalconSignature; -use miden_protocol::note::{Note, NoteId}; +use miden_protocol::note::{Note, NoteId, NoteType}; use miden_protocol::transaction::TransactionSummary; use miden_protocol::utils::serde::{Deserializable, Serializable}; use miden_protocol::{Felt, Word}; @@ -94,6 +94,9 @@ pub enum TransactionType { recipient: AccountId, faucet_id: AccountId, amount: u64, + /// Visibility of the created note (issue #322). Absent in legacy + /// proposal metadata, which maps to [`NoteType::Public`]. + note_type: NoteType, }, ConsumeNotes { note_ids: Vec, @@ -130,12 +133,24 @@ pub enum TransactionType { } impl TransactionType { - /// Creates a P2ID transfer transaction. + /// Creates a P2ID transfer transaction with a public output note. pub fn transfer(recipient: AccountId, faucet_id: AccountId, amount: u64) -> Self { + Self::transfer_with_note_type(recipient, faucet_id, amount, NoteType::Public) + } + + /// Creates a P2ID transfer transaction with an explicit note visibility + /// (issue #322). + pub fn transfer_with_note_type( + recipient: AccountId, + faucet_id: AccountId, + amount: u64, + note_type: NoteType, + ) -> Self { Self::P2ID { recipient, faucet_id, amount, + note_type, } } @@ -261,6 +276,9 @@ pub struct ProposalMetadata { pub recipient_hex: Option, pub faucet_id_hex: Option, pub amount: Option, + /// P2ID note visibility, `"public"` or `"private"` (issue #322). + /// `None` => public, the wire shape of pre-#322 proposals. + pub note_type: Option, pub note_ids_hex: Vec, @@ -316,6 +334,22 @@ impl ProposalMetadata { Ok(commitments) } + /// Parses `note_type` for a P2ID proposal. Absent => public (the only + /// behavior before issue #322); an unrecognized value is rejected rather + /// than silently rebuilt as a public note that could never match the + /// signed tx_summary commitment. + pub fn p2id_note_type(&self) -> Result { + match self.note_type.as_deref() { + None => Ok(NoteType::Public), + Some(value) => value.parse().map_err(|_| { + MultisigError::InvalidConfig(format!( + "unsupported metadata.note_type '{}': expected 'public' or 'private'", + value + )) + }), + } + } + /// Converts note ID hex strings to NoteIds. pub fn note_ids(&self) -> Result> { self.note_ids_hex @@ -373,6 +407,7 @@ impl ProposalMetadata { recipient, faucet_id, amount: parsed_amount, + note_type: self.p2id_note_type()?, }) } "switch_guardian" => { @@ -579,6 +614,7 @@ impl Proposal { Some(parsed) => Some(parsed?), None => None, }; + let note_type = metadata_payload.note_type; let note_ids_hex = metadata_payload.note_ids; let consume_notes_metadata_version = metadata_payload.consume_notes_metadata_version; let consume_notes_notes = metadata_payload @@ -600,6 +636,7 @@ impl Proposal { recipient_hex: recipient_hex.clone(), faucet_id_hex: faucet_id_hex.clone(), amount, + note_type, note_ids_hex: note_ids_hex.clone(), consume_notes_metadata_version, consume_notes_notes, @@ -826,7 +863,8 @@ mod tests { TransactionType::P2ID { recipient, faucet_id, - amount + amount, + note_type: NoteType::Public, } ); } @@ -1258,6 +1296,59 @@ mod tests { assert!(err.to_string().contains("proposal_type is required")); } + // ---------- p2id note_type (issue #322) ---------- + + fn p2id_metadata(note_type: Option<&str>) -> ProposalMetadata { + ProposalMetadata { + recipient_hex: Some("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string()), + faucet_id_hex: Some("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c".to_string()), + amount: Some(1000), + note_type: note_type.map(str::to_string), + ..Default::default() + } + } + + /// Absent `note_type` must keep mapping to a public note — the only + /// behavior that existed before the field, so pre-#322 proposals + /// rebuild identically. + #[test] + fn to_transaction_type_p2id_defaults_to_public_note() { + let tx_type = p2id_metadata(None) + .to_transaction_type("p2id") + .expect("to_transaction_type"); + assert!(matches!( + tx_type, + TransactionType::P2ID { + note_type: NoteType::Public, + .. + } + )); + } + + #[test] + fn to_transaction_type_p2id_threads_private_note_type() { + let tx_type = p2id_metadata(Some("private")) + .to_transaction_type("p2id") + .expect("to_transaction_type"); + assert!(matches!( + tx_type, + TransactionType::P2ID { + note_type: NoteType::Private, + .. + } + )); + } + + /// An unknown `note_type` must be rejected, not silently rebuilt as a + /// public note that could never match the signed tx_summary commitment. + #[test] + fn to_transaction_type_p2id_rejects_unknown_note_type() { + let err = p2id_metadata(Some("encrypted")) + .to_transaction_type("p2id") + .expect_err("unknown note_type must be rejected"); + assert!(err.to_string().contains("unsupported metadata.note_type")); + } + #[test] fn builtin_proposal_types_are_recognized() { for label in [ diff --git a/crates/miden-multisig-client/src/transaction/builder.rs b/crates/miden-multisig-client/src/transaction/builder.rs index c712289f..fd1907a8 100644 --- a/crates/miden-multisig-client/src/transaction/builder.rs +++ b/crates/miden-multisig-client/src/transaction/builder.rs @@ -4,7 +4,7 @@ use guardian_client::GuardianClient; use guardian_shared::ToJson; use miden_protocol::Word; use miden_protocol::account::AccountId; -use miden_protocol::note::NoteId; +use miden_protocol::note::{NoteId, NoteType}; use crate::MidenSdkClient; use crate::account::MultisigAccount; @@ -77,6 +77,7 @@ impl ProposalBuilder { recipient, faucet_id, amount, + note_type, } => { self.build_p2id( miden_client, @@ -85,6 +86,7 @@ impl ProposalBuilder { recipient, faucet_id, amount, + note_type, key_manager, ) .await @@ -198,6 +200,7 @@ impl ProposalBuilder { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: Vec::new(), consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), @@ -305,6 +308,7 @@ impl ProposalBuilder { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: Vec::new(), consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), @@ -357,6 +361,7 @@ impl ProposalBuilder { recipient: AccountId, faucet_id: AccountId, amount: u64, + note_type: NoteType, key_manager: &dyn KeyManager, ) -> Result { let account_id = account.id(); @@ -373,6 +378,7 @@ impl ProposalBuilder { account.inner(), recipient, vec![asset.into()], + note_type, salt, std::iter::empty(), )?; @@ -393,6 +399,7 @@ impl ProposalBuilder { recipient_hex: Some(recipient.to_string()), faucet_id_hex: Some(faucet_id.to_string()), amount: Some(amount), + note_type: (note_type != NoteType::Public).then(|| note_type.to_string()), note_ids_hex: Vec::new(), consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), @@ -411,6 +418,7 @@ impl ProposalBuilder { faucet_id.to_string(), amount, word_to_hex(&salt), + note_type, ) .with_required_signatures(required_signatures); @@ -431,6 +439,7 @@ impl ProposalBuilder { recipient, faucet_id, amount, + note_type, }, metadata, ); @@ -481,6 +490,7 @@ impl ProposalBuilder { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: note_ids_hex.clone(), consume_notes_metadata_version: Some( crate::proposal::CONSUME_NOTES_METADATA_VERSION_V2, @@ -584,6 +594,7 @@ impl ProposalBuilder { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: Vec::new(), consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), @@ -662,6 +673,7 @@ impl ProposalBuilder { recipient_hex: None, faucet_id_hex: None, amount: None, + note_type: None, note_ids_hex: Vec::new(), consume_notes_metadata_version: None, consume_notes_notes: Vec::new(), diff --git a/crates/miden-multisig-client/src/transaction/payment.rs b/crates/miden-multisig-client/src/transaction/payment.rs index 474bc659..b5f19d5d 100644 --- a/crates/miden-multisig-client/src/transaction/payment.rs +++ b/crates/miden-multisig-client/src/transaction/payment.rs @@ -16,11 +16,13 @@ use crate::error::{MultisigError, Result}; /// Builds a P2ID transaction request. /// -/// Creates a pay-to-id note and builds a transaction request to send it. +/// Creates a pay-to-id note of the given `note_type` and builds a transaction +/// request to send it. pub fn build_p2id_transaction_request( sender_account: &Account, recipient: AccountId, assets: Vec, + note_type: NoteType, salt: Word, signature_advice: I, ) -> Result @@ -33,7 +35,7 @@ where sender_account.id(), recipient, assets, - NoteType::Public, + note_type, Default::default(), &mut rng, ) @@ -114,6 +116,7 @@ mod tests { &account, recipient, vec![asset], + NoteType::Public, Word::from([1u32, 2, 3, 4]), std::iter::empty::<(Word, Vec)>(), ) @@ -125,4 +128,62 @@ mod tests { )); assert_eq!(request.expected_output_recipients().count(), 1); } + + #[test] + fn build_p2id_transaction_request_respects_note_type() { + let secret_key = SecretKey::new(); + let signer_commitment = secret_key.public_key().to_commitment(); + let account = MultisigGuardianBuilder::new(MultisigGuardianConfig::new( + 1, + vec![signer_commitment], + Word::from([9u32, 8, 7, 6]), + )) + .build() + .unwrap(); + let faucet_definition = FungibleFaucet::builder() + .name(TokenName::new("test token").unwrap()) + .symbol(TokenSymbol::try_from("TST").unwrap()) + .decimals(8) + .max_supply(AssetAmount::from(1_000_000u32)) + .build() + .unwrap(); + let faucet = create_fungible_faucet( + [5u8; 32], + faucet_definition, + AccountType::Public, + AuthMethod::SingleSig { + approver: ( + secret_key.public_key().to_commitment().into(), + AuthScheme::Falcon512Poseidon2, + ), + }, + AccessControl::AuthControlled, + TokenPolicyManager::new(), + ) + .unwrap(); + let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap(); + let salt = Word::from([1u32, 2, 3, 4]); + let build = |note_type: NoteType| { + let asset: Asset = miden_protocol::asset::FungibleAsset::new(faucet.id(), 100) + .unwrap() + .into(); + build_p2id_transaction_request( + &account, + recipient, + vec![asset], + note_type, + salt, + std::iter::empty::<(Word, Vec)>(), + ) + .unwrap() + }; + + let private_request = build(NoteType::Private); + let public_request = build(NoteType::Public); + + // The note type feeds the generated send script, so identically + // parameterized public and private requests must not be identical. + use miden_protocol::utils::serde::Serializable; + assert_ne!(private_request.to_bytes(), public_request.to_bytes()); + } } diff --git a/crates/server/src/delta_summary/mod.rs b/crates/server/src/delta_summary/mod.rs index fee0986b..5892ef86 100644 --- a/crates/server/src/delta_summary/mod.rs +++ b/crates/server/src/delta_summary/mod.rs @@ -139,6 +139,11 @@ pub struct ProposalMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub amount: Option, + /// P2ID note visibility, `"public"` or `"private"` (issue #322). + /// Absent => public (pre-#322 proposals). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_type: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub note_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/docs/openapi-client.json b/docs/openapi-client.json index b88f4688..0a0c3861 100644 --- a/docs/openapi-client.json +++ b/docs/openapi-client.json @@ -1626,6 +1626,13 @@ "type": "string" } }, + "note_type": { + "type": [ + "string", + "null" + ], + "description": "P2ID note visibility, `\"public\"` or `\"private\"` (issue #322).\nAbsent => public (pre-#322 proposals)." + }, "proposal_type": { "type": "string", "description": "One of the validated multisig proposal types (`add_signer`,\n`remove_signer`, `change_threshold`, `update_procedure_threshold`,\n`p2id`, `consume_notes`, `switch_guardian`). Free string so new\ntypes from the multisig client don't force a wire-contract bump\nhere — `category` is the closed enum." diff --git a/docs/openapi-dashboard.json b/docs/openapi-dashboard.json index d81e759a..469a28f0 100644 --- a/docs/openapi-dashboard.json +++ b/docs/openapi-dashboard.json @@ -3043,6 +3043,13 @@ "type": "string" } }, + "note_type": { + "type": [ + "string", + "null" + ], + "description": "P2ID note visibility, `\"public\"` or `\"private\"` (issue #322).\nAbsent => public (pre-#322 proposals)." + }, "proposal_type": { "type": "string", "description": "One of the validated multisig proposal types (`add_signer`,\n`remove_signer`, `change_threshold`, `update_procedure_threshold`,\n`p2id`, `consume_notes`, `switch_guardian`). Free string so new\ntypes from the multisig client don't force a wire-contract bump\nhere — `category` is the closed enum." diff --git a/docs/openapi.json b/docs/openapi.json index e95f4068..26410be4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5511,6 +5511,13 @@ "type": "string" } }, + "note_type": { + "type": [ + "string", + "null" + ], + "description": "P2ID note visibility, `\"public\"` or `\"private\"` (issue #322).\nAbsent => public (pre-#322 proposals)." + }, "proposal_type": { "type": "string", "description": "One of the validated multisig proposal types (`add_signer`,\n`remove_signer`, `change_threshold`, `update_procedure_threshold`,\n`p2id`, `consume_notes`, `switch_guardian`). Free string so new\ntypes from the multisig client don't force a wire-contract bump\nhere — `category` is the closed enum." diff --git a/examples/_shared/multisig-browser/src/multisigApi.ts b/examples/_shared/multisig-browser/src/multisigApi.ts index acea33a0..16f6219a 100644 --- a/examples/_shared/multisig-browser/src/multisigApi.ts +++ b/examples/_shared/multisig-browser/src/multisigApi.ts @@ -2,6 +2,7 @@ import { MidenClient, Word, type AdviceMap, + type NoteType, type TransactionRequest, } from '@miden-sdk/miden-sdk'; import { @@ -317,6 +318,7 @@ export async function createP2idProposal( recipientId: string, faucetId: string, amount: bigint, + noteType?: NoteType, ): Promise<{ proposal: Proposal; proposals: Proposal[] }> { return createProposalResult(multisig, () => multisig.createP2idProposal( @@ -324,6 +326,7 @@ export async function createP2idProposal( faucetId, amount, proposalNonce(multisig), + { noteType }, )); } diff --git a/examples/demo/src/actions/proposal_management.rs b/examples/demo/src/actions/proposal_management.rs index 6e1c9ebb..4d626019 100644 --- a/examples/demo/src/actions/proposal_management.rs +++ b/examples/demo/src/actions/proposal_management.rs @@ -11,6 +11,7 @@ use miden_multisig_client::{ }; use miden_protocol::account::AccountId; use miden_protocol::address::NetworkId; +use miden_protocol::note::NoteType; use rustyline::DefaultEditor; use crate::display::{ @@ -914,12 +915,13 @@ async fn action_create_custom_proposal( } print_info("Building a transfer transaction to use as the custom transaction request."); - let (recipient, faucet_id, amount) = match prompt_p2id(state, editor)? { + let (recipient, faucet_id, amount, note_type) = match prompt_p2id(state, editor)? { TransactionType::P2ID { recipient, faucet_id, amount, - } => (recipient, faucet_id, amount), + note_type, + } => (recipient, faucet_id, amount, note_type), _ => return Err("expected a P2ID transaction".to_string()), }; @@ -936,6 +938,7 @@ async fn action_create_custom_proposal( account.inner(), recipient, vec![asset.into()], + note_type, salt, std::iter::empty(), ) @@ -957,6 +960,7 @@ async fn action_create_custom_proposal( recipient, faucet_id, amount, + note_type, salt, }, ); @@ -999,6 +1003,7 @@ async fn action_execute_custom_proposal( account.inner(), recipe.recipient, vec![asset.into()], + recipe.note_type, recipe.salt, std::iter::empty(), ) @@ -1180,17 +1185,31 @@ fn prompt_p2id( return Err("Amount must be greater than 0".to_string()); } + // Get note visibility (issue #322) + let note_type_input = prompt_input( + editor, + " Note visibility [public/private] (default public): ", + )?; + let note_type = match note_type_input.trim().to_lowercase().as_str() { + "" | "public" => NoteType::Public, + "private" => NoteType::Private, + other => return Err(format!("Invalid note visibility '{}'", other)), + }; + println!("\nTransfer details:"); println!(" Recipient: {}", shorten_hex(recipient_input.trim())); println!(" Faucet: {}", shorten_hex(&faucet_id.to_hex())); println!(" Amount: {} / {} available", amount, max_amount); + println!(" Note: {}", note_type); let confirm = prompt_input(editor, "\nConfirm? [y/N]: ")?; if confirm.to_lowercase() != "y" { return Err("Cancelled".to_string()); } - Ok(TransactionType::transfer(recipient, faucet_id, amount)) + Ok(TransactionType::transfer_with_note_type( + recipient, faucet_id, amount, note_type, + )) } async fn prompt_consume_notes( diff --git a/examples/demo/src/state.rs b/examples/demo/src/state.rs index 6575d9a5..4c9350f6 100644 --- a/examples/demo/src/state.rs +++ b/examples/demo/src/state.rs @@ -5,6 +5,7 @@ use miden_client::rpc::Endpoint; use miden_multisig_client::{ExportedProposal, MultisigClient, SignatureScheme}; use miden_protocol::account::AccountId; use miden_protocol::address::NetworkId; +use miden_protocol::note::NoteType; use miden_protocol::Word; use tempfile::TempDir; @@ -15,6 +16,7 @@ pub struct CustomProposalRecipe { pub recipient: AccountId, pub faucet_id: AccountId, pub amount: u64, + pub note_type: NoteType, pub salt: Word, } diff --git a/examples/smoke-web/src/smokeHarness.ts b/examples/smoke-web/src/smokeHarness.ts index 89ecf962..8ae8e75b 100644 --- a/examples/smoke-web/src/smokeHarness.ts +++ b/examples/smoke-web/src/smokeHarness.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react'; import { useModal } from '@getpara/react-sdk-lite'; import { MidenWalletAdapter } from '@demox-labs/miden-wallet-adapter-miden'; +import { NoteType } from '@miden-sdk/miden-sdk'; import type { MidenClient } from '@miden-sdk/miden-sdk'; import { AccountInspector, @@ -101,7 +102,7 @@ export type CreateProposalInput = | { type: 'change_threshold'; newThreshold: number } | { type: 'update_procedure_threshold'; procedure: ProcedureName; threshold: number } | { type: 'consume_notes'; noteIds: string[] } - | { type: 'p2id'; recipientId: string; faucetId: string; amount: string | number } + | { type: 'p2id'; recipientId: string; faucetId: string; amount: string | number; noteType?: 'public' | 'private' } | { type: 'switch_guardian'; newGuardianEndpoint: string; newGuardianPubkey: string }; export interface CreateCustomProposalInput { @@ -1073,6 +1074,7 @@ export function useSmokeHarness(): { input.recipientId.trim(), input.faucetId.trim(), BigInt(input.amount), + input.noteType === 'private' ? NoteType.Private : undefined, ); break; case 'switch_guardian': diff --git a/examples/web/src/lib/multisigApi.ts b/examples/web/src/lib/multisigApi.ts index d50874aa..776022b1 100644 --- a/examples/web/src/lib/multisigApi.ts +++ b/examples/web/src/lib/multisigApi.ts @@ -18,7 +18,7 @@ import { AccountInspector, type DetectedMultisigConfig, } from '@openzeppelin/miden-multisig-client'; -import type { MidenClient } from '@miden-sdk/miden-sdk'; +import type { MidenClient, NoteType } from '@miden-sdk/miden-sdk'; import type { SignerInfo } from '@/types'; import type { WalletSource } from '@/wallets/types'; @@ -356,6 +356,7 @@ export async function createP2idProposal( recipientId: string, faucetId: string, amount: bigint, + noteType?: NoteType, ): Promise<{ proposal: Proposal; proposals: Proposal[] }> { return createProposalResult(multisig, () => multisig.createP2idProposal( @@ -363,6 +364,7 @@ export async function createP2idProposal( faucetId, amount, proposalNonce(multisig), + { noteType }, )); } diff --git a/packages/guardian-client/src/conversion.test.ts b/packages/guardian-client/src/conversion.test.ts index 93f5a8cf..b3f6f9d3 100644 --- a/packages/guardian-client/src/conversion.test.ts +++ b/packages/guardian-client/src/conversion.test.ts @@ -416,5 +416,21 @@ describe('conversion', () => { expect(result.amount).toBe(original.amount); expect(result.salt).toBe(original.salt); }); + + it('p2id noteType survives roundtrip as note_type on the wire (issue #322)', () => { + const original: ProposalMetadata = { + proposalType: 'p2id', + recipientId: '0xrecipient', + faucetId: '0xfaucet', + amount: '1000', + noteType: 'private', + }; + + const server = toServerProposalMetadata(original); + expect(server.note_type).toBe('private'); + + const result = fromServerProposalMetadata(server); + expect(result.noteType).toBe('private'); + }); }); }); diff --git a/packages/guardian-client/src/conversion.ts b/packages/guardian-client/src/conversion.ts index ae2e8fe9..da52dcba 100644 --- a/packages/guardian-client/src/conversion.ts +++ b/packages/guardian-client/src/conversion.ts @@ -106,6 +106,7 @@ export function fromServerProposalMetadata(server: ServerProposalMetadata): Prop recipientId: server.recipient_id, faucetId: server.faucet_id, amount: server.amount, + noteType: server.note_type, }; } @@ -215,6 +216,7 @@ export function toServerProposalMetadata(meta: ProposalMetadata): ServerProposal recipient_id: meta.recipientId, faucet_id: meta.faucetId, amount: meta.amount, + note_type: meta.noteType, }; } diff --git a/packages/guardian-client/src/server-types.ts b/packages/guardian-client/src/server-types.ts index 57e0f6e6..8aba9777 100644 --- a/packages/guardian-client/src/server-types.ts +++ b/packages/guardian-client/src/server-types.ts @@ -54,6 +54,8 @@ export interface ServerProposalMetadata { recipient_id?: string; faucet_id?: string; amount?: string; + /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */ + note_type?: string; } export interface ServerDeltaObject { diff --git a/packages/guardian-client/src/types.ts b/packages/guardian-client/src/types.ts index 1282d2dc..5a6dc239 100644 --- a/packages/guardian-client/src/types.ts +++ b/packages/guardian-client/src/types.ts @@ -98,6 +98,8 @@ export interface ProposalMetadata { recipientId?: string; faucetId?: string; amount?: string; + /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */ + noteType?: string; } export interface DeltaObject { diff --git a/packages/guardian-operator-client/src/http.ts b/packages/guardian-operator-client/src/http.ts index fe6c0255..ee6f7d86 100644 --- a/packages/guardian-operator-client/src/http.ts +++ b/packages/guardian-operator-client/src/http.ts @@ -1695,6 +1695,7 @@ function parseDeltaProposalMetadata( if (typeof record.recipient_id === 'string') proposal.recipientId = record.recipient_id; if (typeof record.faucet_id === 'string') proposal.faucetId = record.faucet_id; if (typeof record.amount === 'string') proposal.amount = record.amount; + if (typeof record.note_type === 'string') proposal.noteType = record.note_type; if (record.note_ids !== undefined) proposal.noteIds = assertStringArray( requireArray(record, 'note_ids', context), diff --git a/packages/guardian-operator-client/src/types.ts b/packages/guardian-operator-client/src/types.ts index 2f3fd64e..445ce0e6 100644 --- a/packages/guardian-operator-client/src/types.ts +++ b/packages/guardian-operator-client/src/types.ts @@ -288,6 +288,8 @@ export interface DashboardDeltaProposalMetadata { recipientId?: string; faucetId?: string; amount?: string; + /** P2ID note visibility, "public" or "private" (issue #322). Absent => public. */ + noteType?: string; noteIds?: string[]; consumeNotesMetadataVersion?: number; consumeNotesNotes?: string[]; diff --git a/packages/miden-multisig-client/src/index.ts b/packages/miden-multisig-client/src/index.ts index c3997a83..2031c473 100644 --- a/packages/miden-multisig-client/src/index.ts +++ b/packages/miden-multisig-client/src/index.ts @@ -55,6 +55,9 @@ export { buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idTransactionRequest, + parseP2idNoteType, + p2idNoteTypeToMetadata, + type P2idTransactionOptions, } from './transaction.js'; export { GuardianHttpClient, GuardianHttpError } from '@openzeppelin/guardian-client'; @@ -85,6 +88,8 @@ export { MAX_CONSUME_NOTES_METADATA_BYTES, isConsumeNotesV1, isConsumeNotesV2, + isP2idNoteVisibility, + type P2idNoteVisibility, } from './types/proposal.js'; export { diff --git a/packages/miden-multisig-client/src/multisig.test.ts b/packages/miden-multisig-client/src/multisig.test.ts index ca648b60..0c422fa0 100644 --- a/packages/miden-multisig-client/src/multisig.test.ts +++ b/packages/miden-multisig-client/src/multisig.test.ts @@ -22,6 +22,10 @@ vi.mock('@miden-sdk/miden-sdk', () => ({ AccountId: { fromHex: vi.fn((hex: string) => ({ toString: () => hex })), }, + NoteType: { + Private: 0, + Public: 1, + }, TransactionSummary: { deserialize: vi.fn().mockReturnValue({ toCommitment: () => ({ @@ -87,6 +91,14 @@ vi.mock('./transaction.js', () => ({ request: {}, salt: { toHex: () => '0x' + 'd'.repeat(64) }, }), + // Mirrors the real implementations against the mocked NoteType values + // (Private = 0, Public = 1). + parseP2idNoteType: vi.fn((value?: string) => { + if (value === undefined || value === 'public') return 1; + if (value === 'private') return 0; + throw new Error(`unsupported metadata.noteType '${value}': expected 'public' or 'private'`); + }), + p2idNoteTypeToMetadata: vi.fn((noteType?: number) => (noteType === 0 ? 'private' : undefined)), })); vi.mock('./utils/signature.js', async () => { @@ -1255,6 +1267,81 @@ describe('Multisig', () => { expect(proposal.metadata.description).toBe('Send 100 of asset 0xfaucet... to 0xrecipien...'); }); + + it('threads a private noteType into the request and wire metadata (issue #322)', async () => { + const { executeForSummary, buildP2idTransactionRequest } = await import('./transaction.js'); + const { NoteType } = await import('@miden-sdk/miden-sdk'); + vi.mocked(executeForSummary).mockResolvedValue({ + toCommitment: () => ({ + toHex: () => '0x' + 'c'.repeat(64), + }), + serialize: () => new Uint8Array([1, 2, 3]), + } as any); + + const config = { + threshold: 1, + signerCommitments: ['0x' + 'a'.repeat(64)], + guardianCommitment: '0x' + 'c'.repeat(64), + }; + + const multisig = createTestMultisig(config); + + const mockDelta = { + account_id: '0x' + 'a'.repeat(30), + nonce: 1, + prev_commitment: '0x' + 'b'.repeat(64), + delta_payload: { + tx_summary: { data: 'AQID' }, + signatures: [], + metadata: { + proposal_type: 'p2id', + recipient_id: '0xrecipient', + faucet_id: '0xfaucet', + amount: '100', + note_type: 'private', + description: '', + }, + }, + status: { + status: 'pending', + timestamp: '2024-01-01T00:00:00Z', + proposer_id: '0x' + 'c'.repeat(64), + cosigner_sigs: [], + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + delta: mockDelta, + commitment: '0x' + 'c'.repeat(64), + }), + }); + + const proposal = await multisig.createP2idProposal('0xrecipient', '0xfaucet', 100n, 1, { + noteType: NoteType.Private, + }); + + // Propose path builds the private note... + expect(vi.mocked(buildP2idTransactionRequest)).toHaveBeenCalledWith( + expect.any(String), + '0xrecipient', + '0xfaucet', + 100n, + { noteType: NoteType.Private }, + ); + // ...and the rebuild-from-metadata path parses note_type back to Private. + const lastCall = vi.mocked(buildP2idTransactionRequest).mock.calls.at(-1)!; + expect(lastCall[4]).toMatchObject({ noteType: NoteType.Private }); + + // The pushed wire metadata carries note_type so cosigners rebuild the + // same private note at verification/execution. + const pushBody = JSON.parse(mockFetch.mock.calls.at(-1)![1].body as string); + expect(pushBody.delta_payload.metadata.note_type).toBe('private'); + + expect(proposal.metadata.proposalType).toBe('p2id'); + expect((proposal.metadata as { noteType?: string }).noteType).toBe('private'); + }); }); describe('createChangeThresholdProposal', () => { diff --git a/packages/miden-multisig-client/src/multisig.ts b/packages/miden-multisig-client/src/multisig.ts index 9f05ab8f..e7dcdae0 100644 --- a/packages/miden-multisig-client/src/multisig.ts +++ b/packages/miden-multisig-client/src/multisig.ts @@ -29,6 +29,7 @@ import { Endpoint, FeltArray, Note, + NoteType, RpcClient, Signature, TransactionRequest, @@ -42,6 +43,8 @@ import { buildUpdateGuardianTransactionRequest, buildConsumeNotesTransactionRequest, buildP2idTransactionRequest, + parseP2idNoteType, + p2idNoteTypeToMetadata, } from './transaction.js'; import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js'; import { @@ -808,12 +811,15 @@ export class Multisig { * @param faucetId - Faucet/token account ID (hex string) * @param amount - Amount to send * @param nonce - Optional proposal nonce (defaults to Date.now()) + * @param options - Optional settings; `noteType` selects the created note's + * visibility (defaults to `NoteType.Public`, issue #322) */ async createP2idProposal( recipientId: string, faucetId: string, amount: bigint, nonce?: number, + options: { noteType?: NoteType } = {}, ): Promise { const webClient = await this.getRawClient(); if (amount <= 0n) { @@ -825,6 +831,7 @@ export class Multisig { recipientId, faucetId, amount, + { noteType: options.noteType }, ); const summary = await executeForSummary(webClient, this._accountId, request); @@ -838,6 +845,8 @@ export class Multisig { recipientId, faucetId, amount: amount.toString(), + // Omitted for public notes so the wire shape matches pre-#322 proposals. + noteType: p2idNoteTypeToMetadata(options.noteType), description: `Send ${amount} of asset ${faucetId.slice(0, 10)}... to ${recipientId.slice(0, 10)}...`, }; @@ -1684,7 +1693,7 @@ export class Multisig { metadata.recipientId, metadata.faucetId, BigInt(metadata.amount), - { salt, signatureAdviceMap } + { salt, signatureAdviceMap, noteType: parseP2idNoteType(metadata.noteType) } ); return request; } diff --git a/packages/miden-multisig-client/src/proposal/metadata.test.ts b/packages/miden-multisig-client/src/proposal/metadata.test.ts index 61cc71f9..3d1d5810 100644 --- a/packages/miden-multisig-client/src/proposal/metadata.test.ts +++ b/packages/miden-multisig-client/src/proposal/metadata.test.ts @@ -4,6 +4,7 @@ import { ProposalMetadataCodec } from './metadata.js'; import type { ConsumeNotesProposalMetadata, CustomProposalMetadata, + P2IdProposalMetadata, } from '../types/proposal.js'; describe('ProposalMetadataCodec consume_notes v2 round-trip (issue #229)', () => { @@ -97,3 +98,47 @@ describe('ProposalMetadataCodec custom proposal types (issue #266)', () => { expect(back.targetThreshold).toBe(2); }); }); + +describe('ProposalMetadataCodec p2id noteType (issue #322)', () => { + const baseWire: GuardianProposalMetadata = { + proposalType: 'p2id', + recipientId: '0xrecipient', + faucetId: '0xfaucet', + amount: '1000', + }; + + it('round-trips a private noteType through the codec', () => { + const md = ProposalMetadataCodec.fromGuardian({ + ...baseWire, + noteType: 'private', + }) as P2IdProposalMetadata; + expect(md.noteType).toBe('private'); + + const wire = ProposalMetadataCodec.toGuardian(md); + expect(wire.noteType).toBe('private'); + }); + + it('leaves noteType absent for legacy proposals (=> public)', () => { + const md = ProposalMetadataCodec.fromGuardian(baseWire) as P2IdProposalMetadata; + expect(md.noteType).toBeUndefined(); + expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined(); + }); + + it('fromGuardian rejects an unsupported noteType', () => { + expect(() => + ProposalMetadataCodec.fromGuardian({ ...baseWire, noteType: 'encrypted' }), + ).toThrow(/unsupported noteType/); + }); + + it('validate rejects an unsupported noteType', () => { + const md = { + proposalType: 'p2id', + description: '', + recipientId: '0xrecipient', + faucetId: '0xfaucet', + amount: '1000', + noteType: 'encrypted', + } as unknown as P2IdProposalMetadata; + expect(() => ProposalMetadataCodec.validate(md)).toThrow(/unsupported noteType/); + }); +}); diff --git a/packages/miden-multisig-client/src/proposal/metadata.ts b/packages/miden-multisig-client/src/proposal/metadata.ts index c5a82428..a70cf629 100644 --- a/packages/miden-multisig-client/src/proposal/metadata.ts +++ b/packages/miden-multisig-client/src/proposal/metadata.ts @@ -1,6 +1,7 @@ import type { ProposalMetadata as GuardianProposalMetadata } from '@openzeppelin/guardian-client'; import type { ProposalMetadata } from '../types.js'; import { isProcedureName } from '../procedures.js'; +import { isP2idNoteVisibility } from '../types/proposal.js'; export class ProposalMetadataCodec { static toGuardian(metadata: ProposalMetadata): GuardianProposalMetadata { @@ -25,6 +26,7 @@ export class ProposalMetadataCodec { recipientId: metadata.recipientId, faucetId: metadata.faucetId, amount: metadata.amount, + noteType: metadata.noteType, }; case 'switch_guardian': return { @@ -70,12 +72,18 @@ export class ProposalMetadataCodec { if (!guardian.recipientId || !guardian.faucetId || !guardian.amount) { throw new Error('p2id proposal is missing required metadata fields'); } + if (guardian.noteType !== undefined && !isP2idNoteVisibility(guardian.noteType)) { + throw new Error( + `p2id proposal has unsupported noteType '${guardian.noteType}': expected 'public' or 'private'`, + ); + } return { ...base, proposalType: 'p2id', recipientId: guardian.recipientId, faucetId: guardian.faucetId, amount: guardian.amount, + noteType: guardian.noteType, }; case 'consume_notes': if (!guardian.noteIds || guardian.noteIds.length === 0) { @@ -168,6 +176,9 @@ export class ProposalMetadataCodec { if (!metadata.recipientId || !metadata.faucetId || !metadata.amount) { throw new Error('p2id proposal metadata is incomplete'); } + if (metadata.noteType !== undefined && !isP2idNoteVisibility(metadata.noteType)) { + throw new Error(`p2id proposal has unsupported noteType '${metadata.noteType}'`); + } return metadata; case 'custom': // Custom proposals are opaque to the SDK; nothing to validate beyond diff --git a/packages/miden-multisig-client/src/transaction.ts b/packages/miden-multisig-client/src/transaction.ts index 9fd492ff..e71e081e 100644 --- a/packages/miden-multisig-client/src/transaction.ts +++ b/packages/miden-multisig-client/src/transaction.ts @@ -4,6 +4,9 @@ export { export { executeForSummary } from './transaction/summary.js'; export { buildP2idTransactionRequest, + parseP2idNoteType, + p2idNoteTypeToMetadata, + type P2idTransactionOptions, } from './transaction/p2id.js'; export { buildUpdateGuardianTransactionRequest, diff --git a/packages/miden-multisig-client/src/transaction/p2id.test.ts b/packages/miden-multisig-client/src/transaction/p2id.test.ts index 26c498e5..a52d79c1 100644 --- a/packages/miden-multisig-client/src/transaction/p2id.test.ts +++ b/packages/miden-multisig-client/src/transaction/p2id.test.ts @@ -6,6 +6,7 @@ const { mockNormalizeHexWord, mockRandomWord, mockWordFromHex, + noteMetadataCalls, saltFelts, } = vi.hoisted(() => { const saltFelts = [ @@ -16,6 +17,7 @@ const { ]; return { + noteMetadataCalls: [] as unknown[][], mockHashElements: vi.fn().mockReturnValue({ toString: () => 'serial' }), mockNormalizeHexWord: vi.fn((hex: string) => hex), mockRandomWord: vi.fn().mockReturnValue({ @@ -66,10 +68,12 @@ vi.mock('@miden-sdk/miden-sdk', () => { class NoteMetadata { constructor( - _sender: unknown, - _noteType: unknown, - _noteTag: unknown, - ) {} + sender: unknown, + noteType: unknown, + noteTag: unknown, + ) { + noteMetadataCalls.push([sender, noteType, noteTag]); + } } class NoteRecipient { @@ -140,7 +144,8 @@ vi.mock('@miden-sdk/miden-sdk', () => { withAccountTarget: vi.fn(() => ({ kind: 'tag' })), }, NoteType: { - Public: 'public', + Private: 0, + Public: 1, }, OutputNote: { full: vi.fn((note: unknown) => ({ note })), @@ -163,7 +168,8 @@ vi.mock('../utils/random.js', () => ({ randomWord: mockRandomWord, })); -import { buildP2idTransactionRequest } from './p2id.js'; +import { NoteType } from '@miden-sdk/miden-sdk'; +import { buildP2idTransactionRequest, parseP2idNoteType, p2idNoteTypeToMetadata } from './p2id.js'; describe('buildP2idTransactionRequest', () => { beforeEach(() => { @@ -171,6 +177,7 @@ describe('buildP2idTransactionRequest', () => { mockNormalizeHexWord.mockClear(); mockRandomWord.mockClear(); mockWordFromHex.mockClear(); + noteMetadataCalls.length = 0; }); it('derives serial number from salt felts plus four zero felts', () => { @@ -197,4 +204,55 @@ describe('buildP2idTransactionRequest', () => { expect((felt as { value: bigint }).value).toBe(0n); } }); + + it('creates a public note by default (issue #322)', () => { + buildP2idTransactionRequest( + '0x7bfb0f38b0fafa103f86a805594170', + '0x8a65fc5a39e4cd106d648e3eb4ab5f', + '0x7bfb0f38b0fafa103f86a805594171', + 10n, + ); + + expect(noteMetadataCalls).toHaveLength(1); + expect(noteMetadataCalls[0][1]).toBe(NoteType.Public); + }); + + it('threads the requested noteType into the note metadata (issue #322)', () => { + buildP2idTransactionRequest( + '0x7bfb0f38b0fafa103f86a805594170', + '0x8a65fc5a39e4cd106d648e3eb4ab5f', + '0x7bfb0f38b0fafa103f86a805594171', + 10n, + { noteType: NoteType.Private }, + ); + + expect(noteMetadataCalls).toHaveLength(1); + expect(noteMetadataCalls[0][1]).toBe(NoteType.Private); + }); +}); + +describe('parseP2idNoteType', () => { + it('maps absent to Public (pre-#322 proposals)', () => { + expect(parseP2idNoteType(undefined)).toBe(NoteType.Public); + }); + + it('maps wire values to note types', () => { + expect(parseP2idNoteType('public')).toBe(NoteType.Public); + expect(parseP2idNoteType('private')).toBe(NoteType.Private); + }); + + it('rejects unknown values instead of silently rebuilding a public note', () => { + expect(() => parseP2idNoteType('encrypted')).toThrow(/unsupported metadata.noteType/); + }); +}); + +describe('p2idNoteTypeToMetadata', () => { + it('omits the default so public payloads keep the legacy wire shape', () => { + expect(p2idNoteTypeToMetadata(undefined)).toBeUndefined(); + expect(p2idNoteTypeToMetadata(NoteType.Public)).toBeUndefined(); + }); + + it('serializes private', () => { + expect(p2idNoteTypeToMetadata(NoteType.Private)).toBe('private'); + }); }); diff --git a/packages/miden-multisig-client/src/transaction/p2id.ts b/packages/miden-multisig-client/src/transaction/p2id.ts index a9dd4acb..fed55e92 100644 --- a/packages/miden-multisig-client/src/transaction/p2id.ts +++ b/packages/miden-multisig-client/src/transaction/p2id.ts @@ -19,6 +19,38 @@ import { import { randomWord } from '../utils/random.js'; import { normalizeHexWord } from '../utils/encoding.js'; import type { SignatureOptions } from './options.js'; +import type { P2idNoteVisibility } from '../types/proposal.js'; + +export interface P2idTransactionOptions extends SignatureOptions { + /** Visibility of the created note. Defaults to `NoteType.Public`. */ + noteType?: NoteType; +} + +/** + * Parses the metadata wire value for a P2ID note visibility (issue #322). + * Absent => public, the only behavior before the field existed. An unknown + * value is rejected rather than silently rebuilt as a public note that could + * never match the signed tx_summary commitment. + */ +export function parseP2idNoteType(value: string | undefined): NoteType { + switch (value) { + case undefined: + case 'public': + return NoteType.Public; + case 'private': + return NoteType.Private; + default: + throw new Error(`unsupported metadata.noteType '${value}': expected 'public' or 'private'`); + } +} + +/** + * Maps a note type to its metadata wire value, omitting the default so + * public-note payloads keep the pre-#322 wire shape. + */ +export function p2idNoteTypeToMetadata(noteType: NoteType | undefined): P2idNoteVisibility | undefined { + return noteType === NoteType.Private ? 'private' : undefined; +} export function deriveP2idSerialNumber(salt: Word): Word { const zeroWord = WordType.fromHex(`0x${'00'.repeat(32)}`); @@ -61,7 +93,7 @@ export function buildP2idTransactionRequest( recipientId: string, faucetId: string, amount: bigint, - options: SignatureOptions = {}, + options: P2idTransactionOptions = {}, ): { request: TransactionRequest; salt: Word } { const sender = AccountId.fromHex(senderId); const recipient = AccountId.fromHex(recipientId); @@ -76,7 +108,7 @@ export function buildP2idTransactionRequest( sender, recipient, noteAssets, - NoteType.Public, + options.noteType ?? NoteType.Public, authSaltHex, ); diff --git a/packages/miden-multisig-client/src/types/proposal.ts b/packages/miden-multisig-client/src/types/proposal.ts index b9af7042..c9db11be 100644 --- a/packages/miden-multisig-client/src/types/proposal.ts +++ b/packages/miden-multisig-client/src/types/proposal.ts @@ -82,11 +82,20 @@ export function isConsumeNotesV1(md: ConsumeNotesProposalMetadata): boolean { return md.metadataVersion === undefined || md.metadataVersion === 1; } +/** Wire values for a P2ID note's visibility (issue #322). */ +export type P2idNoteVisibility = 'public' | 'private'; + +export function isP2idNoteVisibility(value: string): value is P2idNoteVisibility { + return value === 'public' || value === 'private'; +} + export interface P2IdProposalMetadata extends BaseProposalMetadata { proposalType: 'p2id'; recipientId: string; faucetId: string; amount: string; + /** Visibility of the created note. Absent on the wire => 'public' (pre-#322 proposals). */ + noteType?: P2idNoteVisibility; } export interface CustomProposalMetadata extends BaseProposalMetadata { From 064a5d8d972962b104ebe268d3561f0984bcc4e3 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 22 Jul 2026 13:16:14 +0200 Subject: [PATCH 2/2] chore: Small improvements --- crates/miden-multisig-client/README.md | 9 +++++- crates/miden-multisig-client/src/lib.rs | 2 +- docs/MULTISIG_SDK.md | 29 +++++++++++++++++-- examples/smoke-web/src/App.tsx | 9 ++++++ packages/miden-multisig-client/README.md | 5 ++++ .../src/proposal/metadata.test.ts | 15 ++++++++++ .../src/proposal/metadata.ts | 5 +++- spec/api.md | 2 ++ 8 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/miden-multisig-client/README.md b/crates/miden-multisig-client/README.md index 139bcebf..7a2b44e9 100644 --- a/crates/miden-multisig-client/README.md +++ b/crates/miden-multisig-client/README.md @@ -63,6 +63,12 @@ let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b")?; let faucet = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c")?; let tx = TransactionType::transfer(recipient, faucet, 1_000); +// P2ID notes are public by default. For a private note (only its hash is +// published on chain; the recipient needs the note shared out-of-band) use +// `transfer_with_note_type`. `NoteType` is re-exported from `miden_protocol::note`. +use miden_protocol::note::NoteType; +let tx = TransactionType::transfer_with_note_type(recipient, faucet, 1_000, NoteType::Private); + // Proposer creates the delta on GUARDIAN let proposal = client.propose_transaction(tx).await?; @@ -152,11 +158,12 @@ on-chain transaction — the integration owns that recipe and drives execution. ```rust use miden_client::Serializable; use miden_multisig_client::{build_p2id_transaction_request, generate_salt}; +use miden_protocol::note::NoteType; // Producer: build a transaction and propose it under a custom label. let salt = generate_salt(); let mut request = build_p2id_transaction_request( - account.inner(), recipient, vec![asset], salt, std::iter::empty(), + account.inner(), recipient, vec![asset], NoteType::Public, salt, std::iter::empty(), )?; let proposal = client.propose_custom_transaction(&request.to_bytes(), "b2agg").await?; diff --git a/crates/miden-multisig-client/src/lib.rs b/crates/miden-multisig-client/src/lib.rs index 06449077..dfb657b9 100644 --- a/crates/miden-multisig-client/src/lib.rs +++ b/crates/miden-multisig-client/src/lib.rs @@ -105,4 +105,4 @@ pub use miden_protocol::account::AccountId; pub use miden_protocol::asset::Asset; pub use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey as EcdsaSecretKey; pub use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; -pub use miden_protocol::note::NoteId; +pub use miden_protocol::note::{NoteId, NoteType}; diff --git a/docs/MULTISIG_SDK.md b/docs/MULTISIG_SDK.md index 3ccbe7e9..13cef35b 100644 --- a/docs/MULTISIG_SDK.md +++ b/docs/MULTISIG_SDK.md @@ -379,8 +379,24 @@ const proposal = await multisig.createP2idProposal( faucetAccountId, // Faucet (token) ID 1000n // Amount to send ); + +// Private note: only the note's hash is published on chain. Pass `noteType` +// in the options object (`NoteType` comes from `@miden-sdk/miden-sdk`). +import { NoteType } from '@miden-sdk/miden-sdk'; + +const privateProposal = await multisig.createP2idProposal( + recipientAccountId, + faucetAccountId, + 1000n, + undefined, // nonce (defaults to Date.now()) + { noteType: NoteType.Private }, // note visibility; defaults to NoteType.Public +); ``` +> **Warning:** a `private` P2ID note publishes only its hash on chain. The +> recipient cannot discover the note by syncing; the full note details must +> be shared with them out-of-band before they can consume it. + #### Consume Notes (Claim Received Funds) ```typescript @@ -490,7 +506,7 @@ await multisig.executeProposal(signedProposal.id); | `registerOnGuardian()` | Register new account with GUARDIAN | | `syncProposals()` | Sync proposals from GUARDIAN | | `listProposals()` | Get cached proposals | -| `createP2idProposal(recipient, faucet, amount, nonce?)` | Create transfer proposal | +| `createP2idProposal(recipient, faucet, amount, nonce?, { noteType }?)` | Create transfer proposal (`noteType`: `NoteType.Public` (default) or `NoteType.Private`) | | `createConsumeNotesProposal(noteIds, nonce?)` | Create note consumption proposal | | `createAddSignerProposal(commitment, nonce?, threshold?)` | Create add signer proposal | | `createRemoveSignerProposal(commitment, nonce?, threshold?)` | Create remove signer proposal | @@ -620,9 +636,16 @@ responses, and per-account `get_state` failures are returned as errors. ### Transaction Types ```rust -// P2ID Transfer +// P2ID Transfer (public note) let tx = TransactionType::transfer(recipient_id, faucet_id, 1000); +// P2ID Transfer with a private note (only the note hash is published on +// chain; the recipient needs the note shared out-of-band). `NoteType` is +// re-exported from `miden_protocol::note`. +let tx = TransactionType::transfer_with_note_type( + recipient_id, faucet_id, 1000, NoteType::Private, +); + // Consume Notes let tx = TransactionType::consume_notes(vec![note_id1, note_id2]); @@ -776,7 +799,7 @@ for note in notes { | Variant | Description | |---------|-------------| -| `P2ID { recipient, faucet_id, amount }` | Transfer funds | +| `P2ID { recipient, faucet_id, amount, note_type }` | Transfer funds (`note_type` selects note visibility; defaults to `NoteType::Public` via `transfer()`) | | `ConsumeNotes { note_ids }` | Consume notes | | `AddCosigner { new_commitment }` | Add signer | | `RemoveCosigner { commitment }` | Remove signer | diff --git a/examples/smoke-web/src/App.tsx b/examples/smoke-web/src/App.tsx index 948764ea..7b8b9d18 100644 --- a/examples/smoke-web/src/App.tsx +++ b/examples/smoke-web/src/App.tsx @@ -701,6 +701,15 @@ await window.smoke.createProposal({ increaseThreshold: false, }); +// P2ID transfer; noteType 'private' keeps the note off-chain (default 'public'). +await window.smoke.createProposal({ + type: 'p2id', + recipientId: '0x...', + faucetId: '0x...', + amount: '100', + noteType: 'private', +}); + // Custom proposal (producer API): create, sign on cosigner tabs, then execute. const { recipe } = await window.smoke.createCustomProposal({ recipientId: '0x...', diff --git a/packages/miden-multisig-client/README.md b/packages/miden-multisig-client/README.md index 9e47244a..35933014 100644 --- a/packages/miden-multisig-client/README.md +++ b/packages/miden-multisig-client/README.md @@ -216,6 +216,11 @@ transaction — the integration owns that recipe and submits it itself. import { buildP2idTransactionRequest } from '@openzeppelin/miden-multisig-client'; // Producer: build a transaction and propose it under a custom label. +// The options object accepts `noteType` (`NoteType.Public` (default) or +// `NoteType.Private`, from `@miden-sdk/miden-sdk`); a private note publishes +// only its hash on chain, so the recipient needs the note shared out-of-band. +// The typed path is `createP2idProposal(recipient, faucet, amount, nonce?, +// { noteType })`, which persists the choice in signed metadata. const { request, salt } = buildP2idTransactionRequest(senderId, recipientId, faucetId, amount); const proposal = await multisig.createCustomProposal(request.serialize(), 'b2agg'); diff --git a/packages/miden-multisig-client/src/proposal/metadata.test.ts b/packages/miden-multisig-client/src/proposal/metadata.test.ts index 3d1d5810..35af16d7 100644 --- a/packages/miden-multisig-client/src/proposal/metadata.test.ts +++ b/packages/miden-multisig-client/src/proposal/metadata.test.ts @@ -124,6 +124,21 @@ describe('ProposalMetadataCodec p2id noteType (issue #322)', () => { expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined(); }); + it('canonicalizes an explicit public noteType to absent on encode', () => { + const md = { + proposalType: 'p2id', + description: '', + recipientId: '0xrecipient', + faucetId: '0xfaucet', + amount: '1000', + noteType: 'public', + } as P2IdProposalMetadata; + + // toGuardian omits the field so a public note keeps the pre-#322 wire + // shape and matches the Rust encoder, even if handed an explicit 'public'. + expect(ProposalMetadataCodec.toGuardian(md).noteType).toBeUndefined(); + }); + it('fromGuardian rejects an unsupported noteType', () => { expect(() => ProposalMetadataCodec.fromGuardian({ ...baseWire, noteType: 'encrypted' }), diff --git a/packages/miden-multisig-client/src/proposal/metadata.ts b/packages/miden-multisig-client/src/proposal/metadata.ts index a70cf629..71bb0953 100644 --- a/packages/miden-multisig-client/src/proposal/metadata.ts +++ b/packages/miden-multisig-client/src/proposal/metadata.ts @@ -26,7 +26,10 @@ export class ProposalMetadataCodec { recipientId: metadata.recipientId, faucetId: metadata.faucetId, amount: metadata.amount, - noteType: metadata.noteType, + // Canonicalize: emit note_type only when private, so a public note + // keeps the pre-#322 wire shape and matches the Rust encoder (which + // round-trips through the NoteType enum). Absent => public. + noteType: metadata.noteType === 'private' ? 'private' : undefined, }; case 'switch_guardian': return { diff --git a/spec/api.md b/spec/api.md index 5021da36..614ab96b 100644 --- a/spec/api.md +++ b/spec/api.md @@ -147,6 +147,8 @@ Miden delta proposals use: `metadata.proposal_type` is required and must be a non-empty string, but its value is **not** restricted to a fixed set: the server accepts any label (issue #266). The first-party multisig operations use `add_signer`, `remove_signer`, `change_threshold`, `update_procedure_threshold`, `switch_guardian`, `consume_notes`, and `p2id`; any other label is accepted and surfaced verbatim. Clients that do not model a given label bucket it as `custom` while preserving the original string for display. The server makes no security decision based on `proposal_type` — integrity comes from the tx_summary/state-commitment check, the cosigner threshold, and the GUARDIAN ack. Restricting which types an account may submit is a policy-layer concern, not a core-server one. +`p2id` metadata carries `recipient_id`, `faucet_id`, `amount`, and an optional `note_type` (`"public"` or `"private"`, issue #322). Clients emit `note_type` only when the note is private; an absent field means public, which keeps proposals created before the field existed valid. The value is part of the signed metadata: verifiers rebuild the transaction from it, so a tampered `note_type` fails the tx_summary commitment check. + EVM proposals use EVM-specific request and response shapes under `/evm/proposals`. They do not use `DeltaObject` or the `/delta/proposal` envelope. EVM proposal creation request: