aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcenobite pinhead <pinhead@pinhead>2026-08-15 22:41:55 +0100
committercenobite pinhead <pinhead@pinhead>2026-08-15 22:41:55 +0100
commit274b4caccd4a13ec83897be58cfc2310a83c7af5 (patch)
tree60e2164921579130b1ec34bfb17036aa99b01e8b
parent7c17e803151ef3d568a28e7a3c4533ec6c549cf4 (diff)
downloadmonzero-core-274b4caccd4a13ec83897be58cfc2310a83c7af5.tar.gz
monzero-core-274b4caccd4a13ec83897be58cfc2310a83c7af5.tar.xz
monzero-core-274b4caccd4a13ec83897be58cfc2310a83c7af5.zip
verify confidential per-asset conservation
-rw-r--r--docs/MONZERO_ASSETS_V1_SPEC.md23
-rw-r--r--docs/MONZERO_PHASE0_STABILIZATION.md2
-rw-r--r--src/cryptonote_basic/CMakeLists.txt1
-rw-r--r--src/cryptonote_basic/asset_confidential.cpp131
-rw-r--r--src/cryptonote_basic/asset_confidential.h42
-rw-r--r--tests/unit_tests/CMakeLists.txt1
-rw-r--r--tests/unit_tests/asset_confidential.cpp96
7 files changed, 296 insertions, 0 deletions
diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md
index 73bd27790..55d1391ef 100644
--- a/docs/MONZERO_ASSETS_V1_SPEC.md
+++ b/docs/MONZERO_ASSETS_V1_SPEC.md
@@ -252,6 +252,29 @@ properties remain prerequisites for an isolated prototype network. The
transparent statement must never be mistaken for a production transaction
design.
+### 6.2 Inactive confidential conservation prototype
+
+The source tree now contains an inactive per-asset Pedersen-commitment and
+Bulletproof+ verifier. It reuses the inherited RingCT curve and range-proof
+implementation, requires exact proof coverage of every output and explicit
+burn commitment, and checks that pseudo inputs equal outputs plus burns inside
+each public asset-ID domain. Fixed-supply issuance uses a zero-mask commitment
+to the descriptor's public lifetime supply. Limits currently cap each balance
+group at 16 pseudo inputs and 16 destinations.
+
+Adversarial tests reject inflation, substituted commitments, malformed range
+proofs, duplicate asset groups, unknown assets, cross-asset pseudo inputs, and
+issuance commitments that disagree with the declared supply. Malformed curve
+proofs are converted to deterministic validation failure rather than escaping
+as exceptions.
+
+This layer still does **not** authorize ownership or prevent double spends.
+Production integration requires ring-member references resolved against stored
+asset outputs, asset key images, a domain-separated CLSAG transcript, canonical
+wire serialization, and database indexes for unspent output lookup. Until that
+layer exists and is reviewed, these proofs cannot make an asset transaction
+valid on any Monzero network.
+
## 7. Metadata
Consensus stores only bounded identity and commitment fields. Descriptions,
diff --git a/docs/MONZERO_PHASE0_STABILIZATION.md b/docs/MONZERO_PHASE0_STABILIZATION.md
index 13ce9ef20..4b9a37e72 100644
--- a/docs/MONZERO_PHASE0_STABILIZATION.md
+++ b/docs/MONZERO_PHASE0_STABILIZATION.md
@@ -133,6 +133,8 @@ isolated disposable development network may be used after cryptographic review.
- [x] Atomic ordered block-issuance adapter, detach behavior, and deterministic state commitments.
- [x] Detached versioned transaction extension bound to a native prefix commitment.
- [x] Atomic block-extension validation against independently supplied native carrier commitments.
+- [x] Persistent LMDB issuance registry with restart reconstruction and native reorg rollback.
+- [x] Inactive per-asset Pedersen/Bulletproof+ conservation and burn verifier.
- [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/CMakeLists.txt b/src/cryptonote_basic/CMakeLists.txt
index 91612168c..84e86803c 100644
--- a/src/cryptonote_basic/CMakeLists.txt
+++ b/src/cryptonote_basic/CMakeLists.txt
@@ -45,6 +45,7 @@ target_link_libraries(cryptonote_format_utils_basic
)
set(cryptonote_basic_sources
+ asset_confidential.cpp
account.cpp
asset_types.cpp
connection_context.cpp
diff --git a/src/cryptonote_basic/asset_confidential.cpp b/src/cryptonote_basic/asset_confidential.cpp
new file mode 100644
index 000000000..d2473bea1
--- /dev/null
+++ b/src/cryptonote_basic/asset_confidential.cpp
@@ -0,0 +1,131 @@
+#include "asset_confidential.h"
+
+#include <cstring>
+
+#include "ringct/bulletproofs_plus.h"
+#include "ringct/rctOps.h"
+
+namespace cryptonote
+{
+namespace assets
+{
+namespace
+{
+ bool fail(std::string* error, const std::string& message)
+ {
+ if (error)
+ *error = message;
+ return false;
+ }
+
+ bool valid_points(const rct::keyV& points)
+ {
+ for (const rct::key& point : points)
+ if (!rct::isInMainSubgroup(point))
+ return false;
+ return true;
+ }
+}
+
+bool verify_confidential_asset_balance(const confidential_asset_balance& balance, std::string* error)
+{
+ if (balance.asset_id == crypto::null_hash)
+ return fail(error, "confidential balance has a zero asset id");
+ if (balance.pseudo_inputs.empty())
+ return fail(error, "confidential balance has no pseudo inputs");
+ if (balance.outputs.empty() && balance.burns.empty())
+ return fail(error, "confidential balance has no outputs or burns");
+ if (balance.pseudo_inputs.size() > MAX_CONFIDENTIAL_ASSET_INPUTS
+ || balance.outputs.size() + balance.burns.size() > MAX_CONFIDENTIAL_ASSET_OUTPUTS)
+ return fail(error, "confidential balance exceeds input or output limits");
+ rct::keyV input_commitments;
+ input_commitments.reserve(balance.pseudo_inputs.size());
+ for (const confidential_pseudo_input& input : balance.pseudo_inputs)
+ {
+ if (input.source_asset_id != balance.asset_id)
+ return fail(error, "pseudo input crosses asset balance domains");
+ input_commitments.push_back(input.commitment);
+ }
+ if (!valid_points(input_commitments) || !valid_points(balance.outputs) || !valid_points(balance.burns))
+ return fail(error, "confidential balance contains an invalid curve point");
+
+ const size_t commitment_count = balance.outputs.size() + balance.burns.size();
+ size_t proof_commitments = 0;
+ rct::keyV proven_commitments;
+ proven_commitments.reserve(commitment_count);
+ for (const rct::BulletproofPlus& proof : balance.range_proofs)
+ {
+ if (proof.V.empty() || proof_commitments > commitment_count - proof.V.size())
+ return fail(error, "range proofs do not cover the declared commitments exactly");
+ try
+ {
+ if (!rct::bulletproof_plus_VERIFY(proof))
+ return fail(error, "invalid asset Bulletproof+");
+ }
+ catch (const std::exception&)
+ {
+ return fail(error, "malformed asset Bulletproof+");
+ }
+ for (const rct::key& scaled : proof.V)
+ {
+ proven_commitments.push_back(rct::scalarmult8(scaled));
+ }
+ proof_commitments += proof.V.size();
+ }
+ if (proof_commitments != commitment_count)
+ return fail(error, "range proofs do not cover every output and burn");
+ for (size_t index = 0; index < balance.outputs.size(); ++index)
+ if (!rct::equalKeys(proven_commitments[index], balance.outputs[index]))
+ return fail(error, "range proof output commitment mismatch");
+ for (size_t index = 0; index < balance.burns.size(); ++index)
+ if (!rct::equalKeys(proven_commitments[balance.outputs.size() + index], balance.burns[index]))
+ return fail(error, "range proof burn commitment mismatch");
+
+ const rct::key input_sum = rct::addKeys(input_commitments);
+ rct::keyV destinations = balance.outputs;
+ destinations.insert(destinations.end(), balance.burns.begin(), balance.burns.end());
+ const rct::key output_sum = rct::addKeys(destinations);
+ if (!rct::equalKeys(input_sum, output_sum))
+ return fail(error, "asset commitments do not conserve value");
+ return true;
+}
+
+bool verify_confidential_asset_transaction(
+ const std::vector<confidential_asset_balance>& balances,
+ const std::set<crypto::hash>& known_assets,
+ const boost::optional<issuance_descriptor>& issuance,
+ std::string* error)
+{
+ if (balances.empty())
+ return fail(error, "confidential asset transaction is empty");
+ std::set<crypto::hash> seen;
+ crypto::hash issued_id{};
+ if (issuance && !derive_asset_id(*issuance, issued_id, error))
+ return false;
+ bool found_issuance = false;
+ for (const confidential_asset_balance& balance : balances)
+ {
+ if (!seen.insert(balance.asset_id).second)
+ return fail(error, "asset appears in more than one balance group");
+ const bool is_issuance = issuance && balance.asset_id == issued_id;
+ if (!is_issuance && known_assets.count(balance.asset_id) == 0)
+ return fail(error, "confidential balance references an unknown asset");
+ if (is_issuance)
+ {
+ if (known_assets.count(balance.asset_id) != 0)
+ return fail(error, "confidential issuance duplicates an existing asset");
+ if (balance.pseudo_inputs.size() != 1
+ || !rct::equalKeys(balance.pseudo_inputs.front().commitment, rct::commit(issuance->atomic_supply, rct::zero()))
+ || balance.pseudo_inputs.front().source_asset_id != balance.asset_id)
+ return fail(error, "issuance pseudo input does not commit to the fixed supply");
+ found_issuance = true;
+ }
+ if (!verify_confidential_asset_balance(balance, error))
+ return false;
+ }
+ if (issuance && !found_issuance)
+ return fail(error, "issuance has no matching confidential balance group");
+ return true;
+}
+}
+}
diff --git a/src/cryptonote_basic/asset_confidential.h b/src/cryptonote_basic/asset_confidential.h
new file mode 100644
index 000000000..d79efb56f
--- /dev/null
+++ b/src/cryptonote_basic/asset_confidential.h
@@ -0,0 +1,42 @@
+#pragma once
+
+#include <set>
+#include <string>
+#include <vector>
+
+#include "asset_types.h"
+#include "ringct/rctTypes.h"
+
+namespace cryptonote
+{
+namespace assets
+{
+ constexpr size_t MAX_CONFIDENTIAL_ASSET_INPUTS = 16;
+ constexpr size_t MAX_CONFIDENTIAL_ASSET_OUTPUTS = 16;
+
+ struct confidential_pseudo_input
+ {
+ crypto::hash source_asset_id{};
+ rct::key commitment{};
+ };
+
+ struct confidential_asset_balance
+ {
+ crypto::hash asset_id{};
+ std::vector<confidential_pseudo_input> pseudo_inputs;
+ rct::keyV outputs;
+ rct::keyV burns;
+ std::vector<rct::BulletproofPlus> range_proofs;
+ };
+
+ bool verify_confidential_asset_balance(
+ const confidential_asset_balance& balance,
+ std::string* error = nullptr);
+
+ bool verify_confidential_asset_transaction(
+ const std::vector<confidential_asset_balance>& balances,
+ const std::set<crypto::hash>& known_assets,
+ const boost::optional<issuance_descriptor>& issuance,
+ std::string* error = nullptr);
+}
+}
diff --git a/tests/unit_tests/CMakeLists.txt b/tests/unit_tests/CMakeLists.txt
index 16e498ae9..b3328fe74 100644
--- a/tests/unit_tests/CMakeLists.txt
+++ b/tests/unit_tests/CMakeLists.txt
@@ -30,6 +30,7 @@ set(unit_tests_sources
account.cpp
apply_permutation.cpp
address_from_url.cpp
+ asset_confidential.cpp
asset_types.cpp
base58.cpp
blockchain_db.cpp
diff --git a/tests/unit_tests/asset_confidential.cpp b/tests/unit_tests/asset_confidential.cpp
new file mode 100644
index 000000000..2b3980758
--- /dev/null
+++ b/tests/unit_tests/asset_confidential.cpp
@@ -0,0 +1,96 @@
+#include "gtest/gtest.h"
+
+#include "cryptonote_basic/asset_confidential.h"
+#include "ringct/bulletproofs_plus.h"
+#include "ringct/rctOps.h"
+
+namespace
+{
+ crypto::hash asset_id(unsigned char value)
+ {
+ crypto::hash id{};
+ id.data[0] = value;
+ return id;
+ }
+
+ cryptonote::assets::confidential_asset_balance make_balance(
+ const crypto::hash& id, uint64_t input_amount,
+ const std::vector<uint64_t>& outputs, const std::vector<uint64_t>& burns)
+ {
+ rct::keyV masks = rct::skvGen(outputs.size() + burns.size());
+ rct::key input_mask = rct::zero();
+ for (const rct::key& mask : masks)
+ sc_add(input_mask.bytes, input_mask.bytes, mask.bytes);
+ cryptonote::assets::confidential_asset_balance balance;
+ balance.asset_id = id;
+ balance.pseudo_inputs.push_back({id, rct::commit(input_amount, input_mask)});
+ size_t index = 0;
+ for (const uint64_t amount : outputs)
+ balance.outputs.push_back(rct::commit(amount, masks[index++]));
+ for (const uint64_t amount : burns)
+ balance.burns.push_back(rct::commit(amount, masks[index++]));
+ std::vector<uint64_t> amounts = outputs;
+ amounts.insert(amounts.end(), burns.begin(), burns.end());
+ balance.range_proofs.push_back(rct::bulletproof_plus_PROVE(amounts, masks));
+ return balance;
+ }
+}
+
+TEST(asset_confidential, verifies_private_transfer_and_explicit_burn)
+{
+ const crypto::hash id = asset_id(1);
+ const auto balance = make_balance(id, 10, {7}, {3});
+ std::string error;
+ ASSERT_TRUE(cryptonote::assets::verify_confidential_asset_balance(balance, &error)) << error;
+ ASSERT_TRUE(cryptonote::assets::verify_confidential_asset_transaction({balance}, {id}, boost::none, &error)) << error;
+}
+
+TEST(asset_confidential, rejects_inflation_and_commitment_substitution)
+{
+ const crypto::hash id = asset_id(2);
+ std::string error;
+ auto inflated = make_balance(id, 10, {11}, {});
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_balance(inflated, &error));
+ auto substituted = make_balance(id, 10, {10}, {});
+ substituted.outputs.front() = rct::commit(10, rct::skGen());
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_balance(substituted, &error));
+ auto malformed = make_balance(id, 10, {10}, {});
+ malformed.range_proofs.front().A.bytes[0] ^= 1;
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_balance(malformed, &error));
+}
+
+TEST(asset_confidential, rejects_cross_asset_and_duplicate_balance_domains)
+{
+ const crypto::hash first = asset_id(3);
+ const crypto::hash second = asset_id(4);
+ std::string error;
+ auto crossed = make_balance(first, 9, {9}, {});
+ crossed.pseudo_inputs.front().source_asset_id = second;
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_balance(crossed, &error));
+ const auto valid = make_balance(first, 9, {9}, {});
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction({valid, valid}, {first}, boost::none, &error));
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction({valid}, {second}, boost::none, &error));
+}
+
+TEST(asset_confidential, validates_fixed_supply_issuance_commitment)
+{
+ crypto::public_key issuer{};
+ crypto::secret_key secret{};
+ crypto::generate_keys(issuer, secret);
+ cryptonote::assets::issuance_descriptor descriptor;
+ descriptor.network = cryptonote::TESTNET;
+ descriptor.issuer_key = issuer;
+ descriptor.atomic_supply = 10;
+ descriptor.issuance_nonce.data[0] = 9;
+ crypto::hash id{};
+ ASSERT_TRUE(cryptonote::assets::derive_asset_id(descriptor, id));
+ cryptonote::assets::confidential_asset_balance balance;
+ balance.asset_id = id;
+ balance.pseudo_inputs.push_back({id, rct::commit(10, rct::zero())});
+ balance.outputs.push_back(rct::commit(10, rct::zero()));
+ balance.range_proofs.push_back(rct::bulletproof_plus_PROVE(10, rct::zero()));
+ std::string error;
+ ASSERT_TRUE(cryptonote::assets::verify_confidential_asset_transaction({balance}, {}, descriptor, &error)) << error;
+ balance.pseudo_inputs.front().commitment = rct::commit(11, rct::zero());
+ EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction({balance}, {}, descriptor, &error));
+}