diff --git a/CHANGELOG.md b/CHANGELOG.md index 541caee308..5f0b2f0a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - [BREAKING] AggLayer bridge and faucet accounts now map note repricing to an initial `FEE_MNGR` role instead of the built-in `ADMIN` role ([#3571](https://github.com/0xMiden/protocol/issues/3571)). - [BREAKING] AggLayer bridge accounts now map emergency pause to an initial `PAUSER` role, while unpause remains restricted to `ADMIN` ([#3572](https://github.com/0xMiden/protocol/issues/3572)). - Added the block kernel skeleton, establishing its public input/output contract and the `BlockExecutor` that runs it ([#3703](https://github.com/0xMiden/protocol/pull/3703)). +- Added the `miden-objects` crate with canonical, `no_std`-compatible Protobuf representations and validated conversions for protocol objects exchanged between clients and nodes ([#3707](https://github.com/0xMiden/protocol/pull/3707)). +- Added canonical Protobuf representations and validated conversions for `TransactionInputs` ([#3776](https://github.com/0xMiden/protocol/pull/3776)). ### Changes diff --git a/Cargo.lock b/Cargo.lock index 88239d4ab7..5bf4bb3f31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bench-note-checker" version = "0.1.0" @@ -1477,6 +1483,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flume" version = "0.12.0" @@ -1486,6 +1498,18 @@ dependencies = [ "spin 0.9.9", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1688,13 +1712,22 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2035,6 +2068,72 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" +dependencies = [ + "logos-derive 0.15.1", +] + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive 0.16.1", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version 0.4.1", + "syn 2.0.119", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen 0.15.1", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen 0.16.1", +] + [[package]] name = "loom" version = "0.7.2" @@ -2446,6 +2545,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "miden-objects" +version = "0.17.0" +dependencies = [ + "assert_matches", + "miden-protocol", + "prost", + "prost-build", + "protox", +] + [[package]] name = "miden-package-registry" version = "0.30.0" @@ -2791,6 +2901,28 @@ dependencies = [ "thiserror", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2800,6 +2932,12 @@ dependencies = [ "adler2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nix" version = "0.26.4" @@ -3236,6 +3374,17 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3477,6 +3626,94 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.13.0", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "logos 0.16.1", + "miette", + "prost", + "prost-types", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protox" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1" +dependencies = [ + "bytes", + "miette", + "prost", + "prost-reflect", + "prost-types", + "protox-parse", + "thiserror", +] + +[[package]] +name = "protox-parse" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366" +dependencies = [ + "logos 0.15.1", + "miette", + "prost-types", + "thiserror", +] + [[package]] name = "pubgrub" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 7c28805d74..6c290b12da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "bin/bench-transaction", "crates/miden-agglayer", "crates/miden-block-prover", + "crates/miden-objects", "crates/miden-protocol", "crates/miden-protocol-build-utils", "crates/miden-standards", @@ -70,6 +71,9 @@ bon = { default-features = false, version = "3" } criterion = { default-features = false, version = "0.5" } fs-err = { default-features = false, version = "3" } primitive-types = { default-features = false, version = "0.14" } +prost = { default-features = false, version = "0.14" } +prost-build = { default-features = false, version = "0.14" } +protox = { default-features = false, version = "0.9" } rand = { default-features = false, version = "0.10" } rand_chacha = { default-features = false, version = "0.10" } regex = { version = "1.11" } diff --git a/README.md b/README.md index 3d5e4d12a1..d80a1d6f58 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ We expect to keep making changes (including breaking changes) to all components. | [miden-agglayer](crates/miden-agglayer) | AggLayer components for the Miden protocol. | | [miden-block-prover](crates/miden-block-prover) | Block execution and proving tools. | | [miden-protocol](crates/miden-protocol) | Core protocol components, including the protocol kernels. | +| [miden-objects](crates/miden-objects) | Canonical Protobuf representations for protocol objects exchanged by clients and nodes. | | [miden-protocol-build-utils](crates/miden-protocol-build-utils) | Build-time MASM helpers. | | [miden-standards](crates/miden-standards) | Standardized smart contracts. | | [miden-testing](crates/miden-testing) | Testing tools for Miden transactions, batches, and blocks. | diff --git a/crates/miden-objects/Cargo.toml b/crates/miden-objects/Cargo.toml new file mode 100644 index 0000000000..e392c68898 --- /dev/null +++ b/crates/miden-objects/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors.workspace = true +categories = ["encoding", "no-std"] +description = "Canonical Protobuf representations for Miden protocol objects" +edition.workspace = true +homepage.workspace = true +keywords = ["miden", "protobuf", "protocol", "serialization"] +license.workspace = true +name = "miden-objects" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[features] +default = ["std"] +std = ["miden-protocol/std", "prost/std"] + +[dependencies] +miden-protocol = { workspace = true } +prost = { features = ["derive"], workspace = true } + +[build-dependencies] +prost = { features = ["derive"], workspace = true } +prost-build = { workspace = true } +protox = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +miden-protocol = { features = ["testing"], workspace = true } diff --git a/crates/miden-objects/README.md b/crates/miden-objects/README.md new file mode 100644 index 0000000000..a7e68ea109 --- /dev/null +++ b/crates/miden-objects/README.md @@ -0,0 +1,18 @@ +# Miden Objects + +Canonical Protobuf representations for values exchanged between Miden clients and nodes. + +This crate owns the transport representation and conversions for protocol objects. It does not +define protocol commitments, replace the protocol's native serialization, or define RPC services. +Generated messages are exposed under `miden_objects::proto`. + +The crate supports `no_std` consumers when default features are disabled. + +RPC crates can use `FILE_DESCRIPTOR_SET` as the import descriptor and apply every entry in +`EXTERN_PATHS` with `prost_build::Config::extern_path`. This makes imported object messages resolve +to the canonical generated types from this crate instead of generating duplicate Rust types in the +RPC crate. + +## License + +This project is [MIT licensed](../../LICENSE). diff --git a/crates/miden-objects/build.rs b/crates/miden-objects/build.rs new file mode 100644 index 0000000000..f64f909dc0 --- /dev/null +++ b/crates/miden-objects/build.rs @@ -0,0 +1,35 @@ +use std::env; +use std::path::PathBuf; + +use prost::Message; + +fn main() -> Result<(), Box> { + println!("cargo::rerun-if-changed=proto"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let proto_dir = manifest_dir.join("proto"); + let files = [ + "primitives.proto", + "block_number.proto", + "account.proto", + "asset.proto", + "protocol_config.proto", + "note.proto", + "transaction.proto", + "block.proto", + "partial_blockchain.proto", + "transaction_inputs.proto", + "batch.proto", + ]; + + let mut compiler = protox::Compiler::new([&proto_dir])?; + compiler.include_imports(true); + compiler.open_files(files.iter().map(|file| proto_dir.join(file)))?; + let descriptors = compiler.file_descriptor_set(); + + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + std::fs::write(out_dir.join("miden_objects_descriptor.bin"), descriptors.encode_to_vec())?; + + prost_build::Config::new().out_dir(out_dir).compile_fds(descriptors)?; + Ok(()) +} diff --git a/crates/miden-objects/proto/account.proto b/crates/miden-objects/proto/account.proto new file mode 100644 index 0000000000..0308ef1f58 --- /dev/null +++ b/crates/miden-objects/proto/account.proto @@ -0,0 +1,146 @@ +syntax = "proto3"; +package account; + +import "primitives.proto"; + +message AccountId { + // Exactly 15 bytes in the protocol's canonical AccountId encoding. + bytes id = 1; +} + +message AccountStorageHeader { + message StorageSlot { + string slot_name = 1; + StorageSlotType slot_type = 2; + primitives.Word commitment = 3; + } + + repeated StorageSlot slots = 1; +} + +enum StorageSlotType { + STORAGE_SLOT_TYPE_UNSPECIFIED = 0; + STORAGE_SLOT_TYPE_VALUE = 1; + STORAGE_SLOT_TYPE_MAP = 2; +} + +enum AccountVersion { + ACCOUNT_VERSION_UNSPECIFIED = 0; + ACCOUNT_VERSION_V1 = 1; +} + +message AccountHeader { + AccountVersion version = 1; + AccountId account_id = 2; + primitives.Word vault_root = 3; + primitives.Word storage_commitment = 4; + primitives.Word code_commitment = 5; + uint64 nonce = 6; +} + +message AccountWitness { + AccountId witness_id = 1; + primitives.Word commitment = 2; + primitives.SparseMerklePath path = 3; +} + +message AccountCode { + primitives.MastForest mast = 1; + repeated primitives.Word procedure_roots = 2; +} + +message StorageSlotId { + primitives.Felt suffix = 1; + primitives.Felt prefix = 2; +} + +message PartialAccount { + AccountId account_id = 1; + primitives.Felt nonce = 2; + AccountCode code = 3; + PartialStorage storage = 4; + PartialVault vault = 5; + optional primitives.Word seed = 6; +} + +message PartialStorage { + AccountStorageHeader header = 1; + repeated PartialStorageMap maps = 2; +} + +message PartialStorageMap { + primitives.PartialSmt smt = 1; + repeated primitives.Word keys = 2; +} + +message PartialVault { + primitives.PartialSmt smt = 1; + repeated primitives.Word asset_ids = 2; +} + +enum StoragePatchOperation { + STORAGE_PATCH_OPERATION_UNSPECIFIED = 0; + STORAGE_PATCH_OPERATION_CREATE = 1; + STORAGE_PATCH_OPERATION_UPDATE = 2; + STORAGE_PATCH_OPERATION_REMOVE = 3; +} + +message StorageValuePatch { + StoragePatchOperation operation = 1; + primitives.Word value = 2; +} + +message StorageMapEntry { + primitives.Word key = 1; + primitives.Word value = 2; +} + +message StorageMapPatch { + StoragePatchOperation operation = 1; + repeated StorageMapEntry entries = 2; +} + +message StorageSlotPatch { + string slot_name = 1; + oneof patch { + StorageValuePatch value = 2; + StorageMapPatch map = 3; + } +} + +message AccountStoragePatch { + repeated StorageSlotPatch slots = 1; +} + +message AccountVaultPatchEntry { + primitives.Word asset_id = 1; + // An empty word removes the asset; any other value must form a valid asset with asset_id. + primitives.Word value = 2; +} + +message AccountVaultPatch { + repeated AccountVaultPatchEntry entries = 1; +} + +enum AccountPatchVersion { + ACCOUNT_PATCH_VERSION_UNSPECIFIED = 0; + ACCOUNT_PATCH_VERSION_V1 = 1; +} + +message AccountPatch { + AccountPatchVersion version = 1; + AccountId account_id = 2; + AccountStoragePatch storage = 3; + AccountVaultPatch vault = 4; + optional AccountCode code = 5; + optional primitives.Felt final_nonce = 6; +} + +message PrivateAccountUpdate {} + +message AccountUpdateDetails { + oneof update { + PrivateAccountUpdate private = 1; + AccountPatch public = 2; + } +} diff --git a/crates/miden-objects/proto/asset.proto b/crates/miden-objects/proto/asset.proto new file mode 100644 index 0000000000..c5887cc291 --- /dev/null +++ b/crates/miden-objects/proto/asset.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; +package asset; + +import "account.proto"; +import "primitives.proto"; + +enum AssetVersion { + ASSET_VERSION_UNSPECIFIED = 0; + ASSET_VERSION_V1 = 1; +} + +message AssetClass { + primitives.Felt suffix = 1; + primitives.Felt prefix = 2; +} + +enum AssetComposition { + ASSET_COMPOSITION_UNSPECIFIED = 0; + ASSET_COMPOSITION_NONE = 1; + ASSET_COMPOSITION_FUNGIBLE = 2; + ASSET_COMPOSITION_CUSTOM = 3; +} + +message AssetId { + AssetVersion version = 1; + AssetClass asset_class = 2; + AssetComposition composition = 3; + account.AccountId faucet_id = 4; +} + +message Asset { + AssetId asset_id = 1; + primitives.Word value = 2; +} diff --git a/crates/miden-objects/proto/batch.proto b/crates/miden-objects/proto/batch.proto new file mode 100644 index 0000000000..c2744bdc42 --- /dev/null +++ b/crates/miden-objects/proto/batch.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; +package transaction; + +import "account.proto"; +import "block.proto"; +import "block_number.proto"; +import "note.proto"; +import "partial_blockchain.proto"; +import "primitives.proto"; +import "transaction.proto"; + +message ProposedBatch { + repeated ProvenTransaction transactions = 1; + blockchain.BlockHeader reference_block_header = 2; + blockchain.PartialBlockchain partial_blockchain = 3; + repeated note.NoteInclusionProof unauthenticated_note_proofs = 4; +} + +message BatchAccountUpdate { + account.AccountId account_id = 1; + primitives.Word initial_state_commitment = 2; + primitives.Word final_state_commitment = 3; + account.AccountUpdateDetails details = 4; +} + +message ProvenBatch { + primitives.Word reference_block_commitment = 1; + blockchain.BlockNumber reference_block_num = 2; + repeated BatchAccountUpdate account_updates = 3; + repeated InputNoteCommitment input_notes = 4; + repeated OutputNote output_notes = 5; + blockchain.BlockNumber expiration_block_num = 6; + repeated TransactionHeader transactions = 7; + primitives.ExecutionProof proof = 8; +} diff --git a/crates/miden-objects/proto/block.proto b/crates/miden-objects/proto/block.proto new file mode 100644 index 0000000000..a0e319a5cd --- /dev/null +++ b/crates/miden-objects/proto/block.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; +package blockchain; + +import "account.proto"; +import "block_number.proto"; +import "primitives.proto"; +import "transaction.proto"; + +message FeeParameters { + fixed32 verification_base_fee = 1; +} + +message ValidatorConfig { + repeated primitives.PublicKey keys = 1; + uint32 quorum = 2; +} + +message NextProtocolConfig { + blockchain.BlockNumber effective_from = 1; + primitives.Word protocol_config = 2; +} + +enum BlockVersion { + BLOCK_VERSION_UNSPECIFIED = 0; + BLOCK_VERSION_V1 = 1; +} + +message BlockHeader { + BlockVersion version = 1; + fixed32 timestamp = 2; + blockchain.BlockNumber block_num = 3; + primitives.Word prev_block_commitment = 4; + primitives.Word chain_commitment = 5; + primitives.Word account_root = 6; + primitives.Word nullifier_root = 7; + primitives.Word note_root = 8; + primitives.Word tx_commitment = 9; + ValidatorConfig validator_config = 10; + FeeParameters fee_parameters = 11; + primitives.Word protocol_config_commitment = 12; + NextProtocolConfig next_protocol_config = 13; +} + +message BlockAccountUpdate { + account.AccountId account_id = 1; + primitives.Word final_state_commitment = 2; + account.AccountUpdateDetails details = 3; +} + +message IndexedOutputNote { + uint32 note_index_in_batch = 1; + transaction.OutputNote note = 2; +} + +message OutputNoteBatch { + repeated IndexedOutputNote notes = 1; +} + +message BlockBody { + repeated BlockAccountUpdate updated_accounts = 1; + repeated OutputNoteBatch output_note_batches = 2; + repeated primitives.Word created_nullifiers = 3; + repeated transaction.TransactionHeader transactions = 4; +} + +message SignedBlock { + BlockHeader header = 1; + BlockBody body = 2; + repeated primitives.Signature signatures = 3; +} diff --git a/crates/miden-objects/proto/block_number.proto b/crates/miden-objects/proto/block_number.proto new file mode 100644 index 0000000000..0a5b7d0dd4 --- /dev/null +++ b/crates/miden-objects/proto/block_number.proto @@ -0,0 +1,6 @@ +syntax = "proto3"; +package blockchain; + +message BlockNumber { + fixed32 block_num = 1; +} diff --git a/crates/miden-objects/proto/note.proto b/crates/miden-objects/proto/note.proto new file mode 100644 index 0000000000..09ee065866 --- /dev/null +++ b/crates/miden-objects/proto/note.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; +package note; + +import "account.proto"; +import "asset.proto"; +import "block_number.proto"; +import "primitives.proto"; + +enum NoteType { + NOTE_TYPE_UNSPECIFIED = 0; + NOTE_TYPE_PRIVATE = 1; + NOTE_TYPE_PUBLIC = 2; +} + +enum NoteVersion { + NOTE_VERSION_UNSPECIFIED = 0; + NOTE_VERSION_V1 = 1; +} + +message NoteId { + primitives.Word id = 1; +} + +message NoteMetadata { + NoteVersion version = 1; + account.AccountId sender = 2; + NoteType note_type = 3; + fixed32 tag = 4; + repeated fixed32 attachment_schemes = 5; + primitives.Word attachments_commitment = 6; +} + +message NoteAttachment { + uint32 scheme = 1; + repeated primitives.Word words = 2; +} + +message NoteAttachments { + repeated NoteAttachment attachments = 1; +} + +message NoteStorage { + repeated primitives.Felt items = 1; +} + +message NoteRecipient { + primitives.Word serial_num = 1; + NoteScript script = 2; + NoteStorage storage = 3; +} + +message NoteDetails { + repeated asset.Asset assets = 1; + NoteRecipient recipient = 2; +} + +message Note { + NoteMetadata metadata = 1; + NoteDetails note_details = 2; + NoteAttachments note_attachments = 3; +} + +message NoteInclusionProof { + NoteId note_id = 1; + blockchain.BlockNumber block_num = 2; + uint32 note_index_in_block = 3; + primitives.SparseMerklePath inclusion_path = 4; +} + +message NoteHeader { + NoteMetadata metadata = 1; + primitives.Word details_commitment = 2; +} + +message NoteScript { + uint32 entrypoint = 1; + primitives.MastForest mast = 2; +} diff --git a/crates/miden-objects/proto/partial_blockchain.proto b/crates/miden-objects/proto/partial_blockchain.proto new file mode 100644 index 0000000000..5730c209ed --- /dev/null +++ b/crates/miden-objects/proto/partial_blockchain.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package blockchain; + +import "block.proto"; +import "primitives.proto"; + +message TrackedMmrLeaf { + fixed64 position = 1; + primitives.Word leaf = 2; + repeated primitives.Word path = 3; +} + +message PartialBlockchain { + fixed64 forest = 1; + repeated primitives.Word peaks = 2; + repeated TrackedMmrLeaf tracked_leaves = 3; + repeated BlockHeader block_headers = 4; +} diff --git a/crates/miden-objects/proto/primitives.proto b/crates/miden-objects/proto/primitives.proto new file mode 100644 index 0000000000..f0fa43d4d0 --- /dev/null +++ b/crates/miden-objects/proto/primitives.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; +package primitives; + +message Felt { + fixed64 value = 1; +} + +message Word { + // Exactly 32 bytes containing four canonically serialized field elements. + bytes encoded = 1; +} + +message ExecutionProof { + // Canonically serialized protocol execution proof. + bytes encoded = 1; +} + +message MastForest { + // Canonically serialized MAST forest. + bytes encoded = 1; +} + +enum PublicKeyVariant { + PUBLIC_KEY_VARIANT_UNSPECIFIED = 0; + PUBLIC_KEY_VARIANT_ECDSA_K256_KECCAK = 1; +} + +message PublicKey { + PublicKeyVariant variant = 1; + bytes encoded = 2; +} + +enum SignatureVariant { + SIGNATURE_VARIANT_UNSPECIFIED = 0; + SIGNATURE_VARIANT_ECDSA_K256_KECCAK = 1; +} + +message Signature { + SignatureVariant variant = 1; + bytes encoded = 2; +} + +message MerklePath { + repeated Word siblings = 1; +} + +message SparseMerklePath { + fixed64 empty_nodes_mask = 1; + repeated Word siblings = 2; +} + +message MmrDelta { + uint64 forest = 1; + repeated Word update_data = 2; +} + +message SmtLeafEntry { + Word key = 1; + Word value = 2; +} + +message SmtLeafEntryList { + repeated SmtLeafEntry entries = 1; +} + +message SmtLeaf { + oneof leaf { + uint64 empty_leaf_index = 1; + SmtLeafEntry single = 2; + SmtLeafEntryList multiple = 3; + } +} + +message SmtOpening { + SparseMerklePath path = 1; + SmtLeaf leaf = 2; +} + +// A compact representation of a partial SMT containing the nodes needed to reconstruct the +// tracked openings. +message PartialSmt { + // The root expected after reconstruction. + Word root = 1; + + // Boundary nodes needed to reconstruct intermediate nodes, grouped by depth. + repeated PartialSmtNodeLevel node_levels = 2; + + // Fully materialized, non-empty leaves. + repeated IndexedSmtLeaf leaves = 3; + + // Leaves for which only the hash is known. + repeated IndexedDigest value_only_leaves = 4; +} + +// Partial SMT boundary nodes at a single depth. +message PartialSmtNodeLevel { + // Depth of all nodes in this group. + uint32 depth = 1; + + // Boundary nodes at this depth. + repeated PartialSmtNode nodes = 2; +} + +// A boundary node needed to reconstruct a partial SMT. +message PartialSmtNode { + // Position of the node within its depth. + fixed64 index = 1; + + // Hash of the boundary node. + Word digest = 2; +} + +// A fully materialized SMT leaf together with its index. +message IndexedSmtLeaf { + fixed64 index = 1; + SmtLeaf leaf = 2; +} + +// A digest together with its SMT leaf index. +message IndexedDigest { + fixed64 index = 1; + Word value = 2; +} + +message AdviceStack { + repeated Felt values = 1; +} + +message AdviceMapEntry { + Word key = 1; + repeated Felt values = 2; +} + +message AdviceMap { + repeated AdviceMapEntry entries = 1; +} + +message MerkleStoreNode { + Word value = 1; + Word left = 2; + Word right = 3; +} + +message MerkleStore { + repeated MerkleStoreNode nodes = 1; +} + +message AdviceInputs { + AdviceStack advice_stack = 1; + AdviceMap advice_map = 2; + MerkleStore merkle_store = 3; +} diff --git a/crates/miden-objects/proto/protocol_config.proto b/crates/miden-objects/proto/protocol_config.proto new file mode 100644 index 0000000000..61999b00a8 --- /dev/null +++ b/crates/miden-objects/proto/protocol_config.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; +package protocol_config; + +import "primitives.proto"; + +message KernelConfig { + primitives.Word main_proc = 1; + repeated primitives.Word kernel_procs = 2; +} + +message ProofSecurityPolicy { + primitives.Word security_estimator_root = 1; + uint32 minimum_bits = 2; +} + +message ProofVerificationConfig { + primitives.Word vm_verifier_root = 1; + primitives.Word precompile_verifier_root = 2; + ProofSecurityPolicy security_policy = 3; +} + +message ProtocolConfig { + primitives.Word fee_asset_id = 1; + KernelConfig tx_kernel = 2; + KernelConfig batch_kernel = 3; + KernelConfig block_kernel = 4; + ProofVerificationConfig proof_verification = 5; +} diff --git a/crates/miden-objects/proto/transaction.proto b/crates/miden-objects/proto/transaction.proto new file mode 100644 index 0000000000..7b134dd9a9 --- /dev/null +++ b/crates/miden-objects/proto/transaction.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; +package transaction; + +import "account.proto"; +import "block_number.proto"; +import "note.proto"; +import "primitives.proto"; + +message TransactionId { + primitives.Word id = 1; +} + +message InputNoteCommitment { + primitives.Word nullifier = 1; + optional note.NoteHeader header = 2; +} + +message TransactionHeader { + TransactionId transaction_id = 1; + account.AccountId account_id = 2; + primitives.Word initial_state_commitment = 3; + primitives.Word final_state_commitment = 4; + repeated InputNoteCommitment input_notes = 5; + repeated note.NoteHeader output_notes = 6; +} + +message PublicOutputNote { + note.Note note = 1; +} + +message PrivateOutputNote { + note.NoteHeader header = 1; + note.NoteAttachments attachments = 2; +} + +message OutputNote { + oneof note { + PublicOutputNote public = 1; + PrivateOutputNote private = 2; + } +} + +message TxAccountUpdate { + account.AccountId account_id = 1; + primitives.Word initial_state_commitment = 2; + primitives.Word final_state_commitment = 3; + primitives.Word account_patch_commitment = 4; + account.AccountUpdateDetails details = 5; +} + +message ProvenTransaction { + TxAccountUpdate account_update = 1; + repeated InputNoteCommitment input_notes = 2; + repeated OutputNote output_notes = 3; + blockchain.BlockNumber reference_block_num = 4; + primitives.Word reference_block_commitment = 5; + blockchain.BlockNumber expiration_block_num = 6; + primitives.ExecutionProof proof = 7; +} + +message TransactionScript { + uint32 entrypoint = 1; + primitives.MastForest mast = 2; +} + +message NoteArgument { + note.NoteId note_id = 1; + primitives.Word args = 2; +} + +message TransactionArgs { + optional TransactionScript tx_script = 1; + primitives.Word tx_script_args = 2; + repeated NoteArgument note_args = 3; + primitives.AdviceInputs advice_inputs = 4; + primitives.Word auth_args = 5; +} diff --git a/crates/miden-objects/proto/transaction_inputs.proto b/crates/miden-objects/proto/transaction_inputs.proto new file mode 100644 index 0000000000..8d6dc0b3f3 --- /dev/null +++ b/crates/miden-objects/proto/transaction_inputs.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; +package transaction; + +import "account.proto"; +import "block.proto"; +import "note.proto"; +import "partial_blockchain.proto"; +import "primitives.proto"; +import "protocol_config.proto"; +import "transaction.proto"; + +message AuthenticatedInputNote { + note.Note note = 1; + note.NoteInclusionProof proof = 2; +} + +message InputNote { + oneof note { + AuthenticatedInputNote authenticated = 1; + note.Note unauthenticated = 2; + } +} + +message InputNotes { + repeated InputNote notes = 1; +} + +message ForeignAccountSlotName { + account.StorageSlotId slot_id = 1; + string slot_name = 2; +} + +message TransactionInputs { + oneof version { + TransactionInputsV1 v1 = 1; + } +} + +message TransactionInputsV1 { + account.PartialAccount account = 1; + blockchain.BlockHeader block_header = 2; + protocol_config.ProtocolConfig protocol_config = 3; + blockchain.PartialBlockchain partial_blockchain = 4; + InputNotes input_notes = 5; + TransactionArgs tx_args = 6; + primitives.AdviceInputs advice_inputs = 7; + repeated account.AccountCode foreign_account_code = 8; + repeated ForeignAccountSlotName foreign_account_slot_names = 9; +} diff --git a/crates/miden-objects/src/conversion/account.rs b/crates/miden-objects/src/conversion/account.rs new file mode 100644 index 0000000000..18f5c9e313 --- /dev/null +++ b/crates/miden-objects/src/conversion/account.rs @@ -0,0 +1,389 @@ +use alloc::collections::BTreeSet; +use alloc::format; +use alloc::string::ToString; +use alloc::vec::Vec; + +use miden_protocol::account::{ + AccountHeader, + AccountId, + AccountStorageHeader, + PartialAccount, + PartialStorage, + PartialStorageMap, + StorageMapKey, + StorageSlotHeader, + StorageSlotId, + StorageSlotName, + StorageSlotType, +}; +use miden_protocol::asset::{AssetId, PartialVault}; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::{Felt, Word}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +impl TryFrom for AccountId { + type Error = ConversionError; + + fn try_from(message: proto::account::AccountId) -> Result { + let bytes: [u8; AccountId::SERIALIZED_SIZE] = + message.id.as_slice().try_into().map_err(ConversionError::new)?; + + AccountId::try_from(bytes).map_err(ConversionError::new) + } +} + +impl From<&AccountId> for proto::account::AccountId { + fn from(account_id: &AccountId) -> Self { + let id: [u8; AccountId::SERIALIZED_SIZE] = (*account_id).into(); + Self { id: id.into() } + } +} + +impl From for proto::account::AccountId { + fn from(account_id: AccountId) -> Self { + (&account_id).into() + } +} + +// STORAGE SLOT ID +// ================================================================================================ + +impl TryFrom for StorageSlotId { + type Error = ConversionError; + + fn try_from(message: proto::account::StorageSlotId) -> Result { + let decoder = message.decoder(); + let suffix = required!(decoder, message.suffix)?; + let prefix = required!(decoder, message.prefix)?; + Ok(Self::new(suffix, prefix)) + } +} + +impl From for proto::account::StorageSlotId { + fn from(id: StorageSlotId) -> Self { + Self { + suffix: Some(id.suffix().into()), + prefix: Some(id.prefix().into()), + } + } +} + +impl From<&StorageSlotId> for proto::account::StorageSlotId { + fn from(id: &StorageSlotId) -> Self { + (*id).into() + } +} + +/// Decodes a protobuf storage slot type into its domain representation. +/// +/// Protobuf reserves discriminant 0 for an unspecified value, while the domain +/// enum uses discriminants 0 and 1 for `Value` and `Map`, respectively. +fn decode_storage_slot_type(slot_type: i32) -> Result { + match proto::account::StorageSlotType::try_from(slot_type) { + Ok(proto::account::StorageSlotType::Value) => Ok(StorageSlotType::Value), + Ok(proto::account::StorageSlotType::Map) => Ok(StorageSlotType::Map), + Ok(proto::account::StorageSlotType::Unspecified) => { + Err(ConversionError::message("storage slot type is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown storage slot type {slot_type}"), + error, + )), + } +} + +/// Encodes a domain storage slot type using its protobuf representation. +fn encode_storage_slot_type(slot_type: StorageSlotType) -> i32 { + match slot_type { + StorageSlotType::Value => proto::account::StorageSlotType::Value as i32, + StorageSlotType::Map => proto::account::StorageSlotType::Map as i32, + } +} + +impl TryFrom for AccountStorageHeader { + type Error = ConversionError; + + fn try_from(message: proto::account::AccountStorageHeader) -> Result { + let slots = message + .slots + .into_iter() + .map(|slot| { + let decoder = slot.decoder(); + let name = StorageSlotName::new(slot.slot_name)?; + let slot_type = decode_storage_slot_type(slot.slot_type).context("slot_type")?; + let commitment = required!(decoder, slot.commitment)?; + Ok(StorageSlotHeader::new(name, slot_type, commitment)) + }) + .collect::, ConversionError>>() + .context("slots")?; + AccountStorageHeader::new(slots).map_err(ConversionError::new) + } +} + +impl From<&AccountStorageHeader> for proto::account::AccountStorageHeader { + fn from(account_storage_header: &AccountStorageHeader) -> Self { + Self { + slots: account_storage_header + .slots() + .map(|slot| proto::account::account_storage_header::StorageSlot { + slot_name: slot.name().to_string(), + slot_type: encode_storage_slot_type(slot.slot_type()), + commitment: Some(slot.value().into()), + }) + .collect(), + } + } +} + +impl From for proto::account::AccountStorageHeader { + fn from(account_storage_header: AccountStorageHeader) -> Self { + (&account_storage_header).into() + } +} + +fn decode_account_version(version: i32) -> Result<(), ConversionError> { + match proto::account::AccountVersion::try_from(version) { + Ok(proto::account::AccountVersion::V1) => Ok(()), + Ok(proto::account::AccountVersion::Unspecified) => { + Err(ConversionError::message("account header version is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown account header version {version}"), + error, + )), + } +} + +// PARTIAL STORAGE MAP +// ================================================================================================ + +impl TryFrom for PartialStorageMap { + type Error = ConversionError; + + fn try_from(message: proto::account::PartialStorageMap) -> Result { + let decoder = message.decoder(); + let smt = required!(decoder, message.smt)?; + let keys = message + .keys + .into_iter() + .enumerate() + .map(|(index, key)| { + Word::try_from(key) + .map(StorageMapKey::from_raw) + .context(format!("keys[{index}]")) + }) + .collect::, _>>()?; + + PartialStorageMap::try_from_parts(smt, keys).map_err(ConversionError::new) + } +} + +impl From<&PartialStorageMap> for proto::account::PartialStorageMap { + fn from(map: &PartialStorageMap) -> Self { + Self { + smt: Some(map.partial_smt().clone().into()), + keys: map.entries().map(|(key, _)| Word::from(*key).into()).collect(), + } + } +} + +impl From for proto::account::PartialStorageMap { + fn from(map: PartialStorageMap) -> Self { + (&map).into() + } +} + +// PARTIAL STORAGE +// ================================================================================================ + +impl TryFrom for PartialStorage { + type Error = ConversionError; + + fn try_from(message: proto::account::PartialStorage) -> Result { + let decoder = message.decoder(); + let header = required!(decoder, message.header)?; + let mut roots = BTreeSet::new(); + let maps = message + .maps + .into_iter() + .enumerate() + .map(|(index, map)| { + let map_context = format!("maps[{index}]"); + let map = PartialStorageMap::try_from(map).context(&map_context)?; + if !roots.insert(map.root()) { + return Err(ConversionError::message("duplicate partial storage map root") + .context(map_context)); + } + Ok(map) + }) + .collect::, _>>()?; + + PartialStorage::new(header, maps).map_err(ConversionError::new) + } +} + +impl From<&PartialStorage> for proto::account::PartialStorage { + fn from(storage: &PartialStorage) -> Self { + Self { + header: Some(storage.header().into()), + maps: storage.maps().map(Into::into).collect(), + } + } +} + +impl From for proto::account::PartialStorage { + fn from(storage: PartialStorage) -> Self { + (&storage).into() + } +} + +// PARTIAL VAULT +// ================================================================================================ + +impl TryFrom for PartialVault { + type Error = ConversionError; + + fn try_from(message: proto::account::PartialVault) -> Result { + let decoder = message.decoder(); + let smt = required!(decoder, message.smt)?; + let asset_ids = message + .asset_ids + .into_iter() + .enumerate() + .map(|(index, id)| { + let asset_id_context = format!("asset_ids[{index}]"); + Word::try_from(id) + .context(&asset_id_context) + .and_then(|id| AssetId::try_from(id).context(asset_id_context)) + }) + .collect::, _>>()?; + + PartialVault::try_from_parts(smt, asset_ids).map_err(ConversionError::new) + } +} + +impl From<&PartialVault> for proto::account::PartialVault { + fn from(vault: &PartialVault) -> Self { + Self { + smt: Some(vault.partial_smt().clone().into()), + asset_ids: vault.asset_ids().map(|id| Word::from(id).into()).collect(), + } + } +} + +impl From for proto::account::PartialVault { + fn from(vault: PartialVault) -> Self { + (&vault).into() + } +} + +// PARTIAL ACCOUNT +// ================================================================================================ + +impl TryFrom for PartialAccount { + type Error = ConversionError; + + fn try_from(message: proto::account::PartialAccount) -> Result { + let decoder = message.decoder(); + let account_id = required!(decoder, message.account_id)?; + let nonce = required!(decoder, message.nonce)?; + let code = required!(decoder, message.code)?; + let storage = required!(decoder, message.storage)?; + let vault = required!(decoder, message.vault)?; + let seed = message.seed.map(Word::try_from).transpose().context("seed")?; + + PartialAccount::new(account_id, nonce, code, storage, vault, seed) + .map_err(ConversionError::new) + } +} + +impl From<&PartialAccount> for proto::account::PartialAccount { + fn from(account: &PartialAccount) -> Self { + Self { + account_id: Some(account.id().into()), + nonce: Some(account.nonce().into()), + code: Some(account.code().into()), + storage: Some(account.storage().into()), + vault: Some(account.vault().into()), + seed: account.seed().map(Into::into), + } + } +} + +impl From for proto::account::PartialAccount { + fn from(account: PartialAccount) -> Self { + (&account).into() + } +} + +impl TryFrom for AccountHeader { + type Error = ConversionError; + + fn try_from(message: proto::account::AccountHeader) -> Result { + decode_account_version(message.version).context("version")?; + + let decoder = message.decoder(); + let account_id = required!(decoder, message.account_id)?; + let vault_root = required!(decoder, message.vault_root)?; + let storage_commitment = required!(decoder, message.storage_commitment)?; + let code_commitment = required!(decoder, message.code_commitment)?; + let nonce = Felt::try_from(message.nonce).map_err(ConversionError::new).context("nonce")?; + Ok(AccountHeader::new( + account_id, + nonce, + vault_root, + storage_commitment, + code_commitment, + )) + } +} + +impl From<&AccountHeader> for proto::account::AccountHeader { + fn from(account_header: &AccountHeader) -> Self { + Self { + version: proto::account::AccountVersion::V1 as i32, + account_id: Some(account_header.id().into()), + vault_root: Some(account_header.vault_root().into()), + storage_commitment: Some(account_header.storage_commitment().into()), + code_commitment: Some(account_header.code_commitment().into()), + nonce: account_header.nonce().as_canonical_u64(), + } + } +} + +impl From for proto::account::AccountHeader { + fn from(account_header: AccountHeader) -> Self { + (&account_header).into() + } +} + +impl TryFrom for AccountWitness { + type Error = ConversionError; + + fn try_from(message: proto::account::AccountWitness) -> Result { + let decoder = message.decoder(); + let witness_id = required!(decoder, message.witness_id)?; + let commitment = required!(decoder, message.commitment)?; + let path = required!(decoder, message.path)?; + + AccountWitness::new(witness_id, commitment, path).map_err(ConversionError::new) + } +} + +impl From<&AccountWitness> for proto::account::AccountWitness { + fn from(witness: &AccountWitness) -> Self { + Self { + witness_id: Some(witness.id().into()), + commitment: Some(witness.state_commitment().into()), + path: Some(witness.path().clone().into()), + } + } +} + +impl From for proto::account::AccountWitness { + fn from(witness: AccountWitness) -> Self { + (&witness).into() + } +} diff --git a/crates/miden-objects/src/conversion/account_patch.rs b/crates/miden-objects/src/conversion/account_patch.rs new file mode 100644 index 0000000000..e3021d4971 --- /dev/null +++ b/crates/miden-objects/src/conversion/account_patch.rs @@ -0,0 +1,382 @@ +use alloc::borrow::ToOwned; +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::{ + AccountCode, + AccountPatch, + AccountProcedureRoot, + AccountStoragePatch, + AccountUpdateDetails, + AccountVaultPatch, + StorageMapKey, + StorageMapPatch, + StorageMapPatchEntries, + StoragePatchOperation, + StorageSlotName, + StorageSlotPatch, + StorageValuePatch, +}; +use miden_protocol::asset::AssetId; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +// ACCOUNT CODE +// ================================================================================================ + +impl From<&AccountCode> for proto::account::AccountCode { + fn from(code: &AccountCode) -> Self { + Self { + mast: Some(code.mast().as_ref().into()), + procedure_roots: code.procedure_roots().map(Into::into).collect(), + } + } +} + +impl From for proto::account::AccountCode { + fn from(code: AccountCode) -> Self { + Self::from(&code) + } +} + +impl TryFrom for AccountCode { + type Error = ConversionError; + + fn try_from(code: proto::account::AccountCode) -> Result { + let decoder = code.decoder(); + let mast = required!(decoder, code.mast)?; + let procedure_roots = code + .procedure_roots + .into_iter() + .enumerate() + .map(|(index, root)| { + Word::try_from(root) + .map(AccountProcedureRoot::from_raw) + .context(format!("procedure_roots[{index}]")) + }) + .collect::, _>>()?; + + AccountCode::from_parts(Arc::new(mast), procedure_roots).map_err(ConversionError::new) + } +} + +// STORAGE PATCHES +// ================================================================================================ + +const fn encode_storage_operation(operation: StoragePatchOperation) -> i32 { + match operation { + StoragePatchOperation::Create => proto::account::StoragePatchOperation::Create as i32, + StoragePatchOperation::Update => proto::account::StoragePatchOperation::Update as i32, + StoragePatchOperation::Remove => proto::account::StoragePatchOperation::Remove as i32, + } +} + +fn decode_storage_operation(operation: i32) -> Result { + match proto::account::StoragePatchOperation::try_from(operation) { + Ok(proto::account::StoragePatchOperation::Create) => Ok(StoragePatchOperation::Create), + Ok(proto::account::StoragePatchOperation::Update) => Ok(StoragePatchOperation::Update), + Ok(proto::account::StoragePatchOperation::Remove) => Ok(StoragePatchOperation::Remove), + Ok(proto::account::StoragePatchOperation::Unspecified) => { + Err(ConversionError::message("storage patch operation is unspecified")) + }, + Err(_) => { + Err(ConversionError::message(format!("unknown storage patch operation {operation}"))) + }, + } +} + +impl From<&StorageValuePatch> for proto::account::StorageValuePatch { + fn from(patch: &StorageValuePatch) -> Self { + Self { + operation: encode_storage_operation(patch.patch_op()), + value: patch.value().map(Into::into), + } + } +} + +impl TryFrom for StorageValuePatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::StorageValuePatch) -> Result { + let operation = decode_storage_operation(patch.operation).context("operation")?; + match operation { + StoragePatchOperation::Create | StoragePatchOperation::Update => { + let decoder = patch.decoder(); + let value = required!(decoder, patch.value)?; + Ok(if operation.is_create() { + StorageValuePatch::Create { value } + } else { + StorageValuePatch::Update { value } + }) + }, + StoragePatchOperation::Remove => { + if patch.value.is_some() { + return Err(ConversionError::message( + "value must be absent for a remove operation", + ) + .context("value")); + } + Ok(StorageValuePatch::Remove) + }, + } + } +} + +impl From<&StorageMapPatch> for proto::account::StorageMapPatch { + fn from(patch: &StorageMapPatch) -> Self { + let entries = patch + .entries() + .into_iter() + .flat_map(StorageMapPatchEntries::as_map) + .map(|(key, value)| proto::account::StorageMapEntry { + key: Some(Word::from(*key).into()), + value: Some((*value).into()), + }) + .collect(); + + Self { + operation: encode_storage_operation(patch.patch_op()), + entries, + } + } +} + +impl TryFrom for StorageMapPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::StorageMapPatch) -> Result { + let operation = decode_storage_operation(patch.operation).context("operation")?; + if operation.is_remove() { + if !patch.entries.is_empty() { + return Err(ConversionError::message( + "entries must be empty for a remove operation", + ) + .context("entries")); + } + return Ok(StorageMapPatch::Remove); + } + + let mut entries = BTreeMap::new(); + for (index, entry) in patch.entries.into_iter().enumerate() { + let decoder = entry.decoder(); + let entry_context = format!("entries[{index}]"); + let key = StorageMapKey::from_raw( + required!(decoder, entry.key).context(entry_context.clone())?, + ); + let value = required!(decoder, entry.value).context(entry_context.clone())?; + if entries.insert(key, value).is_some() { + return Err(ConversionError::message("duplicate storage map key") + .context(format!("{entry_context}.key"))); + } + } + + let entries = StorageMapPatchEntries::from_raw(entries); + match operation { + StoragePatchOperation::Create => Ok(StorageMapPatch::Create { entries }), + StoragePatchOperation::Update if entries.is_empty() => { + Err(ConversionError::message("entries must be non-empty for an update operation") + .context("entries")) + }, + StoragePatchOperation::Update => Ok(StorageMapPatch::Update { entries }), + StoragePatchOperation::Remove => unreachable!("remove handled above"), + } + } +} + +impl From<&AccountStoragePatch> for proto::account::AccountStoragePatch { + fn from(patch: &AccountStoragePatch) -> Self { + Self { + slots: patch + .slots() + .map(|(slot_name, slot_patch)| { + use proto::account::storage_slot_patch::Patch; + + let patch = match slot_patch { + StorageSlotPatch::Value(value) => Patch::Value(value.into()), + StorageSlotPatch::Map(map) => Patch::Map(map.into()), + }; + proto::account::StorageSlotPatch { + slot_name: slot_name.as_str().to_owned(), + patch: Some(patch), + } + }) + .collect(), + } + } +} + +impl TryFrom for AccountStoragePatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountStoragePatch) -> Result { + use proto::account::storage_slot_patch::Patch; + + let slots = patch + .slots + .into_iter() + .enumerate() + .map(|(index, slot)| { + let slot_path = format!("slots[{index}]"); + let slot_name = StorageSlotName::new(slot.slot_name) + .map_err(ConversionError::from) + .context("slot_name") + .context(slot_path.clone())?; + let patch = match slot.patch { + Some(Patch::Value(value)) => StorageSlotPatch::Value( + value.try_into().context("patch").context(slot_path.clone())?, + ), + Some(Patch::Map(map)) => StorageSlotPatch::Map( + map.try_into().context("patch").context(slot_path.clone())?, + ), + None => { + return Err(ConversionError::missing_field::< + proto::account::StorageSlotPatch, + >("patch") + .context(slot_path)); + }, + }; + Ok((slot_name, patch)) + }) + .collect::, ConversionError>>()?; + + AccountStoragePatch::from_entries(slots) + .map_err(ConversionError::new) + .context("slots") + } +} + +// VAULT AND ACCOUNT PATCHES +// ================================================================================================ + +fn decode_account_patch_version(version: i32) -> Result<(), ConversionError> { + match proto::account::AccountPatchVersion::try_from(version) { + Ok(proto::account::AccountPatchVersion::V1) => Ok(()), + Ok(proto::account::AccountPatchVersion::Unspecified) => { + Err(ConversionError::message("account patch version is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown account patch version {version}"), + error, + )), + } +} + +impl From<&AccountVaultPatch> for proto::account::AccountVaultPatch { + fn from(patch: &AccountVaultPatch) -> Self { + Self { + entries: patch + .iter() + .map(|(asset_id, value)| proto::account::AccountVaultPatchEntry { + asset_id: Some(asset_id.to_word().into()), + value: Some((*value).into()), + }) + .collect(), + } + } +} + +impl TryFrom for AccountVaultPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountVaultPatch) -> Result { + let mut entries = BTreeMap::new(); + for (index, entry) in patch.entries.into_iter().enumerate() { + let decoder = entry.decoder(); + let asset_id: Word = + required!(decoder, entry.asset_id).context(format!("entries[{index}]"))?; + let asset_id = AssetId::try_from(asset_id) + .map_err(ConversionError::from) + .context("asset_id") + .context(format!("entries[{index}]"))?; + let value = required!(decoder, entry.value).context(format!("entries[{index}]"))?; + if entries.insert(asset_id, value).is_some() { + return Err(ConversionError::message("duplicate vault asset ID") + .context(format!("entries[{index}].asset_id"))); + } + } + + AccountVaultPatch::new(entries) + .map_err(ConversionError::from) + .context("entries") + } +} + +impl From<&AccountPatch> for proto::account::AccountPatch { + fn from(patch: &AccountPatch) -> Self { + Self { + version: proto::account::AccountPatchVersion::V1 as i32, + account_id: Some(patch.id().into()), + storage: Some(patch.storage().into()), + vault: Some(patch.vault().into()), + code: patch.code().map(Into::into), + final_nonce: patch.final_nonce().map(Into::into), + } + } +} + +impl From for proto::account::AccountPatch { + fn from(patch: AccountPatch) -> Self { + Self::from(&patch) + } +} + +impl TryFrom for AccountPatch { + type Error = ConversionError; + + fn try_from(patch: proto::account::AccountPatch) -> Result { + decode_account_patch_version(patch.version).context("version")?; + + let decoder = patch.decoder(); + let account_id = required!(decoder, patch.account_id)?; + let storage = required!(decoder, patch.storage)?; + let vault = required!(decoder, patch.vault)?; + let code = patch.code.map(TryInto::try_into).transpose().context("code")?; + let final_nonce = + patch.final_nonce.map(TryInto::try_into).transpose().context("final_nonce")?; + + AccountPatch::new(account_id, storage, vault, code, final_nonce) + .map_err(ConversionError::new) + } +} + +impl From<&AccountUpdateDetails> for proto::account::AccountUpdateDetails { + fn from(details: &AccountUpdateDetails) -> Self { + use proto::account::account_update_details::Update; + + let update = match details { + AccountUpdateDetails::Private => { + Update::Private(proto::account::PrivateAccountUpdate {}) + }, + AccountUpdateDetails::Public(patch) => Update::Public(patch.into()), + }; + Self { update: Some(update) } + } +} + +impl From for proto::account::AccountUpdateDetails { + fn from(details: AccountUpdateDetails) -> Self { + Self::from(&details) + } +} + +impl TryFrom for AccountUpdateDetails { + type Error = ConversionError; + + fn try_from(details: proto::account::AccountUpdateDetails) -> Result { + use proto::account::account_update_details::Update; + + match details.update { + Some(Update::Private(_)) => Ok(AccountUpdateDetails::Private), + Some(Update::Public(patch)) => { + patch.try_into().map(AccountUpdateDetails::Public).context("public") + }, + None => Err(ConversionError::missing_field::( + "update", + )), + } + } +} diff --git a/crates/miden-objects/src/conversion/asset.rs b/crates/miden-objects/src/conversion/asset.rs new file mode 100644 index 0000000000..8cea807bfe --- /dev/null +++ b/crates/miden-objects/src/conversion/asset.rs @@ -0,0 +1,128 @@ +use alloc::format; + +use miden_protocol::asset::{Asset, AssetClass, AssetComposition, AssetId}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +impl From<&AssetClass> for proto::asset::AssetClass { + fn from(asset_class: &AssetClass) -> Self { + Self { + suffix: Some(asset_class.suffix().into()), + prefix: Some(asset_class.prefix().into()), + } + } +} + +impl From for proto::asset::AssetClass { + fn from(asset_class: AssetClass) -> Self { + Self::from(&asset_class) + } +} + +impl TryFrom for AssetClass { + type Error = ConversionError; + + fn try_from(message: proto::asset::AssetClass) -> Result { + let decoder = message.decoder(); + let suffix = required!(decoder, message.suffix)?; + let prefix = required!(decoder, message.prefix)?; + + Ok(Self::new(suffix, prefix)) + } +} + +fn decode_asset_composition(composition: i32) -> Result { + match proto::asset::AssetComposition::try_from(composition) { + Ok(proto::asset::AssetComposition::None) => Ok(AssetComposition::None), + Ok(proto::asset::AssetComposition::Fungible) => Ok(AssetComposition::Fungible), + Ok(proto::asset::AssetComposition::Custom) => Ok(AssetComposition::Custom), + Ok(proto::asset::AssetComposition::Unspecified) => { + Err(ConversionError::message("asset composition is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown asset composition {composition}"), + error, + )), + } +} + +fn decode_asset_version(version: i32) -> Result<(), ConversionError> { + match proto::asset::AssetVersion::try_from(version) { + Ok(proto::asset::AssetVersion::V1) => Ok(()), + Ok(proto::asset::AssetVersion::Unspecified) => { + Err(ConversionError::message("asset id version is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown asset id version {version}"), + error, + )), + } +} + +fn encode_asset_composition(composition: AssetComposition) -> i32 { + match composition { + AssetComposition::None => proto::asset::AssetComposition::None as i32, + AssetComposition::Fungible => proto::asset::AssetComposition::Fungible as i32, + AssetComposition::Custom => proto::asset::AssetComposition::Custom as i32, + } +} + +impl From<&AssetId> for proto::asset::AssetId { + fn from(asset_id: &AssetId) -> Self { + Self { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(asset_id.asset_class().into()), + composition: encode_asset_composition(asset_id.composition()), + faucet_id: Some(asset_id.faucet_id().into()), + } + } +} + +impl From for proto::asset::AssetId { + fn from(asset_id: AssetId) -> Self { + Self::from(&asset_id) + } +} + +impl TryFrom for AssetId { + type Error = ConversionError; + + fn try_from(message: proto::asset::AssetId) -> Result { + decode_asset_version(message.version).context("version")?; + + let decoder = message.decoder(); + let asset_class = required!(decoder, message.asset_class)?; + let composition = decode_asset_composition(message.composition).context("composition")?; + let faucet_id = required!(decoder, message.faucet_id)?; + + Self::new(asset_class, faucet_id, composition).map_err(ConversionError::new) + } +} + +impl From<&Asset> for proto::asset::Asset { + fn from(asset: &Asset) -> Self { + Self { + asset_id: Some(asset.id().into()), + value: Some(asset.to_value_word().into()), + } + } +} + +impl From for proto::asset::Asset { + fn from(asset: Asset) -> Self { + Self::from(&asset) + } +} + +impl TryFrom for Asset { + type Error = ConversionError; + + fn try_from(message: proto::asset::Asset) -> Result { + let decoder = message.decoder(); + let asset_id = required!(decoder, message.asset_id)?; + let value = required!(decoder, message.value)?; + + Self::new(asset_id, value).map_err(ConversionError::new) + } +} diff --git a/crates/miden-objects/src/conversion/batch.rs b/crates/miden-objects/src/conversion/batch.rs new file mode 100644 index 0000000000..270eb5db95 --- /dev/null +++ b/crates/miden-objects/src/conversion/batch.rs @@ -0,0 +1,273 @@ +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::{AccountId, AccountUpdateDetails}; +use miden_protocol::batch::{BatchAccountUpdate, ProposedBatch, ProvenBatch}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::{NoteId, NoteInclusionProof}; +use miden_protocol::transaction::{ + InputNoteCommitment, + InputNotes, + OrderedTransactionHeaders, + OutputNote, + ProvenTransaction, + TransactionHeader, +}; +use miden_protocol::vm::ExecutionProof; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +impl From<&BatchAccountUpdate> for proto::transaction::BatchAccountUpdate { + fn from(value: &BatchAccountUpdate) -> Self { + Self { + account_id: Some(value.account_id().into()), + initial_state_commitment: Some(value.initial_state_commitment().into()), + final_state_commitment: Some(value.final_state_commitment().into()), + details: Some(value.details().into()), + } + } +} + +impl TryFrom for BatchAccountUpdate { + type Error = ConversionError; + + fn try_from(value: proto::transaction::BatchAccountUpdate) -> Result { + let decoder = value.decoder(); + let account_id = required!(decoder, value.account_id)?; + let initial_state_commitment = required!(decoder, value.initial_state_commitment)?; + let final_state_commitment = required!(decoder, value.final_state_commitment)?; + let details: AccountUpdateDetails = required!(decoder, value.details)?; + Self::new(account_id, initial_state_commitment, final_state_commitment, details) + .map_err(ConversionError::new) + } +} + +impl From<&ProposedBatch> for proto::transaction::ProposedBatch { + fn from(value: &ProposedBatch) -> Self { + let (transactions, reference_block_header, partial_blockchain, note_proofs, ..) = + value.clone().into_parts(); + Self { + transactions: transactions.iter().map(|tx| tx.as_ref().into()).collect(), + reference_block_header: Some(reference_block_header.into()), + partial_blockchain: Some((&partial_blockchain).into()), + unauthenticated_note_proofs: note_proofs.iter().map(Into::into).collect(), + } + } +} + +impl From for proto::transaction::ProposedBatch { + fn from(value: ProposedBatch) -> Self { + Self::from(&value) + } +} + +/// Decodes and structurally validates a proposed batch, including transaction proof verification. +/// +/// Callers handling untrusted requests should invoke this in a blocking task. +pub fn decode_proposed_batch( + value: proto::transaction::ProposedBatch, + proof_security_level: u32, +) -> Result { + let decoder = value.decoder(); + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, tx)| { + ProvenTransaction::try_from(tx) + .map(Arc::new) + .context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let reference_block_header = required!(decoder, value.reference_block_header)?; + let partial_blockchain = required!(decoder, value.partial_blockchain)?; + + let mut note_proofs = BTreeMap::new(); + let mut previous_note_id = None; + for (index, proof) in value.unauthenticated_note_proofs.into_iter().enumerate() { + let (note_id, proof) = <(NoteId, NoteInclusionProof)>::try_from(&proof) + .context(format!("unauthenticated_note_proofs[{index}]"))?; + if previous_note_id.is_some_and(|previous| note_id <= previous) { + return Err(ConversionError::message( + "unauthenticated note proofs must have unique, ascending note IDs", + ) + .context(format!("unauthenticated_note_proofs[{index}].note_id"))); + } + previous_note_id = Some(note_id); + note_proofs.insert(note_id, proof); + } + + ProposedBatch::new( + transactions, + reference_block_header, + partial_blockchain, + note_proofs, + proof_security_level, + ) + .map_err(ConversionError::new) +} + +impl From<&ProvenBatch> for proto::transaction::ProvenBatch { + fn from(value: &ProvenBatch) -> Self { + Self { + reference_block_commitment: Some(value.reference_block_commitment().into()), + reference_block_num: Some(value.reference_block_num().into()), + account_updates: value.account_updates().values().map(Into::into).collect(), + input_notes: value.input_notes().iter().map(Into::into).collect(), + output_notes: value.output_notes().iter().map(Into::into).collect(), + expiration_block_num: Some(value.batch_expiration_block_num().into()), + transactions: value.transactions().as_slice().iter().map(Into::into).collect(), + proof: Some(value.proof().into()), + } + } +} + +impl From for proto::transaction::ProvenBatch { + fn from(value: ProvenBatch) -> Self { + Self::from(&value) + } +} + +struct DecodedProvenBatch { + reference_block_commitment: Word, + reference_block_num: BlockNumber, + account_updates: BTreeMap, + input_notes: InputNotes, + output_notes: Vec, + expiration_block_num: BlockNumber, + transactions: Vec, + proof: ExecutionProof, +} + +impl DecodedProvenBatch { + fn decode(value: proto::transaction::ProvenBatch) -> Result { + let decoder = value.decoder(); + let reference_block_commitment = required!(decoder, value.reference_block_commitment)?; + let reference_block_num = + required!(decoder, value.reference_block_num).context("reference_block_num")?; + let expiration_block_num = + required!(decoder, value.expiration_block_num).context("expiration_block_num")?; + + let mut account_updates = BTreeMap::new(); + let mut previous_account_id = None; + for (index, update) in value.account_updates.into_iter().enumerate() { + let update = BatchAccountUpdate::try_from(update) + .context(format!("account_updates[{index}]"))?; + if previous_account_id.is_some_and(|previous| update.account_id() <= previous) { + return Err(ConversionError::message( + "account updates must have unique, ascending account IDs", + ) + .context(format!("account_updates[{index}].account_id"))); + } + previous_account_id = Some(update.account_id()); + account_updates.insert(update.account_id(), update); + } + + let input_notes = value + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let input_notes = InputNotes::new_unchecked(input_notes); + + let output_notes = value + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + OutputNote::try_from(note).context(format!("output_notes[{index}]")) + }) + .collect::, _>>()?; + + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, tx)| { + TransactionHeader::try_from(tx).context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + let proof = required!(decoder, value.proof)?; + + Ok(Self { + reference_block_commitment, + reference_block_num, + account_updates, + input_notes, + output_notes, + expiration_block_num, + transactions, + proof, + }) + } + + fn into_domain(self) -> Result { + ProvenBatch::new( + self.reference_block_commitment, + self.reference_block_num, + self.account_updates.into_values(), + self.input_notes, + self.output_notes, + self.expiration_block_num, + OrderedTransactionHeaders::new_unchecked(self.transactions), + self.proof, + ) + .map_err(ConversionError::new) + } +} + +/// Decodes a proven batch without a proposal and validates every invariant available from the +/// transmitted fields. Cryptographic proof verification remains a service-boundary concern. +pub fn decode_standalone_proven_batch( + value: proto::transaction::ProvenBatch, +) -> Result { + DecodedProvenBatch::decode(value)?.into_domain() +} + +/// Decodes a proven batch and checks every public field duplicated from its proposal. +pub fn decode_proven_batch( + value: proto::transaction::ProvenBatch, + proposed: &ProposedBatch, +) -> Result { + let decoded = DecodedProvenBatch::decode(value)?; + let expected_header = proposed.reference_block_header(); + if decoded.reference_block_num != expected_header.block_num() { + return Err(ConversionError::message("reference block number does not match proposal") + .context("reference_block_num")); + } + if decoded.reference_block_commitment != expected_header.commitment() { + return Err(ConversionError::message("reference block commitment does not match proposal") + .context("reference_block_commitment")); + } + if decoded.account_updates != *proposed.account_updates() { + return Err(ConversionError::message("account updates do not match proposal") + .context("account_updates")); + } + if !decoded.input_notes.iter().eq(proposed.input_notes().iter()) { + return Err( + ConversionError::message("input notes do not match proposal").context("input_notes") + ); + } + if decoded.output_notes != proposed.output_notes() { + return Err( + ConversionError::message("output notes do not match proposal").context("output_notes") + ); + } + if decoded.expiration_block_num != proposed.batch_expiration_block_num() { + return Err(ConversionError::message("expiration block does not match proposal") + .context("expiration_block_num")); + } + if decoded.transactions.as_slice() != proposed.transaction_headers().as_slice() { + return Err(ConversionError::message("transaction headers do not match proposal") + .context("transactions")); + } + + decoded.into_domain() +} diff --git a/crates/miden-objects/src/conversion/block.rs b/crates/miden-objects/src/conversion/block.rs new file mode 100644 index 0000000000..f92ac865b3 --- /dev/null +++ b/crates/miden-objects/src/conversion/block.rs @@ -0,0 +1,518 @@ +use alloc::format; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::account::AccountUpdateDetails; +use miden_protocol::block::{ + BlockAccountUpdate, + BlockBody, + BlockHeader, + BlockNumber, + BlockSignatures, + FeeParameters, + OutputNoteBatch, + SignedBlock, + ValidatorConfig, +}; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::crypto::merkle::MerklePath; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr}; +use miden_protocol::note::Nullifier; +use miden_protocol::protocol_config::NextProtocolConfig; +use miden_protocol::transaction::{ + OrderedTransactionHeaders, + OutputNote, + PartialBlockchain, + TransactionHeader, +}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +// BLOCK NUMBER +// ================================================================================================ + +impl From for proto::blockchain::BlockNumber { + fn from(value: BlockNumber) -> Self { + Self { block_num: value.as_u32() } + } +} + +impl From for BlockNumber { + fn from(value: proto::blockchain::BlockNumber) -> Self { + value.block_num.into() + } +} + +// PARTIAL BLOCKCHAIN +// ================================================================================================ + +impl From<&PartialBlockchain> for proto::blockchain::PartialBlockchain { + fn from(value: &PartialBlockchain) -> Self { + let mmr = value.mmr(); + let tracked_leaves = mmr + .leaves() + .map(|(position, leaf)| { + let proof = mmr + .open(position) + .expect("tracked MMR position must be in bounds") + .expect("tracked MMR leaf must have an opening"); + proto::blockchain::TrackedMmrLeaf { + position: position as u64, + leaf: Some(leaf.into()), + path: proof.merkle_path().nodes().iter().map(Into::into).collect(), + } + }) + .collect(); + Self { + forest: mmr.forest().num_leaves() as u64, + peaks: mmr.peaks().peaks().iter().map(Into::into).collect(), + tracked_leaves, + block_headers: value.block_headers().map(Into::into).collect(), + } + } +} + +impl TryFrom for PartialBlockchain { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::PartialBlockchain) -> Result { + let forest_size = usize::try_from(value.forest).context("forest")?; + let forest = Forest::new(forest_size).map_err(ConversionError::new).context("forest")?; + let peaks = value + .peaks + .into_iter() + .enumerate() + .map(|(index, peak)| Word::try_from(peak).context(format!("peaks[{index}]"))) + .collect::, _>>()?; + let peaks = MmrPeaks::new(forest, peaks).map_err(ConversionError::new).context("peaks")?; + let mut mmr = PartialMmr::from_peaks(peaks); + + let mut previous_position = None; + for (index, tracked) in value.tracked_leaves.into_iter().enumerate() { + let position = usize::try_from(tracked.position) + .context(format!("tracked_leaves[{index}].position"))?; + if position >= forest_size { + return Err(ConversionError::message(format!( + "tracked leaf position {position} is outside forest of size {forest_size}" + )) + .context(format!("tracked_leaves[{index}].position"))); + } + if previous_position.is_some_and(|previous| position <= previous) { + return Err(ConversionError::message( + "tracked leaf positions must be unique and strictly increasing", + ) + .context(format!("tracked_leaves[{index}].position"))); + } + previous_position = Some(position); + + let decoder = tracked.decoder(); + let leaf = required!(decoder, tracked.leaf)?; + let path = tracked + .path + .into_iter() + .enumerate() + .map(|(path_index, node)| { + Word::try_from(node) + .context(format!("tracked_leaves[{index}].path[{path_index}]")) + }) + .collect::, _>>()?; + mmr.track(position, leaf, &MerklePath::new(path)) + .map_err(ConversionError::new) + .context(format!("tracked_leaves[{index}]"))?; + } + + let mut previous_block_num = None; + let block_headers = value + .block_headers + .into_iter() + .enumerate() + .map(|(index, header)| { + let header = + BlockHeader::try_from(header).context(format!("block_headers[{index}]"))?; + if previous_block_num.is_some_and(|previous| header.block_num() <= previous) { + return Err(ConversionError::message( + "block headers must be unique and ordered by ascending block number", + ) + .context(format!("block_headers[{index}].block_num"))); + } + previous_block_num = Some(header.block_num()); + Ok(header) + }) + .collect::, ConversionError>>()?; + + Self::new(mmr, block_headers).map_err(ConversionError::new) + } +} + +// BLOCK HEADER +// ================================================================================================ + +impl From<&BlockHeader> for proto::blockchain::BlockHeader { + fn from(header: &BlockHeader) -> Self { + Self { + version: proto::blockchain::BlockVersion::V1 as i32, + timestamp: header.timestamp(), + block_num: Some(header.block_num().into()), + prev_block_commitment: Some(header.prev_block_commitment().into()), + chain_commitment: Some(header.chain_commitment().into()), + account_root: Some(header.account_root().into()), + nullifier_root: Some(header.nullifier_root().into()), + note_root: Some(header.note_root().into()), + tx_commitment: Some(header.tx_commitment().into()), + validator_config: Some(header.validator_config().into()), + fee_parameters: Some(header.fee_parameters().into()), + protocol_config_commitment: Some(header.protocol_config_commitment().into()), + next_protocol_config: header.next_protocol_config().map(Into::into), + } + } +} + +impl From for proto::blockchain::BlockHeader { + fn from(header: BlockHeader) -> Self { + (&header).into() + } +} + +impl TryFrom<&proto::blockchain::BlockHeader> for BlockHeader { + type Error = ConversionError; + + fn try_from(value: &proto::blockchain::BlockHeader) -> Result { + value.clone().try_into() + } +} + +impl TryFrom for BlockHeader { + type Error = ConversionError; + + fn try_from(header: proto::blockchain::BlockHeader) -> Result { + decode_block_version(header.version).context("version")?; + + let decoder = header.decoder(); + let block_num = required!(decoder, header.block_num).context("block_num")?; + let prev_block_commitment = required!(decoder, header.prev_block_commitment)?; + let chain_commitment = required!(decoder, header.chain_commitment)?; + let account_root = required!(decoder, header.account_root)?; + let nullifier_root = required!(decoder, header.nullifier_root)?; + let note_root = required!(decoder, header.note_root)?; + let tx_commitment = required!(decoder, header.tx_commitment)?; + let validator_config = required!(decoder, header.validator_config)?; + let fee_parameters = required!(decoder, header.fee_parameters)?; + let protocol_config_commitment = required!(decoder, header.protocol_config_commitment)?; + let next_protocol_config = header + .next_protocol_config + .map(TryInto::try_into) + .transpose() + .context("next_protocol_config")?; + + Ok(BlockHeader::new( + prev_block_commitment, + block_num, + chain_commitment, + account_root, + nullifier_root, + note_root, + tx_commitment, + validator_config, + fee_parameters, + protocol_config_commitment, + next_protocol_config, + header.timestamp, + )) + } +} + +fn decode_block_version(version: i32) -> Result<(), ConversionError> { + match proto::blockchain::BlockVersion::try_from(version) { + Ok(proto::blockchain::BlockVersion::V1) => Ok(()), + Ok(proto::blockchain::BlockVersion::Unspecified) => { + Err(ConversionError::message("block header version is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown block header version {version}"), + error, + )), + } +} + +// BLOCK BODY +// ================================================================================================ + +impl From<&BlockBody> for proto::blockchain::BlockBody { + fn from(body: &BlockBody) -> Self { + Self { + updated_accounts: body.updated_accounts().iter().map(Into::into).collect(), + output_note_batches: body.output_note_batches().iter().map(Into::into).collect(), + created_nullifiers: body + .created_nullifiers() + .iter() + .map(|nullifier| nullifier.as_word().into()) + .collect(), + transactions: body.transactions().as_slice().iter().map(Into::into).collect(), + } + } +} + +impl From for proto::blockchain::BlockBody { + fn from(body: BlockBody) -> Self { + (&body).into() + } +} + +impl TryFrom for BlockBody { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::BlockBody) -> Result { + let updated_accounts = value + .updated_accounts + .into_iter() + .enumerate() + .map(|(index, update)| { + BlockAccountUpdate::try_from(update).context(format!("updated_accounts[{index}]")) + }) + .collect::, _>>()?; + let output_note_batches = value + .output_note_batches + .into_iter() + .enumerate() + .map(|(index, batch)| { + OutputNoteBatch::try_from(batch).context(format!("output_note_batches[{index}]")) + }) + .collect::, _>>()?; + let created_nullifiers = value + .created_nullifiers + .into_iter() + .enumerate() + .map(|(index, nullifier)| { + Word::try_from(nullifier) + .map(Nullifier::from_raw) + .context(format!("created_nullifiers[{index}]")) + }) + .collect::, _>>()?; + let transactions = value + .transactions + .into_iter() + .enumerate() + .map(|(index, transaction)| { + TransactionHeader::try_from(transaction).context(format!("transactions[{index}]")) + }) + .collect::, _>>()?; + + BlockBody::new( + updated_accounts, + output_note_batches, + created_nullifiers, + OrderedTransactionHeaders::new_unchecked(transactions), + ) + .map_err(ConversionError::new) + } +} + +impl TryFrom<&proto::blockchain::BlockBody> for BlockBody { + type Error = ConversionError; + + fn try_from(value: &proto::blockchain::BlockBody) -> Result { + value.clone().try_into() + } +} + +// BLOCK BODY COMPONENTS +// ================================================================================================ + +impl From<&BlockAccountUpdate> for proto::blockchain::BlockAccountUpdate { + fn from(update: &BlockAccountUpdate) -> Self { + Self { + account_id: Some(update.account_id().into()), + final_state_commitment: Some(update.final_state_commitment().into()), + details: Some(update.details().into()), + } + } +} + +impl TryFrom for BlockAccountUpdate { + type Error = ConversionError; + + fn try_from(update: proto::blockchain::BlockAccountUpdate) -> Result { + let decoder = update.decoder(); + let account_id = required!(decoder, update.account_id)?; + let final_state_commitment = required!(decoder, update.final_state_commitment)?; + let details: AccountUpdateDetails = required!(decoder, update.details)?; + BlockAccountUpdate::new(account_id, final_state_commitment, details) + .map_err(ConversionError::new) + } +} + +impl From<&(usize, OutputNote)> for proto::blockchain::IndexedOutputNote { + fn from((index, note): &(usize, OutputNote)) -> Self { + Self { + note_index_in_batch: u32::try_from(*index) + .expect("valid output note indices fit into u32"), + note: Some(note.into()), + } + } +} + +impl TryFrom for (usize, OutputNote) { + type Error = ConversionError; + + fn try_from(note: proto::blockchain::IndexedOutputNote) -> Result { + let decoder = note.decoder(); + let index = usize::try_from(note.note_index_in_batch).context("note_index_in_batch")?; + let output_note = required!(decoder, note.note)?; + Ok((index, output_note)) + } +} + +impl From<&OutputNoteBatch> for proto::blockchain::OutputNoteBatch { + fn from(batch: &OutputNoteBatch) -> Self { + Self { + notes: batch.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for OutputNoteBatch { + type Error = ConversionError; + + fn try_from(batch: proto::blockchain::OutputNoteBatch) -> Result { + batch + .notes + .into_iter() + .enumerate() + .map(|(position, note)| { + <(usize, OutputNote)>::try_from(note).context(format!("notes[{position}]")) + }) + .collect() + } +} + +// SIGNED BLOCK +// ================================================================================================ + +impl From<&SignedBlock> for proto::blockchain::SignedBlock { + fn from(block: &SignedBlock) -> Self { + Self { + header: Some(block.header().into()), + body: Some(block.body().into()), + signatures: block.signatures().as_signatures().iter().map(Into::into).collect(), + } + } +} + +impl From for proto::blockchain::SignedBlock { + fn from(block: SignedBlock) -> Self { + (&block).into() + } +} + +impl TryFrom for SignedBlock { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::SignedBlock) -> Result { + let decoder = value.decoder(); + let header = required!(decoder, value.header)?; + let body = required!(decoder, value.body)?; + let signatures = value + .signatures + .into_iter() + .map(Signature::try_from) + .collect::, _>>() + .context("signatures")?; + let signatures = BlockSignatures::new(signatures) + .map_err(ConversionError::new) + .context("signatures")?; + + SignedBlock::new(header, body, signatures) + .map_err(ConversionError::new) + .context("body") + } +} + +impl TryFrom<&proto::blockchain::SignedBlock> for SignedBlock { + type Error = ConversionError; + + fn try_from(value: &proto::blockchain::SignedBlock) -> Result { + value.clone().try_into() + } +} + +// VALIDATOR AND PROTOCOL CONFIGURATION +// ================================================================================================ + +impl TryFrom for ValidatorConfig { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::ValidatorConfig) -> Result { + let keys = value + .keys + .into_iter() + .enumerate() + .map(|(index, key)| PublicKey::try_from(key).context(format!("keys[{index}]"))) + .collect::, _>>()?; + let quorum = u16::try_from(value.quorum).context("quorum")?; + + Self::new(keys, quorum).map_err(ConversionError::new) + } +} + +impl From<&ValidatorConfig> for proto::blockchain::ValidatorConfig { + fn from(value: &ValidatorConfig) -> Self { + Self { + keys: value.keys().iter().map(Into::into).collect(), + quorum: u32::from(value.quorum()), + } + } +} + +impl From for proto::blockchain::ValidatorConfig { + fn from(value: ValidatorConfig) -> Self { + (&value).into() + } +} + +impl TryFrom for NextProtocolConfig { + type Error = ConversionError; + + fn try_from(value: proto::blockchain::NextProtocolConfig) -> Result { + let decoder = value.decoder(); + let effective_from = required!(decoder, value.effective_from)?; + let protocol_config = required!(decoder, value.protocol_config)?; + + Self::new(effective_from, protocol_config).map_err(ConversionError::new) + } +} + +impl From<&NextProtocolConfig> for proto::blockchain::NextProtocolConfig { + fn from(value: &NextProtocolConfig) -> Self { + Self { + effective_from: Some(value.effective_from().into()), + protocol_config: Some(value.protocol_config().into()), + } + } +} + +impl From for proto::blockchain::NextProtocolConfig { + fn from(value: NextProtocolConfig) -> Self { + (&value).into() + } +} + +impl From for FeeParameters { + fn from(value: proto::blockchain::FeeParameters) -> Self { + Self::new(value.verification_base_fee) + } +} + +impl From<&FeeParameters> for proto::blockchain::FeeParameters { + fn from(value: &FeeParameters) -> Self { + Self { + verification_base_fee: value.verification_base_fee(), + } + } +} + +impl From for proto::blockchain::FeeParameters { + fn from(value: FeeParameters) -> Self { + (&value).into() + } +} diff --git a/crates/miden-objects/src/conversion/merkle.rs b/crates/miden-objects/src/conversion/merkle.rs new file mode 100644 index 0000000000..359d0aaa60 --- /dev/null +++ b/crates/miden-objects/src/conversion/merkle.rs @@ -0,0 +1,675 @@ +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::format; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::crypto::merkle::mmr::{Forest, MmrDelta}; +use miden_protocol::crypto::merkle::smt::{ + LeafIndex, + PartialSmt, + SMT_DEPTH, + SmtLeaf, + SmtProof, + UniqueNodes, +}; +use miden_protocol::crypto::merkle::{MerklePath, NodeIndex, SparseMerklePath}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +// MERKLE PATH +// ================================================================================================ + +impl From<&MerklePath> for proto::primitives::MerklePath { + fn from(value: &MerklePath) -> Self { + let siblings = value.nodes().iter().map(Into::into).collect(); + proto::primitives::MerklePath { siblings } + } +} + +impl From for proto::primitives::MerklePath { + fn from(value: MerklePath) -> Self { + (&value).into() + } +} + +impl TryFrom<&proto::primitives::MerklePath> for MerklePath { + type Error = ConversionError; + + fn try_from(merkle_path: &proto::primitives::MerklePath) -> Result { + merkle_path.siblings.iter().map(Word::try_from).collect() + } +} + +impl TryFrom for MerklePath { + type Error = ConversionError; + + fn try_from(merkle_path: proto::primitives::MerklePath) -> Result { + (&merkle_path).try_into() + } +} + +// SPARSE MERKLE PATH +// ================================================================================================ + +impl From for proto::primitives::SparseMerklePath { + fn from(value: SparseMerklePath) -> Self { + let (empty_nodes_mask, siblings) = value.into_parts(); + proto::primitives::SparseMerklePath { + empty_nodes_mask, + siblings: siblings.into_iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for SparseMerklePath { + type Error = ConversionError; + + fn try_from(merkle_path: proto::primitives::SparseMerklePath) -> Result { + Ok(SparseMerklePath::from_parts( + merkle_path.empty_nodes_mask, + merkle_path + .siblings + .into_iter() + .map(Word::try_from) + .collect::, _>>() + .context("siblings")?, + )?) + } +} + +// MMR DELTA +// ================================================================================================ + +impl From for proto::primitives::MmrDelta { + fn from(value: MmrDelta) -> Self { + let update_data = value.data.into_iter().map(Into::into).collect(); + proto::primitives::MmrDelta { + forest: value.forest.num_leaves() as u64, + update_data, + } + } +} + +impl TryFrom for MmrDelta { + type Error = ConversionError; + + fn try_from(value: proto::primitives::MmrDelta) -> Result { + let data: Vec<_> = value + .update_data + .into_iter() + .map(Word::try_from) + .collect::>() + .context("update_data")?; + + let forest_size = value.forest.try_into().context("forest size does not fit in usize")?; + let forest = Forest::new(forest_size).context("forest size out of range")?; + + Ok(MmrDelta { forest, data }) + } +} + +// SPARSE MERKLE TREE +// ================================================================================================ + +// SMT LEAF +// ------------------------------------------------------------------------------------------------ + +impl TryFrom for SmtLeaf { + type Error = ConversionError; + + fn try_from(value: proto::primitives::SmtLeaf) -> Result { + let decoder = value.decoder(); + let leaf = required!(decoder, value.leaf)?; + + match leaf { + proto::primitives::smt_leaf::Leaf::EmptyLeafIndex(leaf_index) => { + Ok(Self::new_empty(LeafIndex::new_max_depth(leaf_index))) + }, + proto::primitives::smt_leaf::Leaf::Single(entry) => { + let (key, value) = entry.try_into().context("entry")?; + + Ok(SmtLeaf::new_single(key, value)) + }, + proto::primitives::smt_leaf::Leaf::Multiple(entries) => { + let domain_entries = entries + .entries + .into_iter() + .map(TryInto::try_into) + .collect::>() + .context("entries")?; + + Ok(SmtLeaf::new_multiple(domain_entries)?) + }, + } + } +} + +impl From for proto::primitives::SmtLeaf { + fn from(smt_leaf: SmtLeaf) -> Self { + use proto::primitives::smt_leaf::Leaf; + + let leaf = match smt_leaf { + SmtLeaf::Empty(leaf_index) => Leaf::EmptyLeafIndex(leaf_index.position()), + SmtLeaf::Single(entry) => Leaf::Single(entry.into()), + SmtLeaf::Multiple(entries) => Leaf::Multiple(proto::primitives::SmtLeafEntryList { + entries: entries.into_iter().map(Into::into).collect(), + }), + }; + + Self { leaf: Some(leaf) } + } +} + +// SMT LEAF ENTRY +// ------------------------------------------------------------------------------------------------ + +impl TryFrom for (Word, Word) { + type Error = ConversionError; + + fn try_from(entry: proto::primitives::SmtLeafEntry) -> Result { + let decoder = entry.decoder(); + let key = required!(decoder, entry.key)?; + let value = required!(decoder, entry.value)?; + + Ok((key, value)) + } +} + +impl From<(Word, Word)> for proto::primitives::SmtLeafEntry { + fn from((key, value): (Word, Word)) -> Self { + Self { + key: Some(key.into()), + value: Some(value.into()), + } + } +} + +// SMT PROOF +// ------------------------------------------------------------------------------------------------ + +impl TryFrom for SmtProof { + type Error = ConversionError; + + fn try_from(opening: proto::primitives::SmtOpening) -> Result { + let decoder = opening.decoder(); + let path = required!(decoder, opening.path)?; + let leaf = required!(decoder, opening.leaf)?; + + Ok(SmtProof::new(path, leaf)?) + } +} + +impl From for proto::primitives::SmtOpening { + fn from(proof: SmtProof) -> Self { + let (path, leaf) = proof.into_parts(); + Self { + path: Some(path.into()), + leaf: Some(leaf.into()), + } + } +} + +// PARTIAL SMT +// ------------------------------------------------------------------------------------------------ + +impl From for proto::primitives::PartialSmt { + fn from(unique_nodes: UniqueNodes) -> Self { + let UniqueNodes { root, nodes, leaves, value_only_leaves } = unique_nodes; + + let mut node_levels = Vec::new(); + let mut nodes = nodes.into_iter().peekable(); + while let Some((index, _)) = nodes.peek() { + let depth = index.depth(); + let mut level_nodes = Vec::new(); + while let Some((index, digest)) = nodes.next_if(|(index, _)| index.depth() == depth) { + level_nodes.push(proto::primitives::PartialSmtNode { + index: index.position(), + digest: Some(digest.into()), + }); + } + node_levels.push(proto::primitives::PartialSmtNodeLevel { + depth: u32::from(depth), + nodes: level_nodes, + }); + } + let leaves = leaves + .into_iter() + .map(|(index, leaf)| proto::primitives::IndexedSmtLeaf { + index, + leaf: Some(leaf.into()), + }) + .collect(); + + let value_only_leaves = value_only_leaves + .into_iter() + .map(|(index, value)| proto::primitives::IndexedDigest { + index, + value: Some(value.into()), + }) + .collect(); + + Self { + root: Some(root.into()), + node_levels, + leaves, + value_only_leaves, + } + } +} + +impl TryFrom for UniqueNodes { + type Error = ConversionError; + + fn try_from(value: proto::primitives::PartialSmt) -> Result { + let decoder = value.decoder(); + let proto::primitives::PartialSmt { + root, + node_levels, + leaves, + value_only_leaves, + } = value; + + let root = required!(decoder, root)?; + + let mut seen_depths = BTreeSet::new(); + let mut decoded_nodes = BTreeMap::new(); + for level in node_levels { + let depth = u8::try_from(level.depth).context("node_levels.depth")?; + if depth == 0 || depth >= SMT_DEPTH { + return Err(ConversionError::message(format!( + "partial SMT node depth {depth} must be in the range 1..{SMT_DEPTH}" + ))); + } + if !seen_depths.insert(depth) { + return Err(ConversionError::message(format!( + "partial SMT contains duplicate node depth {depth}" + ))); + } + + for node in level.nodes { + let index = NodeIndex::new(depth, node.index).context("node_levels.nodes.index")?; + if decoded_nodes.contains_key(&index) { + return Err(ConversionError::message(format!( + "partial SMT contains duplicate node index {} at depth {depth}", + node.index + ))); + } + let digest = node.digest.ok_or_else(|| { + ConversionError::missing_field::("digest") + })?; + decoded_nodes.insert(index, digest.try_into().context("digest")?); + } + } + + let mut seen_leaf_indices = BTreeSet::new(); + let mut decoded_leaves = BTreeMap::new(); + for indexed_leaf in leaves { + if !seen_leaf_indices.insert(indexed_leaf.index) { + return Err(ConversionError::message(format!( + "partial SMT contains duplicate leaf index {}", + indexed_leaf.index + ))); + } + let decoder = indexed_leaf.decoder(); + let leaf = required!(decoder, indexed_leaf.leaf)?; + decoded_leaves.insert(indexed_leaf.index, leaf); + } + + let mut seen_value_only_indices = BTreeSet::new(); + let mut decoded_value_only_leaves = BTreeMap::new(); + for indexed_digest in value_only_leaves { + if !seen_value_only_indices.insert(indexed_digest.index) { + return Err(ConversionError::message(format!( + "partial SMT contains duplicate value-only leaf index {}", + indexed_digest.index + ))); + } + if seen_leaf_indices.contains(&indexed_digest.index) { + return Err(ConversionError::message(format!( + "partial SMT leaf index {} has both a leaf and a value-only leaf", + indexed_digest.index + ))); + } + let decoder = indexed_digest.decoder(); + let digest = required!(decoder, indexed_digest.value)?; + decoded_value_only_leaves.insert(indexed_digest.index, digest); + } + + Ok(UniqueNodes { + root, + nodes: decoded_nodes, + leaves: decoded_leaves, + value_only_leaves: decoded_value_only_leaves, + }) + } +} + +impl From for proto::primitives::PartialSmt { + fn from(partial_smt: PartialSmt) -> Self { + partial_smt.to_unique_nodes().into() + } +} + +impl TryFrom for PartialSmt { + type Error = ConversionError; + + fn try_from(value: proto::primitives::PartialSmt) -> Result { + let unique_nodes = UniqueNodes::try_from(value)?; + PartialSmt::from_unique_nodes(unique_nodes) + .map_err(|err| ConversionError::deserialization("PartialSmt", err)) + } +} + +#[cfg(test)] +mod tests { + use alloc::collections::BTreeMap; + use alloc::string::ToString; + use alloc::vec; + + use miden_protocol::crypto::merkle::smt::{PartialSmt, Smt, UniqueNodes}; + use prost::Message; + + use super::*; + + #[test] + fn partial_smt_round_trip() { + let key0 = Word::from([1, 2, 3, 4u32]); + let key1 = Word::from([5, 6, 7, 8u32]); + let missing_key = Word::from([9, 10, 11, 12u32]); + let value0 = Word::from([13, 14, 15, 16u32]); + let value1 = Word::from([17, 18, 19, 20u32]); + let smt = Smt::with_entries([(key0, value0), (key1, value1)]).unwrap(); + let partial_smt = + PartialSmt::from_proofs([smt.open(&key0), smt.open(&missing_key)]).unwrap(); + + let encoded: proto::primitives::PartialSmt = partial_smt.clone().into(); + assert!(encoded.node_levels.is_sorted_by_key(|level| level.depth)); + + let decoded = PartialSmt::try_from(encoded).unwrap(); + + assert_eq!(decoded, partial_smt); + assert_eq!(decoded.get_value(&key0).unwrap(), value0); + assert_eq!(decoded.get_value(&missing_key).unwrap(), Word::empty()); + } + + #[test] + fn partial_smt_encoding_is_canonical_for_equivalent_unique_nodes() { + let mut first = UniqueNodes::empty(); + first.nodes.insert(NodeIndex::new(1, 1).unwrap(), Word::from([1, 2, 3, 4u32])); + first + .nodes + .insert(NodeIndex::new(1, 0).unwrap(), Word::from([9, 10, 11, 12u32])); + first.leaves = BTreeMap::from([ + (2, SmtLeaf::new_empty(LeafIndex::new_max_depth(2))), + (1, SmtLeaf::new_empty(LeafIndex::new_max_depth(1))), + ]); + first.value_only_leaves = + BTreeMap::from([(2, Word::from([5, 6, 7, 8u32])), (1, Word::from([9, 10, 11, 12u32]))]); + + let mut second = first.clone(); + second.nodes = BTreeMap::from([ + (NodeIndex::new(1, 0).unwrap(), Word::from([9, 10, 11, 12u32])), + (NodeIndex::new(1, 1).unwrap(), Word::from([1, 2, 3, 4u32])), + ]); + second.leaves = BTreeMap::from([ + (1, SmtLeaf::new_empty(LeafIndex::new_max_depth(1))), + (2, SmtLeaf::new_empty(LeafIndex::new_max_depth(2))), + ]); + second.value_only_leaves = + BTreeMap::from([(1, Word::from([9, 10, 11, 12u32])), (2, Word::from([5, 6, 7, 8u32]))]); + + let first: proto::primitives::PartialSmt = first.into(); + let second: proto::primitives::PartialSmt = second.into(); + + assert_eq!(first, second); + assert_eq!(first.encode_to_vec(), second.encode_to_vec()); + } + + #[test] + fn partial_smt_encoding_preserves_nodes_at_every_depth() { + let expected_nodes = BTreeMap::from([ + (NodeIndex::new(1, 0).unwrap(), Word::from([1, 2, 3, 4u32])), + (NodeIndex::new(1, 1).unwrap(), Word::from([5, 6, 7, 8u32])), + (NodeIndex::new(2, 0).unwrap(), Word::from([9, 10, 11, 12u32])), + (NodeIndex::new(2, 3).unwrap(), Word::from([13, 14, 15, 16u32])), + (NodeIndex::new(3, 5).unwrap(), Word::from([17, 18, 19, 20u32])), + ]); + let mut unique_nodes = UniqueNodes::empty(); + unique_nodes.nodes = expected_nodes.clone(); + + let encoded: proto::primitives::PartialSmt = unique_nodes.into(); + + assert_eq!( + encoded.node_levels.iter().map(|level| level.depth).collect::>(), + vec![1, 2, 3] + ); + let decoded = UniqueNodes::try_from(encoded).unwrap(); + assert_eq!(decoded.nodes, expected_nodes); + } + + fn empty_partial_smt_message() -> proto::primitives::PartialSmt { + proto::primitives::PartialSmt { + root: Some(PartialSmt::EMPTY_ROOT.into()), + node_levels: vec![], + leaves: vec![], + value_only_leaves: vec![], + } + } + + fn assert_partial_smt_decode_error( + encoded: proto::primitives::PartialSmt, + expected_error: &str, + ) { + let error = PartialSmt::try_from(encoded).unwrap_err(); + assert_eq!(error.to_string(), expected_error); + } + + #[test] + fn partial_smt_rejects_missing_root() { + let mut encoded = empty_partial_smt_message(); + encoded.root = None; + assert_partial_smt_decode_error( + encoded, + "field miden_objects::proto::primitives::PartialSmt::root is missing", + ); + } + + #[test] + fn partial_smt_rejects_duplicate_depth() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![ + proto::primitives::PartialSmtNodeLevel { depth: 1, nodes: vec![] }, + proto::primitives::PartialSmtNodeLevel { depth: 1, nodes: vec![] }, + ]; + assert_partial_smt_decode_error(encoded, "partial SMT contains duplicate node depth 1"); + } + + #[test] + fn partial_smt_rejects_invalid_node_index() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![proto::primitives::PartialSmtNodeLevel { + depth: 1, + nodes: vec![proto::primitives::PartialSmtNode { + index: 2, + digest: Some(Word::empty().into()), + }], + }]; + assert_partial_smt_decode_error( + encoded, + "node_levels.nodes.index: node index position 2 is not valid for depth 1", + ); + } + + #[test] + fn partial_smt_rejects_missing_node_digest() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![proto::primitives::PartialSmtNodeLevel { + depth: 1, + nodes: vec![proto::primitives::PartialSmtNode { index: 0, digest: None }], + }]; + assert_partial_smt_decode_error( + encoded, + "field miden_objects::proto::primitives::PartialSmtNode::digest is missing", + ); + } + + #[test] + fn partial_smt_rejects_missing_leaf() { + let mut encoded = empty_partial_smt_message(); + encoded.leaves = vec![proto::primitives::IndexedSmtLeaf { index: 0, leaf: None }]; + assert_partial_smt_decode_error( + encoded, + "field miden_objects::proto::primitives::IndexedSmtLeaf::leaf is missing", + ); + } + + #[test] + fn partial_smt_rejects_missing_value_only_leaf() { + let mut encoded = empty_partial_smt_message(); + encoded.value_only_leaves = + vec![proto::primitives::IndexedDigest { index: 0, value: None }]; + assert_partial_smt_decode_error( + encoded, + "field miden_objects::proto::primitives::IndexedDigest::value is missing", + ); + } + + #[test] + fn partial_smt_rejects_depth_overflow() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = + vec![proto::primitives::PartialSmtNodeLevel { depth: 256, nodes: vec![] }]; + assert_partial_smt_decode_error( + encoded, + "node_levels.depth: out of range integral type conversion attempted", + ); + } + + #[test] + fn partial_smt_rejects_zero_depth() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = + vec![proto::primitives::PartialSmtNodeLevel { depth: 0, nodes: vec![] }]; + assert_partial_smt_decode_error( + encoded, + "partial SMT node depth 0 must be in the range 1..64", + ); + } + + #[test] + fn partial_smt_rejects_smt_depth() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![proto::primitives::PartialSmtNodeLevel { + depth: u32::from(SMT_DEPTH), + nodes: vec![], + }]; + assert_partial_smt_decode_error( + encoded, + "partial SMT node depth 64 must be in the range 1..64", + ); + } + + #[test] + fn partial_smt_rejects_duplicate_node_index() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![proto::primitives::PartialSmtNodeLevel { + depth: 1, + nodes: vec![ + proto::primitives::PartialSmtNode { + index: 0, + digest: Some(Word::empty().into()), + }, + proto::primitives::PartialSmtNode { + index: 0, + digest: Some(Word::empty().into()), + }, + ], + }]; + assert_partial_smt_decode_error( + encoded, + "partial SMT contains duplicate node index 0 at depth 1", + ); + } + + #[test] + fn partial_smt_rejects_duplicate_leaf_index() { + let mut encoded = empty_partial_smt_message(); + encoded.leaves = vec![ + proto::primitives::IndexedSmtLeaf { + index: 0, + leaf: Some(SmtLeaf::new_empty(LeafIndex::new_max_depth(0)).into()), + }, + proto::primitives::IndexedSmtLeaf { + index: 0, + leaf: Some(SmtLeaf::new_empty(LeafIndex::new_max_depth(0)).into()), + }, + ]; + assert_partial_smt_decode_error(encoded, "partial SMT contains duplicate leaf index 0"); + } + + #[test] + fn partial_smt_rejects_duplicate_value_only_leaf_index() { + let mut encoded = empty_partial_smt_message(); + encoded.value_only_leaves = vec![ + proto::primitives::IndexedDigest { + index: 0, + value: Some(Word::empty().into()), + }, + proto::primitives::IndexedDigest { + index: 0, + value: Some(Word::empty().into()), + }, + ]; + assert_partial_smt_decode_error( + encoded, + "partial SMT contains duplicate value-only leaf index 0", + ); + } + + #[test] + fn partial_smt_rejects_overlapping_leaf_index() { + let mut encoded = empty_partial_smt_message(); + encoded.leaves = vec![proto::primitives::IndexedSmtLeaf { + index: 0, + leaf: Some(SmtLeaf::new_empty(LeafIndex::new_max_depth(0)).into()), + }]; + encoded.value_only_leaves = vec![proto::primitives::IndexedDigest { + index: 0, + value: Some(Word::empty().into()), + }]; + assert_partial_smt_decode_error( + encoded, + "partial SMT leaf index 0 has both a leaf and a value-only leaf", + ); + } + + #[test] + fn partial_smt_rejects_embedded_leaf_index_mismatch() { + let mut encoded = empty_partial_smt_message(); + encoded.leaves = vec![proto::primitives::IndexedSmtLeaf { + index: 0, + leaf: Some(SmtLeaf::new_empty(LeafIndex::new_max_depth(1)).into()), + }]; + assert_partial_smt_decode_error( + encoded, + "failed to deserialize PartialSmt: invalid value: Node index 0 did not match the embedded leaf index 1", + ); + } + + #[test] + fn partial_smt_rejects_reconstruction_missing_node() { + let mut encoded = empty_partial_smt_message(); + encoded.node_levels = vec![proto::primitives::PartialSmtNodeLevel { + depth: 1, + nodes: vec![proto::primitives::PartialSmtNode { + index: 0, + digest: Some(Word::empty().into()), + }], + }]; + assert_partial_smt_decode_error( + encoded, + "failed to deserialize PartialSmt: invalid value: inner node hash is inconsistent with parent", + ); + } +} diff --git a/crates/miden-objects/src/conversion/mod.rs b/crates/miden-objects/src/conversion/mod.rs new file mode 100644 index 0000000000..c53430c234 --- /dev/null +++ b/crates/miden-objects/src/conversion/mod.rs @@ -0,0 +1,62 @@ +mod account; +mod account_patch; +mod asset; +mod batch; +mod block; +mod merkle; +mod note; +mod primitives; +mod protocol_config; +mod transaction; +mod transaction_inputs; + +use core::marker::PhantomData; + +pub use batch::{decode_proposed_batch, decode_proven_batch, decode_standalone_proven_batch}; + +use crate::ConversionError; + +pub(crate) struct MessageDecoder(PhantomData); + +impl Default for MessageDecoder { + fn default() -> Self { + Self(PhantomData) + } +} + +impl MessageDecoder { + pub(crate) fn required( + &self, + name: &'static str, + value: Option, + ) -> Result + where + T: TryInto, + T::Error: Into, + { + value + .ok_or_else(|| ConversionError::missing_field::(name))? + .try_into() + .map_err(Into::into) + .map_err(|error: ConversionError| error.context(name)) + } +} + +pub(crate) trait MessageDecodeExt: prost::Message + Sized { + fn decoder(&self) -> MessageDecoder { + MessageDecoder::default() + } +} + +impl MessageDecodeExt for T {} + +macro_rules! required { + ($decoder:ident, $message:ident. $field:ident) => { + $decoder.required(stringify!($field), $message.$field) + }; + ($decoder:ident, $field:ident) => { + $decoder.required(stringify!($field), $field) + }; +} + +pub(crate) use required; diff --git a/crates/miden-objects/src/conversion/note.rs b/crates/miden-objects/src/conversion/note.rs new file mode 100644 index 0000000000..5e9b9f36fb --- /dev/null +++ b/crates/miden-objects/src/conversion/note.rs @@ -0,0 +1,474 @@ +use alloc::format; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_protocol::asset::Asset; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachmentHeader, + NoteAttachmentScheme, + NoteAttachments, + NoteDetails, + NoteDetailsCommitment, + NoteHeader, + NoteId, + NoteInclusionProof, + NoteMetadata, + NoteRecipient, + NoteScript, + NoteStorage, + NoteTag, + NoteType, + PartialNoteMetadata, +}; +use miden_protocol::{Felt, MastNodeId, Word}; + +use super::{MessageDecodeExt, MessageDecoder, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +// NOTE TYPE +// ================================================================================================ + +impl From for proto::note::NoteType { + fn from(note_type: NoteType) -> Self { + match note_type { + NoteType::Private => proto::note::NoteType::Private, + NoteType::Public => proto::note::NoteType::Public, + } + } +} + +impl TryFrom for NoteType { + type Error = ConversionError; + + fn try_from(note_type: proto::note::NoteType) -> Result { + match note_type { + proto::note::NoteType::Private => Ok(NoteType::Private), + proto::note::NoteType::Public => Ok(NoteType::Public), + proto::note::NoteType::Unspecified => { + Err(ConversionError::message("enum variant discriminant out of range")) + }, + } + } +} + +// NOTE METADATA +// ================================================================================================ + +impl From for proto::note::NoteMetadata { + fn from(metadata: NoteMetadata) -> Self { + Self { + version: proto::note::NoteVersion::V1 as i32, + sender: Some(metadata.sender().into()), + note_type: proto::note::NoteType::from(metadata.note_type()) as i32, + tag: metadata.tag().as_u32(), + attachment_schemes: metadata + .attachment_headers() + .iter() + .map(|header| u32::from(header.scheme().map_or(0, |scheme| scheme.as_u16()))) + .collect(), + attachments_commitment: Some(metadata.attachments_commitment().into()), + } + } +} + +impl TryFrom for NoteMetadata { + type Error = ConversionError; + + fn try_from(metadata: proto::note::NoteMetadata) -> Result { + decode_note_version(metadata.version).context("version")?; + decode_note_metadata(metadata) + } +} + +// NOTE ATTACHMENTS +// ================================================================================================ + +impl From<&NoteAttachment> for proto::note::NoteAttachment { + fn from(attachment: &NoteAttachment) -> Self { + Self { + scheme: u32::from(attachment.attachment_scheme().as_u16()), + words: attachment.content().as_words().iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteAttachment { + type Error = ConversionError; + + fn try_from(attachment: proto::note::NoteAttachment) -> Result { + let scheme = u16::try_from(attachment.scheme).context("scheme")?; + let scheme = NoteAttachmentScheme::new(scheme) + .map_err(ConversionError::from) + .context("scheme")?; + let words = attachment + .words + .into_iter() + .map(Word::try_from) + .collect::, _>>() + .context("words")?; + + NoteAttachment::with_words(scheme, words) + .map_err(ConversionError::from) + .context("words") + } +} + +impl From for proto::note::NoteAttachments { + fn from(attachments: NoteAttachments) -> Self { + Self::from(&attachments) + } +} + +impl From<&NoteAttachments> for proto::note::NoteAttachments { + fn from(attachments: &NoteAttachments) -> Self { + Self { + attachments: attachments.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteAttachments { + type Error = ConversionError; + + fn try_from(attachments: proto::note::NoteAttachments) -> Result { + let attachments = attachments + .attachments + .into_iter() + .map(NoteAttachment::try_from) + .collect::, _>>() + .context("attachments")?; + + NoteAttachments::new(attachments) + .map_err(ConversionError::from) + .context("attachments") + } +} + +// NOTE DETAILS +// ================================================================================================ + +impl From for proto::note::NoteStorage { + fn from(storage: NoteStorage) -> Self { + Self::from(&storage) + } +} + +impl From<&NoteStorage> for proto::note::NoteStorage { + fn from(storage: &NoteStorage) -> Self { + Self { + items: storage.items().iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for NoteStorage { + type Error = ConversionError; + + fn try_from(storage: proto::note::NoteStorage) -> Result { + let items = storage + .items + .into_iter() + .map(Felt::try_from) + .collect::, _>>() + .context("items")?; + + NoteStorage::new(items).map_err(ConversionError::from).context("items") + } +} + +impl From for proto::note::NoteRecipient { + fn from(recipient: NoteRecipient) -> Self { + Self::from(&recipient) + } +} + +impl From<&NoteRecipient> for proto::note::NoteRecipient { + fn from(recipient: &NoteRecipient) -> Self { + Self { + serial_num: Some(recipient.serial_num().into()), + script: Some(recipient.script().into()), + storage: Some(recipient.storage().into()), + } + } +} + +impl TryFrom for NoteRecipient { + type Error = ConversionError; + + fn try_from(recipient: proto::note::NoteRecipient) -> Result { + let decoder = recipient.decoder(); + let serial_num = required!(decoder, recipient.serial_num)?; + let script = required!(decoder, recipient.script)?; + let storage = required!(decoder, recipient.storage)?; + + Ok(NoteRecipient::new(serial_num, script, storage)) + } +} + +impl From for proto::note::NoteDetails { + fn from(details: NoteDetails) -> Self { + Self::from(&details) + } +} + +impl From<&NoteDetails> for proto::note::NoteDetails { + fn from(details: &NoteDetails) -> Self { + Self { + assets: details.assets().iter().copied().map(Into::into).collect(), + recipient: Some(details.recipient().into()), + } + } +} + +impl TryFrom for NoteDetails { + type Error = ConversionError; + + fn try_from(details: proto::note::NoteDetails) -> Result { + let decoder = details.decoder(); + let assets = details + .assets + .into_iter() + .map(Asset::try_from) + .collect::, _>>() + .context("assets")?; + let assets = NoteAssets::new(assets).map_err(ConversionError::from).context("assets")?; + let recipient = required!(decoder, details.recipient)?; + + Ok(NoteDetails::new(assets, recipient)) + } +} + +// NOTE +// ================================================================================================ + +impl From for proto::note::Note { + fn from(note: Note) -> Self { + let (assets, metadata, recipient, attachments) = note.into_parts(); + Self { + metadata: Some(metadata.into()), + note_details: Some(NoteDetails::new(assets, recipient).into()), + note_attachments: Some(attachments.into()), + } + } +} + +impl TryFrom for Note { + type Error = ConversionError; + + fn try_from(proto_note: proto::note::Note) -> Result { + let decoder = proto_note.decoder(); + let proto::note::Note { metadata, note_details, note_attachments } = proto_note; + + let metadata = required!(decoder, metadata)?; + let partial_metadata = partial_note_metadata_from_proto(metadata)?; + + let note_details: NoteDetails = required!(decoder, note_details)?; + let (assets, recipient) = note_details.into_parts(); + let attachments = decode_note_attachments::(note_attachments)?; + + Ok(Note::with_attachments(assets, partial_metadata, recipient, attachments)) + } +} + +// NOTE ID +// ================================================================================================ + +impl From for proto::note::NoteId { + fn from(digest: Word) -> Self { + Self { id: Some(digest.into()) } + } +} + +impl TryFrom for Word { + type Error = ConversionError; + + fn try_from(note_id: proto::note::NoteId) -> Result { + let decoder = note_id.decoder(); + required!(decoder, note_id.id) + } +} + +impl From<&NoteId> for proto::note::NoteId { + fn from(note_id: &NoteId) -> Self { + Self { id: Some(note_id.as_word().into()) } + } +} + +impl From<(&NoteId, &NoteInclusionProof)> for proto::note::NoteInclusionProof { + fn from((note_id, proof): (&NoteId, &NoteInclusionProof)) -> Self { + Self { + note_id: Some(note_id.into()), + block_num: Some(proof.location().block_num().into()), + note_index_in_block: proof.location().block_note_tree_index().into(), + inclusion_path: Some(proof.note_path().clone().into()), + } + } +} + +impl TryFrom<&proto::note::NoteInclusionProof> for (NoteId, NoteInclusionProof) { + type Error = ConversionError; + + fn try_from( + proof: &proto::note::NoteInclusionProof, + ) -> Result<(NoteId, NoteInclusionProof), Self::Error> { + let proof = proof.clone(); + let decoder = proof.decoder(); + let inclusion_path = required!(decoder, proof.inclusion_path)?; + let note_id = required!(decoder, proof.note_id)?; + let block_num = required!(decoder, proof.block_num).context("block_num")?; + + Ok(( + NoteId::from_raw(note_id), + NoteInclusionProof::new( + block_num, + proof.note_index_in_block.try_into().context("note_index_in_block")?, + inclusion_path, + )?, + )) + } +} + +// NOTE HEADER +// ================================================================================================ + +impl From for proto::note::NoteHeader { + fn from(header: NoteHeader) -> Self { + Self { + details_commitment: Some(header.details_commitment().as_word().into()), + metadata: Some(header.into_metadata().into()), + } + } +} + +impl TryFrom for NoteHeader { + type Error = ConversionError; + + fn try_from(value: proto::note::NoteHeader) -> Result { + let decoder = value.decoder(); + let details_commitment_word = required!(decoder, value.details_commitment)?; + let metadata: NoteMetadata = required!(decoder, value.metadata)?; + + Ok(NoteHeader::new( + NoteDetailsCommitment::from_raw(details_commitment_word), + metadata, + )) + } +} + +// NOTE SCRIPT +// ================================================================================================ + +impl From for proto::note::NoteScript { + fn from(script: NoteScript) -> Self { + Self::from(&script) + } +} + +impl From<&NoteScript> for proto::note::NoteScript { + fn from(script: &NoteScript) -> Self { + Self { + entrypoint: script.entrypoint().into(), + mast: Some(script.mast().as_ref().into()), + } + } +} + +impl TryFrom for NoteScript { + type Error = ConversionError; + + fn try_from(value: proto::note::NoteScript) -> Result { + let decoder = value.decoder(); + let mast = required!(decoder, value.mast)?; + let entrypoint = value.entrypoint; + let entrypoint = MastNodeId::from_u32_safe(entrypoint, &mast) + .map_err(|err| ConversionError::deserialization("note_script.entrypoint", err))?; + + Self::from_parts(Arc::new(mast), entrypoint).map_err(ConversionError::new) + } +} + +// HELPERS +// ================================================================================================ + +/// Decodes the `(sender, note_type, tag)` triple from a proto `NoteMetadata` into a +/// [`PartialNoteMetadata`]. The attachment-related fields on the proto are ignored — when full +/// attachments are also transmitted, the receiver derives the canonical headers and commitment from +/// those instead. +fn partial_note_metadata_from_proto( + value: proto::note::NoteMetadata, +) -> Result { + decode_note_version(value.version).context("version")?; + decode_partial_note_metadata(value.sender, value.note_type, value.tag) +} + +fn decode_note_version(version: i32) -> Result<(), ConversionError> { + match proto::note::NoteVersion::try_from(version) { + Ok(proto::note::NoteVersion::V1) => Ok(()), + Ok(proto::note::NoteVersion::Unspecified) => { + Err(ConversionError::message("note metadata version is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown note metadata version {version}"), + error, + )), + } +} + +fn decode_note_metadata( + metadata: proto::note::NoteMetadata, +) -> Result { + let proto::note::NoteMetadata { + sender, + note_type, + tag, + attachment_schemes, + attachments_commitment, + .. + } = metadata; + + let partial = decode_partial_note_metadata(sender, note_type, tag)?; + let decoder = MessageDecoder::::default(); + let attachments_commitment = required!(decoder, attachments_commitment)?; + + if attachment_schemes.len() > NoteAttachments::MAX_COUNT { + return Err(ConversionError::message("too many attachment schemes")); + } + let mut attachment_headers = [NoteAttachmentHeader::absent(); NoteAttachments::MAX_COUNT]; + for (slot, raw) in attachment_headers.iter_mut().zip(attachment_schemes) { + let raw = u16::try_from(raw) + .map_err(|_| ConversionError::message("attachment scheme out of u16 range"))?; + *slot = if raw == 0 { + NoteAttachmentHeader::absent() + } else { + NoteAttachmentHeader::new(NoteAttachmentScheme::new(raw)?) + }; + } + + Ok(NoteMetadata::from_parts(partial, attachment_headers, attachments_commitment)) +} + +fn decode_partial_note_metadata( + sender: Option, + note_type: i32, + tag: u32, +) -> Result { + let decoder = MessageDecoder::::default(); + let sender = required!(decoder, sender)?; + let note_type = proto::note::NoteType::try_from(note_type) + .map_err(|_| ConversionError::message("enum variant discriminant out of range"))? + .try_into() + .context("note_type")?; + let tag = NoteTag::new(tag); + Ok(PartialNoteMetadata::new(sender, note_type).with_tag(tag)) +} + +/// Requires and decodes the structured attachments carried by a note message. +fn decode_note_attachments( + note_attachments: Option, +) -> Result { + let decoder = MessageDecoder::::default(); + required!(decoder, note_attachments) +} diff --git a/crates/miden-objects/src/conversion/primitives.rs b/crates/miden-objects/src/conversion/primitives.rs new file mode 100644 index 0000000000..f206155d8c --- /dev/null +++ b/crates/miden-objects/src/conversion/primitives.rs @@ -0,0 +1,536 @@ +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::vec::Vec; + +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{PublicKey, Signature}; +use miden_protocol::crypto::merkle::InnerNodeInfo; +use miden_protocol::crypto::merkle::store::MerkleStore; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::vm::{AdviceInputs, AdviceMap, AdviceStack, ExecutionProof}; +use miden_protocol::{Felt, MastForest, Word}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +const WORD_SERIALIZED_SIZE: usize = Word::SERIALIZED_SIZE; + +fn ensure_exact_length( + encoded: &[u8], + expected: usize, + field: &'static str, +) -> Result<(), ConversionError> { + if encoded.len() != expected { + return Err(ConversionError::message(format!( + "expected exactly {expected} bytes, got {}", + encoded.len() + )) + .context(field)); + } + Ok(()) +} + +// FELT +// ================================================================================================ + +impl From for proto::primitives::Felt { + fn from(value: Felt) -> Self { + Self { value: value.as_canonical_u64() } + } +} + +impl From<&Felt> for proto::primitives::Felt { + fn from(value: &Felt) -> Self { + Self { value: value.as_canonical_u64() } + } +} + +impl TryFrom for Felt { + type Error = ConversionError; + + fn try_from(value: proto::primitives::Felt) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::Felt> for Felt { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::Felt) -> Result { + Self::try_from(value.value).map_err(ConversionError::new).context("felt.value") + } +} + +// WORD +// ================================================================================================ + +impl From for proto::primitives::Word { + fn from(value: Word) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl From<&Word> for proto::primitives::Word { + fn from(value: &Word) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl TryFrom for Word { + type Error = ConversionError; + + fn try_from(value: proto::primitives::Word) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::Word> for Word { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::Word) -> Result { + ensure_exact_length(&value.encoded, WORD_SERIALIZED_SIZE, "word.encoded")?; + Self::read_from_bytes(&value.encoded) + .map_err(|error| ConversionError::deserialization("word.encoded", error)) + } +} + +// EXECUTION PROOF +// ================================================================================================ + +impl From<&ExecutionProof> for proto::primitives::ExecutionProof { + fn from(value: &ExecutionProof) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl From for proto::primitives::ExecutionProof { + fn from(value: ExecutionProof) -> Self { + (&value).into() + } +} + +impl TryFrom for ExecutionProof { + type Error = ConversionError; + + fn try_from(value: proto::primitives::ExecutionProof) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::ExecutionProof> for ExecutionProof { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::ExecutionProof) -> Result { + Self::read_from_bytes(&value.encoded) + .map_err(|error| ConversionError::deserialization("ExecutionProof", error)) + .map_err(|error| error.context("encoded")) + } +} + +// MAST FOREST +// ================================================================================================ + +impl From<&MastForest> for proto::primitives::MastForest { + fn from(value: &MastForest) -> Self { + Self { encoded: value.to_bytes() } + } +} + +impl From for proto::primitives::MastForest { + fn from(value: MastForest) -> Self { + (&value).into() + } +} + +impl TryFrom for MastForest { + type Error = ConversionError; + + fn try_from(value: proto::primitives::MastForest) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::MastForest> for MastForest { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::MastForest) -> Result { + Self::read_from_bytes(&value.encoded) + .map_err(|error| ConversionError::deserialization("MastForest", error)) + .map_err(|error| error.context("encoded")) + } +} + +// ADVICE INPUTS +// ================================================================================================ + +impl From<&AdviceStack> for proto::primitives::AdviceStack { + fn from(value: &AdviceStack) -> Self { + Self { + values: value.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for AdviceStack { + type Error = ConversionError; + + fn try_from(value: proto::primitives::AdviceStack) -> Result { + value + .values + .into_iter() + .enumerate() + .map(|(index, value)| Felt::try_from(value).context(format!("values[{index}]"))) + .collect::>() + } +} + +impl From<&AdviceMap> for proto::primitives::AdviceMap { + fn from(value: &AdviceMap) -> Self { + Self { + entries: value + .iter() + .map(|(key, values)| proto::primitives::AdviceMapEntry { + key: Some(key.into()), + values: values.iter().map(Into::into).collect(), + }) + .collect(), + } + } +} + +impl TryFrom for AdviceMap { + type Error = ConversionError; + + fn try_from(value: proto::primitives::AdviceMap) -> Result { + let mut entries = BTreeMap::new(); + for (index, entry) in value.entries.into_iter().enumerate() { + let decoder = entry.decoder(); + let entry_context = format!("entries[{index}]"); + let key = required!(decoder, entry.key).context(&entry_context)?; + let values = entry + .values + .into_iter() + .enumerate() + .map(|(value_index, value)| { + Felt::try_from(value).context(format!("{entry_context}.values[{value_index}]")) + }) + .collect::, _>>()?; + if entries.insert(key, values).is_some() { + return Err(ConversionError::message("duplicate advice map key") + .context(format!("{entry_context}.key"))); + } + } + + Ok(entries.into()) + } +} + +impl From<&MerkleStore> for proto::primitives::MerkleStore { + fn from(value: &MerkleStore) -> Self { + let default_nodes = MerkleStore::new() + .inner_nodes() + .map(|node| (node.value, (node.left, node.right))) + .collect::>(); + let mut nodes = value + .inner_nodes() + .filter(|node| default_nodes.get(&node.value) != Some(&(node.left, node.right))) + .collect::>(); + nodes.sort_by_key(|node| node.value); + + Self { + nodes: nodes + .into_iter() + .map(|node| proto::primitives::MerkleStoreNode { + value: Some(node.value.into()), + left: Some(node.left.into()), + right: Some(node.right.into()), + }) + .collect(), + } + } +} + +impl TryFrom for MerkleStore { + type Error = ConversionError; + + fn try_from(value: proto::primitives::MerkleStore) -> Result { + let mut nodes = BTreeMap::new(); + for (index, node) in value.nodes.into_iter().enumerate() { + let decoder = node.decoder(); + let node_context = format!("nodes[{index}]"); + let parent = required!(decoder, node.value).context(&node_context)?; + let left = required!(decoder, node.left).context(&node_context)?; + let right = required!(decoder, node.right).context(&node_context)?; + if nodes.insert(parent, (left, right)).is_some() { + return Err(ConversionError::message("duplicate Merkle store parent") + .context(format!("{node_context}.value"))); + } + } + + let mut store = MerkleStore::new(); + store.extend(nodes.into_iter().map(|(value, (left, right))| InnerNodeInfo { + value, + left, + right, + })); + Ok(store) + } +} + +impl From<&AdviceInputs> for proto::primitives::AdviceInputs { + fn from(value: &AdviceInputs) -> Self { + Self { + advice_stack: Some((&value.stack()).into()), + advice_map: Some(value.map().into()), + merkle_store: Some(value.store().into()), + } + } +} + +impl TryFrom for AdviceInputs { + type Error = ConversionError; + + fn try_from(value: proto::primitives::AdviceInputs) -> Result { + let decoder = value.decoder(); + let advice_stack = required!(decoder, value.advice_stack)?; + let advice_map: AdviceMap = required!(decoder, value.advice_map)?; + let merkle_store: MerkleStore = required!(decoder, value.merkle_store)?; + + Ok(AdviceInputs::new(advice_stack, advice_map, merkle_store)) + } +} + +// PUBLIC KEY +// ================================================================================================ + +fn decode_public_key_variant(variant: i32) -> Result<(), ConversionError> { + match proto::primitives::PublicKeyVariant::try_from(variant) { + Ok(proto::primitives::PublicKeyVariant::EcdsaK256Keccak) => Ok(()), + Ok(proto::primitives::PublicKeyVariant::Unspecified) => { + Err(ConversionError::message("public key variant is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown public key variant {variant}"), + error, + )), + } +} + +impl From<&PublicKey> for proto::primitives::PublicKey { + fn from(value: &PublicKey) -> Self { + Self { + variant: proto::primitives::PublicKeyVariant::EcdsaK256Keccak as i32, + encoded: value.to_bytes(), + } + } +} + +impl From for proto::primitives::PublicKey { + fn from(value: PublicKey) -> Self { + (&value).into() + } +} + +impl TryFrom for PublicKey { + type Error = ConversionError; + + fn try_from(value: proto::primitives::PublicKey) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::PublicKey> for PublicKey { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::PublicKey) -> Result { + decode_public_key_variant(value.variant).context("variant")?; + Self::read_from_bytes(&value.encoded) + .map_err(|error| ConversionError::deserialization("PublicKey", error)) + .map_err(|error| error.context("encoded")) + } +} + +// SIGNATURE +// ================================================================================================ + +fn decode_signature_variant(variant: i32) -> Result<(), ConversionError> { + match proto::primitives::SignatureVariant::try_from(variant) { + Ok(proto::primitives::SignatureVariant::EcdsaK256Keccak) => Ok(()), + Ok(proto::primitives::SignatureVariant::Unspecified) => { + Err(ConversionError::message("signature variant is unspecified")) + }, + Err(error) => Err(ConversionError::with_source( + format!("unknown signature variant {variant}"), + error, + )), + } +} + +impl From<&Signature> for proto::primitives::Signature { + fn from(value: &Signature) -> Self { + Self { + variant: proto::primitives::SignatureVariant::EcdsaK256Keccak as i32, + encoded: value.to_bytes(), + } + } +} + +impl From for proto::primitives::Signature { + fn from(value: Signature) -> Self { + (&value).into() + } +} + +impl TryFrom for Signature { + type Error = ConversionError; + + fn try_from(value: proto::primitives::Signature) -> Result { + Self::try_from(&value) + } +} + +impl TryFrom<&proto::primitives::Signature> for Signature { + type Error = ConversionError; + + fn try_from(value: &proto::primitives::Signature) -> Result { + decode_signature_variant(value.variant).context("variant")?; + Self::read_from_bytes(&value.encoded) + .map_err(|error| ConversionError::deserialization("Signature", error)) + .map_err(|error| error.context("encoded")) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + use alloc::vec; + use core::error::Error; + + use assert_matches::assert_matches; + use miden_protocol::testing::dummy_execution_proof; + use miden_protocol::testing::random_secret_key::random_secret_key; + use miden_protocol::utils::serde::DeserializationError; + + use super::*; + + #[test] + fn felt_roundtrips_zero_and_rejects_the_field_order() { + for felt in [Felt::ZERO, Felt::from(42_u32)] { + let encoded = proto::primitives::Felt::from(felt); + assert_eq!(encoded.value, felt.as_canonical_u64()); + assert_eq!(Felt::try_from(encoded).unwrap(), felt); + } + + let error = Felt::try_from(proto::primitives::Felt { value: Felt::ORDER }).unwrap_err(); + assert_matches!( + error + .source() + .and_then(|source| source.downcast_ref::<>::Error>()), + Some(source) if source.as_u64() == Felt::ORDER + ); + } + + #[test] + fn word_roundtrips_and_rejects_invalid_lengths() { + let felt = Felt::from(42_u32); + + let word = Word::new([felt, Felt::ZERO, Felt::ONE, Felt::new_unchecked(7)]); + assert_eq!(Word::try_from(proto::primitives::Word::from(word)).unwrap(), word); + + let error = Word::try_from(proto::primitives::Word { encoded: vec![0; 31] }).unwrap_err(); + assert_eq!(error.to_string(), "word.encoded: expected exactly 32 bytes, got 31"); + } + + #[test] + fn public_key_and_signature_roundtrip_with_ecdsa_k256_keccak_variants() { + let signing_key = random_secret_key(); + let public_key = signing_key.public_key(); + let signature = signing_key.sign(Word::empty()); + + let encoded_public_key = proto::primitives::PublicKey::from(&public_key); + assert_eq!( + encoded_public_key.variant, + proto::primitives::PublicKeyVariant::EcdsaK256Keccak as i32 + ); + assert_eq!(PublicKey::try_from(encoded_public_key).unwrap(), public_key); + + let encoded_signature = proto::primitives::Signature::from(&signature); + assert_eq!( + encoded_signature.variant, + proto::primitives::SignatureVariant::EcdsaK256Keccak as i32 + ); + assert_eq!(Signature::try_from(encoded_signature).unwrap(), signature); + } + + #[test] + fn public_key_and_signature_reject_malformed_encodings() { + let public_key_error = PublicKey::try_from(proto::primitives::PublicKey { + variant: proto::primitives::PublicKeyVariant::EcdsaK256Keccak as i32, + encoded: vec![], + }) + .unwrap_err(); + assert_matches!( + public_key_error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(DeserializationError::UnexpectedEOF) + ); + + let signature_error = Signature::try_from(proto::primitives::Signature { + variant: proto::primitives::SignatureVariant::EcdsaK256Keccak as i32, + encoded: vec![], + }) + .unwrap_err(); + assert_matches!( + signature_error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(DeserializationError::UnexpectedEOF) + ); + } + + #[test] + fn public_key_and_signature_reject_unspecified_variants_before_decoding_bytes() { + let public_key_error = + PublicKey::try_from(proto::primitives::PublicKey { variant: 0, encoded: vec![] }) + .unwrap_err(); + assert_eq!(public_key_error.to_string(), "variant: public key variant is unspecified"); + + let signature_error = + Signature::try_from(proto::primitives::Signature { variant: 0, encoded: vec![] }) + .unwrap_err(); + assert_eq!(signature_error.to_string(), "variant: signature variant is unspecified"); + } + + #[test] + fn public_key_and_signature_reject_unknown_variants_before_decoding_bytes() { + let public_key_error = PublicKey::try_from(proto::primitives::PublicKey { + variant: i32::MAX, + encoded: vec![], + }) + .unwrap_err(); + assert_eq!(public_key_error.to_string(), "variant: unknown public key variant 2147483647"); + + let signature_error = Signature::try_from(proto::primitives::Signature { + variant: i32::MAX, + encoded: vec![], + }) + .unwrap_err(); + assert_eq!(signature_error.to_string(), "variant: unknown signature variant 2147483647"); + } + + #[test] + fn execution_proof_roundtrips() { + let proof = dummy_execution_proof(); + let encoded = proto::primitives::ExecutionProof::from(&proof); + assert_eq!(ExecutionProof::try_from(encoded).unwrap(), proof); + } + + #[test] + fn mast_forest_roundtrips() { + let mast = MastForest::new(); + let encoded = proto::primitives::MastForest::from(&mast); + assert_eq!(MastForest::try_from(encoded).unwrap(), mast); + } +} diff --git a/crates/miden-objects/src/conversion/protocol_config.rs b/crates/miden-objects/src/conversion/protocol_config.rs new file mode 100644 index 0000000000..81fb5f9305 --- /dev/null +++ b/crates/miden-objects/src/conversion/protocol_config.rs @@ -0,0 +1,144 @@ +use alloc::format; +use alloc::vec::Vec; + +use miden_protocol::Word; +use miden_protocol::asset::AssetId; +use miden_protocol::protocol_config::{ + KernelConfig, + ProofSecurityPolicy, + ProofVerificationConfig, + ProtocolConfig, +}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +impl TryFrom for KernelConfig { + type Error = ConversionError; + + fn try_from(message: proto::protocol_config::KernelConfig) -> Result { + let decoder = message.decoder(); + let main_proc = required!(decoder, message.main_proc)?; + let kernel_procs = message + .kernel_procs + .into_iter() + .enumerate() + .map(|(index, root)| Word::try_from(root).context(format!("kernel_procs[{index}]"))) + .collect::, _>>()?; + + KernelConfig::new(main_proc, kernel_procs).map_err(ConversionError::new) + } +} + +impl From<&KernelConfig> for proto::protocol_config::KernelConfig { + fn from(config: &KernelConfig) -> Self { + Self { + main_proc: Some(config.main_proc().into()), + kernel_procs: config.kernel_procs().iter().copied().map(Into::into).collect(), + } + } +} + +impl From for proto::protocol_config::KernelConfig { + fn from(config: KernelConfig) -> Self { + (&config).into() + } +} + +impl TryFrom for ProofSecurityPolicy { + type Error = ConversionError; + + fn try_from(message: proto::protocol_config::ProofSecurityPolicy) -> Result { + let decoder = message.decoder(); + let security_estimator_root = required!(decoder, message.security_estimator_root)?; + let minimum_bits = u8::try_from(message.minimum_bits).context("minimum_bits")?; + + ProofSecurityPolicy::new(security_estimator_root, minimum_bits) + .map_err(ConversionError::new) + } +} + +impl From<&ProofSecurityPolicy> for proto::protocol_config::ProofSecurityPolicy { + fn from(policy: &ProofSecurityPolicy) -> Self { + Self { + security_estimator_root: Some(policy.security_estimator_root().into()), + minimum_bits: u32::from(policy.minimum_bits()), + } + } +} + +impl From for proto::protocol_config::ProofSecurityPolicy { + fn from(policy: ProofSecurityPolicy) -> Self { + (&policy).into() + } +} + +impl TryFrom for ProofVerificationConfig { + type Error = ConversionError; + + fn try_from( + message: proto::protocol_config::ProofVerificationConfig, + ) -> Result { + let decoder = message.decoder(); + let vm_verifier_root = required!(decoder, message.vm_verifier_root)?; + let precompile_verifier_root = required!(decoder, message.precompile_verifier_root)?; + let security_policy = required!(decoder, message.security_policy)?; + + Ok(ProofVerificationConfig::new( + vm_verifier_root, + precompile_verifier_root, + security_policy, + )) + } +} + +impl From<&ProofVerificationConfig> for proto::protocol_config::ProofVerificationConfig { + fn from(config: &ProofVerificationConfig) -> Self { + Self { + vm_verifier_root: Some(config.vm_verifier_root().into()), + precompile_verifier_root: Some(config.precompile_verifier_root().into()), + security_policy: Some(config.security_policy().into()), + } + } +} + +impl From for proto::protocol_config::ProofVerificationConfig { + fn from(config: ProofVerificationConfig) -> Self { + (&config).into() + } +} + +impl TryFrom for ProtocolConfig { + type Error = ConversionError; + + fn try_from(message: proto::protocol_config::ProtocolConfig) -> Result { + let decoder = message.decoder(); + let fee_asset_id: Word = required!(decoder, message.fee_asset_id)?; + let fee_asset_id = AssetId::try_from(fee_asset_id).context("fee_asset_id")?; + let tx_kernel = required!(decoder, message.tx_kernel)?; + let batch_kernel = required!(decoder, message.batch_kernel)?; + let block_kernel = required!(decoder, message.block_kernel)?; + let proof_verification = required!(decoder, message.proof_verification)?; + + ProtocolConfig::new(fee_asset_id, tx_kernel, batch_kernel, block_kernel, proof_verification) + .map_err(ConversionError::new) + } +} + +impl From<&ProtocolConfig> for proto::protocol_config::ProtocolConfig { + fn from(config: &ProtocolConfig) -> Self { + Self { + fee_asset_id: Some(Word::from(config.fee_asset_id()).into()), + tx_kernel: Some(config.tx_kernel().into()), + batch_kernel: Some(config.batch_kernel().into()), + block_kernel: Some(config.block_kernel().into()), + proof_verification: Some(config.proof_verification().into()), + } + } +} + +impl From for proto::protocol_config::ProtocolConfig { + fn from(config: ProtocolConfig) -> Self { + (&config).into() + } +} diff --git a/crates/miden-objects/src/conversion/transaction.rs b/crates/miden-objects/src/conversion/transaction.rs new file mode 100644 index 0000000000..40b1670d8b --- /dev/null +++ b/crates/miden-objects/src/conversion/transaction.rs @@ -0,0 +1,420 @@ +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use miden_protocol::account::{AccountId, AccountUpdateDetails}; +use miden_protocol::note::{NoteHeader, NoteId, Nullifier}; +use miden_protocol::transaction::{ + InputNoteCommitment, + InputNotes, + OutputNote, + PrivateOutputNote, + ProvenTransaction, + PublicOutputNote, + TransactionArgs, + TransactionHeader, + TransactionId, + TransactionScript, + TxAccountUpdate, +}; +use miden_protocol::{MastForest, MastNodeId, Word}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +// TRANSACTION ARGUMENTS +// ================================================================================================ + +impl From<&TransactionScript> for proto::transaction::TransactionScript { + fn from(value: &TransactionScript) -> Self { + Self { + entrypoint: value.entrypoint().into(), + mast: Some(value.mast().as_ref().into()), + } + } +} + +impl TryFrom for TransactionScript { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TransactionScript) -> Result { + let decoder = value.decoder(); + let mast: MastForest = required!(decoder, value.mast)?; + let entrypoint = MastNodeId::from_u32_safe(value.entrypoint, &mast).map_err(|error| { + ConversionError::deserialization("transaction_script.entrypoint", error) + })?; + + Self::from_parts(Arc::new(mast), entrypoint).map_err(ConversionError::new) + } +} + +impl From<&TransactionArgs> for proto::transaction::TransactionArgs { + fn from(value: &TransactionArgs) -> Self { + Self { + tx_script: value.tx_script().map(Into::into), + tx_script_args: Some(value.tx_script_args().into()), + note_args: value + .note_args() + .iter() + .map(|(note_id, args)| proto::transaction::NoteArgument { + note_id: Some(note_id.into()), + args: Some(args.into()), + }) + .collect(), + advice_inputs: Some(value.advice_inputs().into()), + auth_args: Some(value.auth_args().into()), + } + } +} + +impl From for proto::transaction::TransactionArgs { + fn from(value: TransactionArgs) -> Self { + (&value).into() + } +} + +impl TryFrom for TransactionArgs { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TransactionArgs) -> Result { + let decoder = value.decoder(); + let tx_script = value.tx_script.map(TryInto::try_into).transpose()?; + let tx_script_args = required!(decoder, value.tx_script_args)?; + let mut note_args = BTreeMap::new(); + for (index, note_arg) in value.note_args.into_iter().enumerate() { + let decoder = note_arg.decoder(); + let note_arg_context = format!("note_args[{index}]"); + let note_id_word: Word = + required!(decoder, note_arg.note_id).context(¬e_arg_context)?; + let note_id = NoteId::from_raw(note_id_word); + let args = required!(decoder, note_arg.args).context(¬e_arg_context)?; + if note_args.insert(note_id, args).is_some() { + return Err(ConversionError::message("duplicate note argument") + .context(format!("{note_arg_context}.note_id"))); + } + } + let advice_inputs = required!(decoder, value.advice_inputs)?; + let auth_args = required!(decoder, value.auth_args)?; + + Ok(Self::from_parts(tx_script, tx_script_args, note_args, advice_inputs, auth_args)) + } +} + +// TX ACCOUNT UPDATE +// ================================================================================================ + +impl From<&TxAccountUpdate> for proto::transaction::TxAccountUpdate { + fn from(value: &TxAccountUpdate) -> Self { + Self { + account_id: Some(value.account_id().into()), + initial_state_commitment: Some(value.initial_state_commitment().into()), + final_state_commitment: Some(value.final_state_commitment().into()), + account_patch_commitment: Some(value.account_patch_commitment().into()), + details: Some(value.details().into()), + } + } +} + +impl TryFrom for TxAccountUpdate { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TxAccountUpdate) -> Result { + let decoder = value.decoder(); + let account_id: AccountId = required!(decoder, value.account_id)?; + let initial_state_commitment = required!(decoder, value.initial_state_commitment)?; + let final_state_commitment = required!(decoder, value.final_state_commitment)?; + let account_patch_commitment = required!(decoder, value.account_patch_commitment)?; + let details: AccountUpdateDetails = required!(decoder, value.details)?; + Self::new( + account_id, + initial_state_commitment, + final_state_commitment, + account_patch_commitment, + details, + ) + .map_err(ConversionError::new) + } +} + +// PROVEN TRANSACTION +// ================================================================================================ + +impl From<&ProvenTransaction> for proto::transaction::ProvenTransaction { + fn from(value: &ProvenTransaction) -> Self { + Self { + account_update: Some(value.account_update().into()), + input_notes: value.input_notes().iter().map(Into::into).collect(), + output_notes: value.output_notes().iter().map(Into::into).collect(), + reference_block_num: Some(value.ref_block_num().into()), + reference_block_commitment: Some(value.ref_block_commitment().into()), + expiration_block_num: Some(value.expiration_block_num().into()), + proof: Some(value.proof().into()), + } + } +} + +impl From for proto::transaction::ProvenTransaction { + fn from(value: ProvenTransaction) -> Self { + Self::from(&value) + } +} + +impl TryFrom for ProvenTransaction { + type Error = ConversionError; + + fn try_from(value: proto::transaction::ProvenTransaction) -> Result { + let decoder = value.decoder(); + let account_update = required!(decoder, value.account_update)?; + let input_notes = value + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let output_notes = value + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + OutputNote::try_from(note).context(format!("output_notes[{index}]")) + }) + .collect::, _>>()?; + let reference_block_commitment = required!(decoder, value.reference_block_commitment)?; + let reference_block_num = + required!(decoder, value.reference_block_num).context("reference_block_num")?; + let expiration_block_num = + required!(decoder, value.expiration_block_num).context("expiration_block_num")?; + let proof = required!(decoder, value.proof)?; + + Self::new( + account_update, + input_notes, + output_notes, + reference_block_num, + reference_block_commitment, + expiration_block_num, + proof, + ) + .map_err(ConversionError::new) + } +} + +// FROM TRANSACTION ID +// ================================================================================================ + +impl From<&TransactionId> for proto::transaction::TransactionId { + fn from(value: &TransactionId) -> Self { + proto::transaction::TransactionId { id: Some(value.as_word().into()) } + } +} + +impl From for proto::transaction::TransactionId { + fn from(value: TransactionId) -> Self { + (&value).into() + } +} + +// INTO TRANSACTION ID +// ================================================================================================ + +impl TryFrom for TransactionId { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TransactionId) -> Result { + let decoder = value.decoder(); + let id: Word = required!(decoder, value.id)?; + Ok(TransactionId::from_raw(id)) + } +} + +// INPUT NOTE COMMITMENT +// ================================================================================================ + +impl From for proto::transaction::InputNoteCommitment { + fn from(value: InputNoteCommitment) -> Self { + Self::from(&value) + } +} + +impl From<&InputNoteCommitment> for proto::transaction::InputNoteCommitment { + fn from(value: &InputNoteCommitment) -> Self { + Self { + nullifier: Some(value.nullifier().as_word().into()), + header: value.header().copied().map(Into::into), + } + } +} + +impl TryFrom for InputNoteCommitment { + type Error = ConversionError; + + fn try_from(value: proto::transaction::InputNoteCommitment) -> Result { + let decoder = value.decoder(); + let nullifier = Nullifier::from_raw(required!(decoder, value.nullifier)?); + + let header = value.header.map(TryInto::try_into).transpose().context("header")?; + + Ok(InputNoteCommitment::from_parts_unchecked(nullifier, header)) + } +} + +// TRANSACTION HEADER +// ================================================================================================ + +impl From<&TransactionHeader> for proto::transaction::TransactionHeader { + fn from(header: &TransactionHeader) -> Self { + Self { + transaction_id: Some(header.id().into()), + account_id: Some(header.account_id().into()), + initial_state_commitment: Some(header.initial_state_commitment().into()), + final_state_commitment: Some(header.final_state_commitment().into()), + input_notes: header.input_notes().iter().map(Into::into).collect(), + output_notes: header.output_notes().iter().copied().map(Into::into).collect(), + } + } +} + +impl From for proto::transaction::TransactionHeader { + fn from(header: TransactionHeader) -> Self { + Self::from(&header) + } +} + +impl TryFrom for TransactionHeader { + type Error = ConversionError; + + fn try_from(header: proto::transaction::TransactionHeader) -> Result { + let decoder = header.decoder(); + let transmitted_id = required!(decoder, header.transaction_id)?; + let account_id = required!(decoder, header.account_id)?; + let initial_state_commitment = required!(decoder, header.initial_state_commitment)?; + let final_state_commitment = required!(decoder, header.final_state_commitment)?; + let input_notes = header + .input_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]")) + }) + .collect::, _>>()?; + let input_notes = InputNotes::new(input_notes) + .map_err(ConversionError::new) + .context("input_notes")?; + let output_notes = header + .output_notes + .into_iter() + .enumerate() + .map(|(index, note)| { + NoteHeader::try_from(note).context(format!("output_notes[{index}]")) + }) + .collect::, _>>()?; + + let header = TransactionHeader::new( + account_id, + initial_state_commitment, + final_state_commitment, + input_notes, + output_notes, + ) + .map_err(ConversionError::new)?; + if header.id() != transmitted_id { + return Err(ConversionError::message(format!( + "transaction ID mismatch: transmitted {transmitted_id}, recomputed {}", + header.id() + )) + .context("transaction_id")); + } + + Ok(header) + } +} + +// OUTPUT NOTES +// ================================================================================================ + +impl From<&PublicOutputNote> for proto::transaction::PublicOutputNote { + fn from(note: &PublicOutputNote) -> Self { + Self { + note: Some(note.as_note().clone().into()), + } + } +} + +impl From for proto::transaction::PublicOutputNote { + fn from(note: PublicOutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for PublicOutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::PublicOutputNote) -> Result { + let decoder = note.decoder(); + let domain_note = required!(decoder, note.note)?; + PublicOutputNote::new(domain_note).map_err(ConversionError::new) + } +} + +impl From<&PrivateOutputNote> for proto::transaction::PrivateOutputNote { + fn from(note: &PrivateOutputNote) -> Self { + Self { + header: Some((*note.header()).into()), + attachments: Some(note.attachments().into()), + } + } +} + +impl From for proto::transaction::PrivateOutputNote { + fn from(note: PrivateOutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for PrivateOutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::PrivateOutputNote) -> Result { + let decoder = note.decoder(); + let header = required!(decoder, note.header)?; + let attachments = required!(decoder, note.attachments)?; + PrivateOutputNote::new(header, attachments).map_err(ConversionError::new) + } +} + +impl From<&OutputNote> for proto::transaction::OutputNote { + fn from(note: &OutputNote) -> Self { + use proto::transaction::output_note::Note; + + let note = match note { + OutputNote::Public(note) => Note::Public(note.into()), + OutputNote::Private(note) => Note::Private(note.into()), + }; + Self { note: Some(note) } + } +} + +impl From for proto::transaction::OutputNote { + fn from(note: OutputNote) -> Self { + Self::from(¬e) + } +} + +impl TryFrom for OutputNote { + type Error = ConversionError; + + fn try_from(note: proto::transaction::OutputNote) -> Result { + use proto::transaction::output_note::Note; + + match note.note { + Some(Note::Public(note)) => note.try_into().map(OutputNote::Public).context("public"), + Some(Note::Private(note)) => { + note.try_into().map(OutputNote::Private).context("private") + }, + None => Err(ConversionError::missing_field::("note")), + } + } +} diff --git a/crates/miden-objects/src/conversion/transaction_inputs.rs b/crates/miden-objects/src/conversion/transaction_inputs.rs new file mode 100644 index 0000000000..629c477695 --- /dev/null +++ b/crates/miden-objects/src/conversion/transaction_inputs.rs @@ -0,0 +1,198 @@ +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use miden_protocol::account::{AccountCode, StorageSlotId, StorageSlotName}; +use miden_protocol::note::{Note, NoteId, NoteInclusionProof}; +use miden_protocol::transaction::{InputNote, InputNotes, TransactionInputs}; + +use super::{MessageDecodeExt, required}; +use crate::{ConversionError, ConversionResultExt, proto}; + +impl From<&InputNote> for proto::transaction::InputNote { + fn from(value: &InputNote) -> Self { + use proto::transaction::input_note::Note as ProtoInputNote; + + let note = match value { + InputNote::Authenticated { note, proof } => { + ProtoInputNote::Authenticated(proto::transaction::AuthenticatedInputNote { + note: Some(note.clone().into()), + proof: Some((¬e.id(), proof).into()), + }) + }, + InputNote::Unauthenticated { note } => { + ProtoInputNote::Unauthenticated(note.clone().into()) + }, + }; + + Self { note: Some(note) } + } +} + +impl TryFrom for InputNote { + type Error = ConversionError; + + fn try_from(value: proto::transaction::InputNote) -> Result { + use proto::transaction::input_note::Note as ProtoInputNote; + + match value.note { + Some(ProtoInputNote::Authenticated(authenticated)) => { + decode_authenticated_input_note(authenticated).context("authenticated") + }, + Some(ProtoInputNote::Unauthenticated(note)) => { + Note::try_from(note).map(InputNote::unauthenticated).context("unauthenticated") + }, + None => Err(ConversionError::missing_field::("note")), + } + } +} + +fn decode_authenticated_input_note( + authenticated: proto::transaction::AuthenticatedInputNote, +) -> Result { + let decoder = authenticated.decoder(); + let note: Note = required!(decoder, authenticated.note)?; + let proof_message: proto::note::NoteInclusionProof = required!(decoder, authenticated.proof)?; + let (proof_note_id, proof): (NoteId, NoteInclusionProof) = + (&proof_message).try_into().context("proof")?; + if proof_note_id != note.id() { + return Err(ConversionError::message(format!( + "note ID mismatch: transmitted {proof_note_id}, decoded {}", + note.id() + )) + .context("proof.note_id")); + } + + Ok(InputNote::authenticated(note, proof)) +} + +impl From<&InputNotes> for proto::transaction::InputNotes { + fn from(value: &InputNotes) -> Self { + Self { + notes: value.iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for InputNotes { + type Error = ConversionError; + + fn try_from(value: proto::transaction::InputNotes) -> Result { + let notes = value + .notes + .into_iter() + .enumerate() + .map(|(index, note)| InputNote::try_from(note).context(format!("notes[{index}]"))) + .collect::, _>>()?; + + Self::new(notes).map_err(ConversionError::new) + } +} + +impl From<&TransactionInputs> for proto::transaction::TransactionInputsV1 { + fn from(value: &TransactionInputs) -> Self { + Self { + account: Some(value.account().into()), + block_header: Some(value.block_header().into()), + protocol_config: Some(value.protocol_config().into()), + partial_blockchain: Some(value.blockchain().into()), + input_notes: Some(value.input_notes().into()), + tx_args: Some(value.tx_args().into()), + advice_inputs: Some(value.advice_inputs().into()), + foreign_account_code: value.foreign_account_code().iter().map(Into::into).collect(), + foreign_account_slot_names: value + .foreign_account_slot_names() + .iter() + .map(|(slot_id, slot_name)| proto::transaction::ForeignAccountSlotName { + slot_id: Some(slot_id.into()), + slot_name: String::from(slot_name.as_str()), + }) + .collect(), + } + } +} + +impl From<&TransactionInputs> for proto::transaction::TransactionInputs { + fn from(value: &TransactionInputs) -> Self { + use proto::transaction::transaction_inputs::Version; + + Self { version: Some(Version::V1(value.into())) } + } +} + +impl From for proto::transaction::TransactionInputs { + fn from(value: TransactionInputs) -> Self { + (&value).into() + } +} + +impl TryFrom for TransactionInputs { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TransactionInputsV1) -> Result { + let decoder = value.decoder(); + let account = required!(decoder, value.account)?; + let block_header = required!(decoder, value.block_header)?; + let protocol_config = required!(decoder, value.protocol_config)?; + let partial_blockchain = required!(decoder, value.partial_blockchain)?; + let input_notes = required!(decoder, value.input_notes)?; + let tx_args = required!(decoder, value.tx_args)?; + let advice_inputs = required!(decoder, value.advice_inputs)?; + let foreign_account_code = value + .foreign_account_code + .into_iter() + .enumerate() + .map(|(index, code)| { + AccountCode::try_from(code).context(format!("foreign_account_code[{index}]")) + }) + .collect::, _>>()?; + + let mut foreign_account_slot_names = BTreeMap::new(); + for (index, entry) in value.foreign_account_slot_names.into_iter().enumerate() { + let decoder = entry.decoder(); + let slot_name_context = format!("foreign_account_slot_names[{index}]"); + let slot_id: StorageSlotId = + required!(decoder, entry.slot_id).context(&slot_name_context)?; + let slot_name = StorageSlotName::new(entry.slot_name) + .map_err(ConversionError::new) + .context(format!("{slot_name_context}.slot_name"))?; + if slot_name.id() != slot_id { + return Err(ConversionError::message("storage slot ID does not match slot name") + .context(format!("{slot_name_context}.slot_id"))); + } + if foreign_account_slot_names.insert(slot_id, slot_name).is_some() { + return Err(ConversionError::message("duplicate foreign account storage slot ID") + .context(format!("{slot_name_context}.slot_id"))); + } + } + + TransactionInputs::try_from_parts( + account, + block_header, + protocol_config, + partial_blockchain, + input_notes, + tx_args, + advice_inputs, + foreign_account_code, + foreign_account_slot_names, + ) + .map_err(ConversionError::new) + } +} + +impl TryFrom for TransactionInputs { + type Error = ConversionError; + + fn try_from(value: proto::transaction::TransactionInputs) -> Result { + use proto::transaction::transaction_inputs::Version; + + match value.version { + Some(Version::V1(v1)) => Self::try_from(v1).context("v1"), + None => Err(ConversionError::missing_field::( + "version", + )), + } + } +} diff --git a/crates/miden-objects/src/error.rs b/crates/miden-objects/src/error.rs new file mode 100644 index 0000000000..fd8093d030 --- /dev/null +++ b/crates/miden-objects/src/error.rs @@ -0,0 +1,167 @@ +use alloc::boxed::Box; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use core::any::type_name; +use core::fmt; + +use miden_protocol::utils::serde::DeserializationError; + +/// Error produced while converting a Protobuf message into a protocol object. +#[derive(Debug)] +pub struct ConversionError { + path: Vec, + source: Box, +} + +impl ConversionError { + pub fn new(source: impl core::error::Error + Send + Sync + 'static) -> Self { + Self { + path: Vec::new(), + source: Box::new(source), + } + } + + #[must_use] + pub fn context(mut self, field: impl Into) -> Self { + self.path.push(field.into()); + self + } + + pub fn missing_field(field_name: &'static str) -> Self { + Self::message(format!("field {}::{field_name} is missing", type_name::())) + } + + pub fn deserialization(entity: &'static str, source: DeserializationError) -> Self { + Self::new(DeserializationConversionError { entity, source }) + } + + pub fn message(message: impl Into) -> Self { + Self { + path: Vec::new(), + source: Box::new(StringError(message.into())), + } + } + + pub(crate) fn with_source( + message: impl Into, + source: impl core::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new(ContextualError { + message: message.into(), + source: Box::new(source), + }) + } +} + +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, segment) in self.path.iter().rev().enumerate() { + if index > 0 { + f.write_str(".")?; + } + f.write_str(segment)?; + } + if !self.path.is_empty() { + f.write_str(": ")?; + } + self.source.fmt(f) + } +} + +impl core::error::Error for ConversionError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&*self.source) + } +} + +#[derive(Debug)] +struct StringError(String); + +impl fmt::Display for StringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl core::error::Error for StringError {} + +#[derive(Debug)] +struct ContextualError { + message: String, + source: Box, +} + +impl fmt::Display for ContextualError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl core::error::Error for ContextualError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&*self.source) + } +} + +#[derive(Debug)] +struct DeserializationConversionError { + entity: &'static str, + source: DeserializationError, +} + +impl fmt::Display for DeserializationConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "failed to deserialize {}: {}", self.entity, self.source) + } +} + +impl core::error::Error for DeserializationConversionError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&self.source) + } +} + +pub trait ConversionResultExt { + fn context(self, field: impl Into) -> Result; +} + +impl> ConversionResultExt for Result { + fn context(self, field: impl Into) -> Result { + self.map_err(|error| error.into().context(field)) + } +} + +macro_rules! impl_conversion_error_from { + ($($ty:ty),* $(,)?) => {$( + impl From<$ty> for ConversionError { + fn from(error: $ty) -> Self { + Self::new(error) + } + } + )*}; +} + +impl_conversion_error_from!( + core::convert::Infallible, + core::num::TryFromIntError, + DeserializationError, + miden_protocol::crypto::merkle::MerkleError, + miden_protocol::crypto::merkle::smt::SmtLeafError, + miden_protocol::crypto::merkle::smt::SmtProofError, + miden_protocol::errors::AccountError, + miden_protocol::errors::AccountTreeError, + miden_protocol::errors::AssetError, + miden_protocol::errors::AssetVaultError, + miden_protocol::errors::NoteError, + miden_protocol::errors::PartialAssetVaultError, + miden_protocol::errors::ProtocolConfigError, + miden_protocol::errors::StorageSlotNameError, + miden_protocol::errors::TransactionInputError, +); + +impl From for ConversionError { + fn from(error: prost::UnknownEnumValue) -> Self { + Self::new(error) + } +} diff --git a/crates/miden-objects/src/lib.rs b/crates/miden-objects/src/lib.rs new file mode 100644 index 0000000000..b8708df1c5 --- /dev/null +++ b/crates/miden-objects/src/lib.rs @@ -0,0 +1,58 @@ +#![no_std] + +extern crate alloc; + +pub mod conversion; +pub mod error; + +pub use error::{ConversionError, ConversionResultExt}; +pub use prost; + +/// Generated canonical Protobuf messages. +pub mod proto { + pub mod account { + include!(concat!(env!("OUT_DIR"), "/account.rs")); + } + + pub mod asset { + include!(concat!(env!("OUT_DIR"), "/asset.rs")); + } + + pub mod blockchain { + include!(concat!(env!("OUT_DIR"), "/blockchain.rs")); + } + + pub mod note { + include!(concat!(env!("OUT_DIR"), "/note.rs")); + } + + pub mod primitives { + include!(concat!(env!("OUT_DIR"), "/primitives.rs")); + } + + pub mod protocol_config { + include!(concat!(env!("OUT_DIR"), "/protocol_config.rs")); + } + + pub mod transaction { + include!(concat!(env!("OUT_DIR"), "/transaction.rs")); + } +} + +/// Self-contained descriptor set for the canonical object schemas. +pub const FILE_DESCRIPTOR_SET: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/miden_objects_descriptor.bin")); + +/// Protobuf paths and their canonical generated Rust paths. +/// +/// Service-binding build scripts should configure these as Prost external paths so that messages +/// imported from this descriptor are represented by this crate's generated Rust types. +pub const EXTERN_PATHS: &[(&str, &str)] = &[ + (".account", "::miden_objects::proto::account"), + (".asset", "::miden_objects::proto::asset"), + (".blockchain", "::miden_objects::proto::blockchain"), + (".note", "::miden_objects::proto::note"), + (".primitives", "::miden_objects::proto::primitives"), + (".protocol_config", "::miden_objects::proto::protocol_config"), + (".transaction", "::miden_objects::proto::transaction"), +]; diff --git a/crates/miden-objects/tests/account_config_conversions.rs b/crates/miden-objects/tests/account_config_conversions.rs new file mode 100644 index 0000000000..bca96f625f --- /dev/null +++ b/crates/miden-objects/tests/account_config_conversions.rs @@ -0,0 +1,389 @@ +use core::error::Error; + +use assert_matches::assert_matches; +use miden_objects::{ConversionError, proto}; +use miden_protocol::account::{ + AccountCode, + AccountId, + AccountIdVersion, + AccountStorage, + AccountStorageHeader, + AccountType, + AssetCallbackFlag, + PartialAccount, + PartialStorage, + PartialStorageMap, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotId, + StorageSlotName, +}; +use miden_protocol::asset::{ + Asset, + AssetId, + AssetVault, + FungibleAsset, + NonFungibleAsset, + PartialVault, +}; +use miden_protocol::crypto::merkle::smt::{PartialSmt, Smt}; +use miden_protocol::errors::{ + AccountError, + AssetError, + PartialAssetVaultError, + ProtocolConfigError, +}; +use miden_protocol::protocol_config::{ + KernelConfig, + ProofSecurityPolicy, + ProofVerificationConfig, + ProtocolConfig, +}; +use miden_protocol::{Felt, Word}; +use prost::Message; + +fn dummy_account_id(seed: u8) -> AccountId { + AccountId::dummy( + [seed; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ) +} + +fn partial_account() -> PartialAccount { + PartialAccount::new( + dummy_account_id(7), + Felt::ONE, + AccountCode::mock(), + PartialStorage::new(AccountStorageHeader::new(vec![]).unwrap(), []).unwrap(), + PartialVault::new(Word::empty()), + None, + ) + .unwrap() +} + +fn dummy_protocol_config() -> ProtocolConfig { + ProtocolConfig::new( + AssetId::new_fungible(dummy_account_id(8)), + KernelConfig::new(Word::from([1_u32, 0, 0, 0]), vec![Word::from([2_u32, 0, 0, 0])]) + .unwrap(), + KernelConfig::new(Word::from([3_u32, 0, 0, 0]), vec![]).unwrap(), + KernelConfig::new(Word::from([4_u32, 0, 0, 0]), vec![]).unwrap(), + ProofVerificationConfig::new( + Word::from([5_u32, 0, 0, 0]), + Word::from([6_u32, 0, 0, 0]), + ProofSecurityPolicy::new(Word::from([7_u32, 0, 0, 0]), 96).unwrap(), + ), + ) + .unwrap() +} + +fn error_source(error: &ConversionError) -> Option<&E> { + error.source().and_then(|source| source.downcast_ref::()) +} + +#[test] +fn storage_slot_id_roundtrips_through_protobuf_bytes() { + let id = StorageSlotId::new(Felt::from(1_u32), Felt::from(2_u32)); + + let encoded = proto::account::StorageSlotId::from(id).encode_to_vec(); + let message = proto::account::StorageSlotId::decode(encoded.as_slice()).unwrap(); + + assert_eq!(StorageSlotId::try_from(message).unwrap(), id); +} + +#[test] +fn partial_account_roundtrips_through_protobuf_bytes() { + let account = partial_account(); + + let encoded = proto::account::PartialAccount::from(&account).encode_to_vec(); + let message = proto::account::PartialAccount::decode(encoded.as_slice()).unwrap(); + + assert_eq!(PartialAccount::try_from(message).unwrap(), account); +} + +#[test] +fn partial_account_requires_nested_messages() { + let mut message = proto::account::PartialAccount::from(partial_account()); + message.account_id = None; + + let error = PartialAccount::try_from(message).unwrap_err(); + + assert!(error.to_string().ends_with("::account_id is missing")); +} + +#[test] +fn partial_account_preserves_seed_validation_source() { + let mut message = proto::account::PartialAccount::from(partial_account()); + message.seed = Some(Word::empty().into()); + + let error = PartialAccount::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(AccountError::ExistingAccountWithSeed) + ); +} + +#[test] +fn partial_account_rejects_new_account_without_seed() { + let mut message = proto::account::PartialAccount::from(partial_account()); + message.nonce = Some(Felt::ZERO.into()); + + let error = PartialAccount::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(AccountError::NewAccountMissingSeed) + ); +} + +#[test] +fn partial_storage_rejects_duplicate_roots_before_collection() { + let mut message = proto::account::PartialStorage::from(partial_account().storage()); + let map = proto::account::PartialStorageMap { + smt: Some(PartialSmt::new(Word::from([9_u32, 0, 0, 0])).into()), + keys: vec![], + }; + message.maps = vec![map.clone(), map]; + + let error = PartialStorage::try_from(message).unwrap_err(); + + assert_eq!(error.to_string(), "maps[1]: duplicate partial storage map root"); +} + +#[test] +fn partial_storage_preserves_root_not_in_header_source() { + let mut message = proto::account::PartialStorage::from(partial_account().storage()); + message.maps.push(proto::account::PartialStorageMap { + smt: Some(PartialSmt::new(Word::from([9_u32, 0, 0, 0])).into()), + keys: vec![], + }); + + let error = PartialStorage::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(AccountError::StorageMapRootNotFound(root)) if *root == Word::from([9_u32, 0, 0, 0]) + ); +} + +#[test] +fn partial_storage_map_rejects_duplicate_raw_keys() { + let key = StorageMapKey::from_index(1); + let storage_map = StorageMap::with_entries([(key, Word::from([2_u32, 0, 0, 0]))]).unwrap(); + let mut message: proto::account::PartialStorageMap = + PartialStorageMap::new_full(storage_map).into(); + message.keys.push(Word::from(key).into()); + + let error = PartialStorageMap::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(miden_protocol::crypto::merkle::MerkleError::DuplicateValuesForIndex(_)) + ); +} + +#[test] +fn partial_storage_map_rejects_untracked_raw_keys() { + let mut message: proto::account::PartialStorageMap = + PartialStorageMap::new(Word::empty()).into(); + message.keys = vec![Word::from(StorageMapKey::from_index(1)).into()]; + + let error = PartialStorageMap::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(miden_protocol::crypto::merkle::MerkleError::UntrackedKey(_)) + ); +} + +#[test] +fn partial_vault_rejects_duplicate_asset_ids() { + let id = AssetId::new_fungible(dummy_account_id(9)); + let asset = Asset::new(id, Word::from([2_u32, 0, 0, 0])).unwrap(); + let mut message: proto::account::PartialVault = + PartialVault::new_full(AssetVault::new(&[asset]).unwrap()).into(); + message.asset_ids.push(Word::from(id).into()); + + let error = PartialVault::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(PartialAssetVaultError::DuplicateAssetId(actual)) if *actual == id + ); +} + +#[test] +fn partial_vault_preserves_invalid_asset_id_source() { + let mut message: proto::account::PartialVault = PartialVault::new(Word::empty()).into(); + message.asset_ids = vec![Word::empty().into()]; + + let error = PartialVault::try_from(message).unwrap_err(); + + assert_matches!(error_source::(&error), Some(AssetError::UnknownAssetIdVersion(0))); +} + +#[test] +fn partial_vault_preserves_invalid_asset_value_source() { + let id = AssetId::new_fungible(dummy_account_id(9)); + let smt = Smt::with_entries([(id.hash().as_word(), Word::from([1_u32, 2, 0, 0]))]).unwrap(); + let message = proto::account::PartialVault { + smt: Some(PartialSmt::from_proofs([smt.open(&id.hash().as_word())]).unwrap().into()), + asset_ids: vec![Word::from(id).into()], + }; + + let error = PartialVault::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(PartialAssetVaultError::InvalidAssetForId { + source: AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(_), + .. + }) + ); +} + +#[test] +fn partial_storage_decoding_normalizes_map_order() { + let storage = AccountStorage::new(vec![ + StorageSlot::with_empty_map(StorageSlotName::mock(1)), + StorageSlot::with_empty_map(StorageSlotName::mock(2)), + ]) + .unwrap(); + let partial_storage = PartialStorage::new_full(storage); + let expected = proto::account::PartialStorage::from(&partial_storage); + let mut unordered = expected.clone(); + unordered.maps.reverse(); + + let decoded = PartialStorage::try_from(unordered).unwrap(); + + assert_eq!(proto::account::PartialStorage::from(decoded), expected); +} + +#[test] +fn partial_vault_decoding_normalizes_asset_id_order() { + let vault = + AssetVault::new(&[FungibleAsset::mock(2), NonFungibleAsset::mock(&[1, 2, 3])]).unwrap(); + let expected: proto::account::PartialVault = PartialVault::new_full(vault).into(); + let mut unordered = expected.clone(); + unordered.asset_ids.reverse(); + + let decoded = PartialVault::try_from(unordered).unwrap(); + + assert_eq!(proto::account::PartialVault::from(decoded), expected); +} + +#[test] +fn partial_account_encoding_canonicalizes_map_like_fields() { + let key_a = StorageMapKey::from_index(1); + let key_b = StorageMapKey::from_index(2); + let storage_map = StorageMap::with_entries([ + (key_a, Word::from([11_u32, 0, 0, 0])), + (key_b, Word::from([12_u32, 0, 0, 0])), + ]) + .unwrap(); + let storage = + AccountStorage::new(vec![StorageSlot::with_map(StorageSlotName::mock(1), storage_map)]) + .unwrap(); + let partial_storage = PartialStorage::new_full(storage); + let account = PartialAccount::new( + dummy_account_id(7), + Felt::ONE, + AccountCode::mock(), + partial_storage, + PartialVault::new(Word::empty()), + None, + ) + .unwrap(); + + let message = proto::account::PartialAccount::from(account); + let keys = &message.storage.unwrap().maps[0].keys; + + assert_eq!(keys, &vec![Word::from(key_a).into(), Word::from(key_b).into()]); +} + +#[test] +fn protocol_config_roundtrips_through_protobuf_bytes_and_preserves_kernel_order() { + let config = dummy_protocol_config(); + + let encoded = proto::protocol_config::ProtocolConfig::from(&config).encode_to_vec(); + let message = proto::protocol_config::ProtocolConfig::decode(encoded.as_slice()).unwrap(); + + assert_eq!( + message.tx_kernel.as_ref().unwrap().kernel_procs, + vec![Word::from([2_u32, 0, 0, 0]).into()] + ); + assert_eq!(ProtocolConfig::try_from(message).unwrap(), config); +} + +#[test] +fn protocol_config_requires_all_nested_messages() { + let mut message = proto::protocol_config::ProtocolConfig::from(dummy_protocol_config()); + message.proof_verification = None; + + let error = ProtocolConfig::try_from(message).unwrap_err(); + + assert!(error.to_string().ends_with("::proof_verification is missing")); +} + +#[test] +fn protocol_config_preserves_fee_asset_validation_source() { + let mut message = proto::protocol_config::ProtocolConfig::from(dummy_protocol_config()); + let non_fungible = AssetId::new( + miden_protocol::asset::AssetClass::default(), + dummy_account_id(10), + miden_protocol::asset::AssetComposition::None, + ) + .unwrap(); + message.fee_asset_id = Some(Word::from(non_fungible).into()); + + let error = ProtocolConfig::try_from(message).unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(ProtocolConfigError::FeeAssetMustBeFungible(_)) + ); +} + +#[test] +fn kernel_config_rejects_oversized_procedure_list() { + let error = KernelConfig::try_from(proto::protocol_config::KernelConfig { + main_proc: Some(Word::empty().into()), + kernel_procs: vec![Word::empty().into(); KernelConfig::MAX_NUM_KERNEL_PROCEDURES + 1], + }) + .unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(ProtocolConfigError::TooManyKernelProcedures { count }) + if *count == KernelConfig::MAX_NUM_KERNEL_PROCEDURES + 1 + ); +} + +#[test] +fn proof_security_policy_rejects_out_of_range_minimum_bits() { + let error = ProofSecurityPolicy::try_from(proto::protocol_config::ProofSecurityPolicy { + security_estimator_root: Some(Word::empty().into()), + minimum_bits: u32::from(u8::MAX) + 1, + }) + .unwrap_err(); + + assert_matches!(error_source::(&error), Some(_)); +} + +#[test] +fn proof_security_policy_preserves_zero_bits_validation_source() { + let error = ProofSecurityPolicy::try_from(proto::protocol_config::ProofSecurityPolicy { + security_estimator_root: Some(Word::empty().into()), + minimum_bits: 0, + }) + .unwrap_err(); + + assert_matches!( + error_source::(&error), + Some(ProtocolConfigError::MinimumSecurityBitsMustBeNonZero) + ); +} diff --git a/crates/miden-objects/tests/conversions.rs b/crates/miden-objects/tests/conversions.rs new file mode 100644 index 0000000000..1e71991833 --- /dev/null +++ b/crates/miden-objects/tests/conversions.rs @@ -0,0 +1,1041 @@ +use core::error::Error; + +use assert_matches::assert_matches; +use miden_objects::conversion::decode_standalone_proven_batch; +use miden_objects::{ConversionError, proto}; +use miden_protocol::account::{ + AccountHeader, + AccountId, + AccountIdVersion, + AccountPatch, + AccountStorageHeader, + AccountStoragePatch, + AccountType, + AccountUpdateDetails, + AccountVaultPatch, + AssetCallbackFlag, + StorageMapPatch, + StorageSlotHeader, + StorageSlotName, + StorageSlotPatch, + StorageSlotType, + StorageValuePatch, +}; +use miden_protocol::asset::{ + Asset, + AssetClass, + AssetComposition as ProtocolAssetComposition, + AssetId, + FungibleAsset, + NonFungibleAsset, +}; +use miden_protocol::batch::BatchAccountUpdate; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::block::{ + BlockAccountUpdate, + BlockBody, + BlockHeader, + BlockNumber, + ValidatorConfig, +}; +use miden_protocol::crypto::merkle::SparseMerklePath; +use miden_protocol::errors::{ + AccountIdError, + AssetError, + OutputNoteError, + ProtocolConfigError, + TransactionHeaderError, + ValidatorConfigError, +}; +use miden_protocol::note::{ + Note, + NoteId, + NoteInclusionProof, + NoteMetadata, + NoteType, + PartialNoteMetadata, +}; +use miden_protocol::protocol_config::NextProtocolConfig; +use miden_protocol::testing::dummy_execution_proof; +use miden_protocol::transaction::{ + InputNotes, + OrderedTransactionHeaders, + ProvenTransaction, + PublicOutputNote, + TransactionHeader, + TxAccountUpdate, +}; +use miden_protocol::{Felt, Word}; +use prost::Message; + +#[test] +fn protobuf_descriptor_includes_structured_asset_schema() { + assert!( + miden_objects::FILE_DESCRIPTOR_SET + .windows(b"asset.proto".len()) + .any(|window| window == b"asset.proto") + ); +} + +#[test] +fn fungible_asset_roundtrips_through_structured_protobuf() { + let asset = FungibleAsset::mock(42); + + let encoded = proto::asset::Asset::from(asset); + + assert_eq!( + encoded.asset_id.as_ref().unwrap().version, + proto::asset::AssetVersion::V1 as i32 + ); + assert_eq!( + encoded.asset_id.as_ref().unwrap().composition, + proto::asset::AssetComposition::Fungible as i32 + ); + assert_eq!(Asset::try_from(encoded).unwrap(), asset); +} + +#[test] +fn non_fungible_asset_roundtrips_through_structured_protobuf() { + let asset = NonFungibleAsset::mock(&[1, 2, 3]); + + let encoded = proto::asset::Asset::from(asset); + + assert_eq!( + encoded.asset_id.as_ref().unwrap().composition, + proto::asset::AssetComposition::None as i32 + ); + assert_eq!(Asset::try_from(encoded).unwrap(), asset); +} + +#[test] +fn structured_asset_conversion_requires_message_fields() { + let suffix_error = AssetClass::try_from(proto::asset::AssetClass { + suffix: None, + prefix: Some(Felt::ZERO.into()), + }) + .unwrap_err(); + assert_eq!( + suffix_error.to_string(), + "field miden_objects::proto::asset::AssetClass::suffix is missing" + ); + + let prefix_error = AssetClass::try_from(proto::asset::AssetClass { + suffix: Some(Felt::ZERO.into()), + prefix: None, + }) + .unwrap_err(); + assert_eq!( + prefix_error.to_string(), + "field miden_objects::proto::asset::AssetClass::prefix is missing" + ); + + let asset_id_error = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + ..Default::default() + }) + .unwrap_err(); + assert!(asset_id_error.to_string().ends_with("::asset_class is missing")); + + let faucet_id_error = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(proto::asset::AssetClass { + suffix: Some(Felt::ZERO.into()), + prefix: Some(Felt::ZERO.into()), + }), + composition: proto::asset::AssetComposition::Fungible as i32, + faucet_id: None, + }) + .unwrap_err(); + assert!(faucet_id_error.to_string().ends_with("::faucet_id is missing")); + + let asset_error = Asset::try_from(proto::asset::Asset::default()).unwrap_err(); + assert!(asset_error.to_string().ends_with("::asset_id is missing")); + + let value_error = Asset::try_from(proto::asset::Asset { + asset_id: Some(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(proto::asset::AssetClass { + suffix: Some(Felt::ZERO.into()), + prefix: Some(Felt::ZERO.into()), + }), + composition: proto::asset::AssetComposition::Fungible as i32, + faucet_id: Some(FungibleAsset::mock_issuer().into()), + }), + value: None, + }) + .unwrap_err(); + assert!(value_error.to_string().ends_with("::value is missing")); +} + +#[test] +fn structured_asset_conversion_rejects_unspecified_unknown_and_custom_compositions() { + let asset_class = proto::asset::AssetClass { + suffix: Some(Felt::ZERO.into()), + prefix: Some(Felt::ZERO.into()), + }; + let faucet_id = Some(FungibleAsset::mock_issuer().into()); + + let unspecified = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(asset_class), + composition: proto::asset::AssetComposition::Unspecified as i32, + faucet_id: faucet_id.clone(), + }) + .unwrap_err(); + assert_eq!(unspecified.to_string(), "composition: asset composition is unspecified"); + + let unknown = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(asset_class), + composition: 4, + faucet_id: faucet_id.clone(), + }) + .unwrap_err(); + assert_eq!(unknown.to_string(), "composition: unknown asset composition 4"); + + let custom = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(asset_class), + composition: proto::asset::AssetComposition::Custom as i32, + faucet_id, + }) + .unwrap_err(); + assert_matches!( + custom.source().and_then(|source| source.downcast_ref::()), + Some(AssetError::UnsupportedAssetComposition(ProtocolAssetComposition::Custom)) + ); +} + +#[test] +fn structured_asset_conversion_rejects_nonzero_fungible_class() { + let error = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(proto::asset::AssetClass { + suffix: Some(Felt::ONE.into()), + prefix: Some(Felt::ZERO.into()), + }), + composition: proto::asset::AssetComposition::Fungible as i32, + faucet_id: Some(FungibleAsset::mock_issuer().into()), + }) + .unwrap_err(); + + assert_matches!( + error.source().and_then(|source| source.downcast_ref::()), + Some(AssetError::FungibleAssetClassMustBeZero(_)) + ); +} + +#[test] +fn structured_asset_conversion_rejects_invalid_fungible_values() { + let error = Asset::try_from(proto::asset::Asset { + asset_id: Some(proto::asset::AssetId { + version: proto::asset::AssetVersion::V1 as i32, + asset_class: Some(proto::asset::AssetClass { + suffix: Some(Felt::ZERO.into()), + prefix: Some(Felt::ZERO.into()), + }), + composition: proto::asset::AssetComposition::Fungible as i32, + faucet_id: Some(FungibleAsset::mock_issuer().into()), + }), + value: Some(Word::from([1_u32, 1, 0, 0]).into()), + }) + .unwrap_err(); + + assert_matches!( + error.source().and_then(|source| source.downcast_ref::()), + Some(AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(_)) + ); +} + +#[test] +fn asset_id_protobuf_rejects_unspecified_version_before_payload_fields() { + let error = AssetId::try_from(proto::asset::AssetId { + version: proto::asset::AssetVersion::Unspecified as i32, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: asset id version is unspecified"); +} + +#[test] +fn asset_id_protobuf_preserves_unknown_version_error_sources() { + for version in [i32::MAX, i32::MIN] { + let error = + AssetId::try_from(proto::asset::AssetId { version, ..Default::default() }).unwrap_err(); + + assert_eq!(error.to_string(), format!("version: unknown asset id version {version}")); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == version + ); + } +} + +#[test] +fn conversion_error_preserves_deserialization_error_source() { + use miden_protocol::utils::serde::DeserializationError; + + let error = ConversionError::deserialization( + "AccountId", + DeserializationError::InvalidValue("invalid account id".into()), + ); + + assert_eq!( + error.to_string(), + "failed to deserialize AccountId: invalid value: invalid account id" + ); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(DeserializationError::InvalidValue(message)) if message == "invalid account id" + ); +} + +fn private_account_id() -> AccountId { + AccountId::dummy( + [7; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ) +} + +fn account_witness(account_id: AccountId) -> AccountWitness { + let path = SparseMerklePath::from_parts(u64::MAX, vec![]).unwrap(); + AccountWitness::new(account_id, Word::empty(), path).unwrap() +} + +fn account_header() -> AccountHeader { + AccountHeader::new( + private_account_id(), + Felt::ONE, + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + Word::from([9_u32, 10, 11, 12]), + ) +} + +fn account_patch() -> AccountPatch { + AccountPatch::new( + private_account_id(), + AccountStoragePatch::from_entries([]).unwrap(), + AccountVaultPatch::new([].into()).unwrap(), + None, + None, + ) + .unwrap() +} + +#[test] +fn account_witness_protobuf_round_trip() { + let witness = account_witness(private_account_id()); + + let message: proto::account::AccountWitness = (&witness).into(); + let decoded = AccountWitness::try_from(message).unwrap(); + + assert_eq!(decoded, witness); +} + +#[test] +fn account_witness_protobuf_requires_witness_id() { + let error = AccountWitness::try_from(proto::account::AccountWitness { + commitment: Some(Word::empty().into()), + path: Some(proto::primitives::SparseMerklePath { + empty_nodes_mask: u64::MAX, + siblings: vec![], + }), + ..Default::default() + }) + .unwrap_err(); + + assert!(error.to_string().ends_with("::witness_id is missing")); +} + +#[test] +fn account_witness_conversion_preserves_account_tree_error_source() { + let account_id = private_account_id(); + let error = AccountWitness::try_from(proto::account::AccountWitness { + witness_id: Some(account_id.into()), + commitment: Some(Word::empty().into()), + path: Some(proto::primitives::SparseMerklePath::default()), + }) + .unwrap_err(); + + assert_matches!( + error + .source() + .and_then(|source| source.downcast_ref::()), + Some( + miden_protocol::errors::AccountTreeError::WitnessMerklePathDepthDoesNotMatchAccountTreeDepth(0) + ) + ); +} + +#[test] +fn account_id_protobuf_requires_exactly_15_bytes() { + for id in [vec![0; AccountId::SERIALIZED_SIZE - 1], vec![0; AccountId::SERIALIZED_SIZE + 1]] { + let error = AccountId::try_from(proto::account::AccountId { id }).unwrap_err(); + + assert!( + error + .source() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + } +} + +#[test] +fn account_id_protobuf_rejects_invalid_metadata() { + let mut id = <[u8; AccountId::SERIALIZED_SIZE]>::from(private_account_id()); + id[7] &= 0b1111_0000; + + let error = AccountId::try_from(proto::account::AccountId { id: id.into() }).unwrap_err(); + + assert_matches!( + error.source().and_then(|source| source.downcast_ref::()), + Some(AccountIdError::UnknownAccountIdVersion(0)) + ); +} + +#[test] +fn account_header_roundtrips_through_explicit_versioned_protobuf_bytes() { + let header = account_header(); + + let encoded = proto::account::AccountHeader::from(&header).encode_to_vec(); + let message = proto::account::AccountHeader::decode(encoded.as_slice()).unwrap(); + + assert_eq!(message.version, proto::account::AccountVersion::V1 as i32); + assert_eq!(AccountHeader::try_from(message).unwrap(), header); +} + +#[test] +fn account_header_protobuf_rejects_unspecified_version_before_payload_fields() { + let error = AccountHeader::try_from(proto::account::AccountHeader { + version: proto::account::AccountVersion::Unspecified as i32, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: account header version is unspecified"); +} + +#[test] +fn account_header_protobuf_preserves_unknown_version_error_sources() { + for version in [i32::MAX, i32::MIN] { + let error = AccountHeader::try_from(proto::account::AccountHeader { + version, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), format!("version: unknown account header version {version}")); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == version + ); + } +} + +#[test] +fn account_header_protobuf_preserves_invalid_nonce_source() { + let error = AccountHeader::try_from(proto::account::AccountHeader { + version: proto::account::AccountVersion::V1 as i32, + account_id: Some(private_account_id().into()), + vault_root: Some(Word::empty().into()), + storage_commitment: Some(Word::empty().into()), + code_commitment: Some(Word::empty().into()), + nonce: Felt::ORDER, + }) + .unwrap_err(); + + assert!(error.to_string().starts_with("nonce: ")); + assert_matches!( + error + .source() + .and_then(|source| source.downcast_ref::<>::Error>()), + Some(source) if source.as_u64() == Felt::ORDER + ); +} + +#[test] +fn account_patch_roundtrips_through_explicit_versioned_protobuf_bytes() { + let patch = account_patch(); + + let encoded = proto::account::AccountPatch::from(&patch).encode_to_vec(); + let message = proto::account::AccountPatch::decode(encoded.as_slice()).unwrap(); + + assert_eq!(message.version, proto::account::AccountPatchVersion::V1 as i32); + assert_eq!(AccountPatch::try_from(message).unwrap(), patch); +} + +#[test] +fn account_patch_protobuf_rejects_unspecified_version_before_payload_fields() { + let error = AccountPatch::try_from(proto::account::AccountPatch { + version: proto::account::AccountPatchVersion::Unspecified as i32, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: account patch version is unspecified"); +} + +#[test] +fn account_patch_protobuf_preserves_unknown_version_error_sources() { + for version in [i32::MAX, i32::MIN] { + let error = + AccountPatch::try_from(proto::account::AccountPatch { version, ..Default::default() }) + .unwrap_err(); + + assert_eq!(error.to_string(), format!("version: unknown account patch version {version}")); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == version + ); + } +} + +#[test] +fn note_metadata_roundtrips_through_flat_v1_protobuf_bytes() { + let metadata = *Note::mock_noop(Word::empty()).metadata(); + + let encoded = proto::note::NoteMetadata::from(metadata).encode_to_vec(); + let message = proto::note::NoteMetadata::decode(encoded.as_slice()).unwrap(); + + assert_eq!(message.version, proto::note::NoteVersion::V1 as i32); + assert_eq!(NoteMetadata::try_from(message).unwrap(), metadata); +} + +#[test] +fn note_protobuf_roundtrips_through_versioned_note_metadata() { + let note = Note::mock_noop(Word::empty()); + + let encoded = proto::note::Note::from(note.clone()).encode_to_vec(); + let message = proto::note::Note::decode(encoded.as_slice()).unwrap(); + + assert_eq!(Note::try_from(message).unwrap(), note); +} + +#[test] +fn note_protobuf_requires_note_attachments() { + let mut message = proto::note::Note::from(Note::mock_noop(Word::empty())); + message.note_attachments = None; + + let error = Note::try_from(message).unwrap_err(); + + assert_eq!( + error.to_string(), + "field miden_objects::proto::note::Note::note_attachments is missing" + ); +} + +#[test] +fn note_protobuf_requires_note_details() { + let mut message = proto::note::Note::from(Note::mock_noop(Word::empty())); + message.note_details = None; + + let error = Note::try_from(message).unwrap_err(); + + assert_eq!( + error.to_string(), + "field miden_objects::proto::note::Note::note_details is missing" + ); +} + +#[test] +fn note_metadata_protobuf_rejects_unspecified_version_before_payload_fields() { + let error = NoteMetadata::try_from(proto::note::NoteMetadata { + version: proto::note::NoteVersion::Unspecified as i32, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: note metadata version is unspecified"); +} + +#[test] +fn note_metadata_protobuf_preserves_unknown_version_error_sources() { + for version in [i32::MAX, i32::MIN] { + let error = + NoteMetadata::try_from(proto::note::NoteMetadata { version, ..Default::default() }) + .unwrap_err(); + + assert_eq!(error.to_string(), format!("version: unknown note metadata version {version}")); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == version + ); + } +} + +#[test] +fn note_protobuf_rejects_unspecified_metadata_version_before_payload_fields() { + let error = Note::try_from(proto::note::Note { + metadata: Some(proto::note::NoteMetadata { + version: proto::note::NoteVersion::Unspecified as i32, + ..Default::default() + }), + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: note metadata version is unspecified"); +} + +#[test] +fn note_protobuf_reconstructs_attachment_metadata_from_structured_attachments() { + let note = Note::mock_noop(Word::empty()); + let mut message = proto::note::Note::from(note.clone()); + let metadata = message.metadata.as_mut().unwrap(); + metadata.attachment_schemes = vec![42]; + metadata.attachments_commitment = Some(Word::empty().into()); + + assert_eq!(Note::try_from(message).unwrap(), note); +} + +#[test] +fn note_metadata_protobuf_reports_invalid_sender() { + let metadata = *Note::mock_noop(Word::empty()).metadata(); + let mut message = proto::note::NoteMetadata::from(metadata); + message.sender.as_mut().unwrap().id.clear(); + + let error = NoteMetadata::try_from(message).unwrap_err(); + + assert!(error.to_string().starts_with("sender: ")); + assert!( + error + .source() + .unwrap() + .downcast_ref::() + .is_some() + ); +} + +fn assert_missing_block_number(error: ConversionError, field: &str) { + let error = error.to_string(); + assert!(error.starts_with(&format!("{field}: field "))); + assert!(error.ends_with(&format!("::{field} is missing"))); +} + +fn proven_transaction_data() -> proto::transaction::ProvenTransaction { + let account_update = TxAccountUpdate::new( + private_account_id(), + Word::empty(), + Word::from([1_u32, 0, 0, 0]), + Word::empty(), + AccountUpdateDetails::Private, + ) + .unwrap(); + + proto::transaction::ProvenTransaction { + account_update: Some((&account_update).into()), + input_notes: vec![], + output_notes: vec![], + reference_block_num: Some(proto::blockchain::BlockNumber { block_num: 1 }), + reference_block_commitment: Some(Word::empty().into()), + expiration_block_num: Some(proto::blockchain::BlockNumber { block_num: 2 }), + proof: Some(dummy_execution_proof().into()), + } +} + +fn public_note() -> Note { + let (assets, metadata, recipient, attachments) = Note::mock_noop(Word::empty()).into_parts(); + let metadata = + PartialNoteMetadata::new(metadata.sender(), NoteType::Public).with_tag(metadata.tag()); + + Note::with_attachments(assets, metadata, recipient, attachments) +} + +#[test] +fn public_output_note_roundtrips_through_protobuf() { + let note = PublicOutputNote::new(public_note()).unwrap(); + + let encoded = proto::transaction::PublicOutputNote::from(note.clone()).encode_to_vec(); + let message = proto::transaction::PublicOutputNote::decode(encoded.as_slice()).unwrap(); + + assert_eq!(PublicOutputNote::try_from(message).unwrap(), note); +} + +#[test] +fn public_output_note_protobuf_requires_nested_note() { + let error = + PublicOutputNote::try_from(proto::transaction::PublicOutputNote::default()).unwrap_err(); + + assert_eq!( + error.to_string(), + "field miden_objects::proto::transaction::PublicOutputNote::note is missing" + ); +} + +#[test] +fn public_output_note_protobuf_rejects_private_note() { + let note = Note::mock_noop(Word::empty()); + let error = PublicOutputNote::try_from(proto::transaction::PublicOutputNote { + note: Some(note.clone().into()), + }) + .unwrap_err(); + + assert_matches!( + error + .source() + .and_then(|source| source.downcast_ref::()), + Some(OutputNoteError::NoteIsPrivate(note_id)) if *note_id == note.id() + ); +} + +fn proven_batch_data() -> proto::transaction::ProvenBatch { + proto::transaction::ProvenBatch { + reference_block_commitment: Some(Word::empty().into()), + reference_block_num: Some(proto::blockchain::BlockNumber { block_num: 1 }), + account_updates: vec![], + input_notes: vec![], + output_notes: vec![], + expiration_block_num: Some(proto::blockchain::BlockNumber { block_num: 2 }), + transactions: vec![], + proof: Some(dummy_execution_proof().into()), + } +} + +#[test] +fn account_update_roundtrips_through_protobuf_bytes() { + let update = BatchAccountUpdate::new( + private_account_id(), + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + AccountUpdateDetails::Private, + ) + .unwrap(); + + let encoded = proto::transaction::BatchAccountUpdate::from(&update).encode_to_vec(); + let message = proto::transaction::BatchAccountUpdate::decode(encoded.as_slice()).unwrap(); + assert_eq!(BatchAccountUpdate::try_from(message).unwrap(), update); +} + +#[test] +fn block_body_and_transaction_header_roundtrip() { + let account_id = private_account_id(); + let transaction = TransactionHeader::new( + account_id, + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + InputNotes::default(), + vec![], + ) + .unwrap(); + let account_update = BlockAccountUpdate::new( + account_id, + transaction.final_state_commitment(), + AccountUpdateDetails::Private, + ) + .unwrap(); + let body = BlockBody::new( + vec![account_update], + vec![], + vec![], + OrderedTransactionHeaders::new_unchecked(vec![transaction]), + ) + .unwrap(); + + let encoded = proto::blockchain::BlockBody::from(&body).encode_to_vec(); + let message = proto::blockchain::BlockBody::decode(encoded.as_slice()).unwrap(); + assert_eq!(BlockBody::try_from(message).unwrap(), body); +} + +#[test] +fn account_storage_header_rejects_invalid_slot_types() { + for (slot_type, expected_message) in [ + (Default::default(), "storage slot type is unspecified"), + (i32::MAX, "unknown storage slot type 2147483647"), + ] { + let message = proto::account::AccountStorageHeader { + slots: vec![proto::account::account_storage_header::StorageSlot { + slot_name: "miden::test::storage".into(), + slot_type, + commitment: Some(Word::empty().into()), + }], + }; + + let error = AccountStorageHeader::try_from(message).unwrap_err(); + assert_eq!(error.to_string(), format!("slots.slot_type: {expected_message}")); + } +} + +#[test] +fn account_storage_header_preserves_unknown_enum_value_source() { + let error = AccountStorageHeader::try_from(proto::account::AccountStorageHeader { + slots: vec![proto::account::account_storage_header::StorageSlot { + slot_name: "miden::test::storage".into(), + slot_type: i32::MAX, + commitment: Some(Word::empty().into()), + }], + }) + .unwrap_err(); + + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == i32::MAX + ); +} + +#[test] +fn account_storage_header_uses_generated_slot_type_values() { + for (slot_type, expected_slot_type) in [ + (StorageSlotType::Value, proto::account::StorageSlotType::Value), + (StorageSlotType::Map, proto::account::StorageSlotType::Map), + ] { + let header = AccountStorageHeader::new(vec![StorageSlotHeader::new( + StorageSlotName::new("miden::test::storage").unwrap(), + slot_type, + Word::empty(), + )]) + .unwrap(); + + let message = proto::account::AccountStorageHeader::from(&header); + assert_eq!(message.slots[0].slot_type, expected_slot_type as i32); + assert_eq!(AccountStorageHeader::try_from(message).unwrap(), header); + } +} + +#[test] +fn account_storage_patch_protobuf_slots_follow_canonical_storage_order() { + let storage_patch = AccountStoragePatch::from_entries([ + (StorageSlotName::mock(3), StorageSlotPatch::Value(StorageValuePatch::Remove)), + (StorageSlotName::mock(1), StorageSlotPatch::Map(StorageMapPatch::Remove)), + (StorageSlotName::mock(4), StorageSlotPatch::Value(StorageValuePatch::Remove)), + (StorageSlotName::mock(2), StorageSlotPatch::Map(StorageMapPatch::Remove)), + ]) + .unwrap(); + + let expected_slots = [ + ("miden::test::slot::3", true), + ("miden::test::slot::1", false), + ("miden::test::slot::4", true), + ("miden::test::slot::2", false), + ]; + let message = proto::account::AccountStoragePatch::from(&storage_patch); + + assert_eq!( + message + .slots + .iter() + .map(|slot| { + ( + slot.slot_name.as_str(), + matches!( + slot.patch.as_ref(), + Some(proto::account::storage_slot_patch::Patch::Value(_)) + ), + ) + }) + .collect::>(), + expected_slots + ); +} + +#[test] +fn empty_protobuf_block_body_decodes_to_an_empty_domain_body() { + let expected = + BlockBody::new(vec![], vec![], vec![], OrderedTransactionHeaders::new_unchecked(vec![])) + .unwrap(); + + assert_eq!(BlockBody::try_from(proto::blockchain::BlockBody::default()).unwrap(), expected); +} + +#[test] +fn block_header_rejects_missing_block_number() { + let header = BlockHeader::mock(1, None, None, &[]); + let mut message = proto::blockchain::BlockHeader::from(header); + message.block_num = Default::default(); + + let error = BlockHeader::try_from(message).unwrap_err(); + assert_eq!( + error.to_string(), + "block_num: field miden_objects::proto::blockchain::BlockHeader::block_num is missing" + ); +} + +#[test] +fn block_header_protobuf_rejects_unspecified_version_before_payload_fields() { + let error = BlockHeader::try_from(proto::blockchain::BlockHeader { + version: proto::blockchain::BlockVersion::Unspecified as i32, + ..Default::default() + }) + .unwrap_err(); + + assert_eq!(error.to_string(), "version: block header version is unspecified"); +} + +#[test] +fn block_header_protobuf_preserves_unknown_version_error_sources() { + for version in [i32::MAX, i32::MIN] { + let error = + BlockHeader::try_from(proto::blockchain::BlockHeader { version, ..Default::default() }) + .unwrap_err(); + + assert_eq!(error.to_string(), format!("version: unknown block header version {version}")); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(prost::UnknownEnumValue(value)) if *value == version + ); + } +} + +fn block_header_with_scheduled_upgrade() -> BlockHeader { + let header = BlockHeader::mock(1, None, None, &[]); + let (_, validator_config) = ValidatorConfig::random_with_signers(3); + let next_protocol_config = + NextProtocolConfig::new(BlockNumber::from(42u32), Word::from([9u32, 8, 7, 6])).unwrap(); + + BlockHeader::new( + header.prev_block_commitment(), + header.block_num(), + header.chain_commitment(), + header.account_root(), + header.nullifier_root(), + header.note_root(), + header.tx_commitment(), + validator_config, + header.fee_parameters().clone(), + header.protocol_config_commitment(), + Some(next_protocol_config), + header.timestamp(), + ) +} + +#[test] +fn block_header_protobuf_round_trip_preserves_current_fields() { + let header = block_header_with_scheduled_upgrade(); + + let encoded = proto::blockchain::BlockHeader::from(&header).encode_to_vec(); + let message = proto::blockchain::BlockHeader::decode(encoded.as_slice()).unwrap(); + + assert_eq!(message.version, proto::blockchain::BlockVersion::V1 as i32); + assert_eq!(BlockHeader::try_from(message).unwrap(), header); +} + +#[test] +fn block_header_protobuf_rejects_invalid_validator_quorum() { + let header = block_header_with_scheduled_upgrade(); + let mut message = proto::blockchain::BlockHeader::from(header); + message.validator_config.as_mut().unwrap().quorum = 0; + + let error = BlockHeader::try_from(message).unwrap_err(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + + assert!(error.to_string().starts_with("validator_config: ")); + assert_matches!( + source, + ValidatorConfigError::QuorumMustEqualValidatorCount { quorum: 0, count: 3 } + ); +} + +#[test] +fn block_header_protobuf_reports_invalid_validator_key_index() { + let header = block_header_with_scheduled_upgrade(); + let mut message = proto::blockchain::BlockHeader::from(header); + message.validator_config.as_mut().unwrap().keys[1].encoded.clear(); + + let error = BlockHeader::try_from(message).unwrap_err(); + + assert!(error.to_string().starts_with("validator_config.keys[1].encoded: ")); +} + +#[test] +fn block_header_protobuf_rejects_upgrade_effective_at_genesis() { + let header = block_header_with_scheduled_upgrade(); + let mut message = proto::blockchain::BlockHeader::from(header); + message.next_protocol_config.as_mut().unwrap().effective_from = + Some(BlockNumber::GENESIS.into()); + + let error = BlockHeader::try_from(message).unwrap_err(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + + assert!(error.to_string().starts_with("next_protocol_config: ")); + assert_matches!(source, ProtocolConfigError::NextConfigEffectiveAtGenesis); +} + +#[test] +fn note_inclusion_proof_rejects_missing_block_number() { + let message = proto::note::NoteInclusionProof { + note_id: Some(Word::empty().into()), + block_num: None, + note_index_in_block: 0, + inclusion_path: Some(proto::primitives::SparseMerklePath { + empty_nodes_mask: 0, + siblings: vec![], + }), + }; + + let error = <(NoteId, NoteInclusionProof)>::try_from(&message).unwrap_err(); + assert_missing_block_number(error, "block_num"); +} + +#[test] +fn proven_transaction_rejects_missing_block_numbers() { + let mut message = proven_transaction_data(); + message.reference_block_num = None; + let error = ProvenTransaction::try_from(message).unwrap_err(); + assert_missing_block_number(error, "reference_block_num"); + + let mut message = proven_transaction_data(); + message.expiration_block_num = None; + let error = ProvenTransaction::try_from(message).unwrap_err(); + assert_missing_block_number(error, "expiration_block_num"); +} + +#[test] +fn proven_batch_rejects_missing_block_numbers() { + let mut message = proven_batch_data(); + message.reference_block_num = None; + let error = decode_standalone_proven_batch(message).unwrap_err(); + assert_missing_block_number(error, "reference_block_num"); + + let mut message = proven_batch_data(); + message.expiration_block_num = None; + let error = decode_standalone_proven_batch(message).unwrap_err(); + assert_missing_block_number(error, "expiration_block_num"); +} + +#[test] +fn transaction_header_conversion_preserves_validation_error_source() { + let note = Note::mock_noop(Word::empty()); + let transaction = TransactionHeader::new( + private_account_id(), + Word::from([1_u32, 2, 3, 4]), + Word::from([5_u32, 6, 7, 8]), + InputNotes::default(), + vec![*note.header()], + ) + .unwrap(); + let mut message = proto::transaction::TransactionHeader::from(transaction); + message.output_notes.push(message.output_notes[0].clone()); + + let error = TransactionHeader::try_from(message).unwrap_err(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + + assert_matches!( + source, + TransactionHeaderError::DuplicateOutputNote(note_id) if *note_id == note.id() + ); +} diff --git a/crates/miden-objects/tests/transaction_args_conversions.rs b/crates/miden-objects/tests/transaction_args_conversions.rs new file mode 100644 index 0000000000..f96e66e3b3 --- /dev/null +++ b/crates/miden-objects/tests/transaction_args_conversions.rs @@ -0,0 +1,328 @@ +use core::error::Error; +use std::collections::BTreeMap; + +use assert_matches::assert_matches; +use miden_objects::proto; +use miden_protocol::crypto::merkle::InnerNodeInfo; +use miden_protocol::crypto::merkle::store::MerkleStore; +use miden_protocol::note::{Note, NoteId}; +use miden_protocol::transaction::TransactionArgs; +use miden_protocol::utils::serde::DeserializationError; +use miden_protocol::vm::{AdviceInputs, AdviceMap}; +use miden_protocol::{Felt, Word}; + +fn dummy_word(value: u32) -> Word { + Word::from([value, 0, 0, 0]) +} + +fn note_id(value: u32) -> NoteId { + Note::mock_noop(dummy_word(value)).id() +} + +#[test] +fn advice_inputs_roundtrip_preserves_stack_order_and_normalizes_map_order() { + let mut store = MerkleStore::new(); + store.extend([InnerNodeInfo { + value: dummy_word(9), + left: dummy_word(10), + right: dummy_word(11), + }]); + let advice_inputs = AdviceInputs::default() + .with_stack({ + let mut stack = AdviceInputs::default().stack(); + stack.append_elements([Felt::from(1_u32), Felt::from(2_u32)]); + stack + }) + .with_map([ + (dummy_word(7), vec![Felt::from(3_u32)]), + (dummy_word(5), vec![Felt::from(4_u32)]), + ]) + .with_merkle_store(store); + + let message = proto::primitives::AdviceInputs::from(&advice_inputs); + + assert_eq!( + message.advice_stack.as_ref().unwrap().values, + vec![Felt::from(1_u32).into(), Felt::from(2_u32).into()] + ); + assert_eq!( + message + .advice_map + .as_ref() + .unwrap() + .entries + .iter() + .map(|entry| Word::try_from(entry.key.clone().unwrap()).unwrap()) + .collect::>(), + vec![dummy_word(5), dummy_word(7)] + ); + assert_eq!(AdviceInputs::try_from(message).unwrap(), advice_inputs); +} + +#[test] +fn advice_map_decoding_normalizes_arbitrary_entry_order() { + let map = AdviceMap::try_from(proto::primitives::AdviceMap { + entries: vec![ + proto::primitives::AdviceMapEntry { + key: Some(dummy_word(7).into()), + values: vec![Felt::from(3_u32).into()], + }, + proto::primitives::AdviceMapEntry { + key: Some(dummy_word(5).into()), + values: vec![Felt::from(4_u32).into()], + }, + ], + }) + .unwrap(); + + assert_eq!( + proto::primitives::AdviceMap::from(&map) + .entries + .iter() + .map(|entry| Word::try_from(entry.key.clone().unwrap()).unwrap()) + .collect::>(), + vec![dummy_word(5), dummy_word(7)] + ); +} + +#[test] +fn merkle_store_omits_identical_defaults_and_retains_default_parent_overrides() { + let default_store = MerkleStore::new(); + let default_node = default_store.inner_nodes().next().unwrap(); + let mut store = MerkleStore::new(); + let override_node = InnerNodeInfo { + value: default_node.value, + left: dummy_word(12), + right: default_node.right, + }; + let custom_node = InnerNodeInfo { + value: dummy_word(9), + left: dummy_word(10), + right: dummy_word(11), + }; + store.extend([override_node.clone(), custom_node.clone()]); + + let default_message = proto::primitives::MerkleStore::from(&default_store); + let override_message = proto::primitives::MerkleStore::from(&store); + + assert!(default_message.nodes.is_empty()); + assert_eq!(override_message.nodes.len(), 2); + assert!( + Word::try_from(override_message.nodes[0].value.clone().unwrap()).unwrap() + < Word::try_from(override_message.nodes[1].value.clone().unwrap()).unwrap() + ); + assert_eq!(MerkleStore::try_from(default_message).unwrap(), default_store); + assert_eq!(MerkleStore::try_from(override_message).unwrap(), store); +} + +#[test] +fn transaction_args_roundtrip_normalizes_note_args_order() { + let first = note_id(1); + let second = note_id(2); + let args = TransactionArgs::from_parts( + None, + dummy_word(3), + BTreeMap::from([(second, dummy_word(4)), (first, dummy_word(5))]), + AdviceInputs::default().with_map([(dummy_word(6), vec![Felt::from(7_u32)])]), + dummy_word(8), + ); + + let message = proto::transaction::TransactionArgs::from(&args); + + assert_eq!( + message + .note_args + .iter() + .map(|entry| NoteId::from_raw(Word::try_from(entry.note_id.clone().unwrap()).unwrap())) + .collect::>(), + vec![first, second] + ); + assert_eq!(TransactionArgs::try_from(message).unwrap(), args); +} + +#[test] +fn note_argument_decoding_normalizes_arbitrary_entry_order() { + let first = note_id(1); + let second = note_id(2); + let args = TransactionArgs::try_from(proto::transaction::TransactionArgs { + tx_script: None, + tx_script_args: Some(dummy_word(3).into()), + note_args: vec![ + proto::transaction::NoteArgument { + note_id: Some((&second).into()), + args: Some(dummy_word(4).into()), + }, + proto::transaction::NoteArgument { + note_id: Some((&first).into()), + args: Some(dummy_word(5).into()), + }, + ], + advice_inputs: Some(proto::primitives::AdviceInputs { + advice_stack: Some(proto::primitives::AdviceStack { values: vec![] }), + advice_map: Some(proto::primitives::AdviceMap { entries: vec![] }), + merkle_store: Some(proto::primitives::MerkleStore { nodes: vec![] }), + }), + auth_args: Some(dummy_word(6).into()), + }) + .unwrap(); + + assert_eq!( + proto::transaction::TransactionArgs::from(&args) + .note_args + .iter() + .map(|entry| NoteId::from_raw(Word::try_from(entry.note_id.clone().unwrap()).unwrap())) + .collect::>(), + vec![first, second] + ); +} + +#[test] +fn advice_inputs_require_nested_messages_and_reject_duplicate_map_keys() { + let missing_stack = proto::primitives::AdviceInputs { + advice_stack: None, + advice_map: Some(proto::primitives::AdviceMap { entries: vec![] }), + merkle_store: Some(proto::primitives::MerkleStore { nodes: vec![] }), + }; + let error = AdviceInputs::try_from(missing_stack).unwrap_err(); + assert!(error.to_string().ends_with("::advice_stack is missing")); + + let duplicate = proto::primitives::AdviceMap { + entries: vec![ + proto::primitives::AdviceMapEntry { + key: Some(dummy_word(1).into()), + values: vec![Felt::from(2_u32).into()], + }, + proto::primitives::AdviceMapEntry { + key: Some(dummy_word(1).into()), + values: vec![Felt::from(3_u32).into()], + }, + ], + }; + let error = AdviceMap::try_from(duplicate).unwrap_err(); + assert_eq!(error.to_string(), "entries[1].key: duplicate advice map key"); +} + +#[test] +fn advice_stack_rejects_invalid_felts() { + let error = miden_protocol::vm::AdviceStack::try_from(proto::primitives::AdviceStack { + values: vec![proto::primitives::Felt { value: Felt::ORDER }], + }) + .unwrap_err(); + + assert_matches!( + error + .source() + .and_then(|source| source.downcast_ref::<>::Error>()), + Some(source) if source.as_u64() == Felt::ORDER + ); +} + +#[test] +fn merkle_store_rejects_duplicate_parents_and_preserves_invalid_word_source() { + let node = proto::primitives::MerkleStoreNode { + value: Some(dummy_word(1).into()), + left: Some(dummy_word(2).into()), + right: Some(dummy_word(3).into()), + }; + let duplicate = proto::primitives::MerkleStore { nodes: vec![node.clone(), node] }; + let error = MerkleStore::try_from(duplicate).unwrap_err(); + assert_eq!(error.to_string(), "nodes[1].value: duplicate Merkle store parent"); + + let invalid = proto::primitives::MerkleStore { + nodes: vec![proto::primitives::MerkleStoreNode { + value: Some(proto::primitives::Word { encoded: vec![0; 31] }), + left: Some(dummy_word(2).into()), + right: Some(dummy_word(3).into()), + }], + }; + let error = MerkleStore::try_from(invalid).unwrap_err(); + assert!(error.to_string().starts_with("nodes[0].value.word.encoded: "), "{error}"); + assert!(error.source().is_some()); +} + +#[test] +fn transaction_args_require_nested_messages_and_reject_duplicate_note_ids() { + let missing = proto::transaction::TransactionArgs { + tx_script: None, + tx_script_args: None, + note_args: vec![], + advice_inputs: Some(proto::primitives::AdviceInputs { + advice_stack: Some(proto::primitives::AdviceStack { values: vec![] }), + advice_map: Some(proto::primitives::AdviceMap { entries: vec![] }), + merkle_store: Some(proto::primitives::MerkleStore { nodes: vec![] }), + }), + auth_args: Some(dummy_word(1).into()), + }; + let error = TransactionArgs::try_from(missing).unwrap_err(); + assert!(error.to_string().ends_with("::tx_script_args is missing")); + + let note = note_id(1); + let duplicate = proto::transaction::TransactionArgs { + tx_script: None, + tx_script_args: Some(dummy_word(2).into()), + note_args: vec![ + proto::transaction::NoteArgument { + note_id: Some((¬e).into()), + args: Some(dummy_word(3).into()), + }, + proto::transaction::NoteArgument { + note_id: Some((¬e).into()), + args: Some(dummy_word(4).into()), + }, + ], + advice_inputs: Some(proto::primitives::AdviceInputs { + advice_stack: Some(proto::primitives::AdviceStack { values: vec![] }), + advice_map: Some(proto::primitives::AdviceMap { entries: vec![] }), + merkle_store: Some(proto::primitives::MerkleStore { nodes: vec![] }), + }), + auth_args: Some(dummy_word(5).into()), + }; + let error = TransactionArgs::try_from(duplicate).unwrap_err(); + assert_eq!(error.to_string(), "note_args[1].note_id: duplicate note argument"); +} + +#[test] +fn transaction_script_rejects_missing_mast_and_invalid_entrypoint() { + let missing_mast = proto::transaction::TransactionScript { entrypoint: 0, mast: None }; + let error = miden_protocol::transaction::TransactionScript::try_from(missing_mast).unwrap_err(); + assert!(error.to_string().ends_with("::mast is missing")); + + let invalid_entrypoint = proto::transaction::TransactionScript { + entrypoint: 1, + mast: Some(miden_protocol::MastForest::new().into()), + }; + let error = + miden_protocol::transaction::TransactionScript::try_from(invalid_entrypoint).unwrap_err(); + assert!( + error + .to_string() + .starts_with("failed to deserialize transaction_script.entrypoint: ") + ); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(DeserializationError::InvalidValue(_)) + ); + + let malformed_mast = proto::transaction::TransactionScript { + entrypoint: 0, + mast: Some(proto::primitives::MastForest { encoded: vec![0] }), + }; + let error = + miden_protocol::transaction::TransactionScript::try_from(malformed_mast).unwrap_err(); + assert!( + error + .to_string() + .starts_with("mast.encoded: failed to deserialize MastForest: "), + "{error}" + ); + assert_matches!( + error + .source() + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()), + Some(DeserializationError::UnexpectedEOF) + ); +} diff --git a/crates/miden-objects/tests/transaction_inputs/common.rs b/crates/miden-objects/tests/transaction_inputs/common.rs new file mode 100644 index 0000000000..efc14a4166 --- /dev/null +++ b/crates/miden-objects/tests/transaction_inputs/common.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeMap; + +use miden_objects::proto; +use miden_protocol::account::{ + AccountCode, + AccountComponent, + AccountComponentMetadata, + AccountId, + AccountIdVersion, + AccountStorageHeader, + AccountType, + AssetCallbackFlag, + PartialAccount, + PartialStorage, + StorageSlotName, +}; +use miden_protocol::asset::{AssetId, PartialVault}; +use miden_protocol::block::{BlockHeader, BlockNoteIndex, BlockNoteTree}; +use miden_protocol::crypto::merkle::InnerNodeInfo; +use miden_protocol::crypto::merkle::store::MerkleStore; +use miden_protocol::note::{Note, NoteInclusionProof}; +use miden_protocol::protocol_config::{ + KernelConfig, + ProofSecurityPolicy, + ProofVerificationConfig, + ProtocolConfig, +}; +use miden_protocol::testing::assembler::assemble_test_package; +use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_protocol::transaction::{ + InputNote, + InputNotes, + PartialBlockchain, + TransactionArgs, + TransactionInputs, +}; +use miden_protocol::vm::AdviceInputs; +use miden_protocol::{Felt, Word}; + +pub fn dummy_word(value: u32) -> Word { + Word::from([value, value + 1, value + 2, value + 3]) +} + +fn dummy_account_id(seed: u8) -> AccountId { + AccountId::dummy( + [seed; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ) +} + +fn dummy_protocol_config() -> ProtocolConfig { + ProtocolConfig::new( + AssetId::new_fungible(dummy_account_id(8)), + KernelConfig::new(dummy_word(10), vec![dummy_word(11)]).unwrap(), + KernelConfig::new(dummy_word(12), vec![dummy_word(13)]).unwrap(), + KernelConfig::new(dummy_word(14), vec![dummy_word(15)]).unwrap(), + ProofVerificationConfig::new( + dummy_word(16), + dummy_word(17), + ProofSecurityPolicy::new(dummy_word(18), 96).unwrap(), + ), + ) + .unwrap() +} + +fn dummy_secondary_account_code() -> AccountCode { + const CODE: &str = " + @account_procedure + pub proc baz + push.3.4 add + end + "; + let package = + assemble_test_package("miden-testing-second-account", "miden::testing::second", CODE); + let component = AccountComponent::new( + package, + vec![], + AccountComponentMetadata::new("miden::testing::second"), + ) + .unwrap(); + + AccountCode::from_components(&[NoopAuthComponent.into(), component]).unwrap() +} + +fn dummy_advice_inputs(stack_values: [u32; 2], map_key: u32, node_value: u32) -> AdviceInputs { + let mut stack = AdviceInputs::default().stack(); + stack.append_elements(stack_values.map(Felt::from)); + let mut store = MerkleStore::new(); + store.extend([InnerNodeInfo { + value: dummy_word(node_value), + left: dummy_word(node_value + 1), + right: dummy_word(node_value + 2), + }]); + + AdviceInputs::default() + .with_stack(stack) + .with_map([(dummy_word(map_key), vec![Felt::from(map_key + 1)])]) + .with_merkle_store(store) +} + +fn dummy_block_header( + blockchain: &PartialBlockchain, + note_root: Word, + protocol_config: &ProtocolConfig, +) -> BlockHeader { + let base = BlockHeader::mock( + blockchain.chain_length(), + Some(blockchain.peaks().hash_peaks()), + Some(note_root), + &[], + ); + BlockHeader::new( + base.prev_block_commitment(), + base.block_num(), + base.chain_commitment(), + base.account_root(), + base.nullifier_root(), + base.note_root(), + base.tx_commitment(), + base.validator_config().clone(), + base.fee_parameters().clone(), + protocol_config.to_commitment(), + base.next_protocol_config().cloned(), + base.timestamp(), + ) +} + +pub fn dummy_transaction_inputs() -> TransactionInputs { + let account_code = AccountCode::mock(); + let account = PartialAccount::new( + dummy_account_id(7), + Felt::from(9_u32), + account_code.clone(), + PartialStorage::new(AccountStorageHeader::new(vec![]).unwrap(), []).unwrap(), + PartialVault::new(dummy_word(20)), + None, + ) + .unwrap(); + + let authenticated_note = Note::mock_noop(dummy_word(30)); + let unauthenticated_note = Note::mock_noop(dummy_word(40)); + let note_index = BlockNoteIndex::new(0, 0).unwrap(); + let note_tree = + BlockNoteTree::with_entries([(note_index, authenticated_note.header())]).unwrap(); + let proof = NoteInclusionProof::new( + 0_u32.into(), + note_index.leaf_index_value(), + note_tree.open(note_index), + ) + .unwrap(); + let input_notes = InputNotes::new(vec![ + InputNote::authenticated(authenticated_note, proof), + InputNote::unauthenticated(unauthenticated_note.clone()), + ]) + .unwrap(); + + let protocol_config = dummy_protocol_config(); + let mut blockchain = PartialBlockchain::default(); + let note_block_header = dummy_block_header(&blockchain, note_tree.root(), &protocol_config); + blockchain.add_block(¬e_block_header, true); + let intermediate_block_header = + dummy_block_header(&blockchain, BlockNoteTree::empty().root(), &protocol_config); + blockchain.add_block(&intermediate_block_header, false); + let block_header = + dummy_block_header(&blockchain, BlockNoteTree::empty().root(), &protocol_config); + let tx_args = TransactionArgs::from_parts( + None, + dummy_word(50), + BTreeMap::from([(unauthenticated_note.id(), dummy_word(51))]), + dummy_advice_inputs([52, 53], 54, 55), + dummy_word(56), + ); + let advice_inputs = dummy_advice_inputs([60, 61], 62, 63); + let foreign_account_code = vec![account_code, dummy_secondary_account_code()]; + let first_slot = StorageSlotName::new("foreign::first::value").unwrap(); + let second_slot = StorageSlotName::new("foreign::second::map").unwrap(); + let foreign_account_slot_names = + BTreeMap::from([(second_slot.id(), second_slot), (first_slot.id(), first_slot)]); + + TransactionInputs::try_from_parts( + account, + block_header, + protocol_config, + blockchain, + input_notes, + tx_args, + advice_inputs, + foreign_account_code, + foreign_account_slot_names, + ) + .unwrap() +} + +#[allow(dead_code)] +pub fn dummy_transaction_inputs_message() -> proto::transaction::TransactionInputs { + dummy_transaction_inputs().into() +} + +pub fn transaction_inputs_v1_mut( + message: &mut proto::transaction::TransactionInputs, +) -> &mut proto::transaction::TransactionInputsV1 { + let Some(proto::transaction::transaction_inputs::Version::V1(v1)) = message.version.as_mut() + else { + panic!("transaction inputs should encode as v1"); + }; + v1 +} + +#[allow(dead_code)] +pub fn authenticated_input_note_mut( + message: &mut proto::transaction::TransactionInputs, +) -> &mut proto::transaction::AuthenticatedInputNote { + let input_notes = transaction_inputs_v1_mut(message).input_notes.as_mut().unwrap(); + let Some(proto::transaction::input_note::Note::Authenticated(note)) = + input_notes.notes[0].note.as_mut() + else { + panic!("first input note should be authenticated"); + }; + note +} diff --git a/crates/miden-objects/tests/transaction_inputs/errors.rs b/crates/miden-objects/tests/transaction_inputs/errors.rs new file mode 100644 index 0000000000..239b2f0c8b --- /dev/null +++ b/crates/miden-objects/tests/transaction_inputs/errors.rs @@ -0,0 +1,232 @@ +use core::error::Error; + +use assert_matches::assert_matches; +use miden_objects::{ConversionError, proto}; +use miden_protocol::block::BlockNumber; +use miden_protocol::errors::{StorageSlotNameError, TransactionInputError}; +use miden_protocol::note::Note; +use miden_protocol::protocol_config::ProtocolConfig; +use miden_protocol::transaction::TransactionInputs; + +use super::common; + +fn transaction_input_error(error: &ConversionError) -> &TransactionInputError { + error + .source() + .and_then(|source| source.downcast_ref::()) + .expect("transaction input conversion should preserve its domain error") +} + +#[test] +fn transaction_inputs_requires_a_version() { + let error = + TransactionInputs::try_from(proto::transaction::TransactionInputs::default()).unwrap_err(); + + assert!(error.to_string().ends_with("::version is missing")); +} + +#[test] +fn transaction_inputs_v1_requires_every_singular_message() { + type RemoveField = fn(&mut proto::transaction::TransactionInputsV1); + + let fields: [(&str, RemoveField); 7] = [ + ("account", |v1| v1.account = None), + ("block_header", |v1| v1.block_header = None), + ("protocol_config", |v1| v1.protocol_config = None), + ("partial_blockchain", |v1| v1.partial_blockchain = None), + ("input_notes", |v1| v1.input_notes = None), + ("tx_args", |v1| v1.tx_args = None), + ("advice_inputs", |v1| v1.advice_inputs = None), + ]; + + for (field, remove) in fields { + let mut message = common::dummy_transaction_inputs_message(); + remove(common::transaction_inputs_v1_mut(&mut message)); + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert!( + error.to_string().starts_with("v1: field "), + "unexpected error for {field}: {error}" + ); + assert!(error.to_string().ends_with(&format!("::{field} is missing"))); + } +} + +#[test] +fn input_notes_require_their_oneof_and_authenticated_fields() { + let mut message = common::dummy_transaction_inputs_message(); + common::transaction_inputs_v1_mut(&mut message) + .input_notes + .as_mut() + .unwrap() + .notes[0] + .note = None; + let error = TransactionInputs::try_from(message).unwrap_err(); + assert!(error.to_string().ends_with( + "input_notes.notes[0]: field miden_objects::proto::transaction::InputNote::note is missing" + )); + + let mut message = common::dummy_transaction_inputs_message(); + common::authenticated_input_note_mut(&mut message).note = None; + let error = TransactionInputs::try_from(message).unwrap_err(); + assert!(error.to_string().starts_with("v1.input_notes.notes[0].authenticated: field ")); + assert!(error.to_string().ends_with("::note is missing")); + + let mut message = common::dummy_transaction_inputs_message(); + common::authenticated_input_note_mut(&mut message).proof = None; + let error = TransactionInputs::try_from(message).unwrap_err(); + assert!(error.to_string().starts_with("v1.input_notes.notes[0].authenticated: field ")); + assert!(error.to_string().ends_with("::proof is missing")); +} + +#[test] +fn authenticated_input_note_rejects_a_proof_for_a_different_note() { + let mut message = common::dummy_transaction_inputs_message(); + common::authenticated_input_note_mut(&mut message) + .proof + .as_mut() + .unwrap() + .note_id = Some((&Note::mock_noop(common::dummy_word(99)).id()).into()); + + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert!( + error + .to_string() + .starts_with("v1.input_notes.notes[0].authenticated.proof.note_id: note ID mismatch:"), + "unexpected error: {error}" + ); +} + +#[test] +fn input_notes_reject_duplicate_nullifiers_and_preserve_the_domain_source() { + let mut message = common::dummy_transaction_inputs_message(); + let v1 = common::transaction_inputs_v1_mut(&mut message); + let duplicate = v1.input_notes.as_ref().unwrap().notes[0].clone(); + v1.input_notes.as_mut().unwrap().notes.push(duplicate); + + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert!( + error + .to_string() + .starts_with("v1.input_notes: transaction input note with nullifier"), + "unexpected error: {error}" + ); + assert_matches!(transaction_input_error(&error), TransactionInputError::DuplicateInputNote(_)); +} + +#[test] +fn foreign_slot_names_reject_invalid_names_and_preserve_the_domain_source() { + let mut message = common::dummy_transaction_inputs_message(); + common::transaction_inputs_v1_mut(&mut message).foreign_account_slot_names[0].slot_name = + "invalid".into(); + + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert!(error.to_string().starts_with("v1.foreign_account_slot_names[0].slot_name: ")); + assert_matches!( + error.source().and_then(|source| source.downcast_ref::()), + Some(StorageSlotNameError::TooShort) + ); +} + +#[test] +fn foreign_slot_names_reject_id_name_mismatches() { + let mut message = common::dummy_transaction_inputs_message(); + let v1 = common::transaction_inputs_v1_mut(&mut message); + v1.foreign_account_slot_names[0].slot_id = v1.foreign_account_slot_names[1].slot_id; + + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert_eq!( + error.to_string(), + "v1.foreign_account_slot_names[0].slot_id: storage slot ID does not match slot name" + ); +} + +#[test] +fn foreign_slot_names_reject_duplicate_ids() { + let mut message = common::dummy_transaction_inputs_message(); + let v1 = common::transaction_inputs_v1_mut(&mut message); + let mut duplicate = v1.foreign_account_slot_names[0].clone(); + duplicate.slot_name = v1.foreign_account_slot_names[0].slot_name.clone(); + v1.foreign_account_slot_names.push(duplicate); + + let error = TransactionInputs::try_from(message).unwrap_err(); + + assert_eq!( + error.to_string(), + "v1.foreign_account_slot_names[2].slot_id: duplicate foreign account storage slot ID" + ); +} + +#[test] +fn transaction_inputs_reject_an_inconsistent_protocol_config() { + let mut message = common::dummy_transaction_inputs_message(); + common::transaction_inputs_v1_mut(&mut message).protocol_config = + Some(proto::protocol_config::ProtocolConfig::from(ProtocolConfig::mock())); + + let error = TransactionInputs::try_from(message).unwrap_err(); + assert_matches!( + transaction_input_error(&error), + TransactionInputError::InconsistentProtocolConfig { .. } + ); +} + +#[test] +fn transaction_inputs_reject_an_inconsistent_chain_length() { + let mut message = common::dummy_transaction_inputs_message(); + let header = common::transaction_inputs_v1_mut(&mut message).block_header.as_mut().unwrap(); + header.block_num = Some(BlockNumber::from(1_u32).into()); + + let error = TransactionInputs::try_from(message).unwrap_err(); + assert_matches!( + transaction_input_error(&error), + TransactionInputError::InconsistentChainLength { .. } + ); +} + +#[test] +fn transaction_inputs_reject_an_inconsistent_chain_commitment() { + let mut message = common::dummy_transaction_inputs_message(); + let header = common::transaction_inputs_v1_mut(&mut message).block_header.as_mut().unwrap(); + header.chain_commitment = Some(common::dummy_word(100).into()); + + let error = TransactionInputs::try_from(message).unwrap_err(); + assert_matches!( + transaction_input_error(&error), + TransactionInputError::InconsistentChainCommitment { .. } + ); +} + +#[test] +fn transaction_inputs_reject_an_authenticated_note_from_an_untracked_block() { + let mut message = common::dummy_transaction_inputs_message(); + common::authenticated_input_note_mut(&mut message) + .proof + .as_mut() + .unwrap() + .block_num = Some(BlockNumber::from(1_u32).into()); + + let error = TransactionInputs::try_from(message).unwrap_err(); + assert_matches!( + transaction_input_error(&error), + TransactionInputError::InputNoteBlockNotInPartialBlockchain(_) + ); +} + +#[test] +fn transaction_inputs_reject_an_invalid_authenticated_note_path() { + let mut message = common::dummy_transaction_inputs_message(); + let replacement = Note::mock_noop(common::dummy_word(101)); + let authenticated = common::authenticated_input_note_mut(&mut message); + authenticated.note = Some(replacement.clone().into()); + authenticated.proof.as_mut().unwrap().note_id = Some((&replacement.id()).into()); + + let error = TransactionInputs::try_from(message).unwrap_err(); + assert_matches!( + transaction_input_error(&error), + TransactionInputError::InputNoteNotInBlock(note_id, _) if *note_id == replacement.id() + ); +} diff --git a/crates/miden-objects/tests/transaction_inputs/main.rs b/crates/miden-objects/tests/transaction_inputs/main.rs new file mode 100644 index 0000000000..2d2f6ec8b4 --- /dev/null +++ b/crates/miden-objects/tests/transaction_inputs/main.rs @@ -0,0 +1,3 @@ +mod common; +mod errors; +mod roundtrip; diff --git a/crates/miden-objects/tests/transaction_inputs/roundtrip.rs b/crates/miden-objects/tests/transaction_inputs/roundtrip.rs new file mode 100644 index 0000000000..88c6243bec --- /dev/null +++ b/crates/miden-objects/tests/transaction_inputs/roundtrip.rs @@ -0,0 +1,62 @@ +use miden_objects::proto; +use miden_protocol::note::Note; +use miden_protocol::transaction::TransactionInputs; +use prost::Message; + +use super::common; + +#[test] +fn transaction_inputs_roundtrip_preserves_all_nested_fields_and_ordered_collections() { + let expected = common::dummy_transaction_inputs(); + let mut message = proto::transaction::TransactionInputs::from(&expected); + let v1 = common::transaction_inputs_v1_mut(&mut message); + + let encoded_note_ids = v1 + .input_notes + .as_ref() + .unwrap() + .notes + .iter() + .map(|note| match note.note.as_ref().unwrap() { + proto::transaction::input_note::Note::Authenticated(note) => { + Note::try_from(note.note.as_ref().unwrap().clone()).unwrap().id() + }, + proto::transaction::input_note::Note::Unauthenticated(note) => { + Note::try_from(note.clone()).unwrap().id() + }, + }) + .collect::>(); + assert_eq!( + encoded_note_ids, + expected.input_notes().iter().map(|note| note.id()).collect::>() + ); + + v1.foreign_account_slot_names.reverse(); + let encoded = message.encode_to_vec(); + let decoded_message = + proto::transaction::TransactionInputs::decode(encoded.as_slice()).unwrap(); + let actual = TransactionInputs::try_from(decoded_message).unwrap(); + + assert_eq!(actual, expected); + assert_eq!(actual.foreign_account_code(), expected.foreign_account_code()); + assert_eq!( + actual.input_notes().iter().map(|note| note.id()).collect::>(), + expected.input_notes().iter().map(|note| note.id()).collect::>() + ); + + let normalized = proto::transaction::TransactionInputs::from(&actual); + let proto::transaction::transaction_inputs::Version::V1(normalized) = + normalized.version.unwrap(); + let normalized_slot_ids = normalized + .foreign_account_slot_names + .iter() + .map(|entry| entry.slot_id.unwrap()) + .collect::>(); + assert!(normalized_slot_ids.windows(2).all(|ids| { + miden_protocol::account::StorageSlotId::try_from(ids[0]).unwrap() + < miden_protocol::account::StorageSlotId::try_from(ids[1]).unwrap() + })); + + let decoded_code = actual.foreign_account_code(); + assert_ne!(decoded_code[0].commitment(), decoded_code[1].commitment()); +} diff --git a/crates/miden-protocol/src/account/patch/storage/storage_patch.rs b/crates/miden-protocol/src/account/patch/storage/storage_patch.rs index a5030d92cc..d384ad2fa8 100644 --- a/crates/miden-protocol/src/account/patch/storage/storage_patch.rs +++ b/crates/miden-protocol/src/account/patch/storage/storage_patch.rs @@ -100,8 +100,11 @@ impl AccountStoragePatch { self.patches.len() } - /// Returns an iterator over the slot patches. - pub(crate) fn slots(&self) -> impl Iterator { + /// Returns an iterator over the slot patches in ascending slot ID order. + /// + /// The order is guaranteed by the [`BTreeMap`] backing this patch, whose keys are ordered by + /// their slot IDs. + pub fn slots(&self) -> impl Iterator { self.patches.iter() } diff --git a/crates/miden-protocol/src/account/storage/map/partial.rs b/crates/miden-protocol/src/account/storage/map/partial.rs index e120c32d8d..71c041e2f8 100644 --- a/crates/miden-protocol/src/account/storage/map/partial.rs +++ b/crates/miden-protocol/src/account/storage/map/partial.rs @@ -85,6 +85,34 @@ impl PartialStorageMap { Self::new(storage_map.root()) } + /// Creates a [`PartialStorageMap`] from a [`PartialSmt`] and the raw keys whose values are + /// looked up from the SMT. + /// + /// # Errors + /// + /// Returns an error if: + /// - a key is supplied more than once. + /// - a key's hashed form is not tracked by the partial SMT. + pub fn try_from_parts( + partial_smt: PartialSmt, + keys: impl IntoIterator, + ) -> Result { + let mut entries = BTreeMap::new(); + + for key in keys { + if entries.contains_key(&key) { + return Err(MerkleError::DuplicateValuesForIndex( + key.hash().to_leaf_index().position(), + )); + } + + let value = partial_smt.get_value(&key.hash().as_word())?; + entries.insert(key, value); + } + + Ok(Self { partial_smt, entries }) + } + // ACCESSORS // -------------------------------------------------------------------------------------------- @@ -163,22 +191,78 @@ impl Serializable for PartialStorageMap { impl Deserializable for PartialStorageMap { fn read_from(source: &mut R) -> Result { - let mut map = BTreeMap::new(); - let partial_smt: PartialSmt = source.read()?; let num_entries: usize = source.read()?; + let keys = source + .read_many_iter::(num_entries)? + .collect::, _>>()?; - for _ in 0..num_entries { - let key: StorageMapKey = source.read()?; - let hashed_map_key: Word = key.hash().into(); - let value = partial_smt.get_value(&hashed_map_key).map_err(|err| { - DeserializationError::InvalidValue(format!( - "failed to find map key {key} in partial SMT: {err}" - )) - })?; - map.insert(key, value); - } + Self::try_from_parts(partial_smt, keys).map_err(|err| { + DeserializationError::InvalidValue(format!( + "failed to construct partial storage map from supplied keys: {err}" + )) + }) + } +} - Ok(PartialStorageMap { partial_smt, entries: map }) +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use assert_matches::assert_matches; + use miden_crypto::merkle::MerkleError; + use miden_crypto::merkle::smt::PartialSmt; + + use super::PartialStorageMap; + use crate::Word; + use crate::account::{StorageMap, StorageMapKey}; + + #[test] + fn try_from_parts_preserves_unrelated_partial_smt_material() -> anyhow::Result<()> { + let tracked_key = StorageMapKey::from_index(1); + let extra_key = StorageMapKey::from_index(2); + let tracked_value = Word::from([1_u32, 0, 0, 0]); + let extra_value = Word::from([2_u32, 0, 0, 0]); + let storage_map = + StorageMap::with_entries([(tracked_key, tracked_value), (extra_key, extra_value)])?; + let partial_smt = PartialSmt::from_proofs([ + storage_map.open(&tracked_key).into(), + storage_map.open(&extra_key).into(), + ])?; + + let partial_map = PartialStorageMap::try_from_parts(partial_smt, [tracked_key])?; + + assert_eq!(partial_map.entries().collect::>(), [(&tracked_key, &tracked_value)]); + assert_eq!(partial_map.get(&extra_key), Some(extra_value)); + + Ok(()) + } + + #[test] + fn try_from_parts_rejects_duplicate_keys() -> anyhow::Result<()> { + let key = StorageMapKey::from_index(1); + let storage_map = StorageMap::with_entries([(key, Word::from([1_u32, 0, 0, 0]))])?; + let partial_smt = PartialSmt::from_proofs([storage_map.open(&key).into()])?; + + let result = PartialStorageMap::try_from_parts(partial_smt, [key, key]); + + assert_matches!( + result, + Err(MerkleError::DuplicateValuesForIndex(position)) + if position == key.hash().to_leaf_index().position() + ); + + Ok(()) + } + + #[test] + fn try_from_parts_rejects_untracked_keys() { + let key = StorageMapKey::from_index(1); + let result = PartialStorageMap::try_from_parts(PartialSmt::new(Word::empty()), [key]); + + assert_matches!( + result, + Err(MerkleError::UntrackedKey(hashed_key)) if hashed_key == key.hash().as_word() + ); } } diff --git a/crates/miden-protocol/src/asset/vault/partial.rs b/crates/miden-protocol/src/asset/vault/partial.rs index 9fc906eff4..b0a08f504a 100644 --- a/crates/miden-protocol/src/asset/vault/partial.rs +++ b/crates/miden-protocol/src/asset/vault/partial.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::ToString; use alloc::vec::Vec; @@ -102,13 +102,18 @@ impl PartialVault { /// Returns an error if: /// - any ID's hashed form is not present in the partial SMT. /// - any of the resulting `(asset_id, value)` pairs does not form a valid asset. - fn from_partial_smt_and_ids( + pub fn try_from_parts( partial_smt: PartialSmt, ids: impl IntoIterator, ) -> Result { let mut entries = BTreeMap::new(); + let mut seen_ids = BTreeSet::new(); for id in ids { + if !seen_ids.insert(id) { + return Err(PartialAssetVaultError::DuplicateAssetId(id)); + } + let value = partial_smt .get_value(&id.hash().as_word()) .map_err(PartialAssetVaultError::UntrackedAsset)?; @@ -140,6 +145,11 @@ impl PartialVault { self.partial_smt.root() } + /// Returns the partial SMT underlying this vault. + pub fn partial_smt(&self) -> &PartialSmt { + &self.partial_smt + } + /// Returns an iterator over all inner nodes in the Sparse Merkle Tree proofs. /// /// This is useful for reconstructing parts of the Sparse Merkle Tree or for @@ -160,6 +170,11 @@ impl PartialVault { }) } + /// Returns an iterator over the asset IDs tracked by this partial vault. + pub fn asset_ids(&self) -> impl Iterator + '_ { + self.entries.keys().copied() + } + /// Returns an iterator over the raw `(asset_id, value)` pairs tracked by this partial vault. #[cfg(test)] pub(super) fn entries(&self) -> impl Iterator { @@ -244,7 +259,7 @@ impl Deserializable for PartialVault { let num_entries: usize = source.read()?; let ids = source.read_many_iter::(num_entries)?.collect::, _>>()?; - Self::from_partial_smt_and_ids(partial_smt, ids) + Self::try_from_parts(partial_smt, ids) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } } @@ -263,6 +278,14 @@ mod tests { use crate::asset::{FungibleAsset, NonFungibleAsset}; use crate::testing::account_id::ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET; + #[test] + fn partial_smt_accessor_returns_vault_smt() { + let root = Word::from([1_u32, 2, 3, 4]); + let vault = PartialVault::new(root); + + assert_eq!(vault.partial_smt().root(), root); + } + #[test] fn partial_vault_open_returns_correct_asset_after_full_conversion() -> anyhow::Result<()> { let asset = FungibleAsset::mock(500); @@ -375,7 +398,7 @@ mod tests { } #[test] - fn from_partial_smt_and_ids_rejects_inconsistent_asset() -> anyhow::Result<()> { + fn try_from_parts_rejects_inconsistent_asset() -> anyhow::Result<()> { let fungible = FungibleAsset::mock(500); let non_fungible = NonFungibleAsset::mock(&[4, 5, 6]); @@ -387,9 +410,52 @@ mod tests { let proof = inconsistent_smt.open(&fungible_id.hash().as_word()); let partial_smt = PartialSmt::from_proofs([proof])?; - let err = PartialVault::from_partial_smt_and_ids(partial_smt, [fungible_id]).unwrap_err(); + let err = PartialVault::try_from_parts(partial_smt, [fungible_id]).unwrap_err(); assert_matches!(err, PartialAssetVaultError::InvalidAssetForId { .. }); Ok(()) } + + #[test] + fn try_from_parts_preserves_unrelated_partial_smt_material() -> anyhow::Result<()> { + let tracked_asset = FungibleAsset::mock(500); + let extra_asset = NonFungibleAsset::mock(&[1, 2, 3]); + let vault = AssetVault::new(&[tracked_asset, extra_asset])?; + let partial_smt = PartialSmt::from_proofs([ + vault.open(tracked_asset.id()).into(), + vault.open(extra_asset.id()).into(), + ])?; + + let partial_vault = PartialVault::try_from_parts(partial_smt, [tracked_asset.id()])?; + + assert_eq!(partial_vault.asset_ids().collect::>(), [tracked_asset.id()]); + assert_eq!(partial_vault.get(extra_asset.id())?, Some(extra_asset)); + + Ok(()) + } + + #[test] + fn try_from_parts_rejects_duplicate_asset_ids() -> anyhow::Result<()> { + let asset = FungibleAsset::mock(500); + let vault = AssetVault::new(&[asset])?; + let partial_smt = PartialSmt::from_proofs([vault.open(asset.id()).into()])?; + + let result = PartialVault::try_from_parts(partial_smt, [asset.id(), asset.id()]); + + assert_matches!(result, Err(PartialAssetVaultError::DuplicateAssetId(id)) if id == asset.id()); + + Ok(()) + } + + #[test] + fn try_from_parts_rejects_untracked_asset_ids() { + let asset_id = FungibleAsset::mock(500).id(); + let result = PartialVault::try_from_parts(PartialSmt::new(Word::empty()), [asset_id]); + + assert_matches!( + result, + Err(PartialAssetVaultError::UntrackedAsset(MerkleError::UntrackedKey(hashed_id))) + if hashed_id == asset_id.hash().as_word() + ); + } } diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index 6038512051..384f0d4daf 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -844,6 +844,8 @@ pub enum AssetVaultError { #[derive(Debug, Error)] pub enum PartialAssetVaultError { + #[error("duplicate asset ID {0} in partial vault")] + DuplicateAssetId(AssetId), #[error("partial vault contains invalid asset value {value} at ID {id}")] InvalidAssetForId { id: AssetId, diff --git a/crates/miden-protocol/src/lib.rs b/crates/miden-protocol/src/lib.rs index 9a8bc080d1..42f8a6372a 100644 --- a/crates/miden-protocol/src/lib.rs +++ b/crates/miden-protocol/src/lib.rs @@ -67,7 +67,7 @@ pub mod utils; pub mod vm { pub use miden_assembly_syntax::ast::{AttributeSet, QualifiedProcedureName}; - pub use miden_core::advice::{AdviceInputs, AdviceMap}; + pub use miden_core::advice::{AdviceInputs, AdviceMap, AdviceStack}; pub use miden_core::events::{EventId, EventName, SystemEvent}; pub use miden_core::program::{Program, ProgramInfo}; pub use miden_mast_package::debug_info::{DebugSourceNodeId, PackageDebugInfo}; diff --git a/crates/miden-protocol/src/transaction/inputs/mod.rs b/crates/miden-protocol/src/transaction/inputs/mod.rs index 2a6e6415c2..3370af48bb 100644 --- a/crates/miden-protocol/src/transaction/inputs/mod.rs +++ b/crates/miden-protocol/src/transaction/inputs/mod.rs @@ -84,6 +84,60 @@ impl TransactionInputs { blockchain: PartialBlockchain, input_notes: InputNotes, ) -> Result { + Self::try_from_parts( + account, + block_header, + protocol_config, + blockchain, + input_notes, + TransactionArgs::default(), + AdviceInputs::default(), + Vec::new(), + BTreeMap::new(), + ) + } + + /// Creates [`TransactionInputs`] from all transaction-input components. + /// + /// # Errors + /// + /// Returns an error under the same conditions as [`Self::new`]. + #[allow(clippy::too_many_arguments)] + pub fn try_from_parts( + account: PartialAccount, + block_header: BlockHeader, + protocol_config: ProtocolConfig, + blockchain: PartialBlockchain, + input_notes: InputNotes, + tx_args: TransactionArgs, + advice_inputs: AdviceInputs, + foreign_account_code: Vec, + foreign_account_slot_names: BTreeMap, + ) -> Result { + Self::validate(&block_header, &protocol_config, &blockchain, &input_notes)?; + + Ok(Self { + account, + block_header, + protocol_config, + blockchain, + input_notes, + tx_args, + advice_inputs, + foreign_account_code, + foreign_account_slot_names, + }) + } + + // VALIDATION + // -------------------------------------------------------------------------------------------- + + fn validate( + block_header: &BlockHeader, + protocol_config: &ProtocolConfig, + blockchain: &PartialBlockchain, + input_notes: &InputNotes, + ) -> Result<(), TransactionInputError> { // Check that the protocol config is the one the block header commits to. let protocol_config_commitment = protocol_config.to_commitment(); if protocol_config_commitment != block_header.protocol_config_commitment() { @@ -110,7 +164,7 @@ impl TransactionInputs { if let InputNote::Authenticated { note, proof } = note { let note_block_num = proof.location().block_num(); let block_header = if note_block_num == block_header.block_num() { - &block_header + block_header } else { blockchain.get_block(note_block_num).ok_or( TransactionInputError::InputNoteBlockNotInPartialBlockchain(note.id()), @@ -120,17 +174,7 @@ impl TransactionInputs { } } - Ok(Self { - account, - block_header, - protocol_config, - blockchain, - input_notes, - tx_args: TransactionArgs::default(), - advice_inputs: AdviceInputs::default(), - foreign_account_code: Vec::new(), - foreign_account_slot_names: BTreeMap::new(), - }) + Ok(()) } /// Replaces the transaction inputs and assigns the given asset witnesses. diff --git a/crates/miden-protocol/src/transaction/inputs/tests.rs b/crates/miden-protocol/src/transaction/inputs/tests.rs index 71e6a43f3a..7f42f041bd 100644 --- a/crates/miden-protocol/src/transaction/inputs/tests.rs +++ b/crates/miden-protocol/src/transaction/inputs/tests.rs @@ -3,6 +3,8 @@ use alloc::vec; use std::collections::BTreeMap; use std::vec::Vec; +use assert_matches::assert_matches; + use crate::account::{ AccountCode, AccountHeader, @@ -17,7 +19,7 @@ use crate::account::{ use crate::asset::PartialVault; use crate::block::BlockHeader; use crate::block::account_tree::AccountIdKey; -use crate::errors::TransactionInputsExtractionError; +use crate::errors::{TransactionInputError, TransactionInputsExtractionError}; use crate::protocol_config::ProtocolConfig; use crate::testing::account_id::{ ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, @@ -362,3 +364,91 @@ fn test_transaction_inputs_serialization_with_foreign_slot_names() { // Verify the entire structure is identical. assert_eq!(original_tx_inputs, deserialized); } + +#[test] +fn try_from_parts_preserves_all_transaction_input_components() { + let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let code = AccountCode::mock(); + let account = PartialAccount::new( + account_id, + Felt::new_unchecked(10), + code.clone(), + PartialStorage::new(AccountStorageHeader::new(vec![]).unwrap(), []).unwrap(), + PartialVault::new(Word::default()), + None, + ) + .unwrap(); + let blockchain = PartialBlockchain::default(); + let block_header = BlockHeader::mock(0, Some(blockchain.peaks().hash_peaks()), None, &[]); + let protocol_config = ProtocolConfig::mock(); + let input_notes = InputNotes::new(vec![]).unwrap(); + let tx_args = TransactionArgs::from_parts( + None, + Word::new([Felt::from(1_u32); 4]), + BTreeMap::new(), + AdviceInputs::default(), + Word::new([Felt::from(2_u32); 4]), + ); + let advice_inputs = AdviceInputs::default() + .with_map([(Word::new([Felt::from(3_u32); 4]), vec![Felt::from(4_u32)])]); + let foreign_account_code = vec![code]; + let slot_name = StorageSlotName::new("test::slot::value".to_string()).unwrap(); + let foreign_account_slot_names = BTreeMap::from([(slot_name.id(), slot_name)]); + + let tx_inputs = TransactionInputs::try_from_parts( + account.clone(), + block_header.clone(), + protocol_config.clone(), + blockchain.clone(), + input_notes.clone(), + tx_args.clone(), + advice_inputs.clone(), + foreign_account_code.clone(), + foreign_account_slot_names.clone(), + ) + .unwrap(); + + assert_eq!(tx_inputs.account(), &account); + assert_eq!(tx_inputs.block_header(), &block_header); + assert_eq!(tx_inputs.protocol_config(), &protocol_config); + assert_eq!(tx_inputs.blockchain(), &blockchain); + assert_eq!(tx_inputs.input_notes(), &input_notes); + assert_eq!(tx_inputs.tx_args(), &tx_args); + assert_eq!(tx_inputs.advice_inputs(), &advice_inputs); + assert_eq!(tx_inputs.foreign_account_code(), foreign_account_code); + assert_eq!(tx_inputs.foreign_account_slot_names(), &foreign_account_slot_names); +} + +#[test] +fn try_from_parts_rejects_an_inconsistent_protocol_config() { + let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap(); + let account = PartialAccount::new( + account_id, + Felt::new_unchecked(10), + AccountCode::mock(), + PartialStorage::new(AccountStorageHeader::new(vec![]).unwrap(), []).unwrap(), + PartialVault::new(Word::default()), + None, + ) + .unwrap(); + let blockchain = PartialBlockchain::default(); + let block_header = BlockHeader::mock(0, Some(blockchain.peaks().hash_peaks()), None, &[]); + let different_fee_faucet = + AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE_2).unwrap(); + let protocol_config = + ProtocolConfig::current(crate::asset::AssetId::new_fungible(different_fee_faucet)).unwrap(); + + let result = TransactionInputs::try_from_parts( + account, + block_header, + protocol_config, + blockchain, + InputNotes::new(vec![]).unwrap(), + TransactionArgs::default(), + AdviceInputs::default(), + Vec::new(), + BTreeMap::new(), + ); + + assert_matches!(result, Err(TransactionInputError::InconsistentProtocolConfig { .. })); +} diff --git a/crates/miden-protocol/src/transaction/script.rs b/crates/miden-protocol/src/transaction/script.rs index efc8d27744..3231f8ddcb 100644 --- a/crates/miden-protocol/src/transaction/script.rs +++ b/crates/miden-protocol/src/transaction/script.rs @@ -143,6 +143,11 @@ impl TransactionScript { TransactionScriptRoot::from_raw(self.0.digest()) } + /// Returns the entrypoint node ID of this transaction script. + pub fn entrypoint(&self) -> MastNodeId { + self.0.entrypoint() + } + /// Returns a new [TransactionScript] with the provided advice map entries merged into the /// underlying [MastForest]. /// @@ -159,6 +164,24 @@ impl PartialEq for TransactionScript { } } +#[cfg(test)] +mod entrypoint_tests { + use alloc::sync::Arc; + + use super::TransactionScript; + use crate::Word; + use crate::utils::create_external_node_forest; + + #[test] + fn entrypoint_returns_the_script_entrypoint() { + let (mast, entrypoint) = create_external_node_forest(Word::empty()); + let script = TransactionScript::from_parts(Arc::new(mast), entrypoint) + .expect("test MAST forest should contain its entrypoint"); + + assert_eq!(script.entrypoint(), entrypoint); + } +} + impl Eq for TransactionScript {} // SERIALIZATION diff --git a/crates/miden-protocol/src/transaction/tx_args.rs b/crates/miden-protocol/src/transaction/tx_args.rs index de7044ee37..187e43b676 100644 --- a/crates/miden-protocol/src/transaction/tx_args.rs +++ b/crates/miden-protocol/src/transaction/tx_args.rs @@ -54,12 +54,29 @@ impl TransactionArgs { /// Returns new [TransactionArgs] instantiated with the provided transaction script, advice /// map and foreign account inputs. pub fn new(advice_map: AdviceMap) -> Self { + Self::from_parts( + None, + EMPTY_WORD, + BTreeMap::new(), + AdviceInputs::from(advice_map), + EMPTY_WORD, + ) + } + + /// Creates [`TransactionArgs`] from all of its components. + pub fn from_parts( + tx_script: Option, + tx_script_args: Word, + note_args: BTreeMap, + advice_inputs: AdviceInputs, + auth_args: Word, + ) -> Self { Self { - tx_script: None, - tx_script_args: EMPTY_WORD, - note_args: Default::default(), - advice_inputs: AdviceInputs::from(advice_map), - auth_args: EMPTY_WORD, + tx_script, + tx_script_args, + note_args, + advice_inputs, + auth_args, } } @@ -130,6 +147,11 @@ impl TransactionArgs { self.note_args.get(¬e_id) } + /// Returns the note arguments keyed by note ID. + pub fn note_args(&self) -> &BTreeMap { + &self.note_args + } + /// Returns a reference to the internal [AdviceInputs]. pub fn advice_inputs(&self) -> &AdviceInputs { &self.advice_inputs @@ -255,10 +277,15 @@ impl Deserializable for TransactionArgs { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use miden_core::advice::AdviceMap; + use crate::note::Note; use crate::transaction::TransactionArgs; use crate::utils::serde::{Deserializable, Serializable}; + use crate::vm::AdviceInputs; + use crate::{Felt, Word}; #[test] fn test_tx_args_serialization() { @@ -268,4 +295,25 @@ mod tests { assert_eq!(tx_args, decoded); } + + #[test] + fn from_parts_preserves_note_args_and_advice_inputs() { + let note_id = Note::mock_noop(Word::empty()).id(); + let note_args = BTreeMap::from([(note_id, Word::new([Felt::from(1_u32); 4]))]); + let advice_inputs = AdviceInputs::default() + .with_map([(Word::new([Felt::from(2_u32); 4]), vec![Felt::from(3_u32)])]); + + let tx_args = TransactionArgs::from_parts( + None, + Word::new([Felt::from(4_u32); 4]), + note_args.clone(), + advice_inputs.clone(), + Word::new([Felt::from(5_u32); 4]), + ); + + assert_eq!(tx_args.note_args(), ¬e_args); + assert_eq!(tx_args.advice_inputs(), &advice_inputs); + assert_eq!(tx_args.tx_script_args(), Word::new([Felt::from(4_u32); 4])); + assert_eq!(tx_args.auth_args(), Word::new([Felt::from(5_u32); 4])); + } } diff --git a/deny.toml b/deny.toml index ff668726a7..d54eee4498 100644 --- a/deny.toml +++ b/deny.toml @@ -54,6 +54,13 @@ skip = [ { name = "rand" }, { name = "rand_chacha" }, { name = "rand_core" }, + # prost-build's petgraph dependency uses the older hashbrown/foldhash stack. + { name = "foldhash", version = "=0.1.5" }, + { name = "hashbrown", version = "=0.15.5" }, + # protox uses logos 0.15 in its parser and logos 0.16 in prost-reflect. + { name = "logos", version = "=0.15.1" }, + { name = "logos-codegen", version = "=0.15.1" }, + { name = "logos-derive", version = "=0.15.1" }, ] skip-tree = [ # Allow rustc_version v0.2.x - build dependency version diff --git a/scripts/check-features.sh b/scripts/check-features.sh index 8044d55408..c119ff9ef1 100755 --- a/scripts/check-features.sh +++ b/scripts/check-features.sh @@ -12,7 +12,18 @@ export RUSTFLAGS="-D warnings" # Run cargo-hack with comprehensive feature checking # Focus on library packages that have significant feature matrices -for package in miden-protocol miden-standards miden-agglayer miden-tx miden-testing miden-block-prover miden-tx-batch; do +declare -ra PACKAGES=( + miden-protocol + miden-objects + miden-standards + miden-agglayer + miden-tx + miden-testing + miden-block-prover + miden-tx-batch +) + +for package in "${PACKAGES[@]}"; do echo "Checking package: $package" cargo hack check -p "$package" --each-feature --all-targets done