ct_tests.cpp raw
1 // Copyright (c) 2025 The Limenka developers
2 // Distributed under the MIT software license, see the accompanying
3 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
4
5 #include <test/fork_util.h>
6
7 #include <coins.h>
8 #include <consensus/ct.h>
9 #include <consensus/tx_verify.h>
10 #include <consensus/validation.h>
11 #include <crypto/bignum.h>
12 #include <crypto/bulletproofs.h>
13 #include <kernel/cs_main.h>
14 #include <primitives/transaction.h>
15 #include <random.h>
16 #include <script/script.h>
17 #include <uint256.h>
18 #include <validation.h>
19
20 #include <boost/test/unit_test.hpp>
21
22 BOOST_FIXTURE_TEST_SUITE(ct_tests, ForkTestingSetup)
23
24 static std::vector<uint8_t> RandScalar(FastRandomContext& rng)
25 {
26 auto b = rng.randbytes(BP_SCALAR_SIZE);
27 return {b.begin(), b.end()};
28 }
29
30 static CScript P2BPCTScript(const BPCommitment& c)
31 {
32 return CScript() << OP_4 << c;
33 }
34
35 // Build a kernel output script: OP_RETURN <"BK"> <fee:16 LE> <sig:64>.
36 static CScript KernelScript(CAmount fee, const std::vector<uint8_t>& sig)
37 {
38 std::vector<uint8_t> magic = {CT_KERNEL_MAGIC_BYTE0, CT_KERNEL_MAGIC_BYTE1};
39 std::vector<uint8_t> fb(CT_FEE_SIZE);
40 for (size_t i = 0; i < CT_FEE_SIZE; i++) {
41 fb[i] = uint8_t(static_cast<uint64_t>(fee >> (8 * i)));
42 }
43 return CScript() << OP_RETURN << magic << fb << sig;
44 }
45
46 BOOST_AUTO_TEST_CASE(ct_balance_valid)
47 {
48 const auto& cp = ForkConsensus();
49 FastRandomContext rng{uint256{7}};
50 // Attosat convention: satoshis * 10^18 + sub-satoshi fraction.
51 const __int128 SAT = 1000000000000000000LL;
52 const CAmount v_in = 5005 * SAT + 400, fee = 5000 * SAT + 250, v_out = v_in - fee;
53
54 BPScalar r_in = RandScalar(rng);
55 BPScalar zero(BP_SCALAR_SIZE, 0);
56 BPCommitment cin, cout;
57 BOOST_REQUIRE(CommitAmount(v_in, r_in, cin));
58 BOOST_REQUIRE(CommitAmount(v_out, zero, cout));
59
60 const COutPoint outpoint(Txid::FromUint256(uint256{0x11}), 0);
61
62 CMutableTransaction tx;
63 tx.vin.resize(1);
64 tx.vin[0].prevout = outpoint;
65 tx.vout.resize(2);
66 tx.vout[0].nValue = 0;
67 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
68
69 const int kernel_index = 1;
70 CTKernelData kernel_msg_data;
71 kernel_msg_data.fee = fee;
72 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kernel_msg_data);
73 std::vector<uint8_t> sig;
74 BOOST_REQUIRE(CreateCTKernelSig(r_in, {msg.begin(), msg.end()}, sig));
75 tx.vout[1].nValue = 0;
76 tx.vout[1].scriptPubKey = KernelScript(fee, sig);
77
78 LOCK(cs_main);
79 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
80 view.AddCoin(outpoint, Coin(CTxOut(0, P2BPCTScript(cin)), 1, false), false);
81
82 TxValidationState state;
83 CAmount txfee = 0;
84 BOOST_CHECK(Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
85 CheckTxInputsRules::None, cp, /*fork_active=*/true));
86 // The explicit kernel fee (attosats) must be floored into the
87 // satoshi-domain txfee so miners collect it; the 250-attosat
88 // remainder stays burned and is never minted.
89 BOOST_CHECK_EQUAL(txfee, fee / ATTOSATS_PER_SATOSHI);
90 BOOST_CHECK_EQUAL(txfee, CAmount(5000));
91 }
92
93 BOOST_AUTO_TEST_CASE(ct_balance_inflation_rejected)
94 {
95 const auto& cp = ForkConsensus();
96 FastRandomContext rng{uint256{8}};
97 const CAmount v_in = 100000, fee = 5000;
98 const CAmount v_out = v_in + 1000; // output exceeds input -> inflation
99
100 BPScalar r_in = RandScalar(rng);
101 BPScalar zero(BP_SCALAR_SIZE, 0);
102 BPCommitment cin, cout;
103 BOOST_REQUIRE(CommitAmount(v_in, r_in, cin));
104 BOOST_REQUIRE(CommitAmount(v_out, zero, cout));
105
106 const COutPoint outpoint(Txid::FromUint256(uint256{0x12}), 0);
107
108 CMutableTransaction tx;
109 tx.vin.resize(1);
110 tx.vin[0].prevout = outpoint;
111 tx.vout.resize(2);
112 tx.vout[0].nValue = 0;
113 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
114
115 const int kernel_index = 1;
116 CTKernelData kernel_msg_data;
117 kernel_msg_data.fee = fee;
118 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kernel_msg_data);
119 std::vector<uint8_t> sig;
120 BOOST_REQUIRE(CreateCTKernelSig(r_in, {msg.begin(), msg.end()}, sig));
121 tx.vout[1].nValue = 0;
122 tx.vout[1].scriptPubKey = KernelScript(fee, sig);
123
124 LOCK(cs_main);
125 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
126 view.AddCoin(outpoint, Coin(CTxOut(0, P2BPCTScript(cin)), 1, false), false);
127
128 TxValidationState state;
129 CAmount txfee = 0;
130 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
131 CheckTxInputsRules::None, cp, /*fork_active=*/true));
132 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-balance");
133 }
134
135 BOOST_AUTO_TEST_CASE(ct_missing_kernel_rejected)
136 {
137 const auto& cp = ForkConsensus();
138 FastRandomContext rng{uint256{9}};
139
140 BPScalar r_in = RandScalar(rng);
141 BPScalar zero(BP_SCALAR_SIZE, 0);
142 BPCommitment cin, cout;
143 BOOST_REQUIRE(CommitAmount(1000, r_in, cin));
144 BOOST_REQUIRE(CommitAmount(900, zero, cout));
145
146 const COutPoint outpoint(Txid::FromUint256(uint256{0x13}), 0);
147
148 CMutableTransaction tx;
149 tx.vin.resize(1);
150 tx.vin[0].prevout = outpoint;
151 tx.vout.resize(1);
152 tx.vout[0].nValue = 0;
153 tx.vout[0].scriptPubKey = P2BPCTScript(cout); // no kernel output
154
155 LOCK(cs_main);
156 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
157 view.AddCoin(outpoint, Coin(CTxOut(0, P2BPCTScript(cin)), 1, false), false);
158
159 TxValidationState state;
160 CAmount txfee = 0;
161 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
162 CheckTxInputsRules::None, cp, /*fork_active=*/true));
163 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-no-kernel");
164 }
165
166 BOOST_AUTO_TEST_CASE(ct_nonzero_value_rejected)
167 {
168 const auto& cp = ForkConsensus();
169 FastRandomContext rng{uint256{10}};
170
171 BPScalar r_in = RandScalar(rng);
172 BPScalar zero(BP_SCALAR_SIZE, 0);
173 BPCommitment cin, cout;
174 BOOST_REQUIRE(CommitAmount(1000, r_in, cin));
175 BOOST_REQUIRE(CommitAmount(900, zero, cout));
176
177 const COutPoint outpoint(Txid::FromUint256(uint256{0x14}), 0);
178 const CAmount fee = 100;
179
180 CMutableTransaction tx;
181 tx.vin.resize(1);
182 tx.vin[0].prevout = outpoint;
183 tx.vout.resize(2);
184 tx.vout[0].nValue = 50; // P2BPCT output must have nValue = 0
185 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
186
187 const int kernel_index = 1;
188 CTKernelData kernel_msg_data;
189 kernel_msg_data.fee = fee;
190 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kernel_msg_data);
191 std::vector<uint8_t> sig;
192 BOOST_REQUIRE(CreateCTKernelSig(r_in, {msg.begin(), msg.end()}, sig));
193 tx.vout[1].nValue = 0;
194 tx.vout[1].scriptPubKey = KernelScript(fee, sig);
195
196 LOCK(cs_main);
197 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
198 view.AddCoin(outpoint, Coin(CTxOut(0, P2BPCTScript(cin)), 1, false), false);
199
200 TxValidationState state;
201 CAmount txfee = 0;
202 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
203 CheckTxInputsRules::None, cp, /*fork_active=*/true));
204 // The standard value check (nValueIn=0 < value_out=50) rejects first.
205 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-txns-in-belowout");
206 }
207
208 BOOST_AUTO_TEST_CASE(ct_mint_unbound_value_rejected)
209 {
210 // A mint whose kernel ignores the transparent input value fails: the
211 // balance equation leaves the visible value unaccounted for.
212 const auto& cp = ForkConsensus();
213 FastRandomContext rng{uint256{11}};
214
215 BPScalar r = RandScalar(rng);
216 BPCommitment cout;
217 BOOST_REQUIRE(CommitAmount(900, r, cout));
218
219 const COutPoint outpoint(Txid::FromUint256(uint256{0x15}), 0);
220 const CAmount fee = 100;
221
222 CMutableTransaction tx;
223 tx.vin.resize(1);
224 tx.vin[0].prevout = outpoint;
225 tx.vout.resize(2);
226 tx.vout[0].nValue = 0;
227 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
228
229 const int kernel_index = 1;
230 CTKernelData kernel_msg_data;
231 kernel_msg_data.fee = fee;
232 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kernel_msg_data);
233 std::vector<uint8_t> sig;
234 BOOST_REQUIRE(CreateCTKernelSig(r, {msg.begin(), msg.end()}, sig));
235 tx.vout[1].nValue = 0;
236 tx.vout[1].scriptPubKey = KernelScript(fee, sig);
237
238 LOCK(cs_main);
239 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
240 // Transparent mint input worth 1000 satoshis.
241 view.AddCoin(outpoint, Coin(CTxOut(1000, CScript() << OP_TRUE), 1, false), false);
242
243 TxValidationState state;
244 CAmount txfee = 0;
245 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
246 CheckTxInputsRules::None, cp, /*fork_active=*/true));
247 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-balance");
248 }
249
250
251 BOOST_AUTO_TEST_CASE(ct_mint_valid)
252 {
253 // Transparent value mints into CT: transparent input (OP_TRUE) spends
254 // into a confidential output; the kernel balances the visible input
255 // value and the miner collects only the kernel fee.
256 const auto& cp = ForkConsensus();
257 FastRandomContext rng{uint256{20}};
258
259 const CAmount v_in_sats = 1000;
260 const CAmount v_in = v_in_sats * ATTOSATS_PER_SATOSHI;
261 const CAmount fee_attosats = ATTOSATS_PER_SATOSHI / 2; // 0.5 sat
262 const CAmount v_out = v_in - fee_attosats;
263
264 const COutPoint outpoint(Txid::FromUint256(uint256{0x51}), 0);
265
266 BPScalar zero(BP_SCALAR_SIZE, 0);
267 BPCommitment cout;
268 BOOST_REQUIRE(CommitAmount(v_out, zero, cout));
269
270 CMutableTransaction tx;
271 tx.version = 2;
272 tx.vin.resize(1);
273 tx.vin[0].prevout = outpoint;
274 tx.vout.resize(2);
275 tx.vout[0].nValue = 0;
276 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
277
278 // Kernel: excess = -sum(out blindings) = 0 here (blinding zero) - use a
279 // non-zero output blinding to exercise the general path.
280 BPScalar r_out = RandScalar(rng);
281 BPCommitment cout2;
282 BOOST_REQUIRE(CommitAmount(v_out, r_out, cout2));
283 tx.vout[0].scriptPubKey = P2BPCTScript(cout2);
284
285 const int kernel_index = 1;
286 // excess = -r_out (replicate the wallet scalar negation with BigNum).
287 std::vector<unsigned char> msg_bytes;
288 {
289 BigNum n;
290 {
291 static const uint8_t n_bytes[32] = {
292 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFE,
293 0xBA,0xAE,0xDC,0xE6,0xAF,0x48,0xA0,0x3B,0xBF,0xD2,0x5E,0x8C,0xD0,0x36,0x41,0x41};
294 n = BigNum(std::vector<uint8_t>(n_bytes, n_bytes + 32), true);
295 }
296 BigNum r_bn(std::vector<uint8_t>(r_out.begin(), r_out.end()), true);
297 BigNum neg = n - r_bn;
298 auto le = neg.to_bytes(BP_SCALAR_SIZE);
299 BPScalar excess_neg(BP_SCALAR_SIZE, 0);
300 for (size_t i = 0; i < BP_SCALAR_SIZE; i++) excess_neg[i] = le[BP_SCALAR_SIZE - 1 - i];
301
302 std::vector<CAmount> transparent_values{v_in};
303 CTKernelData kd;
304 kd.fee = fee_attosats;
305 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kd,
306 transparent_values);
307 msg_bytes.assign(msg.begin(), msg.end());
308 std::vector<uint8_t> sig;
309 BOOST_REQUIRE(CreateCTKernelSig(excess_neg, msg_bytes, sig));
310 kd.sig = std::move(sig);
311 // Rebuild the kernel script from the data (fee + sig).
312 tx.vout[1].nValue = 0;
313 tx.vout[1].scriptPubKey = KernelScript(kd.fee, kd.sig);
314 }
315
316 LOCK(cs_main);
317 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
318 view.AddCoin(outpoint, Coin(CTxOut(v_in_sats, CScript() << OP_TRUE), 1, false), false);
319
320 TxValidationState state;
321 CAmount txfee = 0;
322 BOOST_CHECK(Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
323 CheckTxInputsRules::None, cp, /*fork_active=*/true));
324 // The miner collects ONLY the kernel fee, not the full input value.
325 BOOST_CHECK_EQUAL(txfee, fee_attosats / ATTOSATS_PER_SATOSHI);
326 BOOST_CHECK_EQUAL(txfee, CAmount(0)); // 0.5 sat floors to zero; no double pay
327 }
328
329 BOOST_AUTO_TEST_CASE(ct_mint_imbalance_rejected)
330 {
331 // Mint where outputs + fee do not equal the transparent input value:
332 // the kernel key has an H component nobody can sign for.
333 const auto& cp = ForkConsensus();
334 FastRandomContext rng{uint256{21}};
335
336 const CAmount v_in_sats = 1000;
337 const CAmount v_in = v_in_sats * ATTOSATS_PER_SATOSHI;
338 const CAmount fee_attosats = ATTOSATS_PER_SATOSHI / 2;
339 const CAmount v_out = v_in - fee_attosats - 1; // 1 attosat short
340
341 const COutPoint outpoint(Txid::FromUint256(uint256{0x52}), 0);
342
343 BPScalar r_out = RandScalar(rng);
344 BPCommitment cout;
345 BOOST_REQUIRE(CommitAmount(v_out, r_out, cout));
346
347 CMutableTransaction tx;
348 tx.version = 2;
349 tx.vin.resize(1);
350 tx.vin[0].prevout = outpoint;
351 tx.vout.resize(2);
352 tx.vout[0].nValue = 0;
353 tx.vout[0].scriptPubKey = P2BPCTScript(cout);
354
355 const int kernel_index = 1;
356 {
357 BigNum n;
358 {
359 static const uint8_t n_bytes[32] = {
360 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFE,
361 0xBA,0xAE,0xDC,0xE6,0xAF,0x48,0xA0,0x3B,0xBF,0xD2,0x5E,0x8C,0xD0,0x36,0x41,0x41};
362 n = BigNum(std::vector<uint8_t>(n_bytes, n_bytes + 32), true);
363 }
364 BigNum r_bn(std::vector<uint8_t>(r_out.begin(), r_out.end()), true);
365 BigNum neg = n - r_bn;
366 auto le = neg.to_bytes(BP_SCALAR_SIZE);
367 BPScalar excess_neg(BP_SCALAR_SIZE, 0);
368 for (size_t i = 0; i < BP_SCALAR_SIZE; i++) excess_neg[i] = le[BP_SCALAR_SIZE - 1 - i];
369
370 std::vector<CAmount> transparent_values{v_in};
371 CTKernelData kd;
372 kd.fee = fee_attosats;
373 const uint256 msg = ComputeCTKernelMessage(CTransaction(tx), kernel_index, kd,
374 transparent_values);
375 std::vector<uint8_t> msg_bytes(msg.begin(), msg.end());
376 std::vector<uint8_t> sig;
377 BOOST_REQUIRE(CreateCTKernelSig(excess_neg, msg_bytes, sig));
378 kd.sig = std::move(sig);
379 tx.vout[1].nValue = 0;
380 tx.vout[1].scriptPubKey = KernelScript(kd.fee, kd.sig);
381 }
382
383 LOCK(cs_main);
384 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
385 view.AddCoin(outpoint, Coin(CTxOut(v_in_sats, CScript() << OP_TRUE), 1, false), false);
386
387 TxValidationState state;
388 CAmount txfee = 0;
389 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tx), state, view, 966501, txfee,
390 CheckTxInputsRules::None, cp, /*fork_active=*/true));
391 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-balance");
392 }
393
394 BOOST_AUTO_TEST_CASE(ct_decoy_kernel_fee_preserved)
395 {
396 // A transparent transaction carrying a kernel-shaped OP_RETURN "BK"
397 // output is NOT a confidential transaction (no CT inputs/outputs), so
398 // its fee must not be zeroed by the kernel-fee path.
399 const auto& cp = ForkConsensus();
400
401 const COutPoint outpoint(Txid::FromUint256(uint256{0x61}), 0);
402 const CAmount v_in = 1000, v_out = 900;
403 const CScript spendable = CScript() << OP_0 << std::vector<uint8_t>(20, 0x42); // P2WPKH-shaped, not CT
404
405 CMutableTransaction mtx;
406 mtx.vin.resize(1);
407 mtx.vin[0].prevout = outpoint;
408 mtx.vout.resize(2);
409 mtx.vout[0].nValue = v_out;
410 mtx.vout[0].scriptPubKey = spendable;
411 // Decoy kernel output: OP_RETURN <0x02> "BK" (matches the kernel
412 // prefix but carries no fee/signature).
413 mtx.vout[1].nValue = 0;
414 mtx.vout[1].scriptPubKey = CScript() << OP_RETURN
415 << std::vector<uint8_t>{CT_KERNEL_MAGIC_BYTE0, CT_KERNEL_MAGIC_BYTE1};
416
417 {
418 LOCK(cs_main);
419 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
420 view.AddCoin(outpoint, Coin(CTxOut(v_in, spendable), 1, false), false);
421 }
422
423 TxValidationState state;
424 CAmount txfee = 0;
425 BOOST_CHECK(Consensus::CheckTxInputs(CTransaction(mtx), state, m_node.chainman->ActiveChainstate().CoinsTip(), 966501, txfee,
426 CheckTxInputsRules::None, cp, /*fork_active=*/true));
427 // The real transparent fee (v_in - v_out) is preserved: no zeroing.
428 BOOST_CHECK_EQUAL(txfee, v_in - v_out);
429 }
430
431 BOOST_AUTO_TEST_CASE(ct_offset_inflation_rejected)
432 {
433 // A1 regression: the balance equation is a plain Pedersen sum with no
434 // offset terms. A kernel carrying an extra 33-byte point push (the old
435 // "blinding offset" field, which allowed minting arbitrary value via
436 // b*H terms) must be rejected at parse time.
437 const auto& cp = ForkConsensus();
438 FastRandomContext rng{uint256{62}};
439 const __int128 SAT = 1000000000000000000LL;
440 const CAmount v_in = 5000 * SAT, fee = 1000, v_out = v_in - fee;
441
442 BPScalar r_in = RandScalar(rng);
443 BPScalar zero(BP_SCALAR_SIZE, 0);
444 BPCommitment cin, cout;
445 BOOST_REQUIRE(CommitAmount(v_in, r_in, cin));
446 BOOST_REQUIRE(CommitAmount(v_out, zero, cout));
447
448 const COutPoint outpoint(Txid::FromUint256(uint256{0x71}), 0);
449
450 CMutableTransaction mtx;
451 mtx.vin.resize(1);
452 mtx.vin[0].prevout = outpoint;
453 mtx.vout.resize(2);
454 mtx.vout[0].nValue = 0;
455 mtx.vout[0].scriptPubKey = P2BPCTScript(cout);
456
457 const int kernel_index = 1;
458 CTKernelData kernel_msg_data;
459 kernel_msg_data.fee = fee;
460 const uint256 msg = ComputeCTKernelMessage(CTransaction(mtx), kernel_index, kernel_msg_data);
461 std::vector<uint8_t> sig;
462 BOOST_REQUIRE(CreateCTKernelSig(r_in, {msg.begin(), msg.end()}, sig));
463 // Valid kernel plus a smuggled 33-byte "offset" point.
464 mtx.vout[1].nValue = 0;
465 mtx.vout[1].scriptPubKey = KernelScript(fee, sig);
466 mtx.vout[1].scriptPubKey << std::vector<uint8_t>(33, 0x42);
467
468 {
469 LOCK(cs_main);
470 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
471 view.AddCoin(outpoint, Coin(CTxOut(0, P2BPCTScript(cin)), 1, false), false);
472 }
473
474 TxValidationState state;
475 CAmount txfee = 0;
476 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(mtx), state, m_node.chainman->ActiveChainstate().CoinsTip(), 966501, txfee,
477 CheckTxInputsRules::None, cp, /*fork_active=*/true));
478 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-kernel-format");
479 }
480
481 BOOST_AUTO_TEST_SUITE_END()
482