ct_wallet_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 <consensus/amount.h>
6 #include <key.h>
7 #include <key_io.h>
8 #include <consensus/ct.h>
9 #include <consensus/tx_verify.h>
10 #include <consensus/validation.h>
11 #include <crypto/bulletproofs.h>
12 #include <random.h>
13 #include <script/interpreter.h>
14 #include <test/fork_util.h>
15 #include <test/util/setup_common.h>
16 #include <validation.h>
17 #include <wallet/context.h>
18 #include <wallet/db.h>
19 #include <util/strencodings.h>
20 #include <psbt.h>
21 #include <wallet/ct.h>
22 #include <wallet/test/wallet_test_fixture.h>
23 #include <wallet/test/util.h>
24 #include <wallet/walletdb.h>
25
26 #include <boost/test/unit_test.hpp>
27 #include <test/util/boost_no_print_int128.h>
28
29 BOOST_AUTO_TEST_SUITE(ct_wallet_tests)
30
31 static constexpr CAmount SAT = ATTOSATS_PER_SATOSHI;
32
33 BOOST_FIXTURE_TEST_CASE(create_output_attosat_scale, BasicTestingSetup)
34 {
35 FastRandomContext rng{uint256{101}};
36 // 5000 satoshis + 250 attosats of sub-satoshi precision.
37 const CAmount amount = 5000 * SAT + 250;
38 BPCommitment commitment;
39 wallet::CTReceipt receipt;
40 BOOST_CHECK(wallet::CreateConfidentialOutput(amount, rng, commitment, receipt));
41 BOOST_CHECK_EQUAL(commitment.size(), size_t(BP_POINT_SIZE));
42 BOOST_CHECK_EQUAL(receipt.blinding.size(), size_t(BP_SCALAR_SIZE));
43 BOOST_CHECK_EQUAL(receipt.seed.size(), size_t(BP_SCALAR_SIZE));
44 BOOST_CHECK_EQUAL(receipt.Amount(), amount);
45 BOOST_CHECK(VerifyBulletproof(commitment, receipt.proof));
46
47 // The script is witness v4 with the commitment as program.
48 const CScript spk = wallet::GetConfidentialScript(commitment);
49 int witver; std::vector<uint8_t> witprog;
50 BOOST_CHECK(spk.IsWitnessProgram(witver, witprog));
51 BOOST_CHECK_EQUAL(witver, 4);
52 BOOST_CHECK_EQUAL(witprog.size(), size_t(WITNESS_V4_BPCT_SIZE));
53 BOOST_CHECK(witprog == commitment);
54
55 // Negative amounts are rejected; the max __int128 (2^127 - 1) still
56 // proves inside the [0, 2^128) range bound.
57 BOOST_CHECK(!wallet::CreateConfidentialOutput(-1, rng, commitment, receipt));
58 BOOST_CHECK(wallet::CreateConfidentialOutput((CAmount{1} << 127) - 1, rng, commitment, receipt));
59 }
60
61 BOOST_FIXTURE_TEST_CASE(excess_computation, BasicTestingSetup)
62 {
63 FastRandomContext rng{uint256{102}};
64 auto rs = [&]() {
65 auto b = rng.randbytes(BP_SCALAR_SIZE);
66 return BPScalar(b.begin(), b.end());
67 };
68 const BPScalar a = rs(), b = rs(), c = rs();
69
70 BPScalar excess;
71 // e = a + b - c
72 BOOST_CHECK(wallet::ComputeCTExcess({a, b}, {c}, excess));
73
74 // Reversing the roles yields the negation (mod n), which is non-zero.
75 BPScalar neg;
76 BOOST_CHECK(wallet::ComputeCTExcess({c}, {a, b}, neg));
77 BOOST_CHECK(neg != excess);
78
79 // Zero excess (same in and out) must be rejected - identity kernel key.
80 BOOST_CHECK(!wallet::ComputeCTExcess({a}, {a}, excess));
81 BOOST_CHECK(!wallet::ComputeCTExcess({a, b}, {a, b}, excess));
82 }
83
84 BOOST_FIXTURE_TEST_CASE(kernel_output_fee_roundtrip, BasicTestingSetup)
85 {
86 FastRandomContext rng{uint256{103}};
87 const CAmount fee = 12345 * SAT + 678;
88
89 CMutableTransaction tx;
90 tx.vout.emplace_back(CTxOut(0, CScript() << OP_4 << std::vector<uint8_t>(WITNESS_V4_BPCT_SIZE, 0x21)));
91
92 BPScalar excess;
93 BOOST_REQUIRE(wallet::ComputeCTExcess({[&]() {
94 auto b = rng.randbytes(BP_SCALAR_SIZE);
95 return BPScalar(b.begin(), b.end());
96 }()}, {[&]() {
97 auto b = rng.randbytes(BP_SCALAR_SIZE);
98 return BPScalar(b.begin(), b.end());
99 }()}, excess));
100
101 CScript kernel_script;
102 BOOST_REQUIRE(wallet::BuildCTKernelOutput(excess, fee, CTransaction(tx), 1,
103 /*E=*/{}, /*enc_amount=*/{}, /*enc_blind=*/{},
104 kernel_script));
105
106 const auto parsed = ParseCTKernelOutput(CTxOut(0, kernel_script));
107 BOOST_REQUIRE(parsed.has_value());
108 BOOST_CHECK_EQUAL(parsed->fee, fee); // 128-bit fee survives the 16-byte field
109 BOOST_CHECK_EQUAL(parsed->sig.size(), size_t(CT_KERNEL_SIG_SIZE));
110 }
111
112 BOOST_FIXTURE_TEST_CASE(receipt_serialization_roundtrip, BasicTestingSetup)
113 {
114 FastRandomContext rng{uint256{104}};
115 BPCommitment commitment;
116 wallet::CTReceipt receipt;
117 BOOST_REQUIRE(wallet::CreateConfidentialOutput(77 * SAT + 3, rng, commitment, receipt));
118
119 DataStream ss{};
120 ss << receipt;
121 wallet::CTReceipt parsed;
122 ss >> parsed;
123 BOOST_CHECK_EQUAL(parsed.Amount(), receipt.Amount());
124 BOOST_CHECK(parsed.blinding == receipt.blinding);
125 BOOST_CHECK(parsed.seed == receipt.seed);
126 BOOST_CHECK(VerifyBulletproof(commitment, parsed.proof));
127 }
128
129 BOOST_FIXTURE_TEST_CASE(db_persistence, wallet::WalletTestingSetup)
130 {
131 FastRandomContext rng{uint256{105}};
132 const uint256 txid{0x99};
133
134 BPCommitment commitment;
135 wallet::CTReceipt r1, r2;
136 BOOST_REQUIRE(wallet::CreateConfidentialOutput(10 * SAT + 1, rng, commitment, r1));
137 BOOST_REQUIRE(wallet::CreateConfidentialOutput(20 * SAT + 2, rng, commitment, r2));
138
139 {
140 wallet::WalletBatch batch{m_wallet.GetDatabase()};
141 BOOST_CHECK(batch.WriteCTReceipts(txid, {r1, r2}));
142 }
143 {
144 wallet::WalletBatch batch{m_wallet.GetDatabase()};
145 std::vector<wallet::CTReceipt> loaded;
146 BOOST_CHECK(batch.ReadCTReceipts(txid, loaded));
147 BOOST_REQUIRE_EQUAL(loaded.size(), size_t(2));
148 BOOST_CHECK_EQUAL(loaded[0].Amount(), r1.Amount());
149 BOOST_CHECK_EQUAL(loaded[1].Amount(), r2.Amount());
150 BOOST_CHECK(loaded[0].blinding == r1.blinding);
151 BOOST_CHECK(VerifyBulletproof(commitment, loaded[1].proof));
152
153 BOOST_CHECK(batch.EraseCTReceipts(txid));
154 std::vector<wallet::CTReceipt> gone;
155 BOOST_CHECK(!batch.ReadCTReceipts(txid, gone));
156 BOOST_CHECK(gone.empty());
157 }
158 }
159
160 BOOST_FIXTURE_TEST_CASE(confidential_tx_end_to_end, ForkTestingSetup)
161 {
162 const auto& cp = ForkConsensus();
163 FastRandomContext rng{uint256{106}};
164
165 // Two owned CT inputs with attosat-scale values.
166 auto make_input = [&](CAmount amount, const COutPoint& outpoint) {
167 BPCommitment commitment;
168 wallet::CTReceipt receipt;
169 BOOST_REQUIRE(wallet::CreateConfidentialOutput(amount, rng, commitment, receipt));
170 LOCK(cs_main);
171 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
172 view.AddCoin(outpoint, Coin(CTxOut(0, wallet::GetConfidentialScript(commitment)), 1, false), false);
173 return std::make_pair(outpoint, receipt);
174 };
175
176 const COutPoint in0(Txid::FromUint256(uint256{0x21}), 0);
177 const COutPoint in1(Txid::FromUint256(uint256{0x22}), 0);
178 auto input0 = make_input(5000 * SAT + 300, in0);
179 auto input1 = make_input(700 * SAT + 700, in1);
180
181 // Outputs + fee balance exactly (attosat precision).
182 const std::vector<CAmount> outputs = {3000 * SAT + 250, 2000 * SAT + 100, 695 * SAT + 600};
183 const CAmount fee = 5 * SAT + 50;
184
185 const auto res = wallet::CreateConfidentialTransaction({input0, input1}, outputs, fee, rng);
186 BOOST_REQUIRE(res);
187 BOOST_REQUIRE_EQUAL(res->new_receipts.size(), outputs.size());
188 for (size_t i = 0; i < outputs.size(); ++i) {
189 BOOST_CHECK_EQUAL(res->new_receipts[i].Amount(), outputs[i]);
190 }
191 BOOST_CHECK_EQUAL(res->kernel_index, 3);
192 BOOST_CHECK_EQUAL(res->tx.vout.size(), size_t(4)); // 3 outputs + kernel
193 BOOST_CHECK_EQUAL(res->tx.vin.size(), size_t(2));
194
195 const CTransaction tx{res->tx};
196
197 // Balance + kernel signature pass; the fee is floored into txfee.
198 LOCK(cs_main);
199 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
200 TxValidationState state;
201 CAmount txfee = 0;
202 BOOST_CHECK(Consensus::CheckTxInputs(tx, state, view, 966501, txfee,
203 CheckTxInputsRules::None, cp, /*fork_active=*/true));
204 BOOST_CHECK_EQUAL(txfee, fee / SAT);
205 BOOST_CHECK_EQUAL(txfee, CAmount(5)); // 5 satoshis; the 50 attosats stay burned
206
207 // The spend witness range proofs verify against the input commitments.
208 for (size_t i = 0; i < tx.vin.size(); ++i) {
209 const auto& coin = view.AccessCoin(tx.vin[i].prevout);
210 ScriptError serror;
211 BOOST_CHECK(VerifyScript(tx.vin[i].scriptSig, coin.out.scriptPubKey,
212 &tx.vin[i].scriptWitness,
213 STANDARD_SCRIPT_VERIFY_FLAGS | SCRIPT_VERIFY_P2BPCT,
214 TransactionSignatureChecker(&tx, i, 0, PrecomputedTransactionData{},
215 MissingDataBehavior::FAIL),
216 &serror));
217 }
218 }
219
220 BOOST_AUTO_TEST_SUITE_END()
221
222 // ---------------------------------------------------------------------------
223 // Stealth CT: non-interactive confidential payments
224 // ---------------------------------------------------------------------------
225
226 static constexpr CAmount SAT = ATTOSATS_PER_SATOSHI;
227
228 BOOST_FIXTURE_TEST_CASE(stealth_create_and_recover, BasicTestingSetup)
229 {
230 FastRandomContext rng{uint256{201}};
231
232 // Receiver publishes a static (view, spend) address.
233 const CKey view = GenerateRandomKey();
234 const CKey spend = GenerateRandomKey();
235 const wallet::StealthCTAddress address = wallet::CreateStealthCTAddress(view, spend);
236
237 // Sender derives a stealth output - no interaction with the receiver.
238 const CAmount amount = 1337 * SAT + 42;
239 wallet::StealthPayment payment;
240 BOOST_REQUIRE(wallet::CreateStealthOutput(amount, address, rng, payment));
241 BOOST_CHECK_EQUAL(payment.amount, amount);
242 BOOST_CHECK_EQUAL(payment.E.size(), size_t(CT_STEALTH_EPHEM_SIZE));
243 BOOST_CHECK_EQUAL(payment.enc_amount.size(), size_t(CT_STEALTH_ENC_SIZE));
244 BOOST_CHECK_EQUAL(payment.enc_blind.size(), size_t(CT_STEALTH_ENC_SIZE));
245 BOOST_CHECK_EQUAL(payment.blinding.size(), size_t(BP_SCALAR_SIZE));
246 BOOST_CHECK_EQUAL(payment.commitment.size(), size_t(BP_POINT_SIZE));
247
248 // The receiver recovers amount and blinding from the kernel fields.
249 const auto recovered = wallet::RecoverStealthOutput(view, spend, payment.E,
250 payment.enc_amount,
251 payment.enc_blind,
252 payment.commitment);
253 BOOST_REQUIRE(recovered.has_value());
254 BOOST_CHECK_EQUAL(recovered->first, amount);
255 BOOST_CHECK_EQUAL(recovered->second.size(), size_t(BP_SCALAR_SIZE));
256 BOOST_CHECK(recovered->second == payment.blinding);
257
258 // The recovered blinding commits to the same commitment.
259 BPCommitment check;
260 BOOST_REQUIRE(CommitAmount(recovered->first, recovered->second, check));
261 BOOST_CHECK(check == payment.commitment);
262
263 // A different receiver (wrong keys) cannot recover the payment.
264 const CKey other_view = GenerateRandomKey();
265 BOOST_CHECK(!wallet::RecoverStealthOutput(other_view, spend, payment.E,
266 payment.enc_amount, payment.enc_blind,
267 payment.commitment).has_value());
268 const CKey other_spend = GenerateRandomKey();
269 BOOST_CHECK(!wallet::RecoverStealthOutput(view, other_spend, payment.E,
270 payment.enc_amount, payment.enc_blind,
271 payment.commitment).has_value());
272
273 // Tampered encrypted amount or blinding fails recovery.
274 auto tampered_enc = payment.enc_amount;
275 tampered_enc[0] ^= 0xff;
276 BOOST_CHECK(!wallet::RecoverStealthOutput(view, spend, payment.E, tampered_enc,
277 payment.enc_blind, payment.commitment).has_value());
278 auto tampered_blind = payment.enc_blind;
279 tampered_blind[0] ^= 0xff;
280 BOOST_CHECK(!wallet::RecoverStealthOutput(view, spend, payment.E, payment.enc_amount,
281 tampered_blind, payment.commitment).has_value());
282 }
283
284 BOOST_FIXTURE_TEST_CASE(stealth_tx_end_to_end, ForkTestingSetup)
285 {
286 const auto& cp = ForkConsensus();
287 FastRandomContext rng{uint256{202}};
288
289 // Sender owns one CT input: create the receipt once, then credit the coin.
290 // Value balance: in = out + fee (2005 sat + 300 attos).
291 const CAmount in_amount = 2005 * SAT + 300;
292 const COutPoint in0(Txid::FromUint256(uint256{0x31}), 0);
293 BPCommitment in_commitment;
294 wallet::CTReceipt in_receipt;
295 BOOST_REQUIRE(wallet::CreateConfidentialOutput(in_amount, rng, in_commitment, in_receipt));
296 {
297 LOCK(cs_main);
298 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
299 view.AddCoin(in0, Coin(CTxOut(0, wallet::GetConfidentialScript(in_commitment)), 1, false), false);
300 }
301
302 // Receiver's static address.
303 const CKey view = GenerateRandomKey();
304 const CKey spend = GenerateRandomKey();
305 const wallet::StealthCTAddress address = wallet::CreateStealthCTAddress(view, spend);
306
307 const CAmount amount = 2000 * SAT + 250;
308 const CAmount fee = 5 * SAT + 50;
309
310 // Build the stealth payment - the sender never learns the receiver's keys.
311 const auto res = wallet::CreateStealthTransaction({{in0, in_receipt}}, address, amount, fee, rng);
312 BOOST_REQUIRE(res);
313 BOOST_REQUIRE_EQUAL(res->tx.vout.size(), size_t(2)); // output + kernel
314 BOOST_CHECK_EQUAL(res->kernel_index, 1);
315 const CTransaction tx{res->tx};
316
317 // Consensus: balance + kernel sig over the sender-chosen blinding pass.
318 {
319 LOCK(cs_main);
320 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
321 TxValidationState state;
322 CAmount txfee = 0;
323 BOOST_CHECK(Consensus::CheckTxInputs(tx, state, view, 966501, txfee,
324 CheckTxInputsRules::None, cp, /*fork_active=*/true));
325 BOOST_CHECK_EQUAL(txfee, fee / SAT);
326 }
327
328 // Parse the kernel: stealth fields must be present.
329 const int kidx = res->kernel_index;
330 const auto parsed = ParseCTKernelOutput(tx.vout[kidx]);
331 BOOST_REQUIRE(parsed.has_value());
332 BOOST_CHECK(parsed->has_stealth);
333 BOOST_CHECK_EQUAL(parsed->E.size(), size_t(CT_STEALTH_EPHEM_SIZE));
334 BOOST_CHECK_EQUAL(parsed->enc_amount.size(), size_t(CT_STEALTH_ENC_SIZE));
335 BOOST_CHECK_EQUAL(parsed->enc_blind.size(), size_t(CT_STEALTH_ENC_SIZE));
336
337 // The receiver scans the kernel and recovers the output.
338 int wv; std::vector<uint8_t> wp;
339 BOOST_REQUIRE(tx.vout[0].scriptPubKey.IsWitnessProgram(wv, wp));
340 const auto recovered = wallet::RecoverStealthOutput(view, spend, parsed->E,
341 parsed->enc_amount,
342 parsed->enc_blind, wp);
343 BOOST_REQUIRE(recovered.has_value());
344 BOOST_CHECK_EQUAL(recovered->first, amount);
345
346 // The receiver holds (v, blind): the commitment matches, so the output
347 // is spendable like any own CT output.
348 BPCommitment check;
349 BOOST_REQUIRE(CommitAmount(recovered->first, recovered->second, check));
350 BOOST_CHECK(check == wp);
351
352 // A tampered kernel field fails the balance check.
353 {
354 auto tampered = res->tx;
355 std::vector<unsigned char> raw(tampered.vout[kidx].scriptPubKey.begin(),
356 tampered.vout[kidx].scriptPubKey.end());
357 raw[raw.size() - 1] ^= 0xff;
358 tampered.vout[kidx].scriptPubKey = CScript(raw.begin(), raw.end());
359 LOCK(cs_main);
360 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
361 TxValidationState state;
362 CAmount txfee = 0;
363 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tampered), state, view, 966501, txfee,
364 CheckTxInputsRules::None, cp, /*fork_active=*/true));
365 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-balance");
366 }
367 }
368
369 // ---------------------------------------------------------------------------
370 // Wallet stealth layer: lm2 setup, scanning, persistence
371 // ---------------------------------------------------------------------------
372
373 BOOST_FIXTURE_TEST_CASE(wallet_stealth_setup_and_recover, wallet::WalletTestingSetup)
374 {
375 wallet::WalletContext context;
376 context.chain = m_node.chain.get();
377 context.args = &m_args;
378 auto wallet = wallet::TestLoadWallet(context);
379 BOOST_REQUIRE(wallet);
380
381 // Setup derived the stealth keypair from the wallet master key.
382 CKey view, spend;
383 BOOST_CHECK(wallet->GetStealthKeys(view, spend));
384 const auto dest = wallet->GetStealthDestination();
385 BOOST_CHECK(IsValidDestination(dest));
386 const std::string addr = EncodeDestination(dest);
387 BOOST_CHECK(addr.starts_with("lm2"));
388
389 // Pay the wallet's static stealth address.
390 FastRandomContext rng{uint256{205}};
391 const CAmount in_amount = 1000 * SAT + 5;
392 BPCommitment in_commitment;
393 wallet::CTReceipt in_receipt;
394 BOOST_REQUIRE(wallet::CreateConfidentialOutput(in_amount, rng, in_commitment, in_receipt));
395
396 const CAmount amount = 700 * SAT + 3, fee = 5 * SAT + 1;
397 const wallet::StealthCTAddress st_addr{view.GetPubKey(), spend.GetPubKey()};
398 const auto res = wallet::CreateStealthTransaction(
399 {{COutPoint(Txid::FromUint256(uint256{0x41}), 0), in_receipt}},
400 st_addr, amount, fee, rng);
401 BOOST_REQUIRE(res);
402 const CTransaction tx{res->tx};
403
404 // The wallet scans the transaction and persists the spendable receipt.
405 {
406 LOCK(wallet->cs_wallet);
407 BOOST_CHECK(wallet->RecoverStealthReceipts(MakeTransactionRef(tx)));
408 }
409 {
410 wallet::WalletBatch batch{wallet->GetDatabase()};
411 std::vector<wallet::CTReceipt> receipts;
412 BOOST_CHECK(batch.ReadCTReceipts(tx.GetHash(), receipts));
413 BOOST_REQUIRE_EQUAL(receipts.size(), size_t(1));
414 BOOST_CHECK_EQUAL(receipts[0].Amount(), amount);
415
416 // The receipt proof verifies against the output commitment.
417 int wv; std::vector<uint8_t> wp;
418 BOOST_REQUIRE(tx.vout[0].scriptPubKey.IsWitnessProgram(wv, wp));
419 BOOST_CHECK(VerifyBulletproof(wp, receipts[0].proof));
420 }
421
422 // A payment to a different address is not recovered.
423 const CKey other_view = GenerateRandomKey();
424 const CKey other_spend = GenerateRandomKey();
425 const wallet::StealthCTAddress other_addr{other_view.GetPubKey(), other_spend.GetPubKey()};
426 const auto res2 = wallet::CreateStealthTransaction(
427 {{COutPoint(Txid::FromUint256(uint256{0x42}), 0), in_receipt}},
428 other_addr, amount, fee, rng);
429 BOOST_REQUIRE(res2);
430 {
431 LOCK(wallet->cs_wallet);
432 BOOST_CHECK(!wallet->RecoverStealthReceipts(MakeTransactionRef(CTransaction(res2->tx))));
433 }
434
435 wallet::TestUnloadWallet(std::move(wallet));
436 }
437
438 BOOST_FIXTURE_TEST_CASE(wallet_stealth_persistence, wallet::WalletTestingSetup)
439 {
440 // Create a real sqlite wallet, unload, reload: the stealth keypair must
441 // survive (dedicated DB record, loaded by LoadWallet).
442 wallet::DatabaseOptions options;
443 options.require_create = true;
444 options.create_flags = wallet::WALLET_FLAG_DESCRIPTORS;
445 wallet::DatabaseStatus status;
446 bilingual_str error;
447 auto database = wallet::MakeWalletDatabase("stealth_persist", options, status, error);
448 BOOST_REQUIRE(database);
449
450 wallet::WalletContext context;
451 context.chain = m_node.chain.get();
452 context.args = &m_args;
453
454 auto wallet = wallet::TestLoadWallet(std::move(database), context, options.create_flags);
455 BOOST_REQUIRE(wallet);
456 const auto dest_before = wallet->GetStealthDestination();
457 BOOST_CHECK(IsValidDestination(dest_before));
458 BOOST_CHECK(EncodeDestination(dest_before).starts_with("lm2"));
459 wallet::TestUnloadWallet(std::move(wallet));
460
461 wallet::DatabaseOptions options2;
462 options2.create_flags = wallet::WALLET_FLAG_DESCRIPTORS;
463 auto database2 = wallet::MakeWalletDatabase("stealth_persist", options2, status, error);
464 BOOST_REQUIRE(database2);
465 auto wallet2 = wallet::TestLoadWallet(std::move(database2), context, options2.create_flags);
466 BOOST_REQUIRE(wallet2);
467 const auto dest_after = wallet2->GetStealthDestination();
468 BOOST_CHECK(IsValidDestination(dest_after));
469 BOOST_CHECK(EncodeDestination(dest_after) == EncodeDestination(dest_before));
470 wallet::TestUnloadWallet(std::move(wallet2));
471 }
472
473 BOOST_AUTO_TEST_CASE(attosats_format_parse_roundtrip)
474 {
475 // λ formatting trims trailing zeros and round-trips through parsing.
476 const std::vector<CAmount> amounts{
477 0, 1, 1000, ATTOSATS_PER_SATOSHI, 5 * ATTOSATS_PER_SATOSHI + 250,
478 wallet::LAMBDA_SCALE, 10 * wallet::LAMBDA_SCALE + 123,
479 (CAmount{1} << 120)};
480 for (const CAmount a : amounts) {
481 const std::string s = wallet::AttosatsToString(a);
482 CAmount back;
483 BOOST_CHECK(wallet::ParseAttosatsString(s, back));
484 BOOST_CHECK_EQUAL(back, a);
485 }
486 // Trailing zeros are trimmed and the whole unit renders without a dot.
487 BOOST_CHECK_EQUAL(wallet::AttosatsToString(wallet::LAMBDA_SCALE), "1");
488 BOOST_CHECK_EQUAL(wallet::AttosatsToString(0), "0");
489 // Invalid inputs are rejected.
490 CAmount out;
491 BOOST_CHECK(!wallet::ParseAttosatsString("", out));
492 BOOST_CHECK(!wallet::ParseAttosatsString("1.2.3", out));
493 BOOST_CHECK(!wallet::ParseAttosatsString("abc", out));
494 }
495
496 BOOST_FIXTURE_TEST_CASE(ct_psbt_data_roundtrip, BasicTestingSetup)
497 {
498 // CTPSBTData round-trips through the PSBT global proprietary field.
499 FastRandomContext rng{uint256{240}};
500 BPCommitment commitment;
501 wallet::CTReceipt in_rec, out_rec;
502 BOOST_REQUIRE(wallet::CreateConfidentialOutput(5 * SAT + 3, rng, commitment, in_rec));
503 BOOST_REQUIRE(wallet::CreateConfidentialOutput(4 * SAT + 2, rng, commitment, out_rec));
504
505 wallet::CTPSBTData data;
506 data.kernel.fee = 1000;
507 data.kernel.has_stealth = true;
508 data.kernel.E.assign(33, 0x11);
509 data.kernel.enc_amount.assign(32, 0x22);
510 data.kernel.enc_blind.assign(32, 0x33);
511 data.kernel.sig.clear();
512 data.in_receipts = {in_rec};
513 data.out_receipts = {out_rec};
514
515 CMutableTransaction mtx;
516 mtx.version = 2;
517 PartiallySignedTransaction psbt{mtx};
518 BOOST_CHECK(wallet::SetCTPSBTData(psbt, data));
519
520 wallet::CTPSBTData recovered;
521 BOOST_CHECK(wallet::GetCTPSBTData(psbt, recovered));
522 BOOST_CHECK_EQUAL(recovered.kernel.fee, data.kernel.fee);
523 BOOST_CHECK(recovered.kernel.has_stealth);
524 BOOST_CHECK(recovered.kernel.E == data.kernel.E);
525 BOOST_CHECK(recovered.kernel.enc_amount == data.kernel.enc_amount);
526 BOOST_CHECK(recovered.kernel.enc_blind == data.kernel.enc_blind);
527 BOOST_REQUIRE_EQUAL(recovered.in_receipts.size(), size_t(1));
528 BOOST_REQUIRE_EQUAL(recovered.out_receipts.size(), size_t(1));
529 BOOST_CHECK_EQUAL(recovered.in_receipts[0].Amount(), in_rec.Amount());
530 BOOST_CHECK(recovered.in_receipts[0].blinding == in_rec.blinding);
531 BOOST_CHECK_EQUAL(recovered.out_receipts[0].Amount(), out_rec.Amount());
532 BOOST_CHECK(recovered.out_receipts[0].blinding == out_rec.blinding);
533
534 // A PSBT without the field returns false.
535 PartiallySignedTransaction empty{mtx};
536 wallet::CTPSBTData none;
537 BOOST_CHECK(!wallet::GetCTPSBTData(empty, none));
538 }
539
540 BOOST_FIXTURE_TEST_CASE(ct_psbt_serialize_roundtrip, BasicTestingSetup)
541 {
542 // The CT payload must survive a full PSBT base64 round-trip (the
543 // functional test path), not just in-memory set/get.
544 FastRandomContext rng{uint256{241}};
545 BPCommitment commitment;
546 wallet::CTReceipt out_rec;
547 BOOST_REQUIRE(wallet::CreateConfidentialOutput(7 * SAT + 1, rng, commitment, out_rec));
548
549 wallet::CTPSBTData data;
550 data.kernel.fee = 42;
551 data.kernel.sig.clear();
552 data.out_receipts = {out_rec};
553
554 CMutableTransaction mtx;
555 mtx.version = 2;
556 PartiallySignedTransaction psbt{mtx};
557 BOOST_REQUIRE(wallet::SetCTPSBTData(psbt, data));
558
559 DataStream ss;
560 ss << psbt;
561 const std::string b64 = EncodeBase64(ss);
562
563 PartiallySignedTransaction decoded;
564 std::string err;
565 BOOST_REQUIRE(DecodeBase64PSBT(decoded, b64, err));
566
567 wallet::CTPSBTData recovered;
568 BOOST_CHECK(wallet::GetCTPSBTData(decoded, recovered));
569 BOOST_CHECK_EQUAL(recovered.kernel.fee, CAmount(42));
570 BOOST_REQUIRE_EQUAL(recovered.out_receipts.size(), size_t(1));
571 BOOST_CHECK_EQUAL(recovered.out_receipts[0].Amount(), out_rec.Amount());
572 }
573
574 // ---------------------------------------------------------------------------
575 // Multi-party kernel aggregation via blinding offsets
576 // ---------------------------------------------------------------------------
577
578 BOOST_FIXTURE_TEST_CASE(multiparty_scalar_excess, ForkTestingSetup)
579 {
580 const auto& cp = ForkConsensus();
581 FastRandomContext rng{uint256{242}};
582 auto rs = [&]() {
583 auto b = rng.randbytes(BP_SCALAR_SIZE);
584 return BPScalar(b.begin(), b.end());
585 };
586
587 // Two parties. Party A (coordinator) and party B (peer) each hold a CT
588 // input and create a CT output. Values conserve across the pair. The
589 // balance equation is a plain Pedersen sum (no offset terms), so
590 // multi-party signing means the coordinator aggregates the parties'
591 // net blinding SCALARS and signs the total excess. B reveals only its
592 // aggregate scalar (b_out - b_in), which leaks none of its individual
593 // blindings.
594 const CAmount v_a_in = 7000 * SAT;
595 const CAmount v_a_out = 6000 * SAT;
596 const CAmount v_b_in = 3000 * SAT;
597 const CAmount v_b_out = 3999 * SAT;
598 const CAmount fee = 1 * SAT; // 7000 + 3000 == 6000 + 3999 + 1
599
600 const BPScalar r_a_in = rs(), r_a_out = rs(), r_b_in = rs(), r_b_out = rs();
601
602 BPCommitment c_a_in, c_a_out, c_b_in, c_b_out;
603 BOOST_REQUIRE(CommitAmount(v_a_in, r_a_in, c_a_in));
604 BOOST_REQUIRE(CommitAmount(v_a_out, r_a_out, c_a_out));
605 BOOST_REQUIRE(CommitAmount(v_b_in, r_b_in, c_b_in));
606 BOOST_REQUIRE(CommitAmount(v_b_out, r_b_out, c_b_out));
607
608 const COutPoint out_a(Txid::FromUint256(uint256{0x51}), 0);
609 const COutPoint out_b(Txid::FromUint256(uint256{0x52}), 0);
610 {
611 LOCK(cs_main);
612 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
613 view.AddCoin(out_a, Coin(CTxOut(0, wallet::GetConfidentialScript(c_a_in)), 1, false), false);
614 view.AddCoin(out_b, Coin(CTxOut(0, wallet::GetConfidentialScript(c_b_in)), 1, false), false);
615 }
616
617 CMutableTransaction mtx;
618 mtx.vin.resize(2);
619 mtx.vin[0].prevout = out_a;
620 mtx.vin[1].prevout = out_b;
621 mtx.vout.resize(3);
622 mtx.vout[0].nValue = 0;
623 mtx.vout[0].scriptPubKey = wallet::GetConfidentialScript(c_a_out);
624 mtx.vout[1].nValue = 0;
625 mtx.vout[1].scriptPubKey = wallet::GetConfidentialScript(c_b_out);
626 const int kernel_index = 2;
627
628 // Each party computes its net blinding scalar (in - out).
629 BPScalar e_a, e_b;
630 BOOST_REQUIRE(wallet::ComputeCTExcess({r_a_in}, {r_a_out}, e_a));
631 BOOST_REQUIRE(wallet::ComputeCTExcess({r_b_in}, {r_b_out}, e_b));
632
633 // The coordinator aggregates both scalars and signs the total excess.
634 BPScalar total_excess;
635 BOOST_REQUIRE(wallet::ComputeCTExcess({e_a, e_b}, {}, total_excess));
636 CTKernelData kernel;
637 kernel.fee = fee;
638 const uint256 msg = ComputeCTKernelMessage(CTransaction(mtx), kernel_index, kernel);
639 std::vector<uint8_t> sig;
640 BOOST_REQUIRE(CreateCTKernelSig(total_excess, {msg.begin(), msg.end()}, sig));
641 kernel.sig = std::move(sig);
642 CScript kernel_script;
643 BOOST_REQUIRE(wallet::BuildCTKernelScript(kernel, kernel_script));
644 mtx.vout[kernel_index].nValue = 0;
645 mtx.vout[kernel_index].scriptPubKey = std::move(kernel_script);
646
647 const CTransaction tx{mtx};
648 {
649 LOCK(cs_main);
650 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
651 TxValidationState state;
652 CAmount txfee = 0;
653 BOOST_CHECK(Consensus::CheckTxInputs(tx, state, view, 966501, txfee,
654 CheckTxInputsRules::None, cp, /*fork_active=*/true));
655 BOOST_CHECK_EQUAL(txfee, fee / SAT);
656 }
657
658 // If the coordinator omits B's scalar, the balance fails (no offset
659 // terms exist to absorb the imbalance).
660 {
661 auto tampered = mtx;
662 const uint256 msg2 = ComputeCTKernelMessage(CTransaction(tampered), kernel_index, kernel);
663 std::vector<uint8_t> sig2;
664 BOOST_REQUIRE(CreateCTKernelSig(e_a, {msg2.begin(), msg2.end()}, sig2));
665 CTKernelData kernel2;
666 kernel2.fee = fee;
667 kernel2.sig = std::move(sig2);
668 CScript ks2;
669 BOOST_REQUIRE(wallet::BuildCTKernelScript(kernel2, ks2));
670 tampered.vout[kernel_index].scriptPubKey = std::move(ks2);
671 LOCK(cs_main);
672 auto& view = m_node.chainman->ActiveChainstate().CoinsTip();
673 TxValidationState state;
674 CAmount txfee = 0;
675 BOOST_CHECK(!Consensus::CheckTxInputs(CTransaction(tampered), state, view, 966501, txfee,
676 CheckTxInputsRules::None, cp, /*fork_active=*/true));
677 BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-ct-balance");
678 }
679 }
680