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