diff --git a/Makefile.am b/Makefile.am
index f75eb39574..8659f0770d 100755
--- a/Makefile.am
+++ b/Makefile.am
@@ -147,6 +147,7 @@ src_libbitcoin_system_la_SOURCES = \
src/wallet/addresses/bitcoin_uri.cpp \
src/wallet/addresses/payment_address.cpp \
src/wallet/addresses/qr_code.cpp \
+ src/wallet/addresses/silent_payment.cpp \
src/wallet/addresses/stealth_address.cpp \
src/wallet/addresses/stealth_receiver.cpp \
src/wallet/addresses/stealth_sender.cpp \
@@ -438,6 +439,8 @@ test_libbitcoin_system_test_SOURCES = \
test/wallet/addresses/checked.cpp \
test/wallet/addresses/payment_address.cpp \
test/wallet/addresses/qr_code.cpp \
+ test/wallet/addresses/silent_payment.cpp \
+ test/wallet/addresses/silent_payment_vectors.hpp \
test/wallet/addresses/stealth_address.cpp \
test/wallet/addresses/stealth_receiver.cpp \
test/wallet/addresses/stealth_sender.cpp \
@@ -983,6 +986,7 @@ include_bitcoin_system_wallet_addresses_HEADERS = \
include/bitcoin/system/wallet/addresses/checked.hpp \
include/bitcoin/system/wallet/addresses/payment_address.hpp \
include/bitcoin/system/wallet/addresses/qr_code.hpp \
+ include/bitcoin/system/wallet/addresses/silent_payment.hpp \
include/bitcoin/system/wallet/addresses/stealth_address.hpp \
include/bitcoin/system/wallet/addresses/stealth_receiver.hpp \
include/bitcoin/system/wallet/addresses/stealth_sender.hpp \
@@ -1035,4 +1039,3 @@ target_examples = \
examples/libbitcoin-system-examples
examples: ${target_examples}
-
diff --git a/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj b/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj
index cee21b11f1..f001dfa982 100644
--- a/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj
+++ b/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj
@@ -398,6 +398,7 @@
+
@@ -461,6 +462,7 @@
+
diff --git a/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
index 3bf532882c..4804222987 100644
--- a/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
+++ b/builds/msvc/vs2022/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
@@ -702,6 +702,9 @@
src\wallet\addresses
+
+ src\wallet\addresses
+
src\wallet\addresses
@@ -827,6 +830,9 @@
src
+
+ src\wallet\addresses
+
src\wallet\mnemonics
diff --git a/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj b/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj
index 6ab768d3cd..74de9a0c83 100644
--- a/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj
+++ b/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj
@@ -315,6 +315,7 @@
$(IntDir)src_wallet_addresses_qrencode_split.c.obj
+
@@ -626,6 +627,7 @@
+
diff --git a/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj.filters
index bb9d7d5f4b..dd7063d303 100644
--- a/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj.filters
+++ b/builds/msvc/vs2022/libbitcoin-system/libbitcoin-system.vcxproj.filters
@@ -603,6 +603,9 @@
src\wallet\addresses\qrencode
+
+ src\wallet\addresses
+
src\wallet\addresses
@@ -1472,6 +1475,9 @@
include\bitcoin\system\wallet\addresses
+
+ include\bitcoin\system\wallet\addresses
+
include\bitcoin\system\wallet\addresses
diff --git a/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj b/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj
index ee05363b90..bd3aa911d2 100644
--- a/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj
+++ b/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj
@@ -398,6 +398,7 @@
+
@@ -461,6 +462,7 @@
+
diff --git a/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
index 3bf532882c..4804222987 100644
--- a/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
+++ b/builds/msvc/vs2026/libbitcoin-system-test/libbitcoin-system-test.vcxproj.filters
@@ -702,6 +702,9 @@
src\wallet\addresses
+
+ src\wallet\addresses
+
src\wallet\addresses
@@ -827,6 +830,9 @@
src
+
+ src\wallet\addresses
+
src\wallet\mnemonics
diff --git a/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj b/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj
index e97be619b8..e2665a5512 100644
--- a/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj
+++ b/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj
@@ -315,6 +315,7 @@
$(IntDir)src_wallet_addresses_qrencode_split.c.obj
+
@@ -626,6 +627,7 @@
+
diff --git a/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj.filters
index bb9d7d5f4b..dd7063d303 100644
--- a/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj.filters
+++ b/builds/msvc/vs2026/libbitcoin-system/libbitcoin-system.vcxproj.filters
@@ -603,6 +603,9 @@
src\wallet\addresses\qrencode
+
+ src\wallet\addresses
+
src\wallet\addresses
@@ -1472,6 +1475,9 @@
include\bitcoin\system\wallet\addresses
+
+ include\bitcoin\system\wallet\addresses
+
include\bitcoin\system\wallet\addresses
diff --git a/include/bitcoin/system.hpp b/include/bitcoin/system.hpp
index df16ed6883..facced5c70 100755
--- a/include/bitcoin/system.hpp
+++ b/include/bitcoin/system.hpp
@@ -276,6 +276,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/include/bitcoin/system/chain/script.hpp b/include/bitcoin/system/chain/script.hpp
index 71c0e345c4..c70cb88b26 100644
--- a/include/bitcoin/system/chain/script.hpp
+++ b/include/bitcoin/system/chain/script.hpp
@@ -61,6 +61,7 @@ class BC_API script
static constexpr bool is_sign_multisig_pattern(const operations& ops) NOEXCEPT;
static constexpr bool is_sign_public_key_pattern(const operations& ops) NOEXCEPT;
static constexpr bool is_sign_key_hash_pattern(const operations& ops) NOEXCEPT;
+ static constexpr bool is_sign_witness_key_hash_pattern(const operations& ops) NOEXCEPT;
static constexpr bool is_sign_script_hash_pattern(const operations& ops) NOEXCEPT;
static bool is_coinbase_pattern(const operations& ops, size_t height) NOEXCEPT;
static bool is_pay_multisig_pattern(const operations& ops) NOEXCEPT;
diff --git a/include/bitcoin/system/crypto/secp256k1.hpp b/include/bitcoin/system/crypto/secp256k1.hpp
index 2f676801dd..3746974eb0 100644
--- a/include/bitcoin/system/crypto/secp256k1.hpp
+++ b/include/bitcoin/system/crypto/secp256k1.hpp
@@ -105,6 +105,10 @@ BC_API bool ec_add(ec_compressed& left, const ec_compressed& right) NOEXCEPT;
/// Compute the sum a += b.
BC_API bool ec_add(ec_compressed& left, const ec_uncompressed& right) NOEXCEPT;
+/// Compute the sum a += b.
+BC_API bool ec_add(ec_uncompressed& left,
+ const ec_uncompressed& right) NOEXCEPT;
+
/// Compute the sum of compressed point values.
BC_API bool ec_sum(ec_compressed& out, const compressed_list& values) NOEXCEPT;
diff --git a/include/bitcoin/system/hash/checksum.hpp b/include/bitcoin/system/hash/checksum.hpp
index ec29c9523d..609802efba 100644
--- a/include/bitcoin/system/hash/checksum.hpp
+++ b/include/bitcoin/system/hash/checksum.hpp
@@ -60,7 +60,7 @@ BC_API data_chunk append_checksum(const data_loaf& slices) NOEXCEPT;
/// Append a four byte bitcoin checksum of data to itself.
BC_API void append_checksum(data_chunk& data) NOEXCEPT;
-// bech32 checksum, used by witness_address.
+// bech32 checksum, used by versioned bech32 encodings.
// ----------------------------------------------------------------------------
/// These utilities are used for witness payment addresses and other standards
@@ -72,14 +72,28 @@ BC_API void append_checksum(data_chunk& data) NOEXCEPT;
/// The implementation does not support unversioned bech32 payloads as there
/// is no use case and support requires exposure of bech32 internals.
+enum class checksum_constant
+{
+ bech32,
+ bech32m
+};
+
/// Combine witness version, program and checksum.
/// The result may be passed to encode_base32 when creating a witness address.
/// For implementation details see wallet::witness_address. Version is limited
/// to 5 bits (less than 32) by bech32 design and is otherwise truncated. Non-
-/// zero versions select bech32m (vs. bech32), resulting in distinct checksum.
+/// zero versions select bech32m (vs. bech32), resulting in distinct checksum
+/// constants.
BC_API base32_chunk bech32_build_checked(uint8_t version,
const data_chunk& program, const std::string& prefix) NOEXCEPT;
+/// Combine version, program and checksum with explicit checksum constant.
+/// This supports encodings whose checksum constant is not selected by the
+/// witness address version rule.
+BC_API base32_chunk bech32_build_checked(uint8_t version,
+ const data_chunk& program, const std::string& prefix,
+ checksum_constant constant) NOEXCEPT;
+
/// Verify the bech32 checksum and extract witness version and program.
/// The data parameter may obtained from a witness address using decode_base32.
/// For implementation details see wallet::witness_address.
@@ -87,6 +101,11 @@ BC_API bool bech32_verify_checked(uint8_t& out_version,
data_chunk& out_program, const std::string& prefix,
const base32_chunk& checked) NOEXCEPT;
+/// Verify the bech32 checksum constant and extract version and program.
+BC_API bool bech32_verify_checked(uint8_t& out_version,
+ data_chunk& out_program, const std::string& prefix,
+ const base32_chunk& checked, checksum_constant constant) NOEXCEPT;
+
} // namespace system
} // namespace libbitcoin
diff --git a/include/bitcoin/system/impl/chain/script_patterns.ipp b/include/bitcoin/system/impl/chain/script_patterns.ipp
index 704ea225ea..6fa4df8735 100644
--- a/include/bitcoin/system/impl/chain/script_patterns.ipp
+++ b/include/bitcoin/system/impl/chain/script_patterns.ipp
@@ -181,7 +181,7 @@ constexpr bool script::is_pay_witness_taproot_pattern(
const operations& ops) NOEXCEPT
{
return ops.size() == 2
- && ops[0].code() == opcode::push_size_1
+ && ops[0].code() == opcode::push_positive_1
&& ops[1].code() == opcode::push_size_32;
}
@@ -216,6 +216,15 @@ constexpr bool script::is_sign_key_hash_pattern(const operations& ops) NOEXCEPT
&& is_public_key(ops[1].data());
}
+constexpr bool script::is_sign_witness_key_hash_pattern(
+ const operations& ops) NOEXCEPT
+{
+ return ops.size() == 1
+ && ops[0].code() == opcode::push_size_22
+ && ops[0].data().size() == short_hash_size + 2u
+ && ops[0].data()[0] == 0x00
+ && ops[0].data()[1] == short_hash_size;
+}
// Ambiguous with is_sign_key_hash when second/last op is a public key.
// Ambiguous with is_sign_public_key_pattern when only op is endorsement.
@@ -364,7 +373,7 @@ inline operations script::to_pay_witness_taproot_pattern(
{
return
{
- { opcode::push_size_1 },
+ { opcode::push_positive_1 },
{ to_chunk(hash), false }
};
}
diff --git a/include/bitcoin/system/impl/stream/streamers/sha256t_writer.ipp b/include/bitcoin/system/impl/stream/streamers/sha256t_writer.ipp
index e2aadbf142..0f7fbc2082 100644
--- a/include/bitcoin/system/impl/stream/streamers/sha256t_writer.ipp
+++ b/include/bitcoin/system/impl/stream/streamers/sha256t_writer.ipp
@@ -74,7 +74,7 @@ void sha256t_writer::do_flush() NOEXCEPT
// static
template
-constexpr sha256::state_t sha256t_writer::midstate() NOEXCEPT
+consteval sha256::state_t sha256t_writer::midstate() NOEXCEPT
{
// Cache midstate of tagged hash part that does not change for a given tag.
// sha256(sha256(tag) || sha256(tag) || message) [bip340].
diff --git a/include/bitcoin/system/stream/streamers/sha256t_writer.hpp b/include/bitcoin/system/stream/streamers/sha256t_writer.hpp
index fe19896af3..6b63eba83d 100644
--- a/include/bitcoin/system/stream/streamers/sha256t_writer.hpp
+++ b/include/bitcoin/system/stream/streamers/sha256t_writer.hpp
@@ -56,7 +56,7 @@ class sha256t_writer
void do_flush() NOEXCEPT override;
private:
- static constexpr sha256::state_t midstate() NOEXCEPT;
+ static consteval sha256::state_t midstate() NOEXCEPT;
void flusher() NOEXCEPT;
diff --git a/include/bitcoin/system/wallet/addresses/silent_payment.hpp b/include/bitcoin/system/wallet/addresses/silent_payment.hpp
new file mode 100644
index 0000000000..82f59c8ea9
--- /dev/null
+++ b/include/bitcoin/system/wallet/addresses/silent_payment.hpp
@@ -0,0 +1,114 @@
+/**
+ * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS)
+ *
+ * This file is part of libbitcoin.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+#ifndef LIBBITCOIN_SYSTEM_WALLET_ADDRESSES_SILENT_PAYMENT_HPP
+#define LIBBITCOIN_SYSTEM_WALLET_ADDRESSES_SILENT_PAYMENT_HPP
+
+#include
+#include
+#include
+
+namespace libbitcoin {
+namespace system {
+namespace chain {
+
+class transaction;
+
+} // namespace chain
+namespace wallet {
+namespace silent_payment {
+
+struct BC_API pay_witness_taproot_output
+{
+ uint32_t index{};
+ ec_xonly key{};
+};
+
+struct BC_API scan_record
+{
+ ec_compressed prevouts_summary{};
+ std_vector outputs{};
+};
+
+struct BC_API scan_match
+{
+ uint32_t index{};
+ ec_secret tweak{};
+ std::optional label{};
+
+ bool operator<(const scan_match& other) const NOEXCEPT;
+};
+
+typedef std_vector scan_matches;
+
+class BC_API scanner
+{
+public:
+ DEFAULT_COPY_MOVE_DESTRUCT(scanner);
+
+ scanner(const ec_secret& scan_secret, const ec_compressed& spend_public,
+ const std_vector& labels) NOEXCEPT;
+
+ operator bool() const NOEXCEPT;
+
+ /// Scan outputs from one transaction, with unique output indexes.
+ bool scan(scan_matches& out, const ec_compressed& prevouts_summary,
+ const std_vector& outputs)
+ const NOEXCEPT;
+ bool scan(scan_matches& out, const ec_uncompressed& prevouts_summary,
+ const std_vector& outputs)
+ const NOEXCEPT;
+
+ /// Match outputs from one transaction, with unique output indexes.
+ bool match(bool& out, const ec_compressed& prevouts_summary,
+ const std_vector& outputs)
+ const NOEXCEPT;
+ bool match(bool& out, const ec_uncompressed& prevouts_summary,
+ const std_vector& outputs)
+ const NOEXCEPT;
+
+private:
+ bool valid() const NOEXCEPT;
+ bool to_shared_secret(ec_compressed& out,
+ const ec_compressed& prevouts_summary) const NOEXCEPT;
+ bool to_shared_secret(ec_compressed& out,
+ const ec_uncompressed& prevouts_summary) const NOEXCEPT;
+ bool scan_shared(scan_matches& out, const ec_compressed& shared_secret,
+ const std_vector& outputs)
+ const NOEXCEPT;
+ bool match_shared(bool& out, const ec_compressed& shared_secret,
+ const std_vector& outputs)
+ const NOEXCEPT;
+
+ ec_secret scan_secret_{};
+ ec_uncompressed spend_point_{};
+ std_vector labels_{};
+ secret_list label_tweaks_{};
+ uncompressed_list label_points_uncompressed_{};
+ bool valid_{};
+};
+
+BC_API bool compute_scan_record(scan_record& out,
+ const chain::transaction& tx) NOEXCEPT;
+
+} // namespace silent_payment
+} // namespace wallet
+} // namespace system
+} // namespace libbitcoin
+
+#endif
diff --git a/include/bitcoin/system/wallet/wallet.hpp b/include/bitcoin/system/wallet/wallet.hpp
index a4b726f543..d3c27aa61d 100644
--- a/include/bitcoin/system/wallet/wallet.hpp
+++ b/include/bitcoin/system/wallet/wallet.hpp
@@ -23,6 +23,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/src/crypto/secp256k1.cpp b/src/crypto/secp256k1.cpp
index ad0b2164c1..25328b3a90 100644
--- a/src/crypto/secp256k1.cpp
+++ b/src/crypto/secp256k1.cpp
@@ -211,6 +211,26 @@ bool ec_add(ec_compressed& left, const ec_uncompressed& right) NOEXCEPT
return compress(out, right) && ec_add(left, out);
}
+bool ec_add(ec_uncompressed& left, const ec_uncompressed& right) NOEXCEPT
+{
+ const auto context = ec_context_verify::context();
+
+ secp256k1_pubkey left_key;
+ secp256k1_pubkey right_key;
+ if (!parse(context, left_key, left) || !parse(context, right_key, right))
+ return false;
+
+ const secp256k1_pubkey* keys[]
+ {
+ &left_key,
+ &right_key
+ };
+
+ secp256k1_pubkey out;
+ return secp256k1_ec_pubkey_combine(context, &out, keys, 2) ==
+ ec_success && serialize(context, left, out);
+}
+
// parse, combine, serialize
bool ec_sum(ec_compressed& out, const compressed_list& points) NOEXCEPT
{
diff --git a/src/hash/checksum.cpp b/src/hash/checksum.cpp
index 13abfd7ac3..7507e1519b 100644
--- a/src/hash/checksum.cpp
+++ b/src/hash/checksum.cpp
@@ -70,6 +70,8 @@ bool verify_checksum(const data_chunk& data) NOEXCEPT
static const size_t bech32_version_size = 1;
static const size_t bech32_checksum_size = 6;
+static constexpr uint32_t bech32_checksum_constant = 0x00000001;
+static constexpr uint32_t bech32m_checksum_constant = 0x2bc830a3;
static base32_chunk bech32_expand_prefix(const std::string& prefix) NOEXCEPT
{
@@ -119,11 +121,21 @@ static uint32_t bech32_checksum(const base32_chunk& data) NOEXCEPT
return checksum;
}
-// BIP173: All versions use 0x00000001 (bech32).
-// BIP350: Nonzero versions use 0x2bc830a3 (bech32m).
-constexpr uint32_t bech32_constant(uint8_t version) NOEXCEPT
+constexpr uint32_t to_bech32_constant(checksum_constant constant) NOEXCEPT
{
- return is_zero(version) ? 0x00000001 : 0x2bc830a3;
+ return constant == checksum_constant::bech32m ?
+ bech32m_checksum_constant : bech32_checksum_constant;
+}
+
+constexpr checksum_constant bech32_witness_constant(uint8_t version) NOEXCEPT
+{
+ return is_zero(version) ? checksum_constant::bech32 :
+ checksum_constant::bech32m;
+}
+
+constexpr uint32_t to_bech32_constant(uint8_t version) NOEXCEPT
+{
+ return to_bech32_constant(bech32_witness_constant(version));
}
static void bech32_prepend_prefix(base32_chunk& data,
@@ -134,26 +146,34 @@ static void bech32_prepend_prefix(base32_chunk& data,
}
static void bech32_append_checksum(base32_chunk& data,
- const std::string& prefix, uint8_t version) NOEXCEPT
+ const std::string& prefix, uint32_t constant) NOEXCEPT
{
base32_chunk prefixed{ data };
bech32_prepend_prefix(prefixed, prefix);
prefixed.resize(prefixed.size() + bech32_checksum_size, 0x00);
- const auto checksum = bech32_checksum(prefixed) ^ bech32_constant(version);
+ const auto checksum = bech32_checksum(prefixed) ^ constant;
const auto checked = bech32_expand_checksum(checksum);
data.insert(data.end(), checked.begin(), checked.end());
}
static bool bech32_verify_checksum(const base32_chunk& checked,
- const std::string& prefix, uint8_t version) NOEXCEPT
+ const std::string& prefix, uint32_t constant) NOEXCEPT
{
base32_chunk prefixed{ checked };
bech32_prepend_prefix(prefixed, prefix);
- return bech32_checksum(prefixed) == bech32_constant(version);
+ return bech32_checksum(prefixed) == constant;
}
-base32_chunk bech32_build_checked(uint8_t version, const data_chunk& program,
- const std::string& prefix) NOEXCEPT
+static bool bech32_verify_checksum(const base32_chunk& checked,
+ const std::string& prefix, uint8_t version) NOEXCEPT
+{
+ return bech32_verify_checksum(checked, prefix,
+ to_bech32_constant(version));
+}
+
+static base32_chunk bech32_build_checked(uint8_t version,
+ const data_chunk& program, const std::string& prefix,
+ uint32_t constant) NOEXCEPT
{
// Version expansion would truncate a value above 5 bits.
if (version >= (1 << 5))
@@ -161,14 +181,14 @@ base32_chunk bech32_build_checked(uint8_t version, const data_chunk& program,
auto checked = base32_unpack(program);
checked.insert(checked.begin(), static_cast(version));
- bech32_append_checksum(checked, prefix, version);
- BC_ASSERT(bech32_verify_checksum(checked, prefix, version));
+ bech32_append_checksum(checked, prefix, constant);
+ BC_ASSERT(bech32_verify_checksum(checked, prefix, constant));
return checked;
}
-bool bech32_verify_checked(uint8_t& out_version, data_chunk& out_program,
- const std::string& prefix, const base32_chunk& checked) NOEXCEPT
+static bool bech32_extract_checked(uint8_t& out_version,
+ data_chunk& out_program, const base32_chunk& checked) NOEXCEPT
{
if (checked.size() < bech32_version_size + bech32_checksum_size)
return false;
@@ -180,7 +200,36 @@ bool bech32_verify_checked(uint8_t& out_version, data_chunk& out_program,
std::prev(checked.end(), bech32_checksum_size)
});
- return bech32_verify_checksum(checked, prefix, out_version);
+ return true;
+}
+
+base32_chunk bech32_build_checked(uint8_t version, const data_chunk& program,
+ const std::string& prefix) NOEXCEPT
+{
+ return bech32_build_checked(version, program, prefix,
+ bech32_witness_constant(version));
+}
+
+base32_chunk bech32_build_checked(uint8_t version, const data_chunk& program,
+ const std::string& prefix, checksum_constant constant) NOEXCEPT
+{
+ return bech32_build_checked(version, program, prefix,
+ to_bech32_constant(constant));
+}
+
+bool bech32_verify_checked(uint8_t& out_version, data_chunk& out_program,
+ const std::string& prefix, const base32_chunk& checked) NOEXCEPT
+{
+ return bech32_extract_checked(out_version, out_program, checked)
+ && bech32_verify_checksum(checked, prefix, out_version);
+}
+
+bool bech32_verify_checked(uint8_t& out_version, data_chunk& out_program,
+ const std::string& prefix, const base32_chunk& checked,
+ checksum_constant constant) NOEXCEPT
+{
+ return bech32_extract_checked(out_version, out_program, checked)
+ && bech32_verify_checksum(checked, prefix, to_bech32_constant(constant));
}
} // namespace system
diff --git a/src/wallet/addresses/silent_payment.cpp b/src/wallet/addresses/silent_payment.cpp
new file mode 100644
index 0000000000..c20c7a77c4
--- /dev/null
+++ b/src/wallet/addresses/silent_payment.cpp
@@ -0,0 +1,588 @@
+/**
+ * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS)
+ *
+ * This file is part of libbitcoin.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace libbitcoin {
+namespace system {
+namespace wallet {
+namespace silent_payment {
+
+BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT)
+
+using chain::input;
+using chain::point;
+using chain::script;
+using chain::script_pattern;
+using chain::script_version;
+using chain::tapscript;
+using chain::transaction;
+
+static constexpr auto bip341_nums_key = base16_array(
+ "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0");
+static constexpr auto ec_odd_sign = add1(ec_even_sign);
+static constexpr size_t maximum_outputs = 2323;
+
+typedef data_array outpoint_data;
+
+static outpoint_data to_outpoint_data(const point& value) NOEXCEPT
+{
+ outpoint_data out{};
+ stream::out::fast stream{ out };
+ write::bytes::fast writer{ stream };
+ value.to_data(writer);
+ return out;
+}
+
+static bool outpoint_data_less(const outpoint_data& left,
+ const outpoint_data& right) NOEXCEPT
+{
+ return std::lexicographical_compare(left.begin(), left.end(),
+ right.begin(), right.end());
+}
+
+static bool is_compressed_key_sign(uint8_t value) NOEXCEPT
+{
+ return value == ec_even_sign || value == ec_odd_sign;
+}
+
+static bool copy_xonly(ec_xonly& out, const data_chunk& value) NOEXCEPT
+{
+ if (value.size() != ec_xonly_size)
+ return false;
+
+ std::copy_n(value.begin(), ec_xonly_size, out.begin());
+ return true;
+}
+
+static bool copy_compressed(ec_compressed& out,
+ const data_chunk& value) NOEXCEPT
+{
+ if (!is_compressed_key(value))
+ return false;
+
+ std::copy_n(value.begin(), ec_compressed_size, out.begin());
+ return true;
+}
+
+static ec_xonly to_xonly(const ec_uncompressed& point) NOEXCEPT
+{
+ ec_xonly out{};
+ std::copy_n(std::next(point.begin()), ec_xonly_size, out.begin());
+ return out;
+}
+
+static bool shared_secret_tweak(ec_secret& out,
+ const ec_compressed& shared_secret, uint32_t index) NOEXCEPT
+{
+ stream::out::fast stream{ out };
+ hash::sha256t::fast<"BIP0352/SharedSecret"> sink{ stream };
+ sink.write_bytes(shared_secret);
+ sink.write_4_bytes_big_endian(index);
+ sink.flush();
+ return verify(out);
+}
+
+static bool contains_match(const scan_matches& matches, uint32_t index) NOEXCEPT
+{
+ for (const auto& match: matches)
+ if (match.index == index)
+ return true;
+
+ return false;
+}
+
+static bool find_unmatched_output(uint32_t& out_index,
+ const std_vector& outputs, const ec_xonly& key,
+ const scan_matches& matches) NOEXCEPT
+{
+ for (const auto& output: outputs)
+ {
+ if (output.key != key || contains_match(matches, output.index))
+ continue;
+
+ out_index = output.index;
+ return true;
+ }
+
+ return false;
+}
+
+static bool contains_output(
+ const std_vector& outputs,
+ const ec_xonly& key) NOEXCEPT
+{
+ for (const auto& output: outputs)
+ if (output.key == key)
+ return true;
+
+ return false;
+}
+
+static bool extract_last_witness_key(ec_compressed& out,
+ const input& in) NOEXCEPT
+{
+ const auto& stack = in.witness().stack();
+ if (stack.empty() || !stack.back())
+ return false;
+
+ return copy_compressed(out, *stack.back());
+}
+
+static bool extract_p2pkh_key(ec_compressed& out, const input& in) NOEXCEPT
+{
+ const auto& ops = in.prevout->script().ops();
+ if (!script::is_pay_key_hash_pattern(ops))
+ return false;
+
+ const auto bytes = in.script().to_data(false);
+ if (bytes.size() < ec_compressed_size)
+ return false;
+
+ // P2PKH input scripts are malleable, so scan backward for the compressed
+ // key whose HASH160 matches the spent output rather than trusting the last
+ // push.
+ for (auto offset = bytes.size() - ec_compressed_size + one; offset > zero;
+ --offset)
+ {
+ const auto start = std::next(bytes.begin(), offset - one);
+ if (!is_compressed_key_sign(*start))
+ continue;
+
+ ec_compressed key{};
+ std::copy_n(start, ec_compressed_size, key.begin());
+ const auto digest = bitcoin_short_hash(key);
+ const auto& hash = unsafe_array_cast(
+ ops.at(2).data().data());
+ if (digest == hash)
+ {
+ out = key;
+ return true;
+ }
+ }
+
+ return false;
+}
+
+static bool to_even_compressed(ec_compressed& out,
+ const ec_xonly& point) NOEXCEPT
+{
+ out.front() = ec_even_sign;
+ std::copy(point.begin(), point.end(), std::next(out.begin()));
+ return verify(out);
+}
+
+static bool is_nums_internal_key(const input& in) NOEXCEPT
+{
+ const auto& stack = in.witness().stack();
+
+ // BIP352 only skips taproot script-path spends whose control block internal
+ // key is the BIP341 NUMS point H.
+ if (stack.size() <= one)
+ return false;
+
+ const auto control = in.witness().annex() ?
+ *std::prev(stack.end(), 2) : stack.back();
+ if (!control)
+ return false;
+
+ const tapscript script{ control };
+ return script.is_valid()
+ && script.is_tapscript()
+ && script.key() == bip341_nums_key;
+}
+
+static bool extract_taproot_key(ec_compressed& out, const input& in) NOEXCEPT
+{
+ if (is_nums_internal_key(in))
+ return false;
+
+ const auto& program = in.prevout->script().witness_program();
+ if (!program || program->size() != ec_xonly_size)
+ return false;
+
+ ec_xonly xonly{};
+ if (!copy_xonly(xonly, *program))
+ return false;
+
+ return to_even_compressed(out, xonly);
+}
+
+static bool extract_pay_witness_taproot_output(pay_witness_taproot_output& out,
+ const chain::output& output,
+ uint32_t index) NOEXCEPT
+{
+ if (!script::is_pay_witness_taproot_pattern(output.script().ops()))
+ return false;
+
+ const auto& program = output.script().witness_program();
+ if (!program || !copy_xonly(out.key, *program))
+ return false;
+
+ out.index = index;
+ return true;
+}
+
+static bool extract_pay_witness_taproot_outputs(
+ std_vector& out,
+ const transaction& tx) NOEXCEPT
+{
+ const auto& outputs = *tx.outputs_ptr();
+ out.reserve(outputs.size());
+
+ uint32_t index{};
+ for (const auto& output: outputs)
+ {
+ pay_witness_taproot_output entry{};
+ if (extract_pay_witness_taproot_output(entry, *output, index))
+ out.push_back(entry);
+
+ ++index;
+ }
+
+ return !out.empty();
+}
+
+static bool label_tweak(ec_secret& out, const ec_secret& scan_secret,
+ uint32_t label) NOEXCEPT
+{
+ stream::out::fast stream{ out };
+ hash::sha256t::fast<"BIP0352/Label"> sink{ stream };
+ sink.write_bytes(scan_secret);
+ sink.write_4_bytes_big_endian(label);
+ sink.flush();
+ return verify(out);
+}
+
+static bool is_excluded_segwit_future(const input& in) NOEXCEPT
+{
+ return in.prevout
+ && in.prevout->script().version() == script_version::reserved;
+}
+
+static bool extract_shared_secret_key(ec_compressed& out,
+ const input& in) NOEXCEPT
+{
+ if (!in.prevout)
+ return false;
+
+ switch (in.prevout->script().output_pattern())
+ {
+ case script_pattern::pay_witness_v1_taproot:
+ return extract_taproot_key(out, in);
+ case script_pattern::pay_witness_key_hash:
+ return extract_last_witness_key(out, in);
+ case script_pattern::pay_script_hash:
+ return script::is_sign_witness_key_hash_pattern(in.script().ops())
+ && extract_last_witness_key(out, in);
+ case script_pattern::pay_key_hash:
+ return extract_p2pkh_key(out, in);
+ default:
+ return false;
+ }
+}
+
+static bool compute_input_hash(ec_secret& out, const transaction& tx,
+ const ec_compressed& sum) NOEXCEPT
+{
+ const auto& inputs = *tx.inputs_ptr();
+ if (inputs.empty())
+ return false;
+
+ // BIP352 orders by serialized outpoints, not point's uniqueness ordering.
+ auto minimum = to_outpoint_data(inputs.front()->point());
+ for (auto it = std::next(inputs.begin()); it != inputs.end(); ++it)
+ {
+ const auto outpoint = to_outpoint_data((*it)->point());
+ if (outpoint_data_less(outpoint, minimum))
+ minimum = outpoint;
+ }
+
+ stream::out::fast stream{ out };
+ hash::sha256t::fast<"BIP0352/Inputs"> sink{ stream };
+ sink.write_bytes(minimum);
+ sink.write_bytes(sum);
+ sink.flush();
+
+ return verify(out);
+}
+
+bool compute_scan_record(scan_record& out, const transaction& tx) NOEXCEPT
+{
+ scan_record record{};
+ if (tx.is_coinbase() ||
+ !extract_pay_witness_taproot_outputs(record.outputs, tx))
+ return false;
+
+ const auto& inputs = *tx.inputs_ptr();
+ compressed_list keys{};
+ keys.reserve(inputs.size());
+
+ for (const auto& in: inputs)
+ {
+ if (!in->prevout || is_excluded_segwit_future(*in))
+ return false;
+
+ ec_compressed key{};
+ if (extract_shared_secret_key(key, *in))
+ keys.push_back(key);
+ }
+
+ if (keys.empty())
+ return false;
+
+ ec_compressed sum{};
+ ec_secret input_hash{};
+ if (!ec_sum(sum, keys) || !compute_input_hash(input_hash, tx, sum))
+ return false;
+
+ record.prevouts_summary = sum;
+ if (!ec_multiply(record.prevouts_summary, input_hash))
+ return false;
+
+ out = std::move(record);
+ return true;
+}
+
+bool scan_match::operator<(const scan_match& other) const NOEXCEPT
+{
+ return index < other.index;
+}
+
+scanner::scanner(const ec_secret& scan_secret,
+ const ec_compressed& spend_public, const std_vector& labels)
+ NOEXCEPT
+ : scan_secret_(scan_secret), labels_(labels)
+{
+ if (!verify(scan_secret_) || !decompress(spend_point_, spend_public))
+ return;
+
+ label_tweaks_.reserve(labels_.size());
+ label_points_uncompressed_.reserve(labels_.size());
+
+ for (const auto label: labels_)
+ {
+ ec_secret tweak{};
+ ec_uncompressed point{};
+ if (!label_tweak(tweak, scan_secret_, label) ||
+ !secret_to_public(point, tweak))
+ {
+ return;
+ }
+
+ label_tweaks_.push_back(tweak);
+ label_points_uncompressed_.push_back(point);
+ }
+
+ valid_ = true;
+}
+
+scanner::operator bool() const NOEXCEPT
+{
+ return valid();
+}
+
+bool scanner::valid() const NOEXCEPT
+{
+ return valid_
+ && labels_.size() == label_tweaks_.size()
+ && labels_.size() == label_points_uncompressed_.size();
+}
+
+bool scanner::to_shared_secret(ec_compressed& out,
+ const ec_compressed& prevouts_summary) const NOEXCEPT
+{
+ out = prevouts_summary;
+ return ec_multiply(out, scan_secret_);
+}
+
+bool scanner::to_shared_secret(ec_compressed& out,
+ const ec_uncompressed& prevouts_summary) const NOEXCEPT
+{
+ ec_uncompressed point{ prevouts_summary };
+ return ec_multiply(point, scan_secret_) && compress(out, point);
+}
+
+bool scanner::scan_shared(scan_matches& out,
+ const ec_compressed& shared_secret,
+ const std_vector& outputs) const NOEXCEPT
+{
+ out.clear();
+ out.reserve(outputs.size());
+
+ if (outputs.empty())
+ return true;
+
+ uint32_t index{};
+ while (out.size() < outputs.size() && index < maximum_outputs)
+ {
+ ec_secret tweak{};
+ if (!shared_secret_tweak(tweak, shared_secret, index))
+ return false;
+
+ ec_uncompressed point{ spend_point_ };
+ if (!ec_add(point, tweak))
+ return false;
+
+ uint32_t output_index{};
+ scan_match found{};
+ found.tweak = tweak;
+ if (find_unmatched_output(output_index, outputs, to_xonly(point), out))
+ {
+ found.index = output_index;
+ out.push_back(found);
+ ++index;
+ continue;
+ }
+
+ bool matched{};
+ const auto label_points = label_points_uncompressed_.size();
+ for (size_t label_index{}; label_index < label_points; ++label_index)
+ {
+ ec_uncompressed labelled{ point };
+ if (!ec_add(labelled, label_points_uncompressed_[label_index]))
+ return false;
+
+ if (find_unmatched_output(output_index, outputs, to_xonly(labelled),
+ out))
+ {
+ auto combined = tweak;
+ if (!ec_add(combined, label_tweaks_[label_index]))
+ return false;
+
+ found.index = output_index;
+ found.tweak = combined;
+ found.label = labels_[label_index];
+ out.push_back(found);
+ ++index;
+ matched = true;
+ break;
+ }
+ }
+
+ if (!matched)
+ break;
+ }
+
+ std::sort(out.begin(), out.end());
+ return true;
+}
+
+bool scanner::match_shared(bool& out,
+ const ec_compressed& shared_secret,
+ const std_vector& outputs) const NOEXCEPT
+{
+ if (outputs.empty())
+ {
+ out = false;
+ return true;
+ }
+
+ ec_secret tweak{};
+ if (!shared_secret_tweak(tweak, shared_secret, zero))
+ return false;
+
+ // Transaction discovery is satisfied by the receiver's initial output.
+ ec_uncompressed point{ spend_point_ };
+ if (!ec_add(point, tweak))
+ return false;
+
+ if (contains_output(outputs, to_xonly(point)))
+ {
+ out = true;
+ return true;
+ }
+
+ for (const auto& label_point: label_points_uncompressed_)
+ {
+ ec_uncompressed labelled{ point };
+ if (!ec_add(labelled, label_point))
+ return false;
+
+ if (contains_output(outputs, to_xonly(labelled)))
+ {
+ out = true;
+ return true;
+ }
+ }
+
+ out = false;
+ return true;
+}
+
+bool scanner::scan(scan_matches& out, const ec_compressed& prevouts_summary,
+ const std_vector& outputs) const NOEXCEPT
+{
+ ec_compressed shared_secret{};
+ return valid()
+ && to_shared_secret(shared_secret, prevouts_summary)
+ && scan_shared(out, shared_secret, outputs);
+}
+
+bool scanner::scan(scan_matches& out, const ec_uncompressed& prevouts_summary,
+ const std_vector& outputs) const NOEXCEPT
+{
+ ec_compressed shared_secret{};
+ return valid()
+ && to_shared_secret(shared_secret, prevouts_summary)
+ && scan_shared(out, shared_secret, outputs);
+}
+
+bool scanner::match(bool& out, const ec_compressed& prevouts_summary,
+ const std_vector& outputs) const NOEXCEPT
+{
+ ec_compressed shared_secret{};
+ return valid()
+ && to_shared_secret(shared_secret, prevouts_summary)
+ && match_shared(out, shared_secret, outputs);
+}
+
+bool scanner::match(bool& out, const ec_uncompressed& prevouts_summary,
+ const std_vector& outputs) const NOEXCEPT
+{
+ ec_compressed shared_secret{};
+ return valid()
+ && to_shared_secret(shared_secret, prevouts_summary)
+ && match_shared(out, shared_secret, outputs);
+}
+
+BC_POP_WARNING()
+
+} // namespace silent_payment
+} // namespace wallet
+} // namespace system
+} // namespace libbitcoin
diff --git a/test/chain/script.cpp b/test/chain/script.cpp
index 54f33e9bb2..57ede1526c 100644
--- a/test/chain/script.cpp
+++ b/test/chain/script.cpp
@@ -51,6 +51,7 @@
//is_sign_multisig_pattern
//is_sign_public_key_pattern
//is_sign_key_hash_pattern
+//is_sign_witness_key_hash_pattern
//is_sign_script_hash_pattern
//
//to_pay_null_data_pattern
@@ -437,6 +438,40 @@ BOOST_AUTO_TEST_CASE(script__pattern__pay_null_data_81_bytes__non_standard)
BOOST_REQUIRE(instance.pattern() == chain::script_pattern::non_standard);
}
+// pay_witness_v1_taproot
+
+BOOST_AUTO_TEST_CASE(script__pattern__pay_witness_v1_taproot_bip341__taproot)
+{
+ // bitcoin/bips bip-0341/wallet-test-vectors.json scriptPubKey[0].
+ const script instance(base16_chunk("512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343"), false);
+ BOOST_REQUIRE(instance.is_valid());
+ BOOST_REQUIRE(instance.output_pattern() == chain::script_pattern::pay_witness_v1_taproot);
+ BOOST_REQUIRE(instance.pattern() == chain::script_pattern::pay_witness_v1_taproot);
+}
+
+BOOST_AUTO_TEST_CASE(script__to_pay_witness_taproot_pattern__valid_key__expected)
+{
+ constexpr auto key = base16_array("53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343");
+
+ const script instance(script::to_pay_witness_taproot_pattern(key));
+ BOOST_REQUIRE_EQUAL(encode_base16(instance.to_data(false)), "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343");
+}
+
+BOOST_AUTO_TEST_CASE(script__is_sign_witness_key_hash_pattern__p2sh_p2wpkh_redeem__true)
+{
+ const auto data = base16_chunk("001419c2f3ae0ca3b642bd3e49598b8da89f50c14161");
+ const operations ops{ { data, false } };
+ BOOST_REQUIRE(script::is_sign_witness_key_hash_pattern(ops));
+}
+
+BOOST_AUTO_TEST_CASE(script__is_sign_witness_key_hash_pattern__pay_witness_key_hash__false)
+{
+ constexpr auto hash = base16_array("19c2f3ae0ca3b642bd3e49598b8da89f50c14161");
+
+ const auto ops = script::to_pay_witness_key_hash_pattern(hash);
+ BOOST_REQUIRE(!script::is_sign_witness_key_hash_pattern(ops));
+}
+
// pay_multisig
BOOST_AUTO_TEST_CASE(script__pattern__0_of_3_multisig__non_standard)
diff --git a/test/crypto/elliptic_curve.cpp b/test/crypto/elliptic_curve.cpp
index 27d9661e62..6d6e25f26f 100644
--- a/test/crypto/elliptic_curve.cpp
+++ b/test/crypto/elliptic_curve.cpp
@@ -167,6 +167,25 @@ BOOST_AUTO_TEST_CASE(elliptic_curve__ec_add__positive__expected)
BOOST_REQUIRE_EQUAL(public1, public2);
}
+BOOST_AUTO_TEST_CASE(elliptic_curve__ec_add_uncompressed__positive__expected)
+{
+ const ec_secret secret_one{ { 1, 2, 3 } };
+ const ec_secret secret_two{ { 3, 2, 1 } };
+
+ ec_secret secret_sum{ secret_one };
+ BOOST_REQUIRE(ec_add(secret_sum, secret_two));
+
+ ec_uncompressed expected_uncompressed{};
+ BOOST_REQUIRE(secret_to_public(expected_uncompressed, secret_sum));
+
+ ec_uncompressed uncompressed_left{};
+ ec_uncompressed uncompressed_right{};
+ BOOST_REQUIRE(secret_to_public(uncompressed_left, secret_one));
+ BOOST_REQUIRE(secret_to_public(uncompressed_right, secret_two));
+ BOOST_REQUIRE(ec_add(uncompressed_left, uncompressed_right));
+ BOOST_REQUIRE_EQUAL(uncompressed_left, expected_uncompressed);
+}
+
BOOST_AUTO_TEST_CASE(elliptic_curve__ec_add__negative__expected)
{
// = n - 1
diff --git a/test/hash/checksum.cpp b/test/hash/checksum.cpp
index faf5c7cc37..33541690d9 100644
--- a/test/hash/checksum.cpp
+++ b/test/hash/checksum.cpp
@@ -247,6 +247,30 @@ BOOST_AUTO_TEST_CASE(checksum__bech32_build_checked__five_program_bytes__expecte
BOOST_REQUIRE_EQUAL(encode_base32(checked), "qqypqxpq939vyak");
}
+BOOST_AUTO_TEST_CASE(checksum__bech32_build_checked__version_zero_bech32m__expected)
+{
+ constexpr auto checksum = checksum_constant::bech32m;
+ const auto checked = bech32_build_checked(0, {}, "abcdef", checksum);
+ BOOST_REQUIRE_EQUAL(checked.size(), 1u + 0u + 6u);
+ BOOST_REQUIRE_EQUAL(encode_base32(checked), "qa40fc4");
+}
+
+BOOST_AUTO_TEST_CASE(checksum__bech32_verify_checked__version_zero_bech32m__true)
+{
+ constexpr auto checksum = checksum_constant::bech32m;
+ base32_chunk checked{};
+ BOOST_REQUIRE(decode_base32(checked, "qa40fc4"));
+
+ uint8_t version{};
+ data_chunk program{};
+ const auto hrp = "abcdef";
+ const auto valid = bech32_verify_checked(version, program, hrp, checked, checksum);
+ BOOST_REQUIRE(valid);
+ BOOST_REQUIRE_EQUAL(version, 0u);
+ BOOST_REQUIRE(program.empty());
+ BOOST_REQUIRE(!bech32_verify_checked(version, program, hrp, checked));
+}
+
// bech32_build_checked - BIP173
BOOST_AUTO_TEST_CASE(checksum__bech32_build_checked__mainnet_p2wkh__expected)
diff --git a/test/wallet/addresses/silent_payment.cpp b/test/wallet/addresses/silent_payment.cpp
new file mode 100644
index 0000000000..8c62cc180d
--- /dev/null
+++ b/test/wallet/addresses/silent_payment.cpp
@@ -0,0 +1,938 @@
+/**
+ * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS)
+ *
+ * This file is part of libbitcoin.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+#include "../../test.hpp"
+#include "silent_payment_vectors.hpp"
+
+BOOST_AUTO_TEST_SUITE(silent_payment_tests)
+
+namespace json = boost::json;
+using namespace system::chain;
+namespace silent_payment = system::wallet::silent_payment;
+
+struct vector_input
+{
+ const char* txid;
+ uint32_t vout;
+ const char* input_script;
+ const char* witness;
+ const char* prevout;
+};
+
+static data_chunk chunk(const std::string_view& text)
+{
+ data_chunk out{};
+ BOOST_REQUIRE(decode_base16(out, text));
+ return out;
+}
+
+static hash_digest digest(const std::string_view& text)
+{
+ hash_digest out{};
+ BOOST_REQUIRE(decode_hash(out, text));
+ return out;
+}
+
+template
+static data_array bytes(const std::string_view& text)
+{
+ data_array out{};
+ BOOST_REQUIRE(decode_base16(out, text));
+ return out;
+}
+
+static script to_script(const char* text)
+{
+ return { chunk(text), false };
+}
+
+static witness to_witness(const char* text)
+{
+ return is_null(text) || text[0] == '\0' ?
+ witness{} :
+ witness{ chunk(text), true };
+}
+
+static input to_input(const vector_input& value)
+{
+ input in
+ {
+ { digest(value.txid), value.vout },
+ to_script(value.input_script),
+ to_witness(value.witness),
+ max_uint32
+ };
+
+ in.prevout = to_shared