// Copyright (c) 2025 The Limenka developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace wallet { static CAmount EstimateCTFee(const CWallet& wallet, int vin_count, int vout_count) { // 10 (base) + transparent in (68) + CT outputs (35) + kernel (86) + // CT input proof witnesses (754/4 rounded up). const int vsize = 10 + vin_count * (68 + 189) + vout_count * 35 + 86; CFeeRate feerate = wallet.chain().mempoolMinFee(); if (feerate == CFeeRate(0)) feerate = CFeeRate(1000); // fallback 1 sat/vB const CAmount fee_sats = feerate.GetFee(vsize); return std::max(fee_sats, CAmount{1}) * ATTOSATS_PER_SATOSHI; } static CAmount AmountFromAttosatValue(const UniValue& value) { // STR only: numeric JSON travels through UniValue's double storage and // silently loses precision beyond ~15 significant digits (26-decimal λ // amounts need all 128 bits). if (!value.isStr()) { throw JSONRPCError(RPC_TYPE_ERROR, "Amount must be a string (full 128-bit precision)"); } CAmount amount_attosats; if (!ParseAttosatsString(value.getValStr(), amount_attosats)) { throw JSONRPCError(RPC_TYPE_ERROR, "Invalid amount"); } if (amount_attosats <= 0) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameter, amount must be positive"); } return amount_attosats; } RPCHelpMan getnewstealthaddress() { return RPCHelpMan{"getnewstealthaddress", "\nReturns the wallet's stealth CT address (lm2). Payments to it are\n" "confidential: amounts and the receiver identity stay hidden on-chain.\n" "The address is deterministic for the wallet (derived from its master\n" "key), so it never needs to be regenerated.\n", {}, RPCResult{ RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR, "address", "The lm2 stealth address"}, {RPCResult::Type::STR, "view_pubkey", "The view (scan) public key"}, {RPCResult::Type::STR, "spend_pubkey", "The spend public key"}, }}, RPCExamples{ HelpExampleCli("getnewstealthaddress", "") + HelpExampleRpc("getnewstealthaddress", "")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; // Pre-activation P2BPCT (v4) outputs are anyone-can-spend: // refuse to hand out lm2 addresses until the fork gate. if (Params().GetChainType() == ChainType::FORK) { const int64_t mtp = wallet->chain().getTipMtp(); if (mtp + Params().GetConsensus().nForkActivationBias < Params().GetConsensus().nForkActivationMTP) { throw JSONRPCError(RPC_WALLET_ERROR, "lm2 stealth addresses cannot be created before fork activation: their outputs would be spendable by anyone"); } } LOCK(wallet->cs_wallet); CKey view, spend; if (!wallet->GetStealthKeys(view, spend)) { throw JSONRPCError(RPC_WALLET_ERROR, "This wallet has no stealth keys"); } const auto dest = wallet->GetStealthDestination(); UniValue result(UniValue::VOBJ); result.pushKV("address", EncodeDestination(dest)); result.pushKV("view_pubkey", HexStr(dest.view)); result.pushKV("spend_pubkey", HexStr(dest.spend)); return result; }}; } RPCHelpMan listctreceipts() { return RPCHelpMan{"listctreceipts", "\nList the confidential (CT) outputs this wallet can spend: created,\n" "changed, and stealth-recovered, with their hidden attosat amounts.\n", {}, RPCResult{ RPCResult::Type::ARR, "", "", { {RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR, "txid", "The transaction that created the output"}, {RPCResult::Type::NUM, "vout", "The output index"}, {RPCResult::Type::STR, "amount", "The committed amount in λ (26 decimals)"}, }}, }}, RPCExamples{ HelpExampleCli("listctreceipts", "") + HelpExampleRpc("listctreceipts", "")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; LOCK(wallet->cs_wallet); WalletBatch batch{wallet->GetDatabase()}; std::map> all; if (!batch.ListCTReceipts(all)) { throw JSONRPCError(RPC_WALLET_ERROR, "Failed to enumerate CT receipts"); } UniValue result(UniValue::VARR); for (const auto& [txid, receipts] : all) { for (const auto& receipt : receipts) { UniValue entry(UniValue::VOBJ); entry.pushKV("txid", txid.ToString()); entry.pushKV("vout", uint64_t(receipt.vout_index)); entry.pushKV("amount", AttosatsToString(receipt.Amount())); result.push_back(std::move(entry)); } } return result; }}; } RPCHelpMan sendtostealth() { return RPCHelpMan{"sendtostealth", "\nSend a confidential payment to an lm2 stealth address. Selects\n" "spendable CT outputs, pays the given amount in λ, and returns the\n" "transaction id. Unspent surplus becomes confidential change.\n", { {"address", RPCArg::Type::STR, RPCArg::Optional::NO, "The lm2 stealth address to pay"}, {"amount", RPCArg::Type::STR, RPCArg::Optional::NO, "The amount in λ (26 decimal places)"}, {"fee", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "The explicit fee in λ (default: feerate estimate)"}, {"change_outputs", RPCArg::Type::NUM, RPCArg::Default{1}, "Number of confidential change outputs to split the surplus into (1-16, random widely-varying sizes)"}, }, RPCResult{ RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR_HEX, "txid", "The transaction id"}, }}, RPCExamples{ HelpExampleCli("sendtostealth", "\"lm2...\" 0.1") + HelpExampleRpc("sendtostealth", "\"lm2...\", 0.1")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; const CTxDestination dest = DecodeDestination(request.params[0].get_str()); if (!std::holds_alternative(dest)) { throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Invalid lm2 stealth address"); } const CAmount amount = AmountFromAttosatValue(request.params[1]); CAmount fee = EstimateCTFee(*wallet, /*vin=*/1, /*vout=*/1); if (!request.params[2].isNull()) { fee = AmountFromAttosatValue(request.params[2]); } int change_outputs = request.params[3].isNull() ? 1 : request.params[3].getInt(); const auto txid = wallet->SendStealthPayment(dest, amount, fee, change_outputs); if (!txid) { throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(txid).original); } UniValue result(UniValue::VOBJ); result.pushKV("txid", txid->ToString()); return result; }}; } RPCHelpMan mintct() { return RPCHelpMan{"mintct", "\nMint transparent value into confidential (CT) outputs. Spends\n" "transparent wallet coins and creates a confidential output of the\n" "given amount in λ. The surplus becomes a second confidential\n" "output (change).\n", { {"amount", RPCArg::Type::STR, RPCArg::Optional::NO, "The amount to mint, in λ (26 decimal places)"}, {"fee", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "The explicit fee in λ (default: feerate estimate)"}, {"outputs", RPCArg::Type::NUM, RPCArg::Default{2}, "Number of confidential outputs to split the minted value into (1-16, random widely-varying sizes; all outputs are confidential)"}, }, RPCResult{ RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR_HEX, "txid", "The transaction id"}, }}, RPCExamples{ HelpExampleCli("mintct", "0.5") + HelpExampleRpc("mintct", "0.5")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; const CAmount amount = AmountFromAttosatValue(request.params[0]); CAmount fee = EstimateCTFee(*wallet, /*vin=*/1, /*vout=*/1); if (!request.params[1].isNull()) { fee = AmountFromAttosatValue(request.params[1]); } int output_count = request.params[2].isNull() ? 2 : request.params[2].getInt(); const auto txid = wallet->MintConfidential(amount, fee, output_count); if (!txid) { throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(txid).original); } UniValue result(UniValue::VOBJ); result.pushKV("txid", txid->ToString()); return result; }}; } RPCHelpMan createctpsbt() { return RPCHelpMan{"createctpsbt", "\nBuild an unsigned confidential-transaction PSBT for a mint.\n" "The transparent inputs are signable by any signer (including a\n" "hardware wallet); the kernel signature and CT proofs are filled\n" "by finalizectpsbt. The PSBT carries blindings, so treat it as\n" "sensitive material.\n", { {"amount", RPCArg::Type::STR, RPCArg::Optional::NO, "The amount to mint, in λ (26 decimal places)"}, {"fee", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "The explicit fee in λ (default: feerate estimate)"}, {"outputs", RPCArg::Type::NUM, RPCArg::Default{2}, "Number of confidential outputs to split the minted value into (1-16)"}, }, RPCResult{ RPCResult::Type::STR, "psbt", "The base64-encoded unsigned PSBT"}, RPCExamples{ HelpExampleCli("createctpsbt", "\"10\"") + HelpExampleRpc("createctpsbt", "\"10\"")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; const CAmount amount = AmountFromAttosatValue(request.params[0]); CAmount fee = EstimateCTFee(*wallet, /*vin=*/1, /*vout=*/1); if (!request.params[1].isNull()) { fee = AmountFromAttosatValue(request.params[1]); } const int output_count = request.params[2].isNull() ? 2 : request.params[2].getInt(); const auto psbt = wallet->CreateCTMintPSBT(amount, fee, output_count); if (!psbt) { throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(psbt).original); } DataStream ss; ss << *psbt; return EncodeBase64(ss); }}; } RPCHelpMan finalizectpsbt() { return RPCHelpMan{"finalizectpsbt", "\nFinalize a confidential-transaction PSBT: signs the transparent\n" "inputs, attaches the CT proofs, and signs the kernel. Returns the\n" "fully-signed transaction as hex.\n", { {"psbt", RPCArg::Type::STR, RPCArg::Optional::NO, "The base64-encoded CT PSBT"}, }, RPCResult{ RPCResult::Type::STR_HEX, "hex", "The hex-encoded finalized transaction"}, RPCExamples{ HelpExampleCli("finalizectpsbt", "\"psbt\"") + HelpExampleRpc("finalizectpsbt", "\"psbt\"")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; PartiallySignedTransaction psbt; std::string error; if (!DecodeBase64PSBT(psbt, request.params[0].get_str(), error)) { throw JSONRPCError(RPC_DESERIALIZATION_ERROR, strprintf("TX decode failed: %s", error)); } CMutableTransaction mtx; if (!wallet->FinalizeCTPSBT(psbt, mtx)) { throw JSONRPCError(RPC_WALLET_ERROR, "Failed to finalize CT PSBT (missing CT payload, zero excess, or signing failure)"); } DataStream ss; ss << TX_WITH_WITNESS(CTransaction(mtx)); return HexStr(ss); }}; } RPCHelpMan createstealthpsbt() { return RPCHelpMan{"createstealthpsbt", "\nBuild an unsigned confidential-transaction PSBT that spends the\n" "wallet's own CT outputs to an lm2 stealth address, with\n" "confidential change. The kernel (which carries the encrypted\n" "amount and blinding fields) is signed at finalization. The PSBT\n" "carries blindings, so treat it as sensitive material.\n", { {"address", RPCArg::Type::STR, RPCArg::Optional::NO, "The lm2 stealth address to pay"}, {"amount", RPCArg::Type::STR, RPCArg::Optional::NO, "The amount in λ (26 decimal places)"}, {"fee", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "The explicit fee in λ (default: feerate estimate)"}, {"change_outputs", RPCArg::Type::NUM, RPCArg::Default{1}, "Number of confidential change outputs (1-16)"}, }, RPCResult{ RPCResult::Type::STR, "psbt", "The base64-encoded unsigned PSBT"}, RPCExamples{ HelpExampleCli("createstealthpsbt", "\"lm2...\" 0.1") + HelpExampleRpc("createstealthpsbt", "\"lm2...\", 0.1")}, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { std::shared_ptr const wallet = GetWalletForJSONRPCRequest(request); if (!wallet) return UniValue::VNULL; const CTxDestination dest = DecodeDestination(request.params[0].get_str()); if (!std::holds_alternative(dest)) { throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Invalid lm2 stealth address"); } const CAmount amount = AmountFromAttosatValue(request.params[1]); CAmount fee = EstimateCTFee(*wallet, /*vin=*/1, /*vout=*/1); if (!request.params[2].isNull()) { fee = AmountFromAttosatValue(request.params[2]); } const int change_outputs = request.params[3].isNull() ? 1 : request.params[3].getInt(); const auto psbt = wallet->CreateCTStealthPSBT(dest, amount, fee, change_outputs); if (!psbt) { throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(psbt).original); } DataStream ss; ss << *psbt; return EncodeBase64(ss); }}; } } // namespace wallet