diff --git a/README.md b/README.md index a584e5a0e9..447590fe09 100644 --- a/README.md +++ b/README.md @@ -120,5 +120,16 @@ make SimpleWallet By default it will compile portable binary, to build optimized for your CPU, run Cmake with flag `-DARCH=native`. +### Checkpointed Sync and CT Validation + +By default the daemon uses built-in checkpoints and signed DNS checkpoints to +speed up historical sync. In the trusted checkpoint zone, a block still has to +match its checkpoint hash, but the daemon may skip expensive historical checks, +including full CT proof validation and local PoW for checkpointed blocks. + +Run with `--without-checkpoints` to sync without checkpoint shortcuts and +perform full local validation from genesis. See +`docs/CT_CHECKPOINT_TRUST.md` for the CT checkpoint trust model and checkpoint +publishing policy. diff --git a/docs/CT-DESIGN.md b/docs/CT-DESIGN.md new file mode 100644 index 0000000000..6640cc228c --- /dev/null +++ b/docs/CT-DESIGN.md @@ -0,0 +1,371 @@ +# Karbo Confidential Transactions: Design Intent and Threat Model + +## Goal + +**Karbo CT hides amounts, not the transaction graph.** + +This single sentence is the design axis. Every consensus rule, validation +helper, wallet primitive, and exchange-integration surface in the Karbo CT +codebase descends from it. If you find a rule, comment, or design decision in +the codebase that contradicts this statement, that rule is the bug. + +## What this means concretely + +Karbo CT provides: + +- **Amount confidentiality.** A transaction's output values are committed via + Pedersen commitments; the plaintext amount is not on-chain. Range and + canonical-denomination membership are proven by Groth–Kohlweiss (GK) proofs. + An observer cannot read amounts directly off the wire. +- **Confidential-supply integrity.** The on-chain `confidentialSupply` + consensus invariant tracks the visible value held in the CT pool. The + identity + `visible_plain_supply + pq_plain_supply + confidential_supply == already_generated_coins` + is preserved by every block — no transaction can mint coins by routing + through CT. +- **Spend authorization.** Triptych spend proofs (for CT inputs) and classic + ring signatures (for transparent shielding inputs) prove the spender owns + the input being consumed. + +Karbo CT *does not* provide: + +- **Sender anonymity at the level Monero claims.** While CT inputs use rings + for compatibility and as defense-in-depth, the ring is not the load-bearing + privacy primitive. A determined observer with access to the transaction + graph can perform standard chain analysis. Karbo is *not* an attempt to + hide who sent to whom. +- **Receiver anonymity beyond stealth addresses.** Output one-time keys + are derived from view+spend keys, but tag/address association at higher + layers (exchanges, services, public posting) is out of scope for the + protocol to defend. +- **Metadata-free transactions.** Output count, transaction size, fee, + unlock-time, broadcast timing, and propagation patterns are visible. CT + rules do not attempt to hide them. + +If a user requires Monero-level untraceability, Karbo is not the right tool. +That is a deliberate design choice and not a future-roadmap deficiency. + +## Why this threat model + +A "confidential amounts only" tier is genuinely useful and underserved: + +- **Business and commercial transactions** where amounts are competitively + sensitive but counterparties are known. Payroll, contractor payments, + vendor settlement, B2B invoicing, treasury operations. +- **Exchange deposits and withdrawals** where the user wants the deposit + amount hidden from passive chain observers (e.g., who else uses the + same exchange) without requiring the exchange itself to integrate + ring-signature analysis. +- **Recurring payments** where the regular amount would otherwise allow + trivial pattern identification. +- **High-value individual transactions** where the amount is the sensitive + fact, not the participants. + +These use cases get strong amount privacy without paying the costs that +full untraceability imposes: + +- Much smaller and faster transactions (no Triptych-over-everything; output + proofs are bounded by the canonical denomination set, not output count). +- Tractable exchange integration (deposits and withdrawals expose amounts + to the exchange when desired; no special view-key dance required). +- Lower verification cost (no large-ring full-anonymity proofs per output). +- Standard atomic-swap and DEX integration paths remain reachable without + protocol-specific machinery. + +This is the Bitcoin "Confidential Transactions" / Elements (Liquid) / Beam +design tier, not the Monero design tier. It is a coherent niche. + +## Consensus rules that follow from the threat model + +These rules embody the threat model directly. They are documented here so +future contributors do not reintroduce strict-privacy assumptions on the +mistaken belief that Karbo's CT was aiming for Monero parity. + +### Amount privacy (load-bearing) + +- CT outputs (`ConfidentialOutput`) carry `amount == 0` on the wire; the + plaintext value is forbidden. The Pedersen commitment is the only on-chain + representation of the value. *See `checkTransactionConsensusShape` and + `checkConfidentialTransaction`.* +- CT output amounts must be one of the canonical denominations enumerated + in `DENOMINATIONS[0..63]` (10^10 through 10^17 atomic units, in {1..9} per + decade plus the 10^17 cap). The GK proof per output proves canonical + membership. *See `gk_prove` / `gk_verify_batch` and `Denominations.h`.* +- The balance kernel proves `sum(input_commitments) - sum(output_commitments) - fee*H = excess*G` + with a Schnorr signature over the prefix hash. This prevents inflation + via mis-committed amounts. *See `crypto/transaction_balance.{h,cpp}`.* +- Triptych spend proofs (for ConfidentialInput) bind the spender to the + real input among ring members. *See `crypto/triptych.{h,cpp}`.* + +### Graph visibility (intentional) + +- Transaction graph is visible. Inputs reference prior outputs by global + index; this is the same shape as v1 plain transactions. +- Output count is visible. +- Fee is visible (carried in `tx.fee` as a plaintext field for CT + transactions; required because the balance kernel needs the explicit fee + to close). +- Transaction size and broadcast timing are visible. +- Unlock-time is visible (see the next section for the relaxation that + goes with this). + +### Cross-shield boundaries + +- `CN/plain → CT` (shield-in) is allowed. A transparent input is consumed + to fund a CT output. The transparent amount is publicly visible going in; + the resulting CT output's amount is hidden. This is a normal use case. +- `CT → CT` is allowed (the common case). +- `CT → CN/plain` (unshield) is being reopened to support moving CT-held + value to a transparent counterparty (atomic-swap redeem, exchange + deposits to non-CT-aware addresses). Reopening this path is consistent + with the "hide amounts, not graph" threat model — the unshield + necessarily reveals the unshielded amount, but the *prior* CT lifetime + kept it hidden, and the user opts into the disclosure at unshield time. + **Unshield is assigned its own transaction version, `v3`** (the version + ladder is `v1 = CN/plain`, `v2 = CT`, `v3 = CT → CN unshield`, `v4 = PQ`; + see "Transaction version ladder" below). `v3` is a CT-aware version that + permits **mixed outputs** — `ConfidentialOutput` and `KeyOutput` in the + same transaction — and so covers pure unshield, **partial unshield** (CT + change + plain payout in one tx, for CEX-deposit ergonomics), and shield + from one shape. The reason for a version *bump* rather than relaxing `v2` + in place is isolation: `v2` stays strictly all-confidential outputs, so the + ordinary shielded-payment hot path never reaches the mixed-output / + mixed-balance code, and a bug in that new path cannot be triggered by a + vanilla shielded send. + **The *spend* path is identical to `v2 CT → CT`:** the confidential value + being consumed is still spent by a `ConfidentialInput` (same Triptych + proof, same key image `J = x·U`), and mixed *inputs* (`KeyInput` + + `ConfidentialInput`) already exist in `v2`, so `v3` inherits input handling + verbatim. The one genuinely new consensus surface is the **plain-output + term in the balance kernel** — see "v3 unshield: scope and the balance + kernel" below. The key-image invariant in the next subsection holds + identically across `v1`/`v2`/`v3`. + Detailed working notes (swap construction, adversarial test set) live in + `karbo-swaps-and-ct-to-cn.md`. + +### Transaction version ladder + +| Version | Meaning | Status | +|---------|---------|--------| +| `v1` | CN / plain transparent (`CURRENT_TRANSACTION_VERSION`) | shipped | +| `v2` | CT — confidential outputs, Triptych spends (`TRANSACTION_VERSION_CT`) | shipped (dev/ct) | +| `v3` | CT → CN unshield (confidential input → transparent output) | planned | +| `v4` | PQ Phase 1 (`TRANSACTION_VERSION_PQ`, post-quantum family) | planned | + +`v1` and `v2` are the only values defined in `src/CryptoNoteConfig.h` today. +`v3` (unshield) and `v4` (PQ) are reserved by this ladder for their +respective follow-up passes. Note these are *transaction* versions and are +orthogonal to the *block-major* fork versions (CT activates at block-major +`v6`, PQ-plain at `v7`). + +### v3 unshield: scope and the balance kernel + +The mechanical `v3` plumbing is small (admit version 3; relax the +output-uniformity check to allow `KeyOutput` alongside `ConfidentialOutput`; +index plain outputs in the normal global-output index so they're later +spendable as ordinary transparent ring-1 outputs; apply the fee as a plain +`·H` term once). The **audit centerpiece** is the balance kernel's new +plain-output term. General form (handles all four directions — shield, +unshield, partial, CT→CT): + +``` +Σ(plain_in)·H + Σ(pseudo-in_CT) − Σ(conf-out) − (Σ plain_out)·H − fee·H ≟ Commit(0) +``` + +Plain inputs and plain outputs touch the **H axis only** (zero blinding on +G). **Critical invariant:** any G-component leaking from a "plain" +input/output is an inflation bug. Supply accounting stays as today, computed +from visible amounts only: `Δconfidential_supply = plain_in − plain_out − +fee`; hidden CT in/out values cancel in the pool; underflow (debit > pool) is +a hard reject. GK/range-proof count must equal `count(ConfidentialOutput)` +and **must accept 0** (a pure unshield has no confidential outputs). + +**Verified against current code (this is genuinely new for v3):** +- `check_outs_valid` ([CryptoNoteFormatUtils.cpp:194](../src/CryptoNoteCore/CryptoNoteFormatUtils.cpp)) + currently *rejects* any non-`ConfidentialOutput` in a `v2` CT tx, so the + balance kernel ([Blockchain.cpp:2968](../src/CryptoNoteCore/Blockchain.cpp)) + safely does `boost::get` on every output. The + `−(Σ plain_out)·H` term has therefore **never run in production** — it is + the spend-from-nothing surface and must be reviewed as if it were the only + thing in the PR. +- Mixed *inputs* already work in `v2`: the kernel's input loop handles + `KeyInput` via `transparent_amount_to_commitment(amount)` (= `amount·H`, + blinding 0) at [Blockchain.cpp:2954](../src/CryptoNoteCore/Blockchain.cpp). + So "mixed inputs are nothing new for v3" is true — but it rests on the + key-image invariant below (TODO-1/TODO-2 in the working notes), which is + now confirmed. +- *Minor cleanup for the implementer:* the comment at + [Core.cpp:455](../src/CryptoNoteCore/Core.cpp) refers to "transparent + change/unshield from a CT tx" and `MIN_CT_DENOMINATION` enforcement on "v2 + mixed outputs," but `check_outs_valid` rejects plain outputs in `v2` — so + that path is aspirational/unreachable today. Reconcile it when `v3` lands. + +Adversarial tests the kernel work must include: pure unshield (0 CT out, 0 GK +proofs); partial unshield (exercises both kernel terms); the +**inflate-the-change** vector (honest `plain_out`, exited value hidden in an +inflated confidential "change" commitment — the kernel must force the change +value via the equation and reject); sign-flipped plain term; fee +double-counted; `Σ plain_out` overflow (checked add, no asserts on the +consensus path); `confidential_supply` underflow; overstated `plain_in` vs +referenced amount; and a round-trip confirming a `v3` `KeyOutput` is later +spendable as a normal `v1` ring-1 transparent spend (closes the loop to swap +funding). + +### Key-image invariant across shield boundaries (double-spend safety) + +> **Route 1 update (key-image binding fix).** The CT key image is now a +> **fixed-generator** tag `J = x·U` (single global NUMS `U`), not the per-key +> `x·Hp(P)`. The earlier Triptych construction did not bind the linking tag to +> the spend key (independent `f_U` witness) and allowed forging distinct valid +> key images for one output — a confidential double-spend. The fix binds the +> tag by reusing the spend response `f_P`, and makes CT rings +> **confidential-output-only**. Authoritative spec + proof: +> `docs/CT-ROUTE1-KEYIMAGE-FIX.md`; regression `tests/forge_ki_poc.cpp`. + +Spending the same output **must** produce the same key image regardless of +which CT transaction form consumes it. This is what makes shield boundaries +safe: the consensus spent-key set collides on a re-spend. + +The invariant rests on four properties: + +1. **One canonical spend path *and one image format* per output.** A + transparent `KeyOutput` is consumable only by a `KeyInput` (image + `x·Hp(P)`); a `ConfidentialOutput` only by a `ConfidentialInput` (image + `J = x·U`). The two never overlap: confidential outputs live in the + sentinel bucket `CT_CONFIDENTIAL_OUTPUT_AMOUNT` (`UINT64_MAX`), transparent + outputs under their real amount bucket, a `KeyInput`'s ring resolves only + `KeyOutput` targets, and a `ConfidentialInput`'s ring is **confidential-only** + (every member must be in the sentinel bucket — transparent outputs are + rejected as CT ring members). So no output is ever spendable by two paths, + hence never produces two different image formats. Consequently neither the + unshield path nor mixed rings may admit a transparent output as a CT + *real* member. + +2. **Key-image determinism within the CT path.** A CT spend's image is + `J = x·U`, depending only on the spend secret `x` — never on the tx version, + the output side (CT→CT / CT→CN unshield), the ring, or the commitment. So a + `ConfidentialInput` spending `P` in a `CT → CT` tx and a `ConfidentialInput` + spending the same `P` in a `CT → CN` unshield emit byte-identical images + (both `x·U`). `triptych_key_image()` is the one primitive on every CT path. + +3. **The Triptych proof binds the same `x`** in both `P = x·G` (P-ring) and + `J = x·U` (linking track), because the linking track **reuses the spend + response `f_P`** rather than an independent inverse witness. The `Xⁿ` + coefficient of the linking equation forces `J = x·U`; `Q_J` spans only + `m outputs; }; -typedef boost::variant transactionInputDetails2; +// Confidential (CT) input detail: amount is hidden, but the ring layout, +// pseudo-commitment and key image are public and useful for explorers. +// ringMembers carries the per-member (amount, outputIndex) tuples needed +// to render mixed transparent/confidential rings; outputs[] holds the +// matching resolved (txHash, outputIndex) pairs in the same order. +struct ConfidentialInputDetails { + Crypto::KeyImage keyImage; + Crypto::EllipticCurvePoint pseudoCommitment; + uint64_t mixin; + std::vector ringMembers; + std::vector outputs; +}; + +typedef boost::variant transactionInputDetails2; struct TransactionExtraDetails2 { std::vector padding; @@ -114,7 +127,8 @@ struct TransactionDetails { uint64_t fee = 0; uint64_t totalInputsAmount = 0; uint64_t totalOutputsAmount = 0; - uint64_t mixin = 0; + uint64_t mixin = 0; // max ring size across inputs (legacy field) + uint64_t minMixin = 0; // min ring size across inputs (CT rings must be a supported power of two: 4, 8, or 16) uint64_t unlockTime = 0; uint64_t timestamp = 0; uint8_t version = 0; @@ -124,9 +138,19 @@ struct TransactionDetails { Crypto::Hash blockHash; uint32_t blockHeight = 0; TransactionExtraDetails2 extra; - std::vector> signatures; + // Per-input authorization, parallel to inputs: + // BaseInput → boost::blank + // KeyInput → std::vector + // ConfidentialInput → CTInputSignature (Triptych spend proof) + std::vector signatures; std::vector inputs; std::vector outputs; + + // CT-family proof body. Empty / value-initialized for non-CT transactions. + // In v3 unshield, ctProofs[i] corresponds to the i-th confidential output, + // not necessarily outputs[i] because transparent payout outputs have no GK proof. + std::vector ctProofs; // per-confidential-output GK denomination membership + TransactionKernel kernel; // balance-equation excess + Schnorr }; struct BlockDetails { @@ -147,6 +171,12 @@ struct BlockDetails { uint64_t blockSize = 0; uint64_t transactionsCumulativeSize = 0; uint64_t alreadyGeneratedCoins = 0; + // Consensus-tracked: total visible value currently locked inside the ECC CT + // pool at this block height. See Blockchain::getConfidentialSupply. + uint64_t confidentialSupply = 0; + // Consensus-tracked: total visible value held by PQ-owned plain outputs. + // Stubbed at 0 today; will become non-zero once PQ-plain activates. + uint64_t pqPlainSupply = 0; uint64_t alreadyGeneratedTransactions = 0; uint64_t sizeMedian = 0; uint64_t effectiveSizeMedian = 0; diff --git a/include/CryptoNote.h b/include/CryptoNote.h index 64ecc2e7c0..c0b1a96c73 100644 --- a/include/CryptoNote.h +++ b/include/CryptoNote.h @@ -1,4 +1,5 @@ // Copyright (c) 2012-2016, The CryptoNote developers, The Bytecoin developers +// Copyright (c) 2016-2026, The Karbo developers // // This file is part of Karbo. // @@ -17,6 +18,7 @@ #pragma once +#include #include #include #include "android.h" @@ -24,6 +26,10 @@ namespace CryptoNote { +// --------------------------------------------------------------------------- +// Inputs +// --------------------------------------------------------------------------- + struct BaseInput { uint32_t blockIndex; }; @@ -34,13 +40,61 @@ struct KeyInput { Crypto::KeyImage keyImage; }; +// Per-ring-member output reference for CT inputs. +// +// Each ring member is self-describing: it names its own amount bucket so a +// single CT input can mix transparent ring members (any KeyOutput amount +// bucket) and confidential ring members (CT_CONFIDENTIAL_OUTPUT_AMOUNT +// sentinel bucket). The outputIndex is the *absolute* global index in that +// amount's LMDB bucket — no delta encoding, since the bucket varies per +// member and delta encoding across buckets is meaningless. +// +// Members within a ConfidentialInput must be sorted by (amount, outputIndex) +// strictly ascending. Same-bucket members must have strictly-ascending +// outputIndex (no duplicates); the cross-bucket ordering provides a canonical +// form that pins ring metadata against malleability and makes the validator +// loop deterministic. +struct RingMemberRef { + uint64_t amount; // CT_CONFIDENTIAL_OUTPUT_AMOUNT for CT members; real amount for transparent + uint32_t outputIndex; // absolute index in that amount's bucket +}; + +// Confidential transaction input (version 2) — prefix portion only. +// Contains the ring of public keys and commitments, a pseudo-output commitment, +// and key image. Triptych spend proofs are stored separately in Transaction body. +// +// Consensus admits confidential-only rings here: each RingMemberRef must +// resolve to a ConfidentialOutput in the CT sentinel bucket. ringMembers[i] +// corresponds to ringPubkeys[i] and ringCommitments[i]; the three vectors are +// parallel and must have equal length. +struct ConfidentialInput { + std::vector ringMembers; // per-member (amount, outputIndex) + std::vector ringPubkeys; // one-time public keys of ring members + std::vector ringCommitments; // Pedersen commitments of ring members + Crypto::EllipticCurvePoint pseudoCommitment; // C' = v*H + r'*G + Crypto::KeyImage keyImage; // J = x * U for CT inputs +}; + +typedef boost::variant TransactionInput; + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- + struct KeyOutput { Crypto::PublicKey key; }; -typedef boost::variant TransactionInput; +// Confidential transaction output (version 2) — prefix portion only. +// Contains a Pedersen commitment and masked amount. GK denomination proofs +// are stored separately in Transaction body. +struct ConfidentialOutput { + Crypto::PublicKey targetKey; // One-time stealth address P = Hs(8aR||idx)*G + B (32 bytes) + Crypto::EllipticCurvePoint commitment; // Pedersen commitment C = v*H + r*G (32 bytes) + std::array maskedAmount; // ECDH-masked denomination (8 bytes) +}; -typedef boost::variant TransactionOutputTarget; +typedef boost::variant TransactionOutputTarget; struct TransactionOutput { uint64_t amount; @@ -49,16 +103,95 @@ struct TransactionOutput { using TransactionInputs = std::vector; +// --------------------------------------------------------------------------- +// CT proof body types (version 2 only — stored in Transaction, not prefix) +// --------------------------------------------------------------------------- + +// Per-input Triptych spend proof — logarithmic linkable one-out-of-many. +// +// All point/scalar vectors below have length n = log2(ring_size). +// Supported ring sizes: 4 (n=2), 8 (n=3), 16 (n=4). On-wire serialization +// rejects any other shape. The struct mirrors Crypto::TriptychSignature +// field-for-field; the verifier reconstructs the in-memory proof by +// decoding each EllipticCurvePoint into ge_p3 form before checking. +struct CTInputSignature { + std::vector I_bits; // bit-decomposition commitments + std::vector A; // bitness aux commitments + std::vector B; // bitness aux commitments + std::vector Q_P; // P-ring polynomial coefficients (G-base) + std::vector Q_M; // M-ring polynomial coefficients (G-base) + std::vector Q_J; // linking-track coefficients ρ_P·U (U-base) + std::vector z; // bit-commitment responses + std::vector za; // opening responses for x·I_bits + A + std::vector zb; // opening responses for (x−z)·I_bits + B + Crypto::EllipticCurveScalar f_P; // spend witness response (reused to bind key image J = x·U) + Crypto::EllipticCurveScalar f_M; // balance witness response +}; + +// Per-output GK denomination membership proof. +// Proves the committed value is one of the 64 canonical denominations. +// 6 points I + 6 points A + 6 points B + 6 points Q = 768 bytes +// 6 scalars z + 6 scalars za + 6 scalars zb + 1 scalar f = 608 bytes +// Total = 1376 bytes. +struct CTOutputProof { + Crypto::EllipticCurvePoint I[6]; // commitments to secret index bits + Crypto::EllipticCurvePoint A[6]; // bit randomness commitments + Crypto::EllipticCurvePoint B[6]; // bit value commitments + Crypto::EllipticCurvePoint Q[6]; // polynomial coefficient commitments + Crypto::EllipticCurveScalar z[6]; // per-bit response scalars + Crypto::EllipticCurveScalar za[6]; // opening responses for I^x * A + Crypto::EllipticCurveScalar zb[6]; // opening responses for I^(x-z) * B + Crypto::EllipticCurveScalar f; // final evaluation scalar +}; + +// Proves the balance equation: sum(C_in) - sum(C_out) - fee*H = excess*G +struct TransactionKernel { + Crypto::EllipticCurvePoint excessCommitment; // excess * G + Crypto::EllipticCurveScalar sigE; // Schnorr signature e + Crypto::EllipticCurveScalar sigS; // Schnorr signature s +}; + +// --------------------------------------------------------------------------- +// TransactionPrefix / Transaction +// --------------------------------------------------------------------------- + struct TransactionPrefix { - uint8_t version; - uint64_t unlockTime; + uint8_t version = 0; + // v1: unlock time; v2 (CT): must be 0. + uint64_t unlockTime = 0; TransactionInputs inputs; std::vector outputs; std::vector extra; + // v2 (CT) only: plaintext fee in atomic units. The v1 path derives fee from + // inputs - outputs and never reads this field, but it must stay zero on v1 + // so any code path that happens to read it on a non-CT tx sees a defined + // value rather than uninitialized memory. + uint64_t fee = 0; }; +// Per-input authorization, shape selected by the matching tx.inputs[i]: +// BaseInput -> std::monostate (coinbase, no sig) +// KeyInput -> std::vector (legacy ring signature) +// ConfidentialInput -> CTInputSignature (Triptych spend proof) +// One entry per input, indexed in lockstep with tx.inputs. The variant +// alternative is implicit from inputs[i].type() — no separate tag on the +// wire — so a mixed v2 tx (KeyInput shielding + ConfidentialInput spend) +// reads as a single self-aligned array instead of two parallel arrays +// with empty-slot sentinels. +typedef boost::variant< + boost::blank, + std::vector, + CTInputSignature +> InputSignatures; + struct Transaction : public TransactionPrefix { - std::vector> signatures; + // Per-input authorization, parallel to inputs. + std::vector signatures; + + // CT-family (v2/v3): output / kernel proof body — separate from prefix so + // getTransactionPrefixHash() excludes them. + std::vector ctProofs; // per-confidential-output GK denomination proofs + TransactionKernel kernel; // balance proof + Schnorr signature }; struct AccountPublicAddress { diff --git a/include/CryptoTypes.h b/include/CryptoTypes.h index b10049dd78..36bbf060bb 100644 --- a/include/CryptoTypes.h +++ b/include/CryptoTypes.h @@ -1,5 +1,5 @@ // Copyright (c) 2012-2018, The CryptoNote developers, The Bytecoin developers -// Copyright (c) 2016-2020, The Karbo developers +// Copyright (c) 2016-2026, The Karbo developers // // This file is part of Karbo. // diff --git a/include/Denominations.h b/include/Denominations.h new file mode 100644 index 0000000000..d944729ad3 --- /dev/null +++ b/include/Denominations.h @@ -0,0 +1,115 @@ +// Copyright (c) 2018-2026, Karbo developers +// +// This file is part of Karbo. +// +// Karbo is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Karbo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Karbo. If not, see . + +#pragma once + +#include +#include +#include +#include +#include + +namespace CryptoNote { + +// Canonical CT denomination set in current Karbo atomic units (1 KRB = 10^12 au). +// Floor = 10^10 au (= 0.01 KRB) so confidential dust is structurally impossible: +// any value below the floor cannot be expressed as a CT output and must remain +// transparent or be absorbed into fee. +// 7 decades of 9 entries each (1-9 × 10^10 through 1-9 × 10^16) plus the +// 100,000 KRB cap (10^17 au) = 64 entries, sorted ascending. +static constexpr size_t DENOMINATION_COUNT = 64; + +static constexpr std::array DENOMINATIONS = {{ + // 0.01 .. 0.09 KRB + UINT64_C(10000000000), UINT64_C(20000000000), UINT64_C(30000000000), + UINT64_C(40000000000), UINT64_C(50000000000), UINT64_C(60000000000), + UINT64_C(70000000000), UINT64_C(80000000000), UINT64_C(90000000000), + // 0.1 .. 0.9 KRB + UINT64_C(100000000000), UINT64_C(200000000000), UINT64_C(300000000000), + UINT64_C(400000000000), UINT64_C(500000000000), UINT64_C(600000000000), + UINT64_C(700000000000), UINT64_C(800000000000), UINT64_C(900000000000), + // 1 .. 9 KRB + UINT64_C(1000000000000), UINT64_C(2000000000000), UINT64_C(3000000000000), + UINT64_C(4000000000000), UINT64_C(5000000000000), UINT64_C(6000000000000), + UINT64_C(7000000000000), UINT64_C(8000000000000), UINT64_C(9000000000000), + // 10 .. 90 KRB + UINT64_C(10000000000000), UINT64_C(20000000000000), UINT64_C(30000000000000), + UINT64_C(40000000000000), UINT64_C(50000000000000), UINT64_C(60000000000000), + UINT64_C(70000000000000), UINT64_C(80000000000000), UINT64_C(90000000000000), + // 100 .. 900 KRB + UINT64_C(100000000000000), UINT64_C(200000000000000), UINT64_C(300000000000000), + UINT64_C(400000000000000), UINT64_C(500000000000000), UINT64_C(600000000000000), + UINT64_C(700000000000000), UINT64_C(800000000000000), UINT64_C(900000000000000), + // 1,000 .. 9,000 KRB + UINT64_C(1000000000000000), UINT64_C(2000000000000000), UINT64_C(3000000000000000), + UINT64_C(4000000000000000), UINT64_C(5000000000000000), UINT64_C(6000000000000000), + UINT64_C(7000000000000000), UINT64_C(8000000000000000), UINT64_C(9000000000000000), + // 10,000 .. 90,000 KRB + UINT64_C(10000000000000000), UINT64_C(20000000000000000), UINT64_C(30000000000000000), + UINT64_C(40000000000000000), UINT64_C(50000000000000000), UINT64_C(60000000000000000), + UINT64_C(70000000000000000), UINT64_C(80000000000000000), UINT64_C(90000000000000000), + // 100,000 KRB cap + UINT64_C(100000000000000000) +}}; + +// Smallest CT denomination. Sub-floor amounts cannot become CT outputs; +// they remain transparent or are absorbed into transaction fees. +static constexpr uint64_t MIN_CT_DENOMINATION = DENOMINATIONS[0]; + +// Returns true if amount is one of the 64 canonical denominations. +inline bool isCanonicalDenomination(uint64_t amount) { + auto it = std::lower_bound(DENOMINATIONS.begin(), DENOMINATIONS.end(), amount); + return it != DENOMINATIONS.end() && *it == amount; +} + +// Returns the index of amount in DENOMINATIONS [0..63], or -1 if not found. +inline int denominationIndex(uint64_t amount) { + auto it = std::lower_bound(DENOMINATIONS.begin(), DENOMINATIONS.end(), amount); + if (it != DENOMINATIONS.end() && *it == amount) { + return static_cast(std::distance(DENOMINATIONS.begin(), it)); + } + return -1; +} + +// Greedy decomposition of amount into canonical denominations (descending). +// Returns the list of denomination values whose sum equals amount. +// Throws std::invalid_argument if amount is 0 or not exactly representable. +inline std::vector decomposeAmount(uint64_t amount) { + if (amount == 0) { + throw std::invalid_argument("Cannot decompose zero amount"); + } + + std::vector result; + uint64_t remaining = amount; + + // Iterate denominations from largest to smallest + for (int i = static_cast(DENOMINATION_COUNT) - 1; i >= 0 && remaining > 0; --i) { + uint64_t denom = DENOMINATIONS[static_cast(i)]; + while (remaining >= denom) { + result.push_back(denom); + remaining -= denom; + } + } + + if (remaining != 0) { + throw std::invalid_argument("Amount is not exactly representable with canonical denominations"); + } + + return result; +} + +} // namespace CryptoNote diff --git a/include/ITransaction.h b/include/ITransaction.h index ae3198a481..7e0b0bda41 100644 --- a/include/ITransaction.h +++ b/include/ITransaction.h @@ -29,12 +29,28 @@ namespace CryptoNote { namespace TransactionTypes { - enum class InputType : uint8_t { Invalid, Key, Generating }; - enum class OutputType : uint8_t { Invalid, Key }; + enum class InputType : uint8_t { Invalid, Key, Generating, Confidential }; + enum class OutputType : uint8_t { Invalid, Key, Confidential }; struct GlobalOutput { + GlobalOutput() = default; + GlobalOutput(const Crypto::PublicKey& targetKey, uint32_t outputIndex) : + targetKey(targetKey), outputIndex(outputIndex) { + } + Crypto::PublicKey targetKey; - uint32_t outputIndex; + Crypto::EllipticCurvePoint commitment{}; + uint32_t outputIndex = 0; + uint32_t blockHeight = 0; + bool isCoinbase = false; + bool isConfidential = false; + // Bucket amount this ring member lives in: for transparent outputs, the + // on-chain amount; for confidential outputs, CT_CONFIDENTIAL_OUTPUT_AMOUNT. + // Lets CT inputs assemble mixed rings (transparent + confidential decoys) + // where each member names its own bucket. Defaults to 0 so legacy callers + // that ignore this field don't accidentally end up with non-zero garbage; + // the CT wallet path populates it explicitly. + uint64_t amount = 0; }; typedef std::vector GlobalOutputsContainer; @@ -46,9 +62,15 @@ namespace TransactionTypes { }; struct InputKeyInfo { + // Legacy single-bucket field: still used by transparent KeyInput rings. + // For CT inputs with mixed-bucket rings, the per-member amount on each + // GlobalOutput in `outputs` is authoritative and this field is ignored. uint64_t amount; GlobalOutputsContainer outputs; OutputKeyInfo realOutput; + uint64_t realOutputAmount = 0; + Crypto::EllipticCurveScalar realOutputBlinding{}; + bool realOutputIsConfidential = false; }; } @@ -83,6 +105,7 @@ class ITransactionReader { virtual uint64_t getOutputTotalAmount() const = 0; virtual TransactionTypes::OutputType getOutputType(size_t index) const = 0; virtual void getOutput(size_t index, KeyOutput& output, uint64_t& amount) const = 0; + virtual void getOutput(size_t index, ConfidentialOutput& output) const = 0; // signatures virtual size_t getRequiredSignaturesCount(size_t inputIndex) const = 0; diff --git a/include/ITransfersContainer.h b/include/ITransfersContainer.h index ba8d3c2b35..522a7770a4 100644 --- a/include/ITransfersContainer.h +++ b/include/ITransfersContainer.h @@ -21,6 +21,7 @@ #include #include #include +#include "CryptoTypes.h" #include "crypto/hash.h" #include "ITransaction.h" #include "IObservable.h" @@ -41,6 +42,15 @@ struct TransactionInformation { uint64_t totalAmountOut; std::vector extra; Crypto::Hash paymentId; + // Plaintext fee. For CT (v2) txs the input/output amounts are blinded, so + // totalAmountIn/Out are meaningless and the fee cannot be derived from them; + // it is taken from the explicit prefix fee at scan time. For v1 txs it is + // totalAmountIn - totalAmountOut (0 for coinbase). + uint64_t fee = 0; + // True iff the tx is a coinbase (carries a BaseInput). Replaces the old + // "totalAmountIn == 0" heuristic, which misfires on fully-confidential CT + // spends whose transparent input total is also 0. + bool isBase = false; }; @@ -56,9 +66,13 @@ struct TransactionOutputInformation { Crypto::PublicKey transactionPublicKey; union { - Crypto::PublicKey outputKey; // Type: Key + Crypto::PublicKey outputKey; // Type: Key uint32_t requiredSignatures; // Type: Multisignature }; + + // CT fields (valid when type == Confidential) + Crypto::EllipticCurvePoint commitment; // Pedersen commitment C = v*H + r*G + Crypto::EllipticCurveScalar blindingFactor; // blinding factor r }; struct TransactionSpentOutputInformation: public TransactionOutputInformation { @@ -80,18 +94,21 @@ class ITransfersContainer : public IStreamSerializable { // output type IncludeTypeKey = 0x100, IncludeTypeMultisignature = 0x200, + IncludeTypeConfidential = 0x400, // combinations IncludeStateAll = 0xff, IncludeTypeAll = 0xff00, IncludeKeyUnlocked = IncludeTypeKey | IncludeStateUnlocked, IncludeKeyNotUnlocked = IncludeTypeKey | IncludeStateLocked | IncludeStateSoftLocked, + IncludeConfidentialUnlocked = IncludeTypeConfidential | IncludeStateUnlocked, + IncludeConfidentialNotUnlocked = IncludeTypeConfidential | IncludeStateLocked | IncludeStateSoftLocked, IncludeAllLocked = IncludeTypeAll | IncludeStateLocked | IncludeStateSoftLocked, IncludeAllUnlocked = IncludeTypeAll | IncludeStateUnlocked, IncludeAll = IncludeTypeAll | IncludeStateAll, - IncludeDefault = IncludeKeyUnlocked + IncludeDefault = IncludeKeyUnlocked | IncludeConfidentialUnlocked }; virtual size_t transfersCount() const = 0; diff --git a/include/IWallet.h b/include/IWallet.h index 43d4ae0f06..be59989ca7 100644 --- a/include/IWallet.h +++ b/include/IWallet.h @@ -22,6 +22,7 @@ #include #include #include +#include "CryptoNoteConfig.h" #include "CryptoNote.h" #include "ITransfersContainer.h" @@ -115,11 +116,17 @@ struct TransactionParameters { std::vector sourceAddresses; std::vector destinations; uint64_t fee = 0; - uint64_t mixIn = 0; + uint64_t mixIn = parameters::DEFAULT_TX_MIXIN; std::string extra; uint64_t unlockTimestamp = 0; DonationSettings donation; std::string changeDestination; + // CT->CN unshield (transaction version 3): when true, the `destinations` + // (payouts) are produced as transparent KeyOutputs with cleartext amounts + // while any change stays confidential. Requires CT to be active. This is a + // privacy-reducing action (the payout amount is published on-chain), so it is + // a deliberate, separate intent rather than a default. + bool unshield = false; }; struct WalletTransactionWithTransfers { diff --git a/include/IWalletLegacy.h b/include/IWalletLegacy.h index 6b3788da2e..1bbee7370e 100644 --- a/include/IWalletLegacy.h +++ b/include/IWalletLegacy.h @@ -26,6 +26,7 @@ #include #include #include +#include "CryptoNoteConfig.h" #include "CryptoNote.h" #include "CryptoTypes.h" #include "CryptoNote.h" @@ -91,6 +92,7 @@ class IWalletLegacyObserver { virtual void synchronizationCompleted(std::error_code result) {} virtual void actualBalanceUpdated(uint64_t actualBalance) {} virtual void pendingBalanceUpdated(uint64_t pendingBalance) {} + virtual void totalBalanceUpdated(uint64_t totalBalance) {} virtual void unmixableBalanceUpdated(uint64_t unmixableBalance) {} virtual void externalTransactionCreated(TransactionId transactionId) {} virtual void sendTransactionCompleted(TransactionId transactionId, std::error_code result) {} @@ -121,6 +123,7 @@ class IWalletLegacy { virtual uint64_t actualBalance() = 0; virtual uint64_t pendingBalance() = 0; + virtual uint64_t totalBalance() = 0; virtual uint64_t unmixableBalance() = 0; virtual size_t getTransactionCount() = 0; @@ -144,12 +147,12 @@ class IWalletLegacy { virtual std::vector getUnlockedOutputs() = 0; virtual std::vector getSpentOutputs() = 0; - virtual TransactionId sendTransaction(const WalletLegacyTransfer& transfer, uint64_t fee, const std::string& extra = "", uint64_t mixIn = 0, uint64_t unlockTimestamp = 0) = 0; - virtual TransactionId sendTransaction(const std::vector& transfers, uint64_t fee, const std::string& extra = "", uint64_t mixIn = 0, uint64_t unlockTimestamp = 0) = 0; - virtual TransactionId sendTransaction(const std::vector& transfers, const std::list& selectedOuts, uint64_t fee, const std::string& extra = "", uint64_t mixIn = 0, uint64_t unlockTimestamp = 0) = 0; - virtual std::string prepareRawTransaction(TransactionId& transactionId, const std::vector& transfers, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp) = 0; - virtual std::string prepareRawTransaction(TransactionId& transactionId, const std::vector& transfers, const std::list& selectedOuts, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp) = 0; - virtual std::string prepareRawTransaction(TransactionId& transactionId, const WalletLegacyTransfer& transfer, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp) = 0; + virtual TransactionId sendTransaction(const WalletLegacyTransfer& transfer, uint64_t fee, const std::string& extra = "", uint64_t mixIn = parameters::DEFAULT_TX_MIXIN, uint64_t unlockTimestamp = 0) = 0; + virtual TransactionId sendTransaction(const std::vector& transfers, uint64_t fee, const std::string& extra = "", uint64_t mixIn = parameters::DEFAULT_TX_MIXIN, uint64_t unlockTimestamp = 0, bool unshield = false) = 0; + virtual TransactionId sendTransaction(const std::vector& transfers, const std::list& selectedOuts, uint64_t fee, const std::string& extra = "", uint64_t mixIn = parameters::DEFAULT_TX_MIXIN, uint64_t unlockTimestamp = 0, bool unshield = false) = 0; + virtual std::string prepareRawTransaction(TransactionId& transactionId, const std::vector& transfers, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp, bool unshield = false) = 0; + virtual std::string prepareRawTransaction(TransactionId& transactionId, const std::vector& transfers, const std::list& selectedOuts, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp, bool unshield = false) = 0; + virtual std::string prepareRawTransaction(TransactionId& transactionId, const WalletLegacyTransfer& transfer, uint64_t fee, const std::string& extra, uint64_t mixIn, uint64_t unlockTimestamp, bool unshield = false) = 0; virtual std::error_code cancelTransaction(size_t transferId) = 0; virtual bool getTransactionInformation(const Crypto::Hash& transactionHash, TransactionInformation& info, diff --git a/src/BlockchainExplorer/BlockchainExplorerDataBuilder.cpp b/src/BlockchainExplorer/BlockchainExplorerDataBuilder.cpp index b33d9d8ea0..fbdca3a9ba 100755 --- a/src/BlockchainExplorer/BlockchainExplorerDataBuilder.cpp +++ b/src/BlockchainExplorer/BlockchainExplorerDataBuilder.cpp @@ -36,14 +36,31 @@ protocol(protocol) { } bool BlockchainExplorerDataBuilder::getMixin(const Transaction& transaction, uint64_t& mixin) { - mixin = 0; + uint64_t minMixin = 0; + return getMixinRange(transaction, minMixin, mixin); +} + +bool BlockchainExplorerDataBuilder::getMixinRange(const Transaction& transaction, + uint64_t& minMixin, + uint64_t& maxMixin) { + minMixin = 0; + maxMixin = 0; + bool first = true; for (const TransactionInput& txin : transaction.inputs) { - if (txin.type() != typeid(KeyInput)) { + uint64_t currentMixin = 0; + if (txin.type() == typeid(KeyInput)) { + currentMixin = boost::get(txin).outputIndexes.size(); + } else if (txin.type() == typeid(ConfidentialInput)) { + currentMixin = boost::get(txin).ringPubkeys.size(); + } else { continue; } - uint64_t currentMixin = boost::get(txin).outputIndexes.size(); - if (currentMixin > mixin) { - mixin = currentMixin; + if (first) { + minMixin = maxMixin = currentMixin; + first = false; + } else { + if (currentMixin > maxMixin) maxMixin = currentMixin; + if (currentMixin < minMixin) minMixin = currentMixin; } } return true; @@ -154,6 +171,16 @@ bool BlockchainExplorerDataBuilder::fillBlockDetails(const Block &block, BlockDe return false; } + // CT pool liability / PQ-plain supply at this block height. Unknown blocks + // are reported as zero (consistent with pre-CT-fork history) rather than a + // hard failure so the explorer can still render the block. + if (!m_core.getConfidentialSupplyAtBlock(hash, blockDetails.confidentialSupply)) { + blockDetails.confidentialSupply = 0; + } + if (!m_core.getPqPlainSupplyAtBlock(hash, blockDetails.pqPlainSupply)) { + blockDetails.pqPlainSupply = 0; + } + if (!m_core.getGeneratedTransactionsNumber(blockDetails.height, blockDetails.alreadyGeneratedTransactions)) { return false; } @@ -168,11 +195,11 @@ bool BlockchainExplorerDataBuilder::fillBlockDetails(const Block &block, BlockDe uint64_t maxReward = 0; uint64_t currentReward = 0; int64_t emissionChange = 0; - if (!m_core.getBlockReward(block.majorVersion, blockDetails.sizeMedian, 0, prevBlockGeneratedCoins, 0, maxReward, emissionChange)) { + if (!m_core.getBlockReward(block.majorVersion, blockDetails.sizeMedian, 0, prevBlockGeneratedCoins, 0, maxReward, emissionChange, blockDetails.height)) { return false; } - if (!m_core.getBlockReward(block.majorVersion, blockDetails.sizeMedian, blockDetails.transactionsCumulativeSize, prevBlockGeneratedCoins, 0, currentReward, emissionChange)) { + if (!m_core.getBlockReward(block.majorVersion, blockDetails.sizeMedian, blockDetails.transactionsCumulativeSize, prevBlockGeneratedCoins, 0, currentReward, emissionChange, blockDetails.height)) { return false; } @@ -244,7 +271,17 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr } transactionDetails.size = getObjectBinarySize(transaction); transactionDetails.unlockTime = transaction.unlockTime; - transactionDetails.totalOutputsAmount = get_outs_money_amount(transaction); + + // For CT transactions amounts are hidden; these helpers correctly return 0 for CT + // (CT inputs/outputs aren't typed as KeyInput / transparent KeyOutput here), which is + // the right "public total" to expose. Both helpers return false on uint64_t overflow — + // an on-chain tx should never reach that, but propagate the failure so the explorer + // doesn't surface a wrapped total. + uint64_t outputsAmount = 0; + if (!get_outs_money_amount(transaction, outputsAmount)) { + return false; + } + transactionDetails.totalOutputsAmount = outputsAmount; uint64_t inputsAmount; if (!get_inputs_money_amount(transaction, inputsAmount)) { @@ -256,17 +293,23 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr //It's gen transaction transactionDetails.fee = 0; transactionDetails.mixin = 0; + transactionDetails.minMixin = 0; } else { uint64_t fee; if (!get_tx_fee(transaction, fee)) { return false; } transactionDetails.fee = fee; - uint64_t mixin; - if (!m_core.getMixin(transaction, mixin)) { + // Per-input mixin: a tx may mix ring sizes (e.g. CT shielding ring-1 coinbase + // inputs alongside ring-4+ normal CT inputs). Report both bounds so consumers + // can detect heterogeneity rather than seeing the legacy max-only field. + uint64_t minMixin = 0; + uint64_t maxMixin = 0; + if (!getMixinRange(transaction, minMixin, maxMixin)) { return false; } - transactionDetails.mixin = mixin; + transactionDetails.mixin = maxMixin; + transactionDetails.minMixin = minMixin; } Crypto::Hash paymentId; if (getPaymentId(transaction, paymentId)) { @@ -277,15 +320,8 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr transactionDetails.hasPaymentId = false; } fillTxExtra(transaction.extra, transactionDetails.extra); - transactionDetails.signatures.reserve(transaction.signatures.size()); - for (const std::vector& signatures : transaction.signatures) { - std::vector signaturesDetails; - signaturesDetails.reserve(signatures.size()); - for (const Crypto::Signature& signature : signatures) { - signaturesDetails.push_back(std::move(signature)); - } - transactionDetails.signatures.push_back(std::move(signaturesDetails)); - } + // Per-input variant: copy through unchanged (parallel to inputs). + transactionDetails.signatures = transaction.signatures; transactionDetails.inputs.reserve(transaction.inputs.size()); for (const TransactionInput& txIn : transaction.inputs) { @@ -293,6 +329,8 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr if (txIn.type() == typeid(BaseInput)) { BaseInputDetails txInGenDetails; txInGenDetails.input.blockIndex = boost::get(txIn).blockIndex; + // For CT-era coinbase, output amounts are still public (coinbase stays transparent), + // so summing output.amount is correct here. txInGenDetails.amount = 0; for (const TransactionOutput& out : transaction.outputs) { txInGenDetails.amount += out.amount; @@ -301,7 +339,7 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr } else if (txIn.type() == typeid(KeyInput)) { CryptoNote::KeyInputDetails txInToKeyDetails; const KeyInput& txInToKey = boost::get(txIn); - txInToKeyDetails.input = txInToKey; + txInToKeyDetails.input = txInToKey; std::list> outputReferences; if (!m_core.scanOutputkeysForIndices(txInToKey, outputReferences)) { return false; @@ -314,6 +352,26 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr txInToKeyDetails.outputs.push_back(d); } txInDetails = txInToKeyDetails; + } else if (txIn.type() == typeid(ConfidentialInput)) { + const ConfidentialInput& cin = boost::get(txIn); + CryptoNote::ConfidentialInputDetails ctInDetails; + ctInDetails.keyImage = cin.keyImage; + ctInDetails.pseudoCommitment = cin.pseudoCommitment; + ctInDetails.mixin = cin.ringMembers.size(); + ctInDetails.ringMembers = cin.ringMembers; + std::list> outputReferences; + if (m_core.scanCtInputRingForIndices(cin, outputReferences)) { + for (const auto& r : outputReferences) { + TransactionOutputReferenceDetails d; + d.number = r.second; + d.transactionHash = r.first; + ctInDetails.outputs.push_back(d); + } + } + // If ring resolution fails (e.g. mempool tx referencing something the explorer + // can't introspect right now), keep the basic CT details rather than dropping + // the whole transaction from explorer view. + txInDetails = ctInDetails; } else { return false; } @@ -339,6 +397,17 @@ bool BlockchainExplorerDataBuilder::fillTransactionDetails(const Transaction& tr transactionDetails.outputs.push_back(std::move(txOutDetails)); } + // CT-family proof body: copy through for full inspection (web wallets use raw-tx + // RPC, so this is for explorers / debugging / human inspection). The per- + // input Triptych proofs are carried inside transactionDetails.signatures via + // the InputSignatures variant; here we forward only the output proofs and + // balance kernel. v3 unshield has one GK proof per confidential output; + // transparent payout outputs deliberately have no matching proof. + if (isCtFamilyTransactionVersion(transaction.version)) { + transactionDetails.ctProofs = transaction.ctProofs; + transactionDetails.kernel = transaction.kernel; + } + return true; } diff --git a/src/BlockchainExplorer/BlockchainExplorerDataBuilder.h b/src/BlockchainExplorer/BlockchainExplorerDataBuilder.h index 10c4b02f23..10557f5cc0 100755 --- a/src/BlockchainExplorer/BlockchainExplorerDataBuilder.h +++ b/src/BlockchainExplorer/BlockchainExplorerDataBuilder.h @@ -45,6 +45,7 @@ class BlockchainExplorerDataBuilder private: bool getMixin(const Transaction& transaction, uint64_t& mixin); + bool getMixinRange(const Transaction& transaction, uint64_t& minMixin, uint64_t& maxMixin); bool fillTxExtra(const std::vector& rawExtra, TransactionExtraDetails2& extraDetails); size_t median(std::vector& v); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 35d00da707..5bfdb3adbf 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -50,7 +50,7 @@ add_library(CryptoNoteProtocol ${CryptoNoteProtocol}) add_library(Common ${Common}) add_library(Crypto ${Crypto}) add_library(CryptoNoteCore ${CryptoNoteCore}) -target_link_libraries(CryptoNoteCore PUBLIC ${LMDB_LIBRARY}) +target_link_libraries(CryptoNoteCore PUBLIC Checkpoints ${LMDB_LIBRARY}) add_library(Http ${Http}) add_library(InProcessNode ${InProcessNode}) add_library(Logging ${Logging}) diff --git a/src/Checkpoints/Checkpoints.cpp b/src/Checkpoints/Checkpoints.cpp index 0480e0564e..627558f1d5 100644 --- a/src/Checkpoints/Checkpoints.cpp +++ b/src/Checkpoints/Checkpoints.cpp @@ -36,6 +36,8 @@ #include "../CryptoNoteConfig.h" #include "Common/StringTools.h" #include "Common/DnsTools.h" +#include "CryptoNoteCore/CryptoNoteBasicImpl.h" +#include "CryptoNoteCore/CryptoNoteFormatUtils.h" using namespace Logging; #undef ERROR @@ -46,7 +48,7 @@ Checkpoints::Checkpoints(Logging::ILogger &log, uint32_t reject_deep_reorg_depth } //--------------------------------------------------------------------------- -bool Checkpoints::add_checkpoint(uint32_t height, const std::string &hash_str) { +bool Checkpoints::add_checkpoint(uint32_t height, const std::string &hash_str, bool hardcoded) { Crypto::Hash h = NULL_HASH; if (!Common::podFromHex(hash_str, h)) { @@ -59,6 +61,10 @@ bool Checkpoints::add_checkpoint(uint32_t height, const std::string &hash_str) { return false; } + if (hardcoded) { + m_hardcoded_heights.insert(height); + } + return true; } //--------------------------------------------------------------------------- @@ -92,6 +98,13 @@ bool Checkpoints::is_in_checkpoint_zone(uint32_t height) const { return !m_points.empty() && (height <= (--m_points.end())->first); } //--------------------------------------------------------------------------- +bool Checkpoints::is_in_hardcoded_checkpoint_zone(uint32_t height) const { + // *rbegin() is the largest trusted checkpoint; the zone is everything at + // or below it. Despite the historical "hardcoded" name, signed DNS + // checkpoints are admitted here after signature verification. + return !m_hardcoded_heights.empty() && (height <= *m_hardcoded_heights.rbegin()); +} +//--------------------------------------------------------------------------- bool Checkpoints::check_block(uint32_t height, const Crypto::Hash &h, bool &is_a_checkpoint) const { auto it = m_points.find(height); @@ -186,27 +199,136 @@ bool Checkpoints::load_checkpoints_from_dns() auto dur = std::chrono::steady_clock::now() - start; logger(Logging::DEBUGGING) << "DNS query time: " << std::chrono::duration_cast(dur).count() << " ms"; + // Fail-closed: if no signer addresses are baked into this build, every DNS + // record is dropped without trying to verify it. Tampered DNS or an + // accidentally-misconfigured release can't sneak past the signature gate + // by simply omitting the signature field. The one-shot warning makes the + // misconfiguration visible to operators reading the log. + if (CryptoNote::DNS_CHECKPOINT_SIGNERS_COUNT == 0) { + logger(Logging::WARNING) << "DNS checkpoints fetched but no DNS_CHECKPOINT_SIGNERS " + "configured in this build; ignoring " << records.size() + << " record(s). Set DNS_CHECKPOINT_SIGNERS in CryptoNoteConfig.h " + "to enable."; + return true; + } + + // Pre-parse the approved signer list once per DNS fetch. Addresses that + // fail Base58/curve validation are logged and dropped so an accidentally- + // mistyped address in the config does not silently lock the verifier + // open to attacker signatures (it can't — Common::Base58::decode_addr + // would just reject — but we want the diagnostic to point at the bad + // entry). + std::vector signers; + signers.reserve(CryptoNote::DNS_CHECKPOINT_SIGNERS_COUNT); + for (size_t i = 0; i < CryptoNote::DNS_CHECKPOINT_SIGNERS_COUNT; ++i) { + CryptoNote::AccountPublicAddress addr; + uint64_t prefix = 0; + if (CryptoNote::parseAccountAddressString(prefix, addr, + std::string(CryptoNote::DNS_CHECKPOINT_SIGNERS[i]))) { + signers.push_back(addr); + } else { + logger(Logging::ERROR, BRIGHT_RED) + << "DNS_CHECKPOINT_SIGNERS[" << i << "]='" + << CryptoNote::DNS_CHECKPOINT_SIGNERS[i] + << "' is not a valid Karbo address; skipping."; + } + } + if (signers.empty()) { + logger(Logging::WARNING) << "No usable DNS checkpoint signers after parsing; " + "ignoring all DNS records."; + return true; + } + for (const auto& record : records) { - uint32_t height; - Crypto::Hash hash = NULL_HASH; - std::stringstream ss; - size_t del = record.find_first_of(':'); - std::string height_str = record.substr(0, del), hash_str = record.substr(del + 1, 64); - ss.str(height_str); - ss >> height; - char c; - if (del == std::string::npos) continue; - if ((ss.fail() || ss.get(c)) || !Common::podFromHex(hash_str, hash)) { - logger(Logging::DEBUGGING) << "Failed to parse DNS checkpoint record: " << record; + // Required wire format: "::" + // The legacy 2-field ":" format is rejected — it has no + // signature and so cannot be trusted to add even an anchor. + const size_t del1 = record.find(':'); + if (del1 == std::string::npos) { + logger(Logging::WARNING) << "Malformed DNS checkpoint (no field delimiter): " << record; + continue; + } + const size_t del2 = record.find(':', del1 + 1); + if (del2 == std::string::npos) { + logger(Logging::WARNING) << "Malformed DNS checkpoint (legacy unsigned format, rejected): " << record; continue; } - if (!(0 == m_points.count(height))) { - logger(DEBUGGING) << "Checkpoint already exists for height: " << height << ". Ignoring DNS checkpoint."; - } else { - add_checkpoint(height, hash_str); - logger(DEBUGGING) << "Added DNS checkpoint: " << height_str << ":" << hash_str; + const std::string height_str = record.substr(0, del1); + const std::string hash_str = record.substr(del1 + 1, del2 - del1 - 1); + const std::string sig_str = record.substr(del2 + 1); + + if (hash_str.size() != 64) { + logger(Logging::WARNING) << "Malformed DNS checkpoint (hash length " << hash_str.size() + << " != 64): " << record; + continue; + } + + uint32_t height = 0; + { + std::stringstream ss(height_str); + char trailing; + ss >> height; + if (ss.fail() || ss.get(trailing)) { + logger(Logging::WARNING) << "Malformed DNS checkpoint (height not a clean number): " << record; + continue; + } + } + + Crypto::Hash hash{}; + if (!Common::podFromHex(hash_str, hash)) { + logger(Logging::WARNING) << "Malformed DNS checkpoint (hash not hex): " << record; + continue; + } + + // Verify the signature against any one of the approved signers. The + // signed payload is the literal ":" string — what the + // maintainer types into simplewallet's sign_message prompt. + const std::string signed_payload = height_str + ":" + hash_str; + bool verified = false; + for (const auto& signer : signers) { + if (CryptoNote::verifyMessage(signed_payload, signer, sig_str, logger.getLogger())) { + verified = true; + break; + } + } + if (!verified) { + logger(Logging::ERROR, BRIGHT_RED) + << "DNS checkpoint signature did not match any approved signer; " + "rejecting record: " << record; + continue; + } + + if (m_points.count(height) != 0) { + logger(Logging::DEBUGGING) << "Checkpoint already exists for height: " << height + << ". Ignoring DNS checkpoint."; + continue; } + // Signed DNS checkpoint: passes `hardcoded=true`. + // + // Rationale for treating signed DNS as equivalent to the baked-in + // CHECKPOINTS table: both anchors are signed by a maintainer key. The + // binary table is signed implicitly (the operator trusts the binary + // they chose to run); DNS records are signed explicitly against the + // DNS_CHECKPOINT_SIGNERS pubkey embedded in that same binary. A + // forger would need either signing key, and the binary release key + // strictly dominates the DNS one — losing the binary key is "publish + // a malicious daemon", losing the DNS key is "ship malicious blocks + // during fresh sync until release N+1 rotates the signer". Both keys + // are inside the same trust boundary; granting them the same in- + // protocol privileges is the consistent position. + // + // The practical effect: at heights anchored by a signed DNS record, + // CT transactions in the block route to the structural-only fast + // path (checkConfidentialTransactionStructure) instead of the full + // pipeline. This is the original sync-speedup intent of the + // checkpoint mechanism; restricting it to baked-in checkpoints only + // defeats the purpose of DNS checkpoints once they're authenticated. + // + // Unsigned DNS records are NOT admitted at all by the loader above — + // legacy 2-field "height:hash" entries are rejected at parse time. + add_checkpoint(height, hash_str, /*hardcoded=*/true); + logger(Logging::DEBUGGING) << "Added signed DNS checkpoint: " << height_str << ":" << hash_str; } return true; diff --git a/src/Checkpoints/Checkpoints.h b/src/Checkpoints/Checkpoints.h index 155925e2d9..5f380bea4f 100644 --- a/src/Checkpoints/Checkpoints.h +++ b/src/Checkpoints/Checkpoints.h @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -41,16 +42,40 @@ namespace CryptoNote std::unique_lock lock_other(other.m_mutex, std::defer_lock); std::lock(lock_this, lock_other); // ensure no deadlock m_points = other.m_points; + // The hardcoded-vs-DNS distinction is consensus-relevant: it gates + // the CT structural-only validation fast path in pushBlock / + // handleIncomingTransaction. Forgetting to copy m_hardcoded_heights + // (and m_reject_deep_reorg_depth) silently downgrades a Checkpoints + // object that *had* hardcoded checkpoints into one that pretends + // every entry came from DNS. The most common path that exercises this + // assignment is Daemon.cpp's `m_core.set_checkpoints(std::move( + // checkpoints))` after seeding the binary table. + m_hardcoded_heights = other.m_hardcoded_heights; + m_reject_deep_reorg_depth = other.m_reject_deep_reorg_depth; logger = other.logger; } return *this; } - bool add_checkpoint(uint32_t height, const std::string& hash_str); + // `hardcoded` is the historical name for "trusted checkpoint". It is true + // for anchors inside the operator's trust boundary: the baked-in + // CryptoNote::CHECKPOINTS table, an operator-supplied file loaded via + // --load-checkpoints, or a DNS record verified against + // DNS_CHECKPOINT_SIGNERS. Unsigned DNS records are rejected before this + // function is called. The flag feeds is_in_hardcoded_checkpoint_zone(), + // which decides whether expensive historical consensus validation, + // including the CT structural-only fast path, may short-circuit. + bool add_checkpoint(uint32_t height, const std::string& hash_str, bool hardcoded = true); bool load_checkpoints_from_file(const std::string& fileName); bool load_checkpoints_from_dns(); bool is_in_checkpoint_zone(uint32_t height) const; + // True iff `height` is at or below the largest trusted checkpoint height. + // The method keeps the historical "hardcoded" name, but the trusted set + // includes built-in checkpoints, operator file checkpoints, and signed DNS + // checkpoints. Returns false when no trusted checkpoints have been seeded + // (e.g. testnet, --without-checkpoints). + bool is_in_hardcoded_checkpoint_zone(uint32_t height) const; bool check_block(uint32_t height, const Crypto::Hash& h) const; bool check_block(uint32_t height, const Crypto::Hash& h, bool& is_a_checkpoint) const; bool is_alternative_block_allowed(uint32_t blockchain_height, uint32_t block_height) const; @@ -59,6 +84,10 @@ namespace CryptoNote private: std::map m_points; + // Subset of m_points whose source was trusted at insertion time. Tracked + // as a parallel index (rather than embedded in the value of m_points) so + // existing iteration patterns over m_points stay unchanged. + std::set m_hardcoded_heights; Logging::LoggerRef logger; mutable std::mutex m_mutex; diff --git a/src/CryptoNoteConfig.h b/src/CryptoNoteConfig.h index 2bdc925253..79580e3238 100644 --- a/src/CryptoNoteConfig.h +++ b/src/CryptoNoteConfig.h @@ -21,6 +21,7 @@ #pragma once +#include #include #include @@ -30,6 +31,15 @@ namespace parameters { const uint64_t DIFFICULTY_TARGET = 240; // seconds const uint64_t EXPECTED_NUMBER_OF_BLOCKS_PER_DAY = 24 * 60 * 60 / DIFFICULTY_TARGET; const uint64_t CRYPTONOTE_MAX_BLOCK_NUMBER = 500000000; +// Maximum unlock_time accepted at block major v6+. Plain txs at v6+ must use +// height interpretation only and stay at or below this cap; the timestamp +// branch is removed. Pre-v6 outputs whose tx carries an unlock_time exceeding +// this cap (e.g. accidental Unix timestamps in seconds) are treated as +// unlocked when referenced from a v6+ tip, recovering funds that were +// effectively frozen by user error under the dual height/timestamp scheme. +// 10,000,000 blocks ≈ 76 years from genesis at 240s/block — well beyond any +// legitimate lock; clearly bogus for everything above it. +const uint64_t CRYPTONOTE_MAX_UNLOCK_HEIGHT_V6 = UINT64_C(10000000); const size_t CRYPTONOTE_MAX_BLOCK_BLOB_SIZE = 500000000; const size_t CRYPTONOTE_MAX_TX_SIZE = 1000000000; const uint64_t CRYPTONOTE_PUBLIC_ADDRESS_BASE58_PREFIX = 111; // addresses start with "K" @@ -37,7 +47,7 @@ const uint64_t CRYPTONOTE_TX_PROOF_BASE58_PREFIX = 3576968; // (0x36 const uint64_t CRYPTONOTE_RESERVE_PROOF_BASE58_PREFIX = 44907175188; // (0xa74ad1d14), starts with "RsrvPrf..." const uint64_t CRYPTONOTE_KEYS_SIGNATURE_BASE58_PREFIX = 176103705; // (0xa7f2119), starts with "SigV1..." const size_t CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW = 10; -const size_t CRYPTONOTE_TX_SPENDABLE_AGE = 6; +const size_t CRYPTONOTE_TX_SPENDABLE_AGE = 2; const uint64_t CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT = DIFFICULTY_TARGET * 7; const uint64_t CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT_V1 = DIFFICULTY_TARGET * 3; const size_t BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW = 60; @@ -51,6 +61,7 @@ const size_t CRYPTONOTE_COIN_VERSION = 1; const unsigned EMISSION_SPEED_FACTOR = 18; static_assert(EMISSION_SPEED_FACTOR <= 8 * sizeof(uint64_t), "Bad EMISSION_SPEED_FACTOR"); + const size_t CRYPTONOTE_REWARD_BLOCKS_WINDOW = 100; const size_t CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE = 1000000; //size of block (bytes) after which reward for block calculated using block size const size_t CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V2 = 1000000; @@ -65,11 +76,36 @@ const uint64_t MINIMUM_FEE_V3 = UINT64_C(10000000 const uint64_t MINIMUM_FEE = MINIMUM_FEE_V3; const uint64_t MAXIMUM_FEE = UINT64_C(100000000000); -const uint64_t DEFAULT_DUST_THRESHOLD = UINT64_C(100000000); +const uint64_t DEFAULT_DUST_THRESHOLD = UINT64_C(10000000000); const uint64_t MIN_TX_MIXIN_SIZE = 2; -const uint64_t MAX_TX_MIXIN_SIZE = 20; +const uint64_t MAX_TX_MIXIN_SIZE = 20; // legacy, actual max mixin is defined in CT_MAX_RING_SIZE const uint64_t MAX_EXTRA_SIZE = 1024; +// Confidential transaction parameters +const size_t CT_MIN_RING_SIZE = 4; // min ring members per CT input +const size_t CT_MAX_RING_SIZE = 16; // max ring members per CT input +const uint64_t DEFAULT_TX_MIXIN = CT_MAX_RING_SIZE - 1; // decoys, gives ring size 16 +// Mixed-bucket decoy sampling for CT inputs (wallet policy, not consensus). +// When a CT input's ring is at least CT_MIN_RING_SIZE_FOR_MIXING members, +// the wallet reserves CT_MIXING_DECOYS_PER_INPUT slots for decoys drawn +// from a *different* amount bucket than the real spend's bucket. Consensus +// already supports mixed rings via the per-member ConfidentialInput schema; +// these knobs control how aggressively the wallet exploits that capability. +const size_t CT_MIXING_DECOYS_PER_INPUT = 2; +const size_t CT_MIN_RING_SIZE_FOR_MIXING = 8; +const uint64_t CT_MINIMUM_FEE = UINT64_C(10000000000); // 0.01 KRB (= MIN_CT_DENOMINATION) +const uint64_t CT_MAXIMUM_FEE = UINT64_C(100000000000000); // 100 KRB +const uint64_t CT_CONFIDENTIAL_OUTPUT_AMOUNT = UINT64_MAX; // internal bucket for hidden-output rings +// Per-tx structural caps. CT_MAX_INPUTS sized for coinbase batch +// consolidation (a long-running miner can spend many cheap KeyInput +// shields in one tx; KeyInput verify is microseconds, Triptych +// ConfidentialInput verify is single-digit ms even batched, so the +// worst-case all-Triptych ring-16 shape is still under ~2 s/tx). +// CT_MAX_OUTPUTS stays narrow because GK output proofs are the real +// asymmetric verifier cost. +const size_t CT_MAX_INPUTS = 512; +const size_t CT_MAX_OUTPUTS = 64; + const uint64_t MAX_TRANSACTION_SIZE_LIMIT = CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_CURRENT / 4 - CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE; const size_t DANDELION_EPOCH = 600; @@ -109,6 +145,8 @@ const uint32_t UPGRADE_HEIGHT_V4_2 = 500000; // Fee pe const uint32_t UPGRADE_HEIGHT_V4_3 = 667000; // Fixed min fee + fee per-byte for extra const uint32_t UPGRADE_HEIGHT_V5 = 700000; // Block v5, back to LWMA1+, Alt. Signed Proof-of-Work const uint32_t UPGRADE_HEIGHT_V6 = 4294967294; // Block v6 +const uint32_t UPGRADE_HEIGHT_V7 = 4294967294; // Block v7 (reserved for future PQ-plain activation) +const uint32_t CT_FORK_HEIGHT = UPGRADE_HEIGHT_V6; // Confidential Transactions, Pubkey-referenced rings for CT transactions, enable mempool-based zero-conf transactions chaining const unsigned UPGRADE_VOTING_THRESHOLD = 90; // percent const uint32_t UPGRADE_VOTING_WINDOW = EXPECTED_NUMBER_OF_BLOCKS_PER_DAY; // blocks @@ -142,13 +180,75 @@ const char GENESIS_COINBASE_TX_HEX[] = "f904925cc23f86f9f3565188862275dc556a9bdfb6aec22c5aca7f0177c45ba8"; // tx pubkey const char DNS_CHECKPOINTS_HOST[] = "checkpoints.karbo.org"; +// Approved signer addresses for DNS checkpoint records. +// +// DNS TXT records served from DNS_CHECKPOINTS_HOST must be in the form +// "::" +// where is produced by signing the string ":" +// with one of the wallets whose address appears in DNS_CHECKPOINT_SIGNERS. The +// signature scheme is the one wired into simplewallet's `sign_message` command +// (CryptoNoteFormatUtils::signMessage / verifyMessage) — Schnorr over the +// account's spend keypair, Base58-encoded with the +// CRYPTONOTE_KEYS_SIGNATURE_BASE58_PREFIX tag. +// +// Operational workflow for a maintainer: +// 1. simplewallet --generate-new-wallet checkpoint-signer.wallet +// 2. note the printed address, e.g. "Kxxx..."; add it to this array in the +// next release build. +// 3. encrypt the wallet file and keep it offline; it should never receive +// funds — the only operation it performs is `sign_message`. +// Any funds sent to it are simply donations to the project. +// 4. to publish a new checkpoint, load the wallet on an offline machine, +// run `sign_message`, enter ":", and copy the +// signature into the corresponding DNS TXT record. +// +// Multi-signer / any-of-N semantics: a DNS record is accepted if its signature +// verifies against ANY address in this list. This lets the project rotate a +// signing wallet (add the new address in release N, drop the old one in +// release N+1) without an emergency rollout, and lets multiple maintainers +// hold independent signers without coordinating on a single hot key. +// +// Empty signer set: leave just the nullptr sentinel below — DNS checkpoint +// loading then fail-closes (the loader logs once and skips every record). +// This is the safe default before keys are provisioned. +// +// Implementation note: nullptr-terminated C array, not std::array. The +// previous std::array form required maintainers to update +// N manually each time they added or removed a signer; under MSVC the +// extra initializers were silently dropped (no diagnostic, COUNT stayed +// at N), which fail-closed the loader even with real signers configured — +// the security-degrading kind of "silent". The sentinel scheme makes +// COUNT auto-track the entry count via sizeof, requires no manual sizing, +// and works for any count including zero (MSVC rejects zero-element C +// arrays, but a one-element `{ nullptr }` is well-formed). +constexpr const char* const DNS_CHECKPOINT_SIGNERS[] = { + // "Kxxx...maintainer-1-address...", + // "Kxxx...maintainer-2-address...", + "Kdns13W9JuUHg8D12yWk9CSMREzZRw5bzFP4qHuMuAYtDwdgQbCdFJJMZEn6iPuZuAMRDuY5S4QcWTj55P7aYfP2SXtTQz7", + nullptr // sentinel — must remain the final entry +}; +constexpr size_t DNS_CHECKPOINT_SIGNERS_COUNT = + (sizeof(DNS_CHECKPOINT_SIGNERS) / sizeof(DNS_CHECKPOINT_SIGNERS[0])) - 1; + const uint8_t CURRENT_TRANSACTION_VERSION = 1; +const uint8_t TRANSACTION_VERSION_CT = 2; +const uint8_t TRANSACTION_VERSION_UNSHIELD = 3; // CT->CN unshield / mixed outputs (v3) +// CT-family transaction versions share the v2 wire format (explicit plaintext +// fee, per-output GK proofs, balance kernel) and the CT validation pipeline. +// v3 (unshield) extends v2 with mixed plain+confidential outputs; until that +// relaxation lands it is treated identically to v2. Per-version ACTIVATION is +// gated in Currency (isConfidentialTransactionsActivated / isUnshieldActivated), +// not here — this predicate is membership only. +inline bool isCtFamilyTransactionVersion(uint8_t version) { + return version == TRANSACTION_VERSION_CT || version == TRANSACTION_VERSION_UNSHIELD; +} const uint8_t BLOCK_MAJOR_VERSION_1 = 1; const uint8_t BLOCK_MAJOR_VERSION_2 = 2; const uint8_t BLOCK_MAJOR_VERSION_3 = 3; const uint8_t BLOCK_MAJOR_VERSION_4 = 4; const uint8_t BLOCK_MAJOR_VERSION_5 = 5; const uint8_t BLOCK_MAJOR_VERSION_6 = 6; +const uint8_t BLOCK_MAJOR_VERSION_7 = 7; const uint8_t BLOCK_MINOR_VERSION_0 = 0; const uint8_t BLOCK_MINOR_VERSION_1 = 1; diff --git a/src/CryptoNoteCore/BlockStats.h b/src/CryptoNoteCore/BlockStats.h index 2a80b2fec0..5e6edceacb 100644 --- a/src/CryptoNoteCore/BlockStats.h +++ b/src/CryptoNoteCore/BlockStats.h @@ -17,12 +17,13 @@ namespace CryptoNote { struct BlockStatsEntry { uint32_t height; - uint64_t alreadyGeneratedCoins; + uint64_t emittedSupply; uint64_t transactionsCount; uint64_t blockSize; difficulty_type difficulty; uint64_t reward; uint64_t timestamp; + uint64_t confidentialSupply; // value held in confidential outputs at this height (0 before CT/v6) }; } // namespace CryptoNote diff --git a/src/CryptoNoteCore/Blockchain.cpp b/src/CryptoNoteCore/Blockchain.cpp index cb8dac318a..7f07894e01 100644 --- a/src/CryptoNoteCore/Blockchain.cpp +++ b/src/CryptoNoteCore/Blockchain.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #include "Common/Math.h" #include "Common/int-util.h" @@ -34,9 +36,17 @@ #include "Rpc/CoreRpcServerCommandsDefinitions.h" #include "Serialization/BinarySerializationTools.h" #include "CryptoNoteTools.h" +#include "CryptoNoteFormatUtils.h" +#include "TransactionValidation.h" #include "TransactionExtra.h" #include "../crypto/hash.h" +#include "../crypto/pedersen.h" +#include "../crypto/gk_proof.h" +#include "../crypto/triptych.h" +#include "../crypto/transaction_balance.h" +#include "../crypto/crypto-ops.h" +#include "../CryptoNoteConfig.h" using namespace Logging; using namespace Common; @@ -79,6 +89,7 @@ Blockchain::Blockchain(const Currency& currency, tx_memory_pool& tx_pool, m_upgradeDetectorV4(currency, m_blockView, BLOCK_MAJOR_VERSION_4, logger), m_upgradeDetectorV5(currency, m_blockView, BLOCK_MAJOR_VERSION_5, logger), m_upgradeDetectorV6(currency, m_blockView, BLOCK_MAJOR_VERSION_6, logger), + m_upgradeDetectorV7(currency, m_blockView, BLOCK_MAJOR_VERSION_7, logger), m_checkpoints(logger, rejectDeepReorgDepth), m_no_blobs(noBlobs) { @@ -96,11 +107,13 @@ bool Blockchain::removeObserver(IBlockchainStorageObserver* observer) { // ─── ITransactionValidator ─────────────────────────────────────────────────── -bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, BlockInfo& maxUsedBlock) { - return checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id); +bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, BlockInfo& maxUsedBlock, + TxValidationContext context) { + return checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id, context); } -bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, BlockInfo& maxUsedBlock, BlockInfo& lastFailed) { +bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, BlockInfo& maxUsedBlock, + BlockInfo& lastFailed, TxValidationContext context) { BlockInfo tail; if (maxUsedBlock.empty()) { @@ -108,7 +121,7 @@ bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, Block getBlockIdByHeight(lastFailed.height) == lastFailed.id) { return false; } - if (!checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id, &tail)) { + if (!checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id, context, &tail)) { lastFailed = tail; return false; } @@ -121,7 +134,7 @@ bool Blockchain::checkTransactionInputs(const CryptoNote::Transaction& tx, Block return false; } } - if (!checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id, &tail)) { + if (!checkTransactionInputs(tx, maxUsedBlock.height, maxUsedBlock.id, context, &tail)) { lastFailed = tail; return false; } @@ -445,7 +458,8 @@ bool Blockchain::init(const std::string& config_folder, bool load_existing) { } if (!m_upgradeDetectorV2.init() || !m_upgradeDetectorV3.init() || - !m_upgradeDetectorV4.init() || !m_upgradeDetectorV5.init() || !m_upgradeDetectorV6.init()) { + !m_upgradeDetectorV4.init() || !m_upgradeDetectorV5.init() || + !m_upgradeDetectorV6.init() || !m_upgradeDetectorV7.init()) { logger(ERROR, BRIGHT_RED) << "Failed to initialize upgrade detector."; } @@ -473,10 +487,12 @@ bool Blockchain::init(const std::string& config_folder, bool load_existing) { else if (checkAndRollback(m_upgradeDetectorV4)) {} else if (checkAndRollback(m_upgradeDetectorV5)) {} else if (checkAndRollback(m_upgradeDetectorV6)) {} + else if (checkAndRollback(m_upgradeDetectorV7)) {} if (reinitUpgradeDetectors && (!m_upgradeDetectorV2.init() || !m_upgradeDetectorV3.init() || - !m_upgradeDetectorV4.init() || !m_upgradeDetectorV5.init() || !m_upgradeDetectorV6.init())) { + !m_upgradeDetectorV4.init() || !m_upgradeDetectorV5.init() || + !m_upgradeDetectorV6.init() || !m_upgradeDetectorV7.init())) { logger(ERROR, BRIGHT_RED) << "Failed to initialize upgrade detector"; return false; } @@ -740,8 +756,42 @@ uint64_t Blockchain::getCoinsInCirculation(uint32_t height) { return meta.alreadyGeneratedCoins; } +uint64_t Blockchain::getConfidentialSupply() { + std::lock_guard lk(m_blockchain_lock); + uint32_t h = m_db.getChainHeight(); + if (h == 0) return 0; + DbBlockMeta meta{}; + m_db.getBlockMeta(h - 1, meta); + return meta.confidentialSupply; +} + +uint64_t Blockchain::getConfidentialSupply(uint32_t height) { + std::lock_guard lk(m_blockchain_lock); + DbBlockMeta meta{}; + m_db.getBlockMeta(height, meta); + return meta.confidentialSupply; +} + +uint64_t Blockchain::getPqPlainSupply() { + std::lock_guard lk(m_blockchain_lock); + uint32_t h = m_db.getChainHeight(); + if (h == 0) return 0; + DbBlockMeta meta{}; + m_db.getBlockMeta(h - 1, meta); + return meta.pqPlainSupply; +} + +uint64_t Blockchain::getPqPlainSupply(uint32_t height) { + std::lock_guard lk(m_blockchain_lock); + DbBlockMeta meta{}; + m_db.getBlockMeta(height, meta); + return meta.pqPlainSupply; +} + uint8_t Blockchain::getBlockMajorVersionForHeight(uint32_t height) const { - if (height > m_upgradeDetectorV6.upgradeHeight()) { + if (height > m_upgradeDetectorV7.upgradeHeight()) { + return m_upgradeDetectorV7.targetVersion(); + } else if (height > m_upgradeDetectorV6.upgradeHeight()) { return m_upgradeDetectorV6.targetVersion(); } else if (height > m_upgradeDetectorV5.upgradeHeight()) { return m_upgradeDetectorV5.targetVersion(); @@ -974,6 +1024,11 @@ bool Blockchain::getBlockLongHash(Crypto::cn_context& context, const Block& b, C // directly from the block template instead of getCurrentBlockchainHeight(). const uint32_t currentHeight = boost::get(b.baseTransaction.inputs[0]).blockIndex; const uint32_t unlockWindow = static_cast(m_currency.minedMoneyUnlockWindow()); + if (currentHeight <= unlockWindow + 1) { + logger(ERROR, BRIGHT_RED) << "[POW] block height " << currentHeight + << " too low for v5+ PoW (unlockWindow=" << unlockWindow << ")"; + return false; + } const uint32_t maxHeight = currentHeight - 1 - unlockWindow; #define ITER 128 @@ -1290,7 +1345,7 @@ bool Blockchain::validate_miner_transaction(const Block& b, uint32_t height, auto blockMajorVersion = getBlockMajorVersionForHeight(height); if (!m_currency.getBlockReward(blockMajorVersion, blocksSizeMedian, cumulativeBlockSize, - alreadyGeneratedCoins, fee, reward, emissionChange)) { + alreadyGeneratedCoins, fee, reward, emissionChange, height)) { logger(INFO, BRIGHT_WHITE) << "block size " << cumulativeBlockSize << " is bigger than allowed for this blockchain"; return false; @@ -1396,8 +1451,30 @@ bool Blockchain::switch_to_alternative_blockchain(const std::list& for (auto alt_ch_iter = alt_chain.begin(); alt_ch_iter != alt_chain.end(); alt_ch_iter++) { const auto& ch_ent_h = *alt_ch_iter; block_verification_context bvc = boost::value_initialized(); - const Block& b = m_alternative_chains[ch_ent_h].bl; - bool r = pushBlock(b, get_block_hash(b), bvc); + const BlockEntry& alt_bei = m_alternative_chains[ch_ent_h]; + const Block& b = alt_bei.bl; + // Replay using the transaction bodies we snapshotted at alt-block-accept + // time. The (Block, vector, hash, bvc) overload does NOT + // touch the mempool, so reorg success no longer depends on whether the + // pool still happens to cache these bodies. After a successful push we + // also take-and-discard each hash from the pool to mirror the cleanup + // that the load-from-pool path used to do implicitly. + std::vector alt_txs; + alt_txs.reserve(alt_bei.transactions.size()); + for (const auto& te : alt_bei.transactions) { + alt_txs.push_back(te.tx); + } + const Crypto::Hash bh = get_block_hash(b); + bool r = pushBlock(b, alt_txs, bh, bvc); + if (r && bvc.m_added_to_main_chain) { + // Best-effort pool cleanup; the tx may or may not still be present and + // either outcome is fine — what matters is that it's not left in the + // mempool now that it has been committed to LMDB. + Transaction discarded; size_t discardedSize; uint64_t discardedFee; + for (const Crypto::Hash& txh : b.transactionHashes) { + (void)m_tx_pool.take_tx(txh, discarded, discardedSize, discardedFee); + } + } if (!r || !bvc.m_added_to_main_chain) { logger(INFO, BRIGHT_WHITE) << "Failed to switch to alternative blockchain"; rollback_blockchain_switching(disconnected_chain, split_height); @@ -1616,6 +1693,52 @@ bool Blockchain::handle_alternative_block(const Block& b, const Crypto::Hash& id } bei.cumulative_difficulty += current_diff; + // Snapshot the non-coinbase transaction bodies from the mempool into the + // alt block entry. Without this, switch_to_alternative_blockchain replays + // alt blocks via pushBlock(b, hash, bvc) → loadTransactions() → take_tx(), + // which depends on the local mempool still holding every referenced tx at + // reorg time. Alt blocks may sit in m_alternative_chains for a long time + // before they win on cumulative difficulty, and during that window the + // pool can churn (TTL eviction, restart, mined-block clearance) — a valid + // CT alt chain could then fail to switch in just because this node no + // longer happens to cache the bodies. Capture them now while the protocol + // layer has just admitted them, so reorg success depends on validated + // alt-chain data rather than local pool state. + // + // getTransaction() copies (does not consume) the body. If the tx is not in + // the pool, fall back to the current main chain: competing branches may + // legitimately include the same tx that our active branch has already + // mined, and Core::add_new_tx() deliberately keeps such txs out of the + // mempool. If any referenced tx is missing from both stores, reject the alt + // block; we cannot validate or replay it later from incomplete state. + bei.transactions.clear(); + bei.transactions.reserve(b.transactionHashes.size()); + for (const Crypto::Hash& txh : b.transactionHashes) { + Transaction tx; + if (!m_tx_pool.getTransaction(txh, tx)) { + uint32_t block = 0; + uint16_t txSlot = 0; + if (!m_db.getTxIndex(txh, block, txSlot)) { + logger(INFO, BRIGHT_RED) << "Alt block " << id << " references tx " << txh + << " not in pool or current chain; cannot snapshot, rejecting alt block."; + bvc.m_verification_failed = true; + return false; + } + + try { + tx = transactionByIndex({block, txSlot}).tx; + } catch (const std::exception& e) { + logger(INFO, BRIGHT_RED) << "Alt block " << id << " references tx " << txh + << " indexed in current chain but unreadable: " << e.what(); + bvc.m_verification_failed = true; + return false; + } + } + TransactionEntry te; + te.tx = std::move(tx); + bei.transactions.push_back(std::move(te)); + } + auto i_res = m_alternative_chains.insert(blocks_ext_by_hash::value_type(id, bei)); if (!i_res.second) { logger(ERROR, BRIGHT_RED) << "insertion of new alternative block returned as it already exist"; @@ -1802,7 +1925,10 @@ bool Blockchain::add_out_to_get_random_outs(uint64_t amount, size_t globalIdx, << outIdx << " more than transaction outputs = " << te.tx.outputs.size(); return false; } - if (!(te.tx.outputs[outIdx].target.type() == typeid(KeyOutput))) { + const auto& target = te.tx.outputs[outIdx].target; + const bool isKeyOutput = target.type() == typeid(KeyOutput); + const bool isConfidentialOutput = target.type() == typeid(ConfidentialOutput); + if (!isKeyOutput && !isConfidentialOutput) { logger(ERROR, BRIGHT_RED) << "unknown tx out type"; return false; } @@ -1814,7 +1940,23 @@ bool Blockchain::add_out_to_get_random_outs(uint64_t amount, size_t globalIdx, *result_outs.outs.insert(result_outs.outs.end(), COMMAND_RPC_GET_RANDOM_OUTPUTS_FOR_AMOUNTS::out_entry()); oen.global_amount_index = static_cast(globalIdx); - oen.out_key = boost::get(te.tx.outputs[outIdx].target).key; + if (isKeyOutput) { + oen.out_key = boost::get(target).key; + if (!Crypto::transparent_amount_to_commitment(te.tx.outputs[outIdx].amount, oen.commitment)) { + logger(ERROR, BRIGHT_RED) << "internal error: failed to build transparent commitment for amount=" + << te.tx.outputs[outIdx].amount << " globalIdx=" << globalIdx; + result_outs.outs.pop_back(); + return false; + } + oen.output_type = static_cast(TransactionTypes::OutputType::Key); + } else { + const auto& cout = boost::get(target); + oen.out_key = cout.targetKey; + oen.commitment = cout.commitment; + oen.output_type = static_cast(TransactionTypes::OutputType::Confidential); + } + oen.block_height = block; + oen.is_coinbase = (txSlot == 0) ? 1 : 0; return true; } @@ -2024,11 +2166,12 @@ bool Blockchain::getTransactionOutputGlobalIndexes(const Crypto::Hash& tx_id, // ─── Transaction input validation ──────────────────────────────────────────── bool Blockchain::checkTransactionInputs(const Transaction& tx, uint32_t& max_used_block_height, - Crypto::Hash& max_used_block_id, BlockInfo* tail) { + Crypto::Hash& max_used_block_id, + TxValidationContext context, BlockInfo* tail) { std::lock_guard lk(m_blockchain_lock); if (tail) tail->id = getTailId(tail->height); - bool res = checkTransactionInputs(tx, &max_used_block_height); + bool res = checkTransactionInputs(tx, context, &max_used_block_height); if (!res) return false; uint32_t chainHeight = m_db.getChainHeight(); @@ -2049,12 +2192,63 @@ bool Blockchain::haveTransactionKeyImagesAsSpent(const Transaction& tx) { if (have_tx_keyimg_as_spent(boost::get(in).keyImage)) { return true; } + } else if (in.type() == typeid(ConfidentialInput)) { + if (have_tx_keyimg_as_spent(boost::get(in).keyImage)) { + return true; + } } } return false; } -bool Blockchain::checkTransactionInputs(const Transaction& tx, uint32_t* pmax_used_block_height) { +bool Blockchain::checkTransactionInputs(const Transaction& tx, + TxValidationContext context, + uint32_t* pmax_used_block_height) { + // Consensus-shaped invariants — must hold identically on every code path + // (mempool admission via Core::check_tx_semantic, block import, alt-chain + // reorg, checkpointed-block replay). Delegated to the shared + // TransactionValidation module so this dispatcher and check_tx_semantic + // cannot drift. Anything that used to be mirrored inline here and is now + // upstream: empty-side reject, supported version, input/output variant + // shape, check_outs_valid (incl. CT amount==0), check_money_overflow, + // signatures.size()==inputs.size(), per-input authorization variant match, + // KeyInput outputIndexes structure + ring-sig count, ConfidentialInput + // ring-size consistency + Triptych proof slot, intra-tx keyimage + // uniqueness, v1 plain amount_in >= amount_out, CT unlockTime within v6 cap. + { + std::string shapeErr; + if (!checkTransactionConsensusShape(tx, /*blockHeight=*/0u, m_currency, &shapeErr)) { + logger(ERROR) << "tx fails consensus shape: " << shapeErr + << " in tx " << getObjectHash(tx); + return false; + } + } + + // CT transactions use a dedicated validation pipeline + if (isCtFamilyTransactionVersion(tx.version)) { + if (pmax_used_block_height) *pmax_used_block_height = 0; + Crypto::Hash transactionHash = getObjectHash(tx); + // v2 is all-confidential outputs; v3 (unshield) permits mixed + // ConfidentialOutput + transparent KeyOutput. Both are handled by the CT + // pipeline below: plain outputs enter the balance kernel as amount*H and + // are skipped by the GK/curve-membership loops (their key validity is + // enforced upstream by check_outs_valid). + + // Under a confirmed checkpoint the block hash is already trusted by the + // network. Run only cheap structural checks so historical CT blocks can + // stream through the pool/index path without re-verifying Triptych, GK and + // balance kernels. + if (context == TxValidationContext::CheckpointedBlock) { + return checkConfidentialTransactionStructure(tx, transactionHash); + } + return checkConfidentialTransaction(tx, transactionHash, pmax_used_block_height); + } + + // Transparent path. The legacy checkpoint short-circuit for per-input ring + // signature checks lives inside the prefix-hash overload below; the context + // doesn't change its behavior because non-CT validation already takes the + // cheap path under checkpoint via `isInCheckpointZone(...)` there. + (void)context; Crypto::Hash tx_prefix_hash = getObjectHash(*static_cast(&tx)); return checkTransactionInputs(tx, tx_prefix_hash, pmax_used_block_height); } @@ -2078,7 +2272,7 @@ bool Blockchain::checkTransactionInputs(const Transaction& tx, const Crypto::Has return false; } if (!isInCheckpointZone(getCurrentBlockchainHeight())) { - if (!check_tx_input(in_to_key, tx_prefix_hash, tx.signatures[inputIndex], pmax_used_block_height)) { + if (!check_tx_input(in_to_key, tx_prefix_hash, keyInputSig(tx.signatures[inputIndex]), pmax_used_block_height)) { logger(INFO, BRIGHT_WHITE) << "Failed to check input in transaction " << transactionHash; return false; } @@ -2094,13 +2288,23 @@ bool Blockchain::checkTransactionInputs(const Transaction& tx, const Crypto::Has } bool Blockchain::is_tx_spendtime_unlocked(uint64_t unlock_time) { + const uint32_t currentHeight = getCurrentBlockchainHeight(); + if (m_currency.isUnlockTimeCappedAt(currentHeight)) { + // v6+ consensus: height-only, capped. Any unlock_time above the cap was + // set under the legacy dual-interpretation rules (e.g. a Unix timestamp + // mistakenly placed in unlock_time) and is treated as unlocked here so + // the underlying output becomes spendable again. + if (unlock_time == 0) return true; + if (unlock_time > CryptoNote::parameters::CRYPTONOTE_MAX_UNLOCK_HEIGHT_V6) return true; + return currentHeight - 1 + m_currency.lockedTxAllowedDeltaBlocks() >= unlock_time; + } if (unlock_time < m_currency.maxBlockHeight()) { - if (getCurrentBlockchainHeight() - 1 + m_currency.lockedTxAllowedDeltaBlocks() >= unlock_time) + if (currentHeight - 1 + m_currency.lockedTxAllowedDeltaBlocks() >= unlock_time) return true; else return false; } else { - const uint64_t lastBlockTimestamp = getBlockTimestamp(getCurrentBlockchainHeight() - 1); + const uint64_t lastBlockTimestamp = getBlockTimestamp(currentHeight - 1); if (lastBlockTimestamp + m_currency.lockedTxAllowedDeltaSeconds() >= unlock_time) return true; else @@ -2110,6 +2314,11 @@ bool Blockchain::is_tx_spendtime_unlocked(uint64_t unlock_time) { } bool Blockchain::is_tx_spendtime_unlocked(uint64_t unlock_time, uint32_t height) { + if (m_currency.isUnlockTimeCappedAt(height)) { + if (unlock_time == 0) return true; + if (unlock_time > CryptoNote::parameters::CRYPTONOTE_MAX_UNLOCK_HEIGHT_V6) return true; + return height - 1 + m_currency.lockedTxAllowedDeltaBlocks() >= unlock_time; + } if (unlock_time < m_currency.maxBlockHeight()) { if (height - 1 + m_currency.lockedTxAllowedDeltaBlocks() >= unlock_time) return true; @@ -2119,7 +2328,8 @@ bool Blockchain::is_tx_spendtime_unlocked(uint64_t unlock_time, uint32_t height) bool Blockchain::check_tx_input(const KeyInput& txin, const Crypto::Hash& tx_prefix_hash, const std::vector& sig, - uint32_t* pmax_related_block_height) { + uint32_t* pmax_related_block_height, + bool forceFullRingSigCheck) { std::lock_guard lk(m_blockchain_lock); struct outputs_visitor { @@ -2182,7 +2392,16 @@ bool Blockchain::check_tx_input(const KeyInput& txin, const Crypto::Hash& tx_pre return false; } - if (isInCheckpointZone(getCurrentBlockchainHeight())) return true; + // Legacy checkpoint-zone fast path: under a confirmed checkpoint we trust + // the network's verdict on the ring signature and skip the verification. + // Disabled when the caller demands a full check (in particular, the CT + // shield-in path passes forceFullRingSigCheck=true so a poisoned + // checkpoint cannot let a fake transparent claim mint a balanced CT + // output — the resulting confidentialSupply inflation is not recoverable + // the way a stale legacy ring sig is). See the comment on the .h + // declaration for the full rationale. + if (!forceFullRingSigCheck && isInCheckpointZone(getCurrentBlockchainHeight())) + return true; bool check_tx_ring_signature = Crypto::check_ring_signature( tx_prefix_hash, txin.keyImage, output_keys, sig.data()); @@ -2192,6 +2411,828 @@ bool Blockchain::check_tx_input(const KeyInput& txin, const Crypto::Hash& tx_pre return check_tx_ring_signature; } +// ─── Confidential Transaction Validation Pipeline (spec Section 15) ────────── + +bool Blockchain::checkConfidentialTransaction(const Transaction& tx, const Crypto::Hash& txHash, + uint32_t* pmax_used_block_height) { + using namespace CryptoNote::parameters; + std::lock_guard lk(m_blockchain_lock); + + // Version, empty-side, and output-variant invariants are now enforced + // upstream by checkTransactionConsensusShape (called from both + // checkTransactionInputs and Core::check_tx_semantic). By the time we + // reach here every output is guaranteed to be a ConfidentialOutput with + // amount == 0; this validator only needs to run the curve-level checks + // that require crypto context. + + // Use the prefix hash for Fiat-Shamir binding. Proof response fields (Triptych, GK, kernel) + // are in the Transaction body, not the prefix, so the prefix hash naturally excludes them. + const Crypto::Hash ct_signing_hash = getObjectHash(*static_cast(&tx)); + + // Step 2: For each CONFIDENTIAL output: verify subgroup membership of + // commitment C and of the stealth public key. v2 is all-confidential; v3 + // unshield may also carry transparent KeyOutputs, which have no commitment + // or CT stealth key — their key validity (on-curve, non-zero amount, no + // duplicates) is enforced upstream by check_outs_valid. We count confidential + // outputs here so the GK-proof count check below can require exactly one GK + // proof per confidential output (and zero for a pure unshield). + size_t numConfidentialOutputs = 0; + for (size_t i = 0; i < tx.outputs.size(); ++i) { + if (tx.outputs[i].target.type() != typeid(ConfidentialOutput)) { + continue; // transparent KeyOutput (v3 unshield) — no CT curve checks + } + ++numConfidentialOutputs; + const auto& cout = boost::get(tx.outputs[i].target); + + // Verify targetKey is a valid prime-order CT public key (stealth address) + if (!Crypto::ct_public_key_valid(cout.targetKey)) { + logger(ERROR) << "CT validation: output " << i << " targetKey is not a valid CT public key in tx " << txHash; + return false; + } + + if (!Crypto::point_valid_for_pedersen(cout.commitment)) { + logger(ERROR) << "CT validation: output " << i << " commitment fails subgroup check in tx " << txHash; + return false; + } + } + + // GK denomination proofs: exactly one per confidential output. For a v2 tx + // this equals tx.outputs.size(); for a v3 unshield with transparent outputs + // it is strictly fewer, and a pure unshield (no confidential outputs) carries + // zero GK proofs. ctProofs[j] corresponds to the j-th confidential output in + // output order — the wallet builder MUST emit proofs in this same order. + if (tx.ctProofs.size() != numConfidentialOutputs) { + logger(ERROR) << "CT validation: ctProofs count " << tx.ctProofs.size() + << " != confidential-output count " << numConfidentialOutputs << " in tx " << txHash; + return false; + } + // Wire deserialization enforces tx.signatures.size() == tx.inputs.size(), + // but this validator also runs on internally-constructed transactions + // (block-template assembly, RPC submitrawtransaction reconstruction, test + // fixtures, alt-block reorgs) that never went through CryptoNoteSerialization. + // Check explicitly so the per-input loop below cannot index past + // tx.signatures. + if (tx.signatures.size() != tx.inputs.size()) { + logger(ERROR) << "CT validation: signatures count " << tx.signatures.size() + << " != inputs count " << tx.inputs.size() << " in tx " << txHash; + return false; + } + + // Step 3: For all outputs: verify GK denomination membership proofs in + // one batched call. Reconstruct each on-wire CTOutputProof into a + // Crypto::GKProof first (point decode + scalar copy); any output that + // fails to decode is rejected up-front the same way as the per-output + // path. Then run a single gk_verify_batch over all outputs. + // + // On a batched failure we fall back to per-output gk_verify to pinpoint + // *which* output is bad — this only fires on rejection, so the happy + // path stays purely batched. + std::vector gkCommitments; + std::vector gkProofs; + gkCommitments.reserve(tx.outputs.size()); + gkProofs.reserve(tx.outputs.size()); + + size_t confProofIdx = 0; + for (size_t i = 0; i < tx.outputs.size(); ++i) { + if (tx.outputs[i].target.type() != typeid(ConfidentialOutput)) { + continue; // transparent KeyOutput (v3 unshield) — no GK denomination proof + } + const auto& cout = boost::get(tx.outputs[i].target); + const auto& gkp = tx.ctProofs[confProofIdx]; + ++confProofIdx; + Crypto::GKProof proof; + for (size_t j = 0; j < 6; ++j) { + if (ge_frombytes_vartime(&proof.I[j], + reinterpret_cast(&gkp.I[j])) != 0) { + logger(ERROR) << "CT validation: output " << i << " GK proof I[" << j << "] invalid point in tx " << txHash; + return false; + } + if (ge_frombytes_vartime(&proof.A[j], + reinterpret_cast(&gkp.A[j])) != 0) { + logger(ERROR) << "CT validation: output " << i << " GK proof A[" << j << "] invalid point in tx " << txHash; + return false; + } + if (ge_frombytes_vartime(&proof.B[j], + reinterpret_cast(&gkp.B[j])) != 0) { + logger(ERROR) << "CT validation: output " << i << " GK proof B[" << j << "] invalid point in tx " << txHash; + return false; + } + if (ge_frombytes_vartime(&proof.Q[j], + reinterpret_cast(&gkp.Q[j])) != 0) { + logger(ERROR) << "CT validation: output " << i << " GK proof Q[" << j << "] invalid point in tx " << txHash; + return false; + } + proof.z[j] = gkp.z[j]; + proof.za[j] = gkp.za[j]; + proof.zb[j] = gkp.zb[j]; + } + proof.f = gkp.f; + + gkCommitments.push_back(cout.commitment); + gkProofs.push_back(std::move(proof)); + } + + if (!gkCommitments.empty() && + !Crypto::gk_verify_batch(gkCommitments.data(), gkProofs.data(), + gkCommitments.size(), ct_signing_hash)) { + // Slow diagnostic path: identify the first offending output so the log + // doesn't just say "the batch failed". Only runs on rejection, so the + // happy path stays purely batched. + for (size_t i = 0; i < gkProofs.size(); ++i) { + if (!Crypto::gk_verify(gkCommitments[i], gkProofs[i], ct_signing_hash)) { + logger(ERROR) << "CT validation: output " << i << " GK membership proof failed in tx " << txHash; + return false; + } + } + // All per-output checks passed individually but the batched check + // failed — should be statistically impossible (~2^-252). Log as a + // soundness anomaly rather than silently accepting. + logger(ERROR) << "CT validation: batched GK proof failed but all proofs verify individually in tx " + << txHash << " (soundness anomaly — investigate)"; + return false; + } + + // Step 4: For each input, validate on-chain ring member binding and subgroup checks. + // CT v2 supports mixed inputs: + // - KeyInput: transparent shielding into the CT pool. Verified here via + // check_tx_input (legacy ring signature). Does not participate in the + // batched Triptych verify; its verifiedRingPubkeys/Commitments slot is + // left empty. + // - ConfidentialInput: CT-to-CT or transparent-decoy spend. Ring members + // resolved here and fed into the batched Triptych verify in Step 5. + std::vector> verifiedRingPubkeys(tx.inputs.size()); + std::vector> verifiedRingCommitments(tx.inputs.size()); + for (size_t i = 0; i < tx.inputs.size(); ++i) { + if (tx.inputs[i].type() == typeid(KeyInput)) { + const auto& ki = boost::get(tx.inputs[i]); + if (ki.amount == 0) { + logger(ERROR) << "CT validation: KeyInput " << i << " has zero amount in tx " << txHash; + return false; + } + if (ki.outputIndexes.empty()) { + logger(ERROR) << "CT validation: KeyInput " << i << " has empty ring in tx " << txHash; + return false; + } + if (!isKeyInputSig(tx.signatures[i]) || + keyInputSig(tx.signatures[i]).size() != ki.outputIndexes.size()) { + logger(ERROR) << "CT validation: KeyInput " << i << " ring sig count mismatch in tx " << txHash; + return false; + } + // Legacy ring sig + key-image-not-spent + ring-member existence / + // unlock-time / pubkey resolution. ct_signing_hash is the prefix hash, + // identical to what check_tx_input expects for the legacy path. + // + // forceFullRingSigCheck=true: CT shield-in must verify the ring sig + // even inside a checkpoint zone. A skipped ring sig here means a + // forged claim of a transparent output, followed by minting a + // balanced CT output against it — permanent confidentialSupply + // inflation. The transparent-only legacy path can recover from a + // bad ring sig (the bogus state lives in plain UTXO indexes that a + // reorg can roll back); the CT pool integer cannot. + if (have_tx_keyimg_as_spent(ki.keyImage)) { + logger(DEBUGGING) << "CT validation: KeyInput " << i << " key image already spent in tx " << txHash; + return false; + } + if (!check_tx_input(ki, ct_signing_hash, keyInputSig(tx.signatures[i]), + pmax_used_block_height, /*forceFullRingSigCheck=*/true)) { + logger(ERROR) << "CT validation: KeyInput " << i << " ring sig check failed in tx " << txHash; + return false; + } + continue; + } + + if (tx.inputs[i].type() != typeid(ConfidentialInput)) { + logger(ERROR) << "CT validation: input " << i << " has unsupported type in tx " << txHash; + return false; + } + const auto& cin = boost::get(tx.inputs[i]); + const size_t ringSize = cin.ringMembers.size(); + + if (ringSize == 0) { + logger(ERROR) << "CT validation: input " << i << " has empty ring in tx " << txHash; + return false; + } + // Triptych supports ring sizes 4, 8, 16. Ring size 1 used to be allowed + // as a Schnorr-branch carve-out for v5+ coinbase, but the simpler + // "two independent Schnorr proofs" shape did not bind the same x in + // P = xG and J = x·U — a holder could forge fresh key images for + // the same spend. Phase B routes transparent shielding (coinbase + // included) through v2 KeyInput with a legacy ring signature, so + // ConfidentialInput never needs ring size 1 in practice. + if (!Crypto::triptych_ring_size_supported(ringSize)) { + logger(ERROR) << "CT validation: input " << i << " ring size " << ringSize + << " is not a supported Triptych shape (4, 8, or 16)" + << " in tx " << txHash; + return false; + } + if (ringSize != cin.ringPubkeys.size() || ringSize != cin.ringCommitments.size()) { + logger(ERROR) << "CT validation: input " << i << " ring field size mismatch in tx " << txHash; + return false; + } + + if (!Crypto::point_valid_for_pedersen(cin.pseudoCommitment)) { + logger(ERROR) << "CT validation: input " << i << " pseudo-commitment fails subgroup check in tx " << txHash; + return false; + } + + // Key image subgroup check: 8*I != identity && I != identity + // Use the same check as transparent: l*I == identity (I is in prime-order subgroup) + if (!(Crypto::scalarmultKey(cin.keyImage, Crypto::EllipticCurveScalar2KeyImage(Crypto::L)) + == Crypto::EllipticCurveScalar2KeyImage(Crypto::I))) { + logger(ERROR) << "CT validation: input " << i << " key image not in valid domain in tx " << txHash; + return false; + } + + // Canonical ordering: members must be sorted by (amount, outputIndex) + // strictly ascending. Same amount → strictly-ascending outputIndex + // (no duplicates within a bucket); different amounts → amount non- + // decreasing, with the cross-bucket break sorting any outputIndex pair. + // This pins ring metadata against malleability across the mixed-bucket + // ring format. + for (size_t k = 1; k < ringSize; ++k) { + const auto& prev = cin.ringMembers[k - 1]; + const auto& cur = cin.ringMembers[k]; + const bool ascending = (cur.amount > prev.amount) || + (cur.amount == prev.amount && cur.outputIndex > prev.outputIndex); + if (!ascending) { + logger(ERROR) << "CT validation: input " << i << " ring members not in canonical (amount, outputIndex)" + << " strictly-ascending order at slot " << k << " in tx " << txHash; + return false; + } + } + + auto& boundPubkeys = verifiedRingPubkeys[i]; + auto& boundCommitments = verifiedRingCommitments[i]; + boundPubkeys.reserve(ringSize); + boundCommitments.reserve(ringSize); + + // Cache per-bucket output counts so we don't hit LMDB once per ring + // member of the same amount. + std::unordered_map bucketOutputCount; + + // Defense-in-depth: even with the (amount, outputIndex) strict-ascending + // check above guaranteeing distinct ring slots, two different slots could + // in principle resolve to outputs that share the same one-time stealth + // pubkey (e.g. a malformed earlier tx). Reject pubkey duplicates per ring + // so a claimed ring-size-N can never effectively shrink to fewer than N + // distinct decoys. + std::unordered_set seenRingPubkeys; + seenRingPubkeys.reserve(ringSize); + + for (size_t k = 0; k < ringSize; ++k) { + const uint64_t memberAmount = cin.ringMembers[k].amount; + const uint32_t memberOffset = cin.ringMembers[k].outputIndex; + + if (memberAmount == 0) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " has zero amount bucket in tx " << txHash; + return false; + } + + auto cacheIt = bucketOutputCount.find(memberAmount); + if (cacheIt == bucketOutputCount.end()) { + const uint32_t outputCount = m_db.getKeyOutputCount(memberAmount); + if (outputCount == 0) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " references empty bucket (amount=" << memberAmount << ") in tx " << txHash; + return false; + } + cacheIt = bucketOutputCount.emplace(memberAmount, outputCount).first; + } + if (memberOffset >= cacheIt->second) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " offset " << memberOffset << " out of range (amount=" << memberAmount + << ", bucket size=" << cacheIt->second << ") in tx " << txHash; + return false; + } + + uint32_t block = 0; + uint16_t txSlot = 0; + uint16_t outIdx = 0; + if (!m_db.getKeyOutput(memberAmount, memberOffset, block, txSlot, outIdx)) { + logger(ERROR) << "CT validation: input " << i << " failed to resolve ring member " << k + << " (amount=" << memberAmount << ", index=" << memberOffset << ") in tx " << txHash; + return false; + } + + TransactionEntry te = transactionByIndex({block, txSlot}); + if (outIdx >= te.tx.outputs.size()) { + logger(ERROR) << "CT validation: input " << i << " resolved ring member " << k + << " with invalid output index in tx " << txHash; + return false; + } + + if (!is_tx_spendtime_unlocked(te.tx.unlockTime)) { + logger(ERROR) << "CT validation: input " << i << " references locked output at ring member " << k + << " in tx " << txHash; + return false; + } + + const auto& referencedOutput = te.tx.outputs[outIdx]; + // Confidential-only rings (Route 1). A ConfidentialInput ring member MUST + // resolve to a ConfidentialOutput in the sentinel bucket. Transparent + // KeyOutputs are no longer admitted as ring members: the CT key image is + // J = x·U (fixed generator) while a transparent output's image is x·Hp(P) + // — allowing a transparent output to be CT-spent here would give the same + // output two distinct image formats and break double-spend detection. + // Transparent outputs are spendable only via a legacy KeyInput. + // (As decoys, transparent members added no anonymity anyway — their type + // is visible on-chain, so an analyst trivially excludes them.) + if (referencedOutput.target.type() != typeid(ConfidentialOutput)) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " is not a confidential output (confidential-only rings) in tx " << txHash; + return false; + } + if (memberAmount != CryptoNote::parameters::CT_CONFIDENTIAL_OUTPUT_AMOUNT) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " member amount " << memberAmount + << " != CT_CONFIDENTIAL_OUTPUT_AMOUNT in tx " << txHash; + return false; + } + + const auto& referencedConfidentialOutput = boost::get(referencedOutput.target); + Crypto::PublicKey referencedPubkey = referencedConfidentialOutput.targetKey; + Crypto::EllipticCurvePoint expectedCommitment = referencedConfidentialOutput.commitment; + + if (!Crypto::ct_public_key_valid(referencedPubkey)) { + logger(ERROR) << "CT validation: input " << i << " ring pubkey " << k << " invalid on-chain in tx " << txHash; + return false; + } + if (!(referencedPubkey == cin.ringPubkeys[k])) { + logger(ERROR) << "CT validation: input " << i << " ring pubkey " << k + << " does not match referenced output in tx " << txHash; + return false; + } + + if (!Crypto::point_valid_for_pedersen(expectedCommitment)) { + logger(ERROR) << "CT validation: input " << i << " expected ring commitment " << k + << " fails subgroup check in tx " << txHash; + return false; + } + if (!(expectedCommitment == cin.ringCommitments[k])) { + logger(ERROR) << "CT validation: input " << i << " ring commitment " << k + << " does not match referenced output in tx " << txHash; + return false; + } + + if (!seenRingPubkeys.insert(referencedPubkey).second) { + logger(ERROR) << "CT validation: input " << i << " ring member " << k + << " has duplicate one-time pubkey in tx " << txHash; + return false; + } + boundPubkeys.push_back(referencedPubkey); + boundCommitments.push_back(expectedCommitment); + + if (pmax_used_block_height && *pmax_used_block_height < block) { + *pmax_used_block_height = block; + } + } + } + + // Step 5: Verify all CT input Triptych spend proofs in one batched MSM. + // + // Ring size for each input is constrained to a Triptych-supported shape + // (4, 8, or 16) by triptych_ring_size_supported above. The serializer + // pins n on each proof body to {2, 3, 4}; here we additionally enforce + // that the proof's shape matches its input's ring size — a prover + // can't swap a ring-size-8 body against a ring-size-4 input. + // + // Batched path: triptych_verify_batch derives α from a Fiat-Shamir + // transcript that hashes every proof byte (so α is unpredictable to + // the prover at sign time) and folds every α-scaled per-input + // equation into one Pippenger MSM. ~3-5× faster than per-input verify + // at txs with several inputs. On failure we fall back to per-input + // triptych_verify to identify which input is malformed. + std::vector batch_ring_pubkeys; + std::vector batch_ring_commits; + std::vector batch_pseudo_commits; + std::vector batch_ring_sizes; + std::vector batch_key_images; + std::vector batch_proofs; + // Map each entry in the batch vectors back to the original tx.inputs + // index it came from, so the per-input fallback log below reports the + // real position (mixed v2 txs skip KeyInput slots when batching). + std::vector batch_input_indices; + batch_ring_pubkeys.reserve(tx.inputs.size()); + batch_ring_commits.reserve(tx.inputs.size()); + batch_pseudo_commits.reserve(tx.inputs.size()); + batch_ring_sizes.reserve(tx.inputs.size()); + batch_key_images.reserve(tx.inputs.size()); + batch_proofs.reserve(tx.inputs.size()); + batch_input_indices.reserve(tx.inputs.size()); + + for (size_t i = 0; i < tx.inputs.size(); ++i) { + if (tx.inputs[i].type() == typeid(KeyInput)) { + // KeyInput is verified by check_tx_input above. It does not participate + // in the batched Triptych verify — Schnorr-on-G against amount*H + 0*G + // would be redundant with the legacy ring sig that already binds the + // input to a real on-chain KeyOutput. + continue; + } + const auto& cin = boost::get(tx.inputs[i]); + if (!isCtInputSig(tx.signatures[i])) { + logger(ERROR) << "CT validation: input " << i << " missing Triptych proof slot in tx " << txHash; + return false; + } + const auto& sig = ctInputSig(tx.signatures[i]); + + const size_t ringSize = verifiedRingPubkeys[i].size(); + if (!Crypto::triptych_ring_size_supported(ringSize)) { + logger(ERROR) << "CT validation: input " << i << " ring size " << ringSize + << " is not a supported Triptych shape in tx " << txHash; + return false; + } + // Vector-length expectations: + // ring_size = 4 → bits = 2, q_len = 2 + // ring_size = 8 → bits = 3, q_len = 3 + // ring_size = 16 → bits = 4, q_len = 4 + const size_t expected_bits = (ringSize == 4) ? 2 : + (ringSize == 8) ? 3 : 4; + const size_t expected_q = expected_bits; + if (sig.I_bits.size() != expected_bits || + sig.A.size() != expected_bits || sig.B.size() != expected_bits || + sig.Q_P.size() != expected_q || sig.Q_M.size() != expected_q || + sig.Q_J.size() != expected_q || + sig.z.size() != expected_bits || sig.za.size() != expected_bits || + sig.zb.size() != expected_bits) { + logger(ERROR) << "CT validation: input " << i << " Triptych proof shape" + << " mismatches ring size in tx " << txHash; + return false; + } + + // The Crypto layer owns its own struct so we hand it the proof field- + // by-field. Copies here are 32 bytes per scalar / 32 bytes per point. + Crypto::TriptychSignature proof; + proof.I_bits = sig.I_bits; + proof.A = sig.A; + proof.B = sig.B; + proof.Q_P = sig.Q_P; + proof.Q_M = sig.Q_M; + proof.Q_J = sig.Q_J; + proof.z = sig.z; + proof.za = sig.za; + proof.zb = sig.zb; + proof.f_P = sig.f_P; + proof.f_M = sig.f_M; + + batch_ring_pubkeys.push_back(verifiedRingPubkeys[i].data()); + batch_ring_commits.push_back(verifiedRingCommitments[i].data()); + batch_pseudo_commits.push_back(cin.pseudoCommitment); + batch_ring_sizes.push_back(ringSize); + batch_key_images.push_back(cin.keyImage); + batch_proofs.push_back(std::move(proof)); + batch_input_indices.push_back(i); + } + + if (!batch_proofs.empty() && + !Crypto::triptych_verify_batch( + ct_signing_hash, + batch_ring_pubkeys.data(), + batch_ring_commits.data(), + batch_pseudo_commits.data(), + batch_ring_sizes.data(), + batch_key_images.data(), + batch_proofs.data(), + batch_proofs.size())) { + // Slow diagnostic path: identify the first offending input so the + // log doesn't just say "the batch failed". Only runs on rejection. + // Report the original tx.inputs index (mixed v2 txs skip KeyInput + // slots when batching, so the batch index can drift from the input + // index). + for (size_t j = 0; j < batch_proofs.size(); ++j) { + if (!Crypto::triptych_verify( + ct_signing_hash, + batch_ring_pubkeys[j], + batch_ring_commits[j], + batch_pseudo_commits[j], + batch_ring_sizes[j], + batch_key_images[j], + batch_proofs[j])) { + logger(ERROR) << "CT validation: input " << batch_input_indices[j] + << " Triptych proof failed in tx " << txHash; + return false; + } + } + // All inputs verify individually but the batch failed — should be + // statistically impossible (~2⁻²⁵²). Log as a soundness anomaly. + logger(ERROR) << "CT validation: batched Triptych verify failed but every" + << " input verifies individually in tx " << txHash + << " (soundness anomaly — investigate)"; + return false; + } + + // Step 6: Verify all ConfidentialInput key images are absent from global + // spent-key set. KeyInput key images were already checked in Step 4 via the + // have_tx_keyimg_as_spent / check_tx_input pair, so we skip them here. + // (Intra-transaction uniqueness across both input types is already enforced + // upstream by checkTransactionConsensusShape.) + for (size_t i = 0; i < tx.inputs.size(); ++i) { + if (tx.inputs[i].type() != typeid(ConfidentialInput)) continue; + const auto& cin = boost::get(tx.inputs[i]); + if (have_tx_keyimg_as_spent(cin.keyImage)) { + logger(DEBUGGING) << "CT validation: input " << i << " key image already spent in tx " << txHash; + return false; + } + } + + // Step 7: Verify subgroup membership of all GK proof elements + // GK proof point/scalar validation is performed inside gk_verify() + // when each output proof is checked above. No separate GK subgroup + // pass is needed here. + + // Step 8: Verify balance equation: sum(C_in) - sum(C_out) - fee*H = excess_commitment + { + // Collect input commitments per input type: + // ConfidentialInput → sender-chosen pseudoCommitment v*H + r*G + // KeyInput → deterministic transparent_amount_to_commitment(amount) + // = amount*H + 0*G (visible plain value entering pool) + // The excess kernel reflects only ConfidentialInput blindings; KeyInput + // contributes blinding 0 on both the input side (here) and the wallet's + // excess computation. + std::vector input_commits; + input_commits.reserve(tx.inputs.size()); + for (const auto& txin : tx.inputs) { + if (txin.type() == typeid(KeyInput)) { + const auto& ki = boost::get(txin); + Crypto::EllipticCurvePoint c; + if (!Crypto::transparent_amount_to_commitment(ki.amount, c)) { + logger(ERROR) << "CT validation: KeyInput amount-to-commitment failed in tx " << txHash; + return false; + } + input_commits.push_back(c); + } else { + const auto& cin = boost::get(txin); + input_commits.push_back(cin.pseudoCommitment); + } + } + + // Collect output commitments per output type: + // ConfidentialOutput → its Pedersen commitment C = v*H + r*G + // KeyOutput (v3 unshield) → transparent_amount_to_commitment(amount) + // = amount*H + 0*G (visible value leaving pool) + // + // This is the -(Sum plain_out)*H balance-kernel term — the one genuinely new + // consensus surface for v3. A plain output contributes amount*H to the output + // side, mirroring how a transparent KeyInput contributes amount*H to the + // input side. Critically it carries ZERO blinding on G: a plain term must add + // no G-component to the excess. Any G leaking from a "plain" output is an + // inflation bug. The amount bound here is the SAME out.amount consumed by + // computeCtPoolDelta (pool debit) and by output indexing, so the unshielded + // value is bound identically across kernel, pool accounting, and the index. + // + // Attack note (inflate-the-change): in a partial unshield the attacker's only + // free variable is the hidden value of a ConfidentialOutput "change". The + // equation Sum(C_in) - Sum(C_out) - fee*H = excess forces that hidden change + // value: plain_in and plain_out are pinned to visible amounts (amount*H, no + // G), so the change commitment cannot absorb extra value without breaking the + // kernel signature. The GK range proof additionally bounds the change to a + // canonical denomination, preventing a negative/wrapped change value. + std::vector output_commits; + output_commits.reserve(tx.outputs.size()); + for (const auto& txout : tx.outputs) { + if (txout.target.type() == typeid(ConfidentialOutput)) { + output_commits.push_back(boost::get(txout.target).commitment); + } else { + // Transparent KeyOutput: amount*H. check_outs_valid guarantees + // amount > 0, so the amount==0 reject path inside + // transparent_amount_to_commitment cannot fire here. + Crypto::EllipticCurvePoint c; + if (!Crypto::transparent_amount_to_commitment(txout.amount, c)) { + logger(ERROR) << "CT validation: KeyOutput amount-to-commitment failed in tx " << txHash; + return false; + } + output_commits.push_back(c); + } + } + + // Map CryptoNote::TransactionKernel to Crypto::TransactionKernel + // CryptoNote kernel has: excessCommitment (Point), sigE (Scalar), sigS (Scalar) + // Crypto kernel has: excess (Point), signature (Signature = {c, r}) + Crypto::TransactionKernel crypto_kernel{}; + crypto_kernel.excess = tx.kernel.excessCommitment; + // Signature is (e, s) pair stored as two 32-byte scalars + crypto_kernel.signature.c = tx.kernel.sigE; + crypto_kernel.signature.r = tx.kernel.sigS; + + if (!Crypto::verify_transaction_balance( + input_commits.data(), input_commits.size(), + output_commits.data(), output_commits.size(), + tx.fee, + ct_signing_hash, + crypto_kernel)) { + logger(ERROR) << "CT validation: balance equation / kernel signature failed in tx " << txHash; + return false; + } + } + + // Step 9: Verify kernel Schnorr signature (done as part of Step 8 in verify_transaction_balance) + + // Step 10: Verify fee >= 0 and within network fee policy + // (Already checked in Core::check_tx_fee; fee field is uint64_t so >= 0 is implicit) + + // Step 11: CT pool liability solvency. + // Reject before block inclusion if this tx would withdraw more visible value + // from the ECC CT pool than the pool currently holds. This duplicates the + // safety check performed at block-push time so that mempool admission, RPC + // sendrawtransaction, and block-template construction all fail-fast on a + // doomed tx instead of waiting for the miner to discover it. + // + // delta = visible_plain_inputs - visible_plain_outputs - fee + // < 0 means value is leaving the CT pool; |delta| must fit in the + // currently-accumulated pool. Mempool chaining can produce a sequence + // whose combined outflow exceeds the pool — that is caught at block-push + // time. The per-tx check here is necessary but not sufficient. + // + // computeCtPoolDelta sums plain_in/plain_out in checked uint64_t and + // reports overflow rather than wrapping or sign-flipping when individual + // KeyInput amounts approach MONEY_SUPPLY (1e19 > INT64_MAX). A wrap would + // misclassify the direction of the delta and let a CT outflow look like + // an inflow. + { + uint64_t inflow = 0; + uint64_t outflow = 0; + if (!computeCtPoolDelta(tx, tx.fee, inflow, outflow)) { + logger(ERROR) << "CT validation: tx " << txHash + << " has plain input/output sums that overflow uint64_t"; + return false; + } + if (outflow > 0) { + uint32_t h = m_db.getChainHeight(); + uint64_t pool = 0; + if (h > 0) { + DbBlockMeta tipMeta{}; + m_db.getBlockMeta(h - 1, tipMeta); + pool = tipMeta.confidentialSupply; + } + if (outflow > pool) { + logger(ERROR) << "CT validation: tx " << txHash + << " would underflow CT pool liability" + << " (outflow=" << outflow << ", pool=" << pool << ")"; + return false; + } + } + } + + return true; +} + +// Cheap structural CT validation used for transactions arriving inside a +// confirmed checkpointed block. We trust the network's verdict on the block +// itself, so we skip Triptych, GK proofs, balance/kernel, DB ring resolution, +// and unlock-time checks against ring members. We still validate: +// - version, container shapes and per-side count invariants +// - every commitment / target / pseudo-commitment / ring pubkey parses as a +// valid point in the prime-order subgroup, so nothing malformed gets +// written into our indices +// - ring-size bounds, canonical strictly-increasing relative offsets and +// pseudo-commitment well-formedness +// - key-image domain (l*I == identity) and global double-spend +bool Blockchain::checkConfidentialTransactionStructure(const Transaction& tx, + const Crypto::Hash& txHash) { + using namespace CryptoNote::parameters; + std::lock_guard lk(m_blockchain_lock); + + // Version, empty-side, output-variant, and signatures.size()==inputs.size() + // are all enforced upstream by checkTransactionConsensusShape. This + // validator runs under checkpoint trust and only needs the CT-specific + // structural checks (ctProofs count, curve membership, ring-size bounds, + // key-image domain, double-spend). + + // One GK proof per confidential output (v3 unshield may carry transparent + // KeyOutputs that have none); count confidential outputs first. + size_t numConfidentialOutputs = 0; + for (const auto& out : tx.outputs) { + if (out.target.type() == typeid(ConfidentialOutput)) { + ++numConfidentialOutputs; + } + } + if (tx.ctProofs.size() != numConfidentialOutputs) { + logger(ERROR) << "CT structural validation: ctProofs count " << tx.ctProofs.size() + << " != confidential-output count " << numConfidentialOutputs << " in tx " << txHash; + return false; + } + + for (size_t i = 0; i < tx.outputs.size(); ++i) { + if (tx.outputs[i].target.type() != typeid(ConfidentialOutput)) { + continue; // transparent KeyOutput (v3 unshield) — key validity enforced by check_outs_valid + } + const auto& cout = boost::get(tx.outputs[i].target); + if (!Crypto::ct_public_key_valid(cout.targetKey)) { + logger(ERROR) << "CT structural validation: output " << i + << " targetKey is not a valid CT public key in tx " << txHash; + return false; + } + if (!Crypto::point_valid_for_pedersen(cout.commitment)) { + logger(ERROR) << "CT structural validation: output " << i + << " commitment fails subgroup check in tx " << txHash; + return false; + } + } + + for (size_t i = 0; i < tx.inputs.size(); ++i) { + if (tx.inputs[i].type() == typeid(KeyInput)) { + // Structural-only check for v2 KeyInput slots: outputIndexes non-empty, + // amount non-zero, key image in valid domain, not double-spent. The + // ring sig itself is trusted under a confirmed checkpoint. + const auto& ki = boost::get(tx.inputs[i]); + if (ki.amount == 0 || ki.outputIndexes.empty()) { + logger(ERROR) << "CT structural validation: KeyInput " << i + << " malformed in tx " << txHash; + return false; + } + if (!(Crypto::scalarmultKey(ki.keyImage, Crypto::EllipticCurveScalar2KeyImage(Crypto::L)) + == Crypto::EllipticCurveScalar2KeyImage(Crypto::I))) { + logger(ERROR) << "CT structural validation: KeyInput " << i + << " key image not in valid domain in tx " << txHash; + return false; + } + if (have_tx_keyimg_as_spent(ki.keyImage)) { + logger(DEBUGGING) << "CT structural validation: KeyInput " << i + << " key image already spent in tx " << txHash; + return false; + } + continue; + } + + if (tx.inputs[i].type() != typeid(ConfidentialInput)) { + logger(ERROR) << "CT structural validation: input " << i + << " has unsupported type in tx " << txHash; + return false; + } + const auto& cin = boost::get(tx.inputs[i]); + const size_t ringSize = cin.ringMembers.size(); + + if (ringSize == 0) { + logger(ERROR) << "CT structural validation: input " << i << " has empty ring in tx " << txHash; + return false; + } + if (!Crypto::triptych_ring_size_supported(ringSize)) { + logger(ERROR) << "CT structural validation: input " << i << " ring size " << ringSize + << " is not a supported Triptych shape (4, 8, or 16)" + << " in tx " << txHash; + return false; + } + if (ringSize != cin.ringPubkeys.size() || ringSize != cin.ringCommitments.size()) { + logger(ERROR) << "CT structural validation: input " << i + << " ring field size mismatch in tx " << txHash; + return false; + } + + if (!Crypto::point_valid_for_pedersen(cin.pseudoCommitment)) { + logger(ERROR) << "CT structural validation: input " << i + << " pseudo-commitment fails subgroup check in tx " << txHash; + return false; + } + + if (!(Crypto::scalarmultKey(cin.keyImage, Crypto::EllipticCurveScalar2KeyImage(Crypto::L)) + == Crypto::EllipticCurveScalar2KeyImage(Crypto::I))) { + logger(ERROR) << "CT structural validation: input " << i + << " key image not in valid domain in tx " << txHash; + return false; + } + + for (size_t k = 0; k < ringSize; ++k) { + if (cin.ringMembers[k].amount == 0) { + logger(ERROR) << "CT structural validation: input " << i << " ring member " << k + << " has zero amount bucket in tx " << txHash; + return false; + } + if (!Crypto::ct_public_key_valid(cin.ringPubkeys[k])) { + logger(ERROR) << "CT structural validation: input " << i << " ring pubkey " << k + << " is not a valid CT public key in tx " << txHash; + return false; + } + if (!Crypto::point_valid_for_pedersen(cin.ringCommitments[k])) { + logger(ERROR) << "CT structural validation: input " << i << " ring commitment " << k + << " fails subgroup check in tx " << txHash; + return false; + } + } + + // Canonical ordering: (amount, outputIndex) strictly ascending. + for (size_t k = 1; k < ringSize; ++k) { + const auto& prev = cin.ringMembers[k - 1]; + const auto& cur = cin.ringMembers[k]; + const bool ascending = (cur.amount > prev.amount) || + (cur.amount == prev.amount && cur.outputIndex > prev.outputIndex); + if (!ascending) { + logger(ERROR) << "CT structural validation: input " << i << " ring members not in canonical" + << " (amount, outputIndex) strictly-ascending order at slot " << k + << " in tx " << txHash; + return false; + } + } + + if (have_tx_keyimg_as_spent(cin.keyImage)) { + logger(DEBUGGING) << "CT structural validation: input " << i + << " key image already spent in tx " << txHash; + return false; + } + } + + return true; +} + // ─── addNewBlock / pushBlock / popBlock ────────────────────────────────────── bool Blockchain::addNewBlock(const Block& bl, block_verification_context& bvc) { @@ -2240,6 +3281,47 @@ bool Blockchain::addNewBlock(const Block& bl, block_verification_context& bvc) { return add_result; } +bool Blockchain::scanCtInputRingForIndexes(const ConfidentialInput& cin, + std::list>& outputReferences) { + std::lock_guard bcLock(m_blockchain_lock); + + if (cin.ringMembers.empty()) { + return false; + } + + std::unordered_map bucketOutputCount; + + for (const auto& member : cin.ringMembers) { + if (member.amount == 0) { + return false; + } + auto cacheIt = bucketOutputCount.find(member.amount); + if (cacheIt == bucketOutputCount.end()) { + const uint32_t count = m_db.getKeyOutputCount(member.amount); + if (count == 0) { + return false; + } + cacheIt = bucketOutputCount.emplace(member.amount, count).first; + } + if (member.outputIndex >= cacheIt->second) { + return false; + } + + uint32_t block = 0; + uint16_t txSlot = 0; + uint16_t outIdx = 0; + if (!m_db.getKeyOutput(member.amount, member.outputIndex, block, txSlot, outIdx)) { + return false; + } + TransactionEntry te = transactionByIndex({block, txSlot}); + if (outIdx >= te.tx.outputs.size()) { + return false; + } + outputReferences.emplace_back(getObjectHash(te.tx), static_cast(outIdx)); + } + return true; +} + Blockchain::TransactionEntry Blockchain::transactionByIndex(TransactionIndex idx) { std::vector raw; if (!m_db.getTxEntry(idx.block, idx.transaction, raw)) { @@ -2292,6 +3374,34 @@ bool Blockchain::pushBlock(const Block& blockData, const std::vector, hash, bvc) + // overload is now also reached from alt-chain replay (snapshotted bodies) + // and any future caller that hand-assembles a tx vector — a count or order + // mismatch would silently mis-pair tx index entries and let a malformed + // alt-block snapshot index past transactionHashes. + if (transactions.size() != blockData.transactionHashes.size()) { + logger(ERROR, BRIGHT_RED) << "Block " << blockHash + << " transaction-vector size " << transactions.size() + << " does not match block transactionHashes count " + << blockData.transactionHashes.size(); + bvc.m_verification_failed = true; + return false; + } + for (size_t i = 0; i < transactions.size(); ++i) { + Crypto::Hash computed = getObjectHash(transactions[i]); + if (computed != blockData.transactionHashes[i]) { + logger(ERROR, BRIGHT_RED) << "Block " << blockHash + << " transaction " << i << " hash mismatch: have=" << computed + << " expected=" << blockData.transactionHashes[i]; + bvc.m_verification_failed = true; + return false; + } + } + { uint32_t h = 0; if (m_db.getHashHeight(blockHash, h)) { @@ -2346,7 +3456,18 @@ bool Blockchain::pushBlock(const Block& blockData, const std::vector( std::chrono::steady_clock::now() - longhashTimeStart).count(); @@ -2385,11 +3511,15 @@ bool Blockchain::pushBlock(const Block& blockData, const std::vector 0) { DbBlockMeta prevMeta{}; m_db.getBlockMeta(newHeight - 1, prevMeta); already_generated_coins = prevMeta.alreadyGeneratedCoins; + confidential_supply = prevMeta.confidentialSupply; + pq_plain_supply = prevMeta.pqPlainSupply; prevCumulativeDifficulty = prevMeta.cumulativeDifficulty; } @@ -2462,14 +3592,69 @@ bool Blockchain::pushBlock(const Block& blockData, const std::vectorCN unshield: gated on its own activation (currently == CT). + if (transactions[i].version == TRANSACTION_VERSION_UNSHIELD && + !m_currency.isUnshieldActivated(newHeight)) { + logger(ERROR, BRIGHT_RED) << "Block " << blockHash << " at height " << newHeight + << " contains unshield (v3) transaction " << tx_id + << " before unshield activation, rejected"; + bvc.m_verification_failed = true; + abortCurrentBlockTxn(); + return false; + } - // Under a confirmed checkpoint the block hash has already been verified by - // the network. Skip the expensive per-input validation (key-image domain - // check, output-key LMDB scans) - pushTransaction still records everything. - if (!inCheckpoint && !checkTransactionInputs(block.transactions.back().tx)) { + size_t blob_size = toBinaryArray(block.transactions.back().tx).size(); + // CT transactions carry an explicit fee field; transparent txs derive fee from I/O difference. + const Transaction& curTx = block.transactions.back().tx; + uint64_t fee = (isCtFamilyTransactionVersion(transactions[i].version)) + ? transactions[i].fee + : getInputAmount(curTx) - getOutputAmount(curTx); + + // Under a trusted checkpoint the block hash has already matched an + // accepted anchor. We accept that the heavy historical crypto + // (Triptych, GK, balance kernel, ring resolution) was validated before + // publication of that checkpoint and run only structural sanity here + // (version, shape, subgroup checks, key-image domain, double-spend). + // Built-in, operator-file, and signed-DNS checkpoints all reach this + // path; --without-checkpoints keeps the full validation path active. + if (inHardcodedCheckpoint) { + if (!checkTransactionInputs(curTx, TxValidationContext::CheckpointedBlock)) { + logger(INFO, BRIGHT_WHITE) << "Block " << blockHash + << " has at least one transaction with structurally invalid inputs: " << tx_id; + bvc.m_verification_failed = true; + abortCurrentBlockTxn(); + return false; + } + } else if (!checkTransactionInputs(curTx, TxValidationContext::Block)) { logger(INFO, BRIGHT_WHITE) << "Block " << blockHash << " has at least one transaction with wrong inputs: " << tx_id; bvc.m_verification_failed = true; @@ -2477,6 +3662,62 @@ bool Blockchain::pushBlock(const Block& blockData, const std::vector