From 24d3d77a21fe6be7db9bde7232af48e8136ec001 Mon Sep 17 00:00:00 2001 From: Monzero Build System Date: Sat, 15 Aug 2026 22:04:37 +0100 Subject: Add authenticated asset registry snapshots --- docs/MONZERO_ASSETS_V1_SPEC.md | 9 +++ docs/MONZERO_PHASE0_STABILIZATION.md | 1 + src/cryptonote_basic/asset_types.cpp | 143 ++++++++++++++++++++++++++++++++++- src/cryptonote_basic/asset_types.h | 4 + tests/unit_tests/asset_types.cpp | 79 +++++++++++++++++++ 5 files changed, 235 insertions(+), 1 deletion(-) diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md index a49764fe2..826536ea2 100644 --- a/docs/MONZERO_ASSETS_V1_SPEC.md +++ b/docs/MONZERO_ASSETS_V1_SPEC.md @@ -158,6 +158,15 @@ issued at or above a detached height. Tests demonstrate removal and subsequent valid reissuance after a simulated reorganisation. This is an executable state model, not the final database schema or consensus integration. +The inactive registry also has a deterministic, network-separated snapshot +format. Records retain issuer and optional collection signatures, are ordered +by height with collections before same-height members, and are fully +revalidated into temporary state before replacing the active registry. +Corruption, cross-network input, noncanonical order, duplicate issuance, +trailing data, and invalid signatures fail without modifying existing state. +This provides a recovery and migration model; it is not yet an LMDB table or a +substitute for replaying authenticated transactions from the chain. + This encoding is a stable prototype vector for review, not an activation decision. A cryptographic review may replace the hash construction or fields; doing so must deliberately update the golden vector before any testnet fork. diff --git a/docs/MONZERO_PHASE0_STABILIZATION.md b/docs/MONZERO_PHASE0_STABILIZATION.md index ec45be62b..267020435 100644 --- a/docs/MONZERO_PHASE0_STABILIZATION.md +++ b/docs/MONZERO_PHASE0_STABILIZATION.md @@ -129,6 +129,7 @@ isolated disposable development network may be used after cryptographic review. - [x] Inactive authenticated issuance registry with deterministic reorg rollback. - [x] Strict canonical issuance-descriptor decoder with byte-boundary truncation tests. - [x] Bounded authenticated issuance-payload envelope with strict signature shape rules. +- [x] Deterministic authenticated registry snapshots with atomic restore failure behavior. - [x] External cryptographic-review brief and mandatory threat cases. - [ ] Reviewed confidential per-asset commitment and range-proof construction. - [ ] Versioned asset transaction serialization with strict parser limits. diff --git a/src/cryptonote_basic/asset_types.cpp b/src/cryptonote_basic/asset_types.cpp index 05a8614b4..a4e9d79b3 100644 --- a/src/cryptonote_basic/asset_types.cpp +++ b/src/cryptonote_basic/asset_types.cpp @@ -19,6 +19,9 @@ namespace assets constexpr char ISSUANCE_AUTHORIZATION_DOMAIN[] = "MonzeroAssetIssuanceAuthorizationV1"; constexpr char COLLECTION_MEMBERSHIP_DOMAIN[] = "MonzeroCollectionMembershipV1"; constexpr char ISSUANCE_PAYLOAD_DOMAIN[] = "MonzeroAssetIssuancePayloadV1"; + constexpr char REGISTRY_SNAPSHOT_DOMAIN[] = "MonzeroAssetRegistrySnapshotV1"; + constexpr uint8_t REGISTRY_SNAPSHOT_VERSION = 1; + constexpr uint32_t MAX_REGISTRY_SNAPSHOT_RECORDS = 100000; bool fail(std::string* error, const char* message) { @@ -46,6 +49,12 @@ namespace assets target.push_back(static_cast(value >> shift)); } + void append_u32_le(std::vector& target, uint32_t value) + { + for (unsigned shift = 0; shift < 32; shift += 8) + target.push_back(static_cast(value >> shift)); + } + template bool read_pod(const std::vector& source, size_t& offset, T& value) { @@ -75,6 +84,21 @@ namespace assets value |= static_cast(source[offset++]) << shift; return true; } + + bool read_u32_le(const std::vector& source, size_t& offset, uint32_t& value) + { + if (offset > source.size() || 4 > source.size() - offset) + return false; + value = 0; + for (unsigned shift = 0; shift < 32; shift += 8) + value |= static_cast(source[offset++]) << shift; + return true; + } + + unsigned snapshot_class_priority(asset_class type) + { + return type == asset_class::collection ? 0 : 1; + } } bool validate_issuance_descriptor(const issuance_descriptor& descriptor, std::string* error) @@ -379,7 +403,7 @@ namespace assets else if (collection_signature) return fail(error, "unexpected collection signature for an uncollected asset"); - records_.emplace(asset_id, asset_record{descriptor, height}); + records_.emplace(asset_id, asset_record{descriptor, height, issuer_signature, collection_signature}); return true; } @@ -428,6 +452,123 @@ namespace assets return result; } + bool asset_registry::encode_snapshot(network_type network, std::vector& encoded, std::string* error) const + { + if (network != MAINNET && network != TESTNET && network != STAGENET) + return fail(error, "asset registry snapshot requires an explicit public network"); + if (records_.size() > MAX_REGISTRY_SNAPSHOT_RECORDS) + return fail(error, "asset registry snapshot has too many records"); + + using ordered_record = std::pair; + std::vector ordered; + ordered.reserve(records_.size()); + for (const auto& entry : records_) + { + if (entry.second.descriptor.network != network) + return fail(error, "asset registry contains a record from another network"); + ordered.emplace_back(entry.first, &entry.second); + } + std::sort(ordered.begin(), ordered.end(), [](const ordered_record& left, const ordered_record& right) { + if (left.second->issuance_height != right.second->issuance_height) + return left.second->issuance_height < right.second->issuance_height; + const unsigned left_priority = snapshot_class_priority(left.second->descriptor.type); + const unsigned right_priority = snapshot_class_priority(right.second->descriptor.type); + if (left_priority != right_priority) + return left_priority < right_priority; + return left.first < right.first; + }); + + encoded.clear(); + encoded.insert(encoded.end(), REGISTRY_SNAPSHOT_DOMAIN, + REGISTRY_SNAPSHOT_DOMAIN + sizeof(REGISTRY_SNAPSHOT_DOMAIN) - 1); + encoded.push_back(REGISTRY_SNAPSHOT_VERSION); + append_pod(encoded, get_config(network).NETWORK_ID); + append_u32_le(encoded, static_cast(ordered.size())); + for (const ordered_record& entry : ordered) + { + issuance_payload payload; + payload.descriptor = entry.second->descriptor; + payload.issuer_signature = entry.second->issuer_signature; + payload.collection_signature = entry.second->collection_signature; + std::vector payload_bytes; + if (!encode_issuance_payload(payload, payload_bytes, error)) + return false; + if (payload_bytes.size() > std::numeric_limits::max()) + return fail(error, "issuance payload is too large for a registry snapshot"); + append_u64_le(encoded, entry.second->issuance_height); + append_u16_le(encoded, static_cast(payload_bytes.size())); + encoded.insert(encoded.end(), payload_bytes.begin(), payload_bytes.end()); + } + return true; + } + + bool asset_registry::decode_snapshot(const std::vector& encoded, network_type expected_network, std::string* error) + { + constexpr size_t domain_size = sizeof(REGISTRY_SNAPSHOT_DOMAIN) - 1; + constexpr size_t header_size = domain_size + 1 + 16 + 4; + if (expected_network != MAINNET && expected_network != TESTNET && expected_network != STAGENET) + return fail(error, "asset registry snapshot requires an explicit expected network"); + if (encoded.size() < header_size) + return fail(error, "truncated asset registry snapshot"); + if (!std::equal(REGISTRY_SNAPSHOT_DOMAIN, + REGISTRY_SNAPSHOT_DOMAIN + domain_size, encoded.begin())) + return fail(error, "invalid asset registry snapshot domain"); + + size_t offset = domain_size; + if (encoded[offset++] != REGISTRY_SNAPSHOT_VERSION) + return fail(error, "unsupported asset registry snapshot version"); + boost::uuids::uuid network_id{}; + if (!read_pod(encoded, offset, network_id) + || network_id != get_config(expected_network).NETWORK_ID) + return fail(error, "asset registry snapshot network mismatch"); + uint32_t count = 0; + if (!read_u32_le(encoded, offset, count) || count > MAX_REGISTRY_SNAPSHOT_RECORDS) + return fail(error, "invalid asset registry snapshot record count"); + + asset_registry restored; + bool have_previous = false; + uint64_t previous_height = 0; + unsigned previous_priority = 0; + crypto::hash previous_id{}; + for (uint32_t index = 0; index < count; ++index) + { + uint64_t height = 0; + uint16_t payload_size = 0; + if (!read_u64_le(encoded, offset, height) + || !read_u16_le(encoded, offset, payload_size) + || offset > encoded.size() || payload_size > encoded.size() - offset) + return fail(error, "truncated asset registry snapshot record"); + const std::vector payload_bytes( + encoded.begin() + offset, encoded.begin() + offset + payload_size); + offset += payload_size; + + issuance_payload payload; + if (!decode_issuance_payload(payload_bytes, payload, error)) + return false; + if (payload.descriptor.network != expected_network) + return fail(error, "asset registry record network mismatch"); + crypto::hash asset_id{}; + if (!derive_asset_id(payload.descriptor, asset_id, error)) + return false; + const unsigned priority = snapshot_class_priority(payload.descriptor.type); + if (have_previous && (height < previous_height + || (height == previous_height && (priority < previous_priority + || (priority == previous_priority && !(previous_id < asset_id)))))) + return fail(error, "asset registry snapshot records are not in canonical order"); + if (!restored.apply_issuance(payload, height, asset_id, error)) + return false; + have_previous = true; + previous_height = height; + previous_priority = priority; + previous_id = asset_id; + } + if (offset != encoded.size()) + return fail(error, "asset registry snapshot has trailing bytes"); + + records_ = std::move(restored.records_); + return true; + } + bool validate_transparent_balance_statement( const transparent_balance_statement& statement, const std::set& known_assets, diff --git a/src/cryptonote_basic/asset_types.h b/src/cryptonote_basic/asset_types.h index 7265ffdf2..70d1d39bd 100644 --- a/src/cryptonote_basic/asset_types.h +++ b/src/cryptonote_basic/asset_types.h @@ -79,6 +79,8 @@ namespace assets { issuance_descriptor descriptor; uint64_t issuance_height = 0; + crypto::signature issuer_signature{}; + boost::optional collection_signature; }; // Inactive in-memory reference model for authenticated issuance state and @@ -100,6 +102,8 @@ namespace assets const asset_record* find(const crypto::hash& asset_id) const; size_t size() const { return records_.size(); } std::set known_assets() const; + bool encode_snapshot(network_type network, std::vector& encoded, std::string* error = nullptr) const; + bool decode_snapshot(const std::vector& encoded, network_type expected_network, std::string* error = nullptr); private: std::map records_; diff --git a/tests/unit_tests/asset_types.cpp b/tests/unit_tests/asset_types.cpp index 700f29ede..494959fcd 100644 --- a/tests/unit_tests/asset_types.cpp +++ b/tests/unit_tests/asset_types.cpp @@ -393,6 +393,85 @@ TEST(asset_types, registry_authenticates_collection_membership_and_reorgs) EXPECT_EQ(0u, registry.size()); } +TEST(asset_types, registry_snapshot_round_trip_preserves_authenticated_state) +{ + crypto::public_key collection_public{}; + crypto::secret_key collection_secret{}; + crypto::generate_keys(collection_public, collection_secret); + crypto::public_key member_public{}; + crypto::secret_key member_secret{}; + crypto::generate_keys(member_public, member_secret); + + cryptonote::assets::asset_registry original; + auto collection = make_descriptor(cryptonote::TESTNET); + collection.type = cryptonote::assets::asset_class::collection; + collection.atomic_supply = 1; + collection.display_decimals = 0; + collection.issuer_key = collection_public; + crypto::hash collection_id{}; + ASSERT_TRUE(original.apply_issuance( + collection, authorize(collection, collection_public, collection_secret), boost::none, 7, collection_id)); + + auto member = make_descriptor(cryptonote::TESTNET); + member.type = cryptonote::assets::asset_class::non_fungible; + member.atomic_supply = 1; + member.display_decimals = 0; + member.issuer_key = member_public; + member.collection_id = collection_id; + crypto::hash member_id{}; + ASSERT_TRUE(cryptonote::assets::derive_asset_id(member, member_id)); + crypto::hash membership_message{}; + ASSERT_TRUE(cryptonote::assets::derive_collection_membership_hash( + collection_id, member_id, membership_message)); + crypto::signature membership_signature{}; + crypto::generate_signature(membership_message, collection_public, collection_secret, membership_signature); + ASSERT_TRUE(original.apply_issuance( + member, authorize(member, member_public, member_secret), membership_signature, 7, member_id)); + + std::vector snapshot; + ASSERT_TRUE(original.encode_snapshot(cryptonote::TESTNET, snapshot)); + cryptonote::assets::asset_registry restored; + ASSERT_TRUE(restored.decode_snapshot(snapshot, cryptonote::TESTNET)); + EXPECT_EQ(original.known_assets(), restored.known_assets()); + ASSERT_NE(nullptr, restored.find(collection_id)); + ASSERT_NE(nullptr, restored.find(member_id)); + EXPECT_EQ(7u, restored.find(member_id)->issuance_height); + EXPECT_TRUE(restored.find(member_id)->collection_signature); + + std::vector second; + ASSERT_TRUE(restored.encode_snapshot(cryptonote::TESTNET, second)); + EXPECT_EQ(snapshot, second); +} + +TEST(asset_types, registry_snapshot_rejects_corruption_and_preserves_existing_state) +{ + crypto::public_key issuer_public{}; + crypto::secret_key issuer_secret{}; + crypto::generate_keys(issuer_public, issuer_secret); + auto descriptor = make_descriptor(cryptonote::STAGENET); + descriptor.issuer_key = issuer_public; + + cryptonote::assets::asset_registry registry; + crypto::hash asset_id{}; + ASSERT_TRUE(registry.apply_issuance( + descriptor, authorize(descriptor, issuer_public, issuer_secret), boost::none, 3, asset_id)); + std::vector snapshot; + ASSERT_TRUE(registry.encode_snapshot(cryptonote::STAGENET, snapshot)); + + auto corrupted = snapshot; + corrupted.back() ^= 1; + EXPECT_FALSE(registry.decode_snapshot(corrupted, cryptonote::STAGENET)); + EXPECT_TRUE(registry.contains(asset_id)); + EXPECT_EQ(1u, registry.size()); + EXPECT_FALSE(registry.decode_snapshot(snapshot, cryptonote::TESTNET)); + EXPECT_TRUE(registry.contains(asset_id)); + + auto trailing = snapshot; + trailing.push_back(0); + EXPECT_FALSE(registry.decode_snapshot(trailing, cryptonote::STAGENET)); + EXPECT_TRUE(registry.contains(asset_id)); +} + TEST(asset_types, registry_rejects_fake_or_missing_collection_authority) { crypto::public_key issuer_public{}; -- cgit v1.2.3