What changed, and why it matters
This commit adds stricter limits during deserialization of Monero transactions. It prevents attackers from sending malformed transactions with absurdly large numbers of inputs, outputs, or ring-member references, which could previously exhaust memory or CPU during parsing. The change moves some safety checks earlier in the process and makes them mandatory for all deserialization paths, not just the main transaction-parsing helpers.
Treat this as a security hardening patch and include it in the next release. Nodes, wallets, and any tools parsing untrusted transaction blobs should be updated. Review whether RPC or P2P endpoints can still receive large blobs before reaching these checks, and consider additional resource limits at the network layer.
Security signals we found
Deserialization-time bounds checking added for container sizes
Mandatory caps on vin, vout, and key_offsets counts
Coinbase input structure enforced during parsing
Integer overflow check on cumulative key_offsets
Removal of redundant post-deserialization size checks
Unit tests changed from expecting success to expecting failure for invalid multi-txin_gen transactions
Evidence from the diff
The patch introduces hard ceilings on vin/vout/key_offsets counts and enforces them inside the serialization layer. A new deserialize_vin method validates transaction inputs while reading them: it caps total input count, rejects non-coinbase transactions that are not txin_to_key, allows only one txin_gen for coinbase, and bounds cumulative key_offsets. CONTAINER_FIELD_CAPPED and do_serialize_container now accept a max_cnt parameter. Existing post-parse checks in cryptonote_format_utils.cpp are removed because the new deserialization-time checks subsume them. Unit tests are updated to expect parse failures for previously accepted invalid forms (e.g., multiple txin_gen).
Changed components
src/cryptonote_basic/cryptonote_basic.hsrc/serialization/container.hsrc/serialization/containers.hsrc/serialization/serialization.hsrc/cryptonote_basic/cryptonote_format_utils.cppsrc/cryptonote_core/blockchain.cppsrc/cryptonote_core/tx_sanity_check.cppsrc/wallet/wallet2.cppsrc/simplewallet/simplewallet.cppsrc/serialization/json_object.cppInspect captured patch +136 / −66
diff --git a/src/cryptonote_basic/cryptonote_basic.h b/src/cryptonote_basic/cryptonote_basic.h
index 4639da3..5abe141 100644
--- a/src/cryptonote_basic/cryptonote_basic.h
+++ b/src/cryptonote_basic/cryptonote_basic.h
@@ -52,6 +52,13 @@
namespace cryptonote
{
+ // These are guaranteed ceilings for the entire chain. Some fork rules may limit some further.
+ constexpr size_t COINBASE_VIN_COUNT = 1;
+ constexpr size_t MAX_VIN_COUNT = CRYPTONOTE_MAX_TX_SIZE / sizeof(crypto::key_image);
+ constexpr size_t MAX_NON_COINBASE_VOUT_COUNT = CRYPTONOTE_MAX_TX_SIZE / sizeof(crypto::public_key);
+ constexpr size_t MAX_COINBASE_VOUT_COUNT = /*LEVIN_DEFAULT_MAX_PACKET_SIZE=*/100000000 / sizeof(crypto::public_key);
+ constexpr size_t MAX_TOTAL_KEY_OFFSETS = CRYPTONOTE_MAX_TX_SIZE / sizeof(crypto::public_key);
+
/* outputs */
struct txout_to_script
@@ -139,7 +146,7 @@ namespace cryptonote
BEGIN_SERIALIZE_OBJECT()
VARINT_FIELD(amount)
- FIELD(key_offsets)
+ CONTAINER_FIELD_CAPPED(key_offsets, MAX_TOTAL_KEY_OFFSETS)
FIELD(k_image)
END_SERIALIZE()
};
@@ -179,11 +186,102 @@ namespace cryptonote
VARINT_FIELD(version)
if((version == 0 || CURRENT_TRANSACTION_VERSION < version)) return false;
VARINT_FIELD(unlock_time)
- FIELD(vin)
- FIELD(vout)
+ if constexpr (W)
+ {
+ // Serializing...
+ FIELD(vin)
+ FIELD(vout)
+ }
+ else
+ {
+ // De-serializing...
+ // vin
+ if (!this->deserialize_vin(ar))
+ {
+ ar.set_fail();
+ return false;
+ }
+
+ // vout
+ const size_t MAX_VOUT_COUNT = (this->is_coinbase() || vin.empty()) ? MAX_COINBASE_VOUT_COUNT : MAX_NON_COINBASE_VOUT_COUNT;
+ CONTAINER_FIELD_CAPPED(vout, MAX_VOUT_COUNT)
+ }
FIELD(extra)
END_SERIALIZE()
+ //-----------------------------------------------------------------------
+ bool is_coinbase() const
+ {
+ if (vin.size() != COINBASE_VIN_COUNT)
+ return false;
+
+ if (vin.at(0).type() != typeid(txin_gen))
+ return false;
+
+ return true;
+ }
+ //-----------------------------------------------------------------------
+ private:
+ // Do some validation on vin before doing allocations and reading the archive:
+ // 1. Can't exceed MAX_VIN_COUNT.
+ // 2. If coinbase, only 1 txin_gen.
+ // 3. If not coinbase: type txin_to_key (see check_inputs_types_supported).
+ // 4. If not coinbase: key offsets don't exceed max allowed for whole tx.
+ // Note: can't enforce non-empty because wallets may store empty vin.
+ template<template <bool> class Archive>
+ bool deserialize_vin(Archive<false> &ar)
+ {
+ auto &v = vin;
+ size_t cnt = 0;
+ ar.begin_array(cnt);
+ if (!ar.good())
+ return false;
+ v.clear();
+
+ if (ar.remaining_bytes() < cnt)
+ return false;
+
+ // 1. Can't exceed MAX_VIN_COUNT.
+ if (cnt > MAX_VIN_COUNT)
+ return false;
+
+ ::serialization::detail::do_reserve(v, cnt, ar.remaining_bytes());
+
+ size_t total_key_offsets = 0;
+ for (size_t i = 0; i < cnt; ++i) {
+ if (i > 0)
+ ar.delimit_array();
+ if (!::serialization::detail::serialize_container_element(ar, v.emplace_back()))
+ return false;
+ if (!ar.good())
+ return false;
+
+ // 2. If coinbase, only 1 txin_gen.
+ const auto &in = v.back();
+ const bool coinbase = i == 0 && in.type() == typeid(txin_gen);
+ if (coinbase && cnt > COINBASE_VIN_COUNT)
+ return false;
+ if (coinbase)
+ break;
+
+ // 3. If not coinbase: type txin_to_key (see check_inputs_types_supported).
+ if (in.type() != typeid(txin_to_key))
+ return false;
+
+ // 4. If not coinbase: key offsets don't exceed max allowed for whole tx.
+ const std::size_t next_toal_key_offsets = total_key_offsets + boost::get<cryptonote::txin_to_key>(in).key_offsets.size();
+ if (next_toal_key_offsets < total_key_offsets)
+ return false; // overflow
+ if (next_toal_key_offsets >= MAX_TOTAL_KEY_OFFSETS)
+ return false;
+ total_key_offsets = next_toal_key_offsets;
+ }
+ ar.end_array();
+ v.shrink_to_fit();
+
+ return ar.good();
+ }
+
public:
transaction_prefix(){ set_null(); }
void set_null()
diff --git a/src/cryptonote_basic/cryptonote_basic_impl.cpp b/src/cryptonote_basic/cryptonote_basic_impl.cpp
index 7ba824e..fd4a642 100644
--- a/src/cryptonote_basic/cryptonote_basic_impl.cpp
+++ b/src/cryptonote_basic/cryptonote_basic_impl.cpp
@@ -150,17 +150,6 @@ namespace cryptonote {
return tools::base58::encode_addr(integrated_address_prefix, t_serializable_object_to_blob(iadr));
}
//-----------------------------------------------------------------------
- bool is_coinbase(const transaction_prefix& tx)
- {
- if(tx.vin.size() != 1)
- return false;
-
- if(tx.vin[0].type() != typeid(txin_gen))
- return false;
-
- return true;
- }
- //-----------------------------------------------------------------------
bool get_account_address_from_str(
address_parse_info& info
, network_type nettype
diff --git a/src/cryptonote_basic/cryptonote_basic_impl.h b/src/cryptonote_basic/cryptonote_basic_impl.h
index 15686f4..e74dc20 100644
--- a/src/cryptonote_basic/cryptonote_basic_impl.h
+++ b/src/cryptonote_basic/cryptonote_basic_impl.h
@@ -98,8 +98,6 @@ namespace cryptonote {
, std::function<std::string(const std::string&, const std::vector<std::string>&, bool)> dns_confirm = return_first_address
);
- bool is_coinbase(const transaction_prefix& tx);
-
bool operator ==(const cryptonote::transaction& a, const cryptonote::transaction& b);
bool operator ==(const cryptonote::block& a, const cryptonote::block& b);
diff --git a/src/cryptonote_basic/cryptonote_format_utils.cpp b/src/cryptonote_basic/cryptonote_format_utils.cpp
index 8cad702..0772754 100644
--- a/src/cryptonote_basic/cryptonote_format_utils.cpp
+++ b/src/cryptonote_basic/cryptonote_format_utils.cpp
@@ -96,27 +96,6 @@ namespace cryptonote
return bp_clawback;
}
- std::size_t max_total_key_offsets()
- {
- // This is a 100% guaranteed ceiling for the entire chain
- return get_max_tx_size() / sizeof(crypto::public_key);
- }
-
- bool n_key_offsets_exceeds_max(const transaction_prefix& tx)
- {
- const std::size_t max_allowed = max_total_key_offsets();
- std::size_t total_key_offsets = 0;
- for (const auto &vin : tx.vin)
- {
- if (vin.type() != typeid(cryptonote::txin_to_key))
- continue;
- const std::size_t n_key_offsets = boost::get<cryptonote::txin_to_key>(vin).key_offsets.size();
- CHECK_AND_ASSERT_MES((n_key_offsets + total_key_offsets) >= total_key_offsets, true, "key offsets overflow");
- total_key_offsets += n_key_offsets;
- }
- return total_key_offsets >= max_allowed;
- }
-
bool passes_max_size_check(const bool max_size_check, const blobdata_ref &tx_blob)
{
if (!max_size_check)
@@ -144,7 +123,7 @@ namespace cryptonote
bool expand_transaction_1(transaction &tx, bool base_only)
{
- if (tx.version >= 2 && !is_coinbase(tx))
+ if (tx.version >= 2 && !tx.is_coinbase())
{
rct::rctSig &rv = tx.rct_signatures;
if (rv.type == rct::RCTTypeNull)
@@ -228,7 +207,6 @@ namespace cryptonote
binary_archive<false> ba{epee::strspan<std::uint8_t>(tx_blob)};
bool r = ::serialization::serialize(ba, tx);
CHECK_AND_ASSERT_MES(r, false, "Failed to parse transaction from blob");
- CHECK_AND_ASSERT_MES(!n_key_offsets_exceeds_max(tx), false, "Transaction contains too many ring members");
CHECK_AND_ASSERT_MES(expand_transaction_1(tx, false), false, "Failed to expand transaction data");
tx.invalidate_hashes();
tx.set_blob_size(tx_blob.size());
@@ -241,7 +219,6 @@ namespace cryptonote
binary_archive<false> ba{epee::strspan<std::uint8_t>(tx_blob)};
bool r = tx.serialize_base(ba);
CHECK_AND_ASSERT_MES(r, false, "Failed to parse transaction from blob");
- CHECK_AND_ASSERT_MES(!n_key_offsets_exceeds_max(tx), false, "Transaction contains too many ring members");
CHECK_AND_ASSERT_MES(expand_transaction_1(tx, true), false, "Failed to expand transaction data");
tx.invalidate_hashes();
return true;
@@ -253,7 +230,6 @@ namespace cryptonote
binary_archive<false> ba{epee::strspan<std::uint8_t>(tx_blob)};
bool r = ::serialization::serialize_noeof(ba, tx);
CHECK_AND_ASSERT_MES(r, false, "Failed to parse transaction prefix from blob");
- CHECK_AND_ASSERT_MES(!n_key_offsets_exceeds_max(tx), false, "Transaction contains too many ring members");
return true;
}
//---------------------------------------------------------------
@@ -263,7 +239,6 @@ namespace cryptonote
binary_archive<false> ba{epee::strspan<std::uint8_t>(tx_blob)};
bool r = ::serialization::serialize(ba, tx);
CHECK_AND_ASSERT_MES(r, false, "Failed to parse transaction from blob");
- CHECK_AND_ASSERT_MES(!n_key_offsets_exceeds_max(tx), false, "Transaction contains too many ring members");
CHECK_AND_ASSERT_MES(expand_transaction_1(tx, false), false, "Failed to expand transaction data");
tx.invalidate_hashes();
tx.set_blob_size(tx_blob.size());
diff --git a/src/cryptonote_core/blockchain.cpp b/src/cryptonote_core/blockchain.cpp
index 2c6942c..f2a231a 100644
--- a/src/cryptonote_core/blockchain.cpp
+++ b/src/cryptonote_core/blockchain.cpp
@@ -618,7 +618,7 @@ block Blockchain::pop_block_from_blockchain(bool keep_txs)
++pruned;
continue;
}
- if (!is_coinbase(tx))
+ if (!tx.is_coinbase())
{
cryptonote::tx_verification_context tvc = AUTO_VAL_INIT(tvc);
diff --git a/src/cryptonote_core/tx_sanity_check.cpp b/src/cryptonote_core/tx_sanity_check.cpp
index 66e6521..d56cfa3 100644
--- a/src/cryptonote_core/tx_sanity_check.cpp
+++ b/src/cryptonote_core/tx_sanity_check.cpp
@@ -49,7 +49,7 @@ bool tx_sanity_check(const cryptonote::blobdata &tx_blob, uint64_t rct_outs_avai
return false;
}
- if (cryptonote::is_coinbase(tx))
+ if (tx.is_coinbase())
{
MERROR("Transaction is coinbase");
return false;
diff --git a/src/serialization/container.h b/src/serialization/container.h
index 5d2ac00..fbe0868 100644
--- a/src/serialization/container.h
+++ b/src/serialization/container.h
@@ -113,7 +113,7 @@ namespace serialization
}
template <template <bool> class Archive, typename C>
-bool do_serialize_container(Archive<false> &ar, C &v)
+bool do_serialize_container(Archive<false> &ar, C &v, size_t max_cnt)
{
size_t cnt;
ar.begin_array(cnt);
@@ -121,8 +121,8 @@ bool do_serialize_container(Archive<false> &ar, C &v)
return false;
v.clear();
- // very basic sanity check
- if (ar.remaining_bytes() < cnt) {
+ // very basic sanity checks
+ if (max_cnt < cnt || ar.remaining_bytes() < cnt) {
ar.set_fail();
return false;
}
@@ -144,9 +144,13 @@ bool do_serialize_container(Archive<false> &ar, C &v)
}
template <template <bool> class Archive, typename C>
-bool do_serialize_container(Archive<true> &ar, C &v)
+bool do_serialize_container(Archive<true> &ar, C &v, size_t max_cnt)
{
size_t cnt = v.size();
+ if (cnt > max_cnt) {
+ ar.set_fail();
+ return false;
+ }
ar.begin_array(cnt);
for (auto i = v.begin(); i != v.end(); ++i)
{
diff --git a/src/serialization/containers.h b/src/serialization/containers.h
index 196bc5a..3159128 100644
--- a/src/serialization/containers.h
+++ b/src/serialization/containers.h
@@ -57,7 +57,7 @@ namespace serialization
template <class Archive, class Container>
std::enable_if_t<::serialization::is_container<Container>::value, bool>
-do_serialize(Archive &ar, Container &c)
+do_serialize(Archive &ar, Container &c, size_t max_cnt = std::numeric_limits<size_t>::max())
{
- return ::do_serialize_container(ar, c);
+ return ::do_serialize_container(ar, c, max_cnt);
}
diff --git a/src/serialization/json_object.cpp b/src/serialization/json_object.cpp
index ad954ee..9f07443 100644
--- a/src/serialization/json_object.cpp
+++ b/src/serialization/json_object.cpp
@@ -303,7 +303,7 @@ void fromJsonValue(const rapidjson::Value& val, cryptonote::transaction& tx)
}
const auto& rsig = tx.rct_signatures;
- if (!cryptonote::is_coinbase(tx) && rsig.p.bulletproofs.empty() && rsig.p.bulletproofs_plus.empty() && rsig.p.rangeSigs.empty() && rsig.p.MGs.empty() && rsig.get_pseudo_outs().empty() && sigs == val.MemberEnd())
+ if (!tx.is_coinbase() && rsig.p.bulletproofs.empty() && rsig.p.bulletproofs_plus.empty() && rsig.p.rangeSigs.empty() && rsig.p.MGs.empty() && rsig.get_pseudo_outs().empty() && sigs == val.MemberEnd())
tx.pruned = true;
}
diff --git a/src/serialization/serialization.h b/src/serialization/serialization.h
index 4287bb2..0bd990c 100644
--- a/src/serialization/serialization.h
+++ b/src/serialization/serialization.h
@@ -293,6 +293,16 @@ inline auto do_serialize(Archive &ar, T &v, Args&&... args)
if (!ar.good()) return false; \
} while(0);
+/*! \macro CONTAINER_FIELD_CAPPED(f, c)
+ *
+ * \brief tags the field with the variable name and then serializes it
+ */
+#define CONTAINER_FIELD_CAPPED(f, c) \
+ do { \
+ ar.tag(#f); \
+ bool r = do_serialize(ar, f, c); \
+ if (!r || !ar.good()) return false; \
+ } while(0);
namespace serialization {
/*! \namespace detail
diff --git a/src/simplewallet/simplewallet.cpp b/src/simplewallet/simplewallet.cpp
index 9937407..f829f7c 100644
--- a/src/simplewallet/simplewallet.cpp
+++ b/src/simplewallet/simplewallet.cpp
@@ -5479,7 +5479,7 @@ void simple_wallet::on_money_received(uint64_t height, const crypto::hash &txid,
message_writer(console_color_red, false) <<
tr("WARNING: this transaction uses an unencrypted payment ID: these are obsolete and ignored. Use subaddresses instead.");
}
- if (unlock_time && !cryptonote::is_coinbase(tx))
+ if (unlock_time && !tx.is_coinbase())
message_writer() << tr("NOTE: This transaction is locked, see details with: show_transfer ") + epee::string_tools::pod_to_hex(txid);
if (m_auto_refresh_refreshing)
m_cmd_binder.print_prompt();
diff --git a/src/wallet/wallet2.cpp b/src/wallet/wallet2.cpp
index 86eba93..a7acdb2 100644
--- a/src/wallet/wallet2.cpp
+++ b/src/wallet/wallet2.cpp
@@ -1750,7 +1750,7 @@ void wallet2::sort_scan_tx_entries(std::vector<process_tx_entry_t> &unsorted_tx_
COMMAND_RPC_GET_BLOCKS_BY_HEIGHT::response res;
for (const auto & tx_info : unsorted_tx_entries)
{
- if (!tx_info.tx_entry.in_pool && !cryptonote::is_coinbase(tx_info.tx))
+ if (!tx_info.tx_entry.in_pool && !tx_info.tx.is_coinbase())
{
const uint64_t height = tx_info.tx_entry.block_height;
if (entry_heights.find(height) == entry_heights.end())
@@ -1796,9 +1796,9 @@ void wallet2::sort_scan_tx_entries(std::vector<process_tx_entry_t> &unsorted_tx_
else // l.tx_entry.block_height == r.tx_entry.block_height
{
// coinbase tx is the first tx in a block
- if (cryptonote::is_coinbase(r.tx))
+ if (r.tx.is_coinbase())
return false;
- if (cryptonote::is_coinbase(l.tx))
+ if (l.tx.is_coinbase())
return true;
// in case std::sort is comparing elem to itself
@@ -1849,7 +1849,7 @@ void wallet2::process_scan_txs(const tx_entry_data &txs_to_scan, const tx_entry_
tx_entry.block_height,
0,
tx_entry.block_timestamp,
- cryptonote::is_coinbase(tx_info.tx),
+ tx_info.tx.is_coinbase(),
tx_entry.in_pool,
tx_entry.double_spend_seen,
{}, {}, // unused caches
@@ -13423,7 +13423,7 @@ uint64_t wallet2::import_key_images(const std::vector<std::pair<crypto::key_imag
THROW_WALLET_EXCEPTION_IF(!r, error::wallet_internal_error, "Failed to generate key derivation");
}
size_t output_index = 0;
- bool miner_tx = cryptonote::is_coinbase(spent_tx);
+ bool miner_tx = spent_tx.is_coinbase();
for (const cryptonote::tx_out& out : spent_tx.vout)
{
tx_scan_info_t tx_scan_info;
@@ -13664,7 +13664,7 @@ void wallet2::process_background_cache(const background_sync_data_t &background_
MDEBUG("Processing background synced tx " << bgs_tx.first);
process_new_transaction(bgs_tx.first, bgs_tx.second.tx, bgs_tx.second.output_indices, bgs_tx.second.height, 0, bgs_tx.second.block_timestamp,
- cryptonote::is_coinbase(bgs_tx.second.tx), false/*pool*/, bgs_tx.second.double_spend_seen, {}, {}, true/*ignore_callbacks*/);
+ bgs_tx.second.tx.is_coinbase(), false/*pool*/, bgs_tx.second.double_spend_seen, {}, {}, true/*ignore_callbacks*/);
// Re-set destination addresses if they were previously set
if (m_confirmed_txs.find(bgs_tx.first) != m_confirmed_txs.end() &&
diff --git a/tests/unit_tests/json_serialization.cpp b/tests/unit_tests/json_serialization.cpp
index 5cdc7cc..172d1af 100644
--- a/tests/unit_tests/json_serialization.cpp
+++ b/tests/unit_tests/json_serialization.cpp
@@ -232,7 +232,7 @@ namespace
CHECK_AND_ASSERT_MES(tx_document.HasMember("signatures"), false, "compare_tx_to_json_tx: missing signatures");
CHECK_AND_ASSERT_MES(tx_document["signatures"].IsArray(), false, "compare_tx_to_json_tx: signatures is not array");
- if (!cryptonote::is_coinbase(tx))
+ if (!tx.is_coinbase())
{
CHECK_AND_ASSERT_MES(tx_document["signatures"].Size() == tx.signatures.size(), false, "compare_tx_to_json_tx: signatures wrong size");
for (size_t i = 0; i < tx.signatures.size(); ++i)
@@ -259,7 +259,7 @@ namespace
CHECK_AND_ASSERT_MES(jrv["type"].IsUint(), false, "compare_tx_to_json_tx: rct_signatures type is not int");
CHECK_AND_ASSERT_MES(jrv["type"].GetUint() == tx.rct_signatures.type, false, "compare_tx_to_json_tx: rct_signatures wrong type");
- if (!cryptonote::is_coinbase(tx))
+ if (!tx.is_coinbase())
{
// txnFee
CHECK_AND_ASSERT_MES(jrv.HasMember("txnFee"), false, "compare_tx_to_json_tx: rct_signatures has no fee");
diff --git a/tests/unit_tests/serialization.cpp b/tests/unit_tests/serialization.cpp
index 3c9433c..806cca9 100644
--- a/tests/unit_tests/serialization.cpp
+++ b/tests/unit_tests/serialization.cpp
@@ -461,9 +461,7 @@ TEST(Serialization, serializes_transacion_signatures_correctly)
tx.invalidate_hashes();
ASSERT_TRUE(serialization::dump_binary(tx, blob));
ASSERT_EQ(9, blob.size()); // 5 bytes + 2 * 2 bytes vins + 0 bytes extra + 0 bytes signatures
- ASSERT_TRUE(serialization::parse_binary(blob, tx1));
- ASSERT_EQ(tx, tx1);
- ASSERT_EQ(linearize_vector2(tx.signatures), linearize_vector2(tx1.signatures));
+ ASSERT_FALSE(serialization::parse_binary(blob, tx1));
// Two txin_gen, signatures vector contains only one empty element
tx.signatures.resize(1);
@@ -475,9 +473,7 @@ TEST(Serialization, serializes_transacion_signatures_correctly)
tx.invalidate_hashes();
ASSERT_TRUE(serialization::dump_binary(tx, blob));
ASSERT_EQ(9, blob.size()); // 5 bytes + 2 * 2 bytes vins + 0 bytes extra + 0 bytes signatures
- ASSERT_TRUE(serialization::parse_binary(blob, tx1));
- ASSERT_EQ(tx, tx1);
- ASSERT_EQ(linearize_vector2(tx.signatures), linearize_vector2(tx1.signatures));
+ ASSERT_FALSE(serialization::parse_binary(blob, tx1));
// Two txin_gen, signatures vector contains three empty elements
tx.signatures.resize(3);
Why this scored 65/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.