diff options
| -rw-r--r-- | docs/MONZERO_ASSETS_V1_SPEC.md | 11 | ||||
| -rw-r--r-- | docs/MONZERO_PHASE0_STABILIZATION.md | 1 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.cpp | 91 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.h | 22 | ||||
| -rw-r--r-- | tests/unit_tests/asset_types.cpp | 68 |
5 files changed, 193 insertions, 0 deletions
diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md index 95ae0bea7..66bb37606 100644 --- a/docs/MONZERO_ASSETS_V1_SPEC.md +++ b/docs/MONZERO_ASSETS_V1_SPEC.md @@ -144,6 +144,17 @@ signatures from being silently omitted or attached to unrelated issuance. Collection-controller verification still requires the future persistent registry lookup and therefore remains part of consensus integration work. +The next inactive layer is a detached, versioned transaction extension. It is +bound to one public network and a non-zero commitment to the carrier native +transaction prefix, then contains exactly one bounded operation payload. +Version 1 recognizes issuance only. Its canonical parser rejects unknown +operations, network disagreement, a missing carrier commitment, malformed +lengths, truncated prefixes, invalid nested signatures, and trailing data. +Changing the carrier commitment changes the extension ID. The extension is not +registered as a `tx_extra` variant and therefore does not change what active +mainnet nodes accept; a reviewed hard-fork integration must define the exact +non-circular carrier-prefix hashing procedure first. + ### 4.2 Inactive registry and reorganisation model The prototype includes an in-memory reference registry, disconnected from the diff --git a/docs/MONZERO_PHASE0_STABILIZATION.md b/docs/MONZERO_PHASE0_STABILIZATION.md index d7cba0206..aa2d73c39 100644 --- a/docs/MONZERO_PHASE0_STABILIZATION.md +++ b/docs/MONZERO_PHASE0_STABILIZATION.md @@ -131,6 +131,7 @@ isolated disposable development network may be used after cryptographic review. - [x] Bounded authenticated issuance-payload envelope with strict signature shape rules. - [x] Deterministic authenticated registry snapshots with atomic restore failure behavior. - [x] Atomic ordered block-issuance adapter, detach behavior, and deterministic state commitments. +- [x] Detached versioned transaction extension bound to a native prefix commitment. - [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 7ac078415..aa17b359f 100644 --- a/src/cryptonote_basic/asset_types.cpp +++ b/src/cryptonote_basic/asset_types.cpp @@ -20,6 +20,7 @@ namespace assets constexpr char COLLECTION_MEMBERSHIP_DOMAIN[] = "MonzeroCollectionMembershipV1"; constexpr char ISSUANCE_PAYLOAD_DOMAIN[] = "MonzeroAssetIssuancePayloadV1"; constexpr char REGISTRY_SNAPSHOT_DOMAIN[] = "MonzeroAssetRegistrySnapshotV1"; + constexpr char TRANSACTION_EXTENSION_DOMAIN[] = "MonzeroAssetTransactionExtensionV1"; constexpr uint8_t REGISTRY_SNAPSHOT_VERSION = 1; constexpr uint32_t MAX_REGISTRY_SNAPSHOT_RECORDS = 100000; @@ -365,6 +366,96 @@ namespace assets return true; } + bool encode_transaction_extension(const transaction_extension& extension, std::vector<uint8_t>& encoded, std::string* error) + { + if (extension.version != TRANSACTION_EXTENSION_VERSION) + return fail(error, "unsupported asset transaction extension version"); + if (extension.network != MAINNET && extension.network != TESTNET && extension.network != STAGENET) + return fail(error, "asset transaction extension requires an explicit public network"); + if (extension.operation != transaction_operation::issuance) + return fail(error, "unsupported asset transaction operation"); + if (extension.carrier_prefix_hash == crypto::null_hash) + return fail(error, "asset transaction extension requires a carrier prefix commitment"); + if (extension.issuance.descriptor.network != extension.network) + return fail(error, "asset transaction extension network does not match its issuance"); + + std::vector<uint8_t> operation; + if (!encode_issuance_payload(extension.issuance, operation, error)) + return false; + if (operation.size() > std::numeric_limits<uint16_t>::max()) + return fail(error, "asset transaction operation is too large"); + + encoded.clear(); + encoded.reserve(sizeof(TRANSACTION_EXTENSION_DOMAIN) - 1 + 1 + 16 + 1 + + sizeof(extension.carrier_prefix_hash) + 2 + operation.size()); + encoded.insert(encoded.end(), TRANSACTION_EXTENSION_DOMAIN, + TRANSACTION_EXTENSION_DOMAIN + sizeof(TRANSACTION_EXTENSION_DOMAIN) - 1); + encoded.push_back(extension.version); + append_pod(encoded, get_config(extension.network).NETWORK_ID); + encoded.push_back(static_cast<uint8_t>(extension.operation)); + append_pod(encoded, extension.carrier_prefix_hash); + append_u16_le(encoded, static_cast<uint16_t>(operation.size())); + encoded.insert(encoded.end(), operation.begin(), operation.end()); + return true; + } + + bool decode_transaction_extension(const std::vector<uint8_t>& encoded, transaction_extension& extension, std::string* error) + { + constexpr size_t domain_size = sizeof(TRANSACTION_EXTENSION_DOMAIN) - 1; + constexpr size_t header_size = domain_size + 1 + 16 + 1 + sizeof(crypto::hash) + 2; + if (encoded.size() < header_size) + return fail(error, "truncated asset transaction extension"); + if (!std::equal(TRANSACTION_EXTENSION_DOMAIN, + TRANSACTION_EXTENSION_DOMAIN + domain_size, encoded.begin())) + return fail(error, "invalid asset transaction extension domain"); + + transaction_extension parsed; + size_t offset = domain_size; + parsed.version = encoded[offset++]; + if (parsed.version != TRANSACTION_EXTENSION_VERSION) + return fail(error, "unsupported asset transaction extension version"); + boost::uuids::uuid network_id{}; + if (!read_pod(encoded, offset, network_id)) + return fail(error, "truncated asset transaction extension network"); + parsed.network = UNDEFINED; + for (const network_type candidate : {MAINNET, TESTNET, STAGENET}) + { + if (network_id == get_config(candidate).NETWORK_ID) + { + parsed.network = candidate; + break; + } + } + parsed.operation = static_cast<transaction_operation>(encoded[offset++]); + if (!read_pod(encoded, offset, parsed.carrier_prefix_hash)) + return fail(error, "truncated asset transaction carrier commitment"); + uint16_t operation_size = 0; + if (!read_u16_le(encoded, offset, operation_size) + || offset > encoded.size() || operation_size != encoded.size() - offset) + return fail(error, "asset transaction operation length is not canonical"); + const std::vector<uint8_t> operation( + encoded.begin() + offset, encoded.begin() + offset + operation_size); + if (parsed.operation != transaction_operation::issuance) + return fail(error, "unsupported asset transaction operation"); + if (!decode_issuance_payload(operation, parsed.issuance, error)) + return false; + + std::vector<uint8_t> canonical; + if (!encode_transaction_extension(parsed, canonical, error) || canonical != encoded) + return fail(error, "asset transaction extension encoding is not canonical"); + extension = std::move(parsed); + return true; + } + + bool derive_transaction_extension_id(const transaction_extension& extension, crypto::hash& extension_id, std::string* error) + { + std::vector<uint8_t> encoded; + if (!encode_transaction_extension(extension, encoded, error)) + return false; + extension_id = crypto::cn_fast_hash(encoded.data(), encoded.size()); + return true; + } + bool asset_registry::apply_issuance( const issuance_descriptor& descriptor, const crypto::signature& issuer_signature, diff --git a/src/cryptonote_basic/asset_types.h b/src/cryptonote_basic/asset_types.h index 2b0e01890..5b2bf6006 100644 --- a/src/cryptonote_basic/asset_types.h +++ b/src/cryptonote_basic/asset_types.h @@ -75,6 +75,28 @@ namespace assets bool encode_issuance_payload(const issuance_payload& payload, std::vector<uint8_t>& encoded, std::string* error = nullptr); bool decode_issuance_payload(const std::vector<uint8_t>& encoded, issuance_payload& payload, std::string* error = nullptr); + constexpr uint8_t TRANSACTION_EXTENSION_VERSION = 1; + enum class transaction_operation : uint8_t + { + issuance = 1 + }; + + // Detached transaction-extension prototype. The carrier commitment binds + // the operation to a native transaction prefix without changing active + // transaction serialization or validity. + struct transaction_extension + { + uint8_t version = TRANSACTION_EXTENSION_VERSION; + network_type network = UNDEFINED; + transaction_operation operation = transaction_operation::issuance; + crypto::hash carrier_prefix_hash{}; + issuance_payload issuance; + }; + + bool encode_transaction_extension(const transaction_extension& extension, std::vector<uint8_t>& encoded, std::string* error = nullptr); + bool decode_transaction_extension(const std::vector<uint8_t>& encoded, transaction_extension& extension, std::string* error = nullptr); + bool derive_transaction_extension_id(const transaction_extension& extension, crypto::hash& extension_id, std::string* error = nullptr); + struct asset_record { issuance_descriptor descriptor; diff --git a/tests/unit_tests/asset_types.cpp b/tests/unit_tests/asset_types.cpp index 270f8100e..5f196301d 100644 --- a/tests/unit_tests/asset_types.cpp +++ b/tests/unit_tests/asset_types.cpp @@ -184,6 +184,74 @@ TEST(asset_types, issuance_payload_rejects_tampering_and_signature_shape_errors) EXPECT_FALSE(cryptonote::assets::encode_issuance_payload(payload, encoded, &error)); } +TEST(asset_types, detached_transaction_extension_is_canonical_and_network_bound) +{ + crypto::public_key issuer_public{}; + crypto::secret_key issuer_secret{}; + crypto::generate_keys(issuer_public, issuer_secret); + + cryptonote::assets::transaction_extension original; + original.network = cryptonote::TESTNET; + original.carrier_prefix_hash.data[0] = 0x7a; + original.issuance.descriptor = make_descriptor(cryptonote::TESTNET); + original.issuance.descriptor.issuer_key = issuer_public; + original.issuance.issuer_signature = authorize( + original.issuance.descriptor, issuer_public, issuer_secret); + + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_transaction_extension(original, encoded)); + cryptonote::assets::transaction_extension decoded; + ASSERT_TRUE(cryptonote::assets::decode_transaction_extension(encoded, decoded)); + EXPECT_EQ(original.network, decoded.network); + EXPECT_EQ(original.operation, decoded.operation); + EXPECT_EQ(original.carrier_prefix_hash, decoded.carrier_prefix_hash); + + crypto::hash first{}; + crypto::hash second{}; + ASSERT_TRUE(cryptonote::assets::derive_transaction_extension_id(original, first)); + ASSERT_TRUE(cryptonote::assets::derive_transaction_extension_id(decoded, second)); + EXPECT_EQ(first, second); + + decoded.carrier_prefix_hash.data[0] ^= 1; + ASSERT_TRUE(cryptonote::assets::derive_transaction_extension_id(decoded, second)); + EXPECT_NE(first, second); +} + +TEST(asset_types, detached_transaction_extension_rejects_malformed_or_unbound_data) +{ + crypto::public_key issuer_public{}; + crypto::secret_key issuer_secret{}; + crypto::generate_keys(issuer_public, issuer_secret); + + cryptonote::assets::transaction_extension extension; + extension.network = cryptonote::STAGENET; + extension.carrier_prefix_hash.data[0] = 1; + extension.issuance.descriptor = make_descriptor(cryptonote::STAGENET); + extension.issuance.descriptor.issuer_key = issuer_public; + extension.issuance.issuer_signature = authorize( + extension.issuance.descriptor, issuer_public, issuer_secret); + + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_transaction_extension(extension, encoded)); + cryptonote::assets::transaction_extension decoded; + std::string error; + for (size_t size = 0; size < encoded.size(); ++size) + { + const std::vector<uint8_t> truncated(encoded.begin(), encoded.begin() + size); + EXPECT_FALSE(cryptonote::assets::decode_transaction_extension(truncated, decoded, &error)) + << "accepted truncated extension size " << size; + } + + extension.carrier_prefix_hash = crypto::null_hash; + EXPECT_FALSE(cryptonote::assets::encode_transaction_extension(extension, encoded, &error)); + extension.carrier_prefix_hash.data[0] = 1; + extension.network = cryptonote::TESTNET; + EXPECT_FALSE(cryptonote::assets::encode_transaction_extension(extension, encoded, &error)); + extension.network = cryptonote::STAGENET; + extension.operation = static_cast<cryptonote::assets::transaction_operation>(2); + EXPECT_FALSE(cryptonote::assets::encode_transaction_extension(extension, encoded, &error)); +} + TEST(asset_types, network_domain_separation) { auto mainnet = make_descriptor(cryptonote::MAINNET); |
