persist silent payment address mappings for safe rbf of sp-sending transactions
What changed, and why it matters
This commit adds a safety feature for a new Bitcoin privacy technology called 'silent payments.' It records the relationship between a regular-looking payment address and its silent-payment counterpart in the wallet's database. The main goal is to prevent users from accidentally breaking silent-payment outputs when they later modify (RBF) a transaction, such as by removing inputs. The commit also blocks one risky UI action with a warning dialog when such a change would be unsafe.
Review the `clearAndAddAll` implementation for transactional safety and ensure the `silentPaymentAddress` table is included in backup/restore logic. Verify that the `isSafeToAddInputsOrOutputs` check covers all RBF/modification paths, not just input exclusion. Confirm the `drongo` submodule change aligns with the new persistence contract and does not introduce serialization incompatibilities.
Security signals we found
New database table storing address-to-silent-payment mappings
Persistence of silent payment metadata across wallet sessions
UI guard added to prevent unsafe input exclusion during RBF of silent-payment transactions
PSBT signing/combining/finalization paths now learn and store silent payment addresses
Potential data-loss risk if mapping is cleared and re-inserted transactionally (clearAndAddAll pattern)
No input validation or sanitization visible for serialized silent payment address bytes before DB insert
Evidence from the diff
The patch persists a mapping from on-chain P2TR addresses to SilentPaymentAddress objects in a new SQLite table silentPaymentAddress. It introduces SilentPaymentAddressDao, SilentPaymentAddressMapper, a WalletSilentPaymentAddressesChangedEvent, and wires persistence into DbPersistence and WalletDao. The HeadersController learns these mappings when signing, combining, or finalizing a PSBT. SendController.excludeUtxo now checks Wallet.isSafeToAddInputsOrOutputs and aborts with an error if removing an input could break silent payment outputs. A new DB migration V10__SilentPayments.sql adds the table. The drongo submodule is also updated.
Changed components
com.sparrowwallet.sparrow.io.db.DbPersistencecom.sparrowwallet.sparrow.io.db.SilentPaymentAddressDaocom.sparrowwallet.sparrow.io.db.SilentPaymentAddressMappercom.sparrowwallet.sparrow.io.db.WalletDaocom.sparrowwallet.sparrow.transaction.HeadersControllercom.sparrowwallet.sparrow.wallet.SendControllercom.sparrowwallet.sparrow.wallet.WalletFormcom.sparrowwallet.sparrow.event.WalletSilentPaymentAddressesChangedEventdrongo submoduleSQLite schema migration V10__SilentPayments.sqlInspect captured patch +161 / −3
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletSilentPaymentAddressesChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletSilentPaymentAddressesChangedEvent.java
new file mode 100644
index 0000000..4002ddf
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletSilentPaymentAddressesChangedEvent.java
@@ -0,0 +1,9 @@
+package com.sparrowwallet.sparrow.event;
+
+import com.sparrowwallet.drongo.wallet.Wallet;
+
+public class WalletSilentPaymentAddressesChangedEvent extends WalletChangedEvent {
+ public WalletSilentPaymentAddressesChangedEvent(Wallet wallet) {
+ super(wallet.resolveMasterWallet());
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
index 08ee1fe..11f238a 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
@@ -337,6 +337,11 @@ public class DbPersistence implements Persistence {
walletConfigDao.addOrUpdate(wallet, wallet.getWalletConfig());
}
+ if(dirtyPersistables.silentPaymentAddresses) {
+ SilentPaymentAddressDao silentPaymentAddressDao = handle.attach(SilentPaymentAddressDao.class);
+ silentPaymentAddressDao.clearAndAddAll(wallet);
+ }
+
if(dirtyPersistables.walletTable != null) {
WalletTableDao walletTableDao = handle.attach(WalletTableDao.class);
walletTableDao.addOrUpdate(wallet, dirtyPersistables.walletTable.getTableType(), dirtyPersistables.walletTable);
@@ -888,6 +893,13 @@ public class DbPersistence implements Persistence {
}
}
+ @Subscribe
+ public void walletSilentPaymentAddressesChanged(WalletSilentPaymentAddressesChangedEvent event) {
+ if(persistsFor(event.getWallet())) {
+ updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).silentPaymentAddresses = true);
+ }
+ }
+
private static class DirtyPersistables {
public boolean deleteAccount;
public boolean clearHistory;
@@ -906,6 +918,7 @@ public class DbPersistence implements Persistence {
public final List<Keystore> labelKeystores = new ArrayList<>();
public final List<Keystore> encryptionKeystores = new ArrayList<>();
public final List<Keystore> registrationKeystores = new ArrayList<>();
+ public boolean silentPaymentAddresses;
public String toString() {
return "Dirty Persistables" +
@@ -927,7 +940,8 @@ public class DbPersistence implements Persistence {
"\nUTXO mixes removed:" + removedUtxoMixes +
"\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
"\nKeystore encryptions:" + encryptionKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
- "\nKeystore registrations:" + registrationKeystores.stream().map(Keystore::getDeviceRegistration).collect(Collectors.toList());
+ "\nKeystore registrations:" + registrationKeystores.stream().map(Keystore::getDeviceRegistration).collect(Collectors.toList()) +
+ "\nSilent payment addresses:" + silentPaymentAddresses;
}
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressDao.java
new file mode 100644
index 0000000..a216e71
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressDao.java
@@ -0,0 +1,40 @@
+package com.sparrowwallet.sparrow.io.db;
+
+import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
+import org.jdbi.v3.sqlobject.statement.SqlBatch;
+import org.jdbi.v3.sqlobject.statement.SqlQuery;
+import org.jdbi.v3.sqlobject.statement.SqlUpdate;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public interface SilentPaymentAddressDao {
+ @SqlQuery("select address, silentPaymentAddress from silentPaymentAddress")
+ @RegisterRowMapper(SilentPaymentAddressMapper.class)
+ Map<Address, SilentPaymentAddress> getAll();
+
+ @SqlBatch("insert into silentPaymentAddress (address, silentPaymentAddress) values (?, ?)")
+ void insertSilentPaymentAddresses(List<byte[]> addresses, List<byte[]> silentPaymentAddresses);
+
+ @SqlUpdate("delete from silentPaymentAddress")
+ void clear();
+
+ default void clearAndAddAll(Wallet wallet) {
+ clear();
+
+ List<byte[]> addresses = new ArrayList<>();
+ List<byte[]> silentPaymentAddresses = new ArrayList<>();
+ for(Map.Entry<Address, SilentPaymentAddress> entry : wallet.getSilentPaymentAddresses().entrySet()) {
+ addresses.add(entry.getKey().getData());
+ silentPaymentAddresses.add(entry.getValue().serialize());
+ }
+
+ if(!addresses.isEmpty()) {
+ insertSilentPaymentAddresses(addresses, silentPaymentAddresses);
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressMapper.java b/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressMapper.java
new file mode 100644
index 0000000..20cefa7
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/SilentPaymentAddressMapper.java
@@ -0,0 +1,36 @@
+package com.sparrowwallet.sparrow.io.db;
+
+import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.address.P2TRAddress;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
+import org.jdbi.v3.core.mapper.RowMapper;
+import org.jdbi.v3.core.statement.StatementContext;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Map;
+
+public class SilentPaymentAddressMapper implements RowMapper<Map.Entry<Address, SilentPaymentAddress>> {
+ @Override
+ public Map.Entry<Address, SilentPaymentAddress> map(ResultSet rs, StatementContext ctx) throws SQLException {
+ Address address = new P2TRAddress(rs.getBytes("address"));
+ SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.fromBytes(rs.getBytes("silentPaymentAddress"));
+
+ return new Map.Entry<>() {
+ @Override
+ public Address getKey() {
+ return address;
+ }
+
+ @Override
+ public SilentPaymentAddress getValue() {
+ return silentPaymentAddress;
+ }
+
+ @Override
+ public SilentPaymentAddress setValue(SilentPaymentAddress value) {
+ return null;
+ }
+ };
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java
index 36005bf..60b8ef0 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java
@@ -30,6 +30,9 @@ public interface WalletDao {
@CreateSqlObject
DetachedLabelDao createDetachedLabelDao();
+ @CreateSqlObject
+ SilentPaymentAddressDao createSilentPaymentAddressDao();
+
@CreateSqlObject
WalletConfigDao createWalletConfigDao();
@@ -121,6 +124,8 @@ public interface WalletDao {
Map<String, String> detachedLabels = createDetachedLabelDao().getAll();
wallet.getDetachedLabels().putAll(detachedLabels);
+ wallet.getSilentPaymentAddresses().putAll(createSilentPaymentAddressDao().getAll());
+
wallet.setWalletConfig(createWalletConfigDao().getForWalletId(wallet.getId()));
Map<TableType, WalletTable> walletTables = createWalletTableDao().getForWalletId(wallet.getId());
@@ -144,6 +149,7 @@ public interface WalletDao {
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
createDetachedLabelDao().clearAndAddAll(wallet);
+ createSilentPaymentAddressDao().clearAndAddAll(wallet);
createWalletConfigDao().addWalletConfig(wallet);
createWalletTableDao().addWalletTables(wallet);
createMixConfigDao().addMixConfig(wallet);
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index 2c1460c..c6373ac 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -578,6 +578,8 @@ public class HeadersController extends TransactionFormController implements Init
int threshold = signingWallet.getDefaultPolicy().getNumSignaturesRequired();
signaturesProgressBar.initialize(headersForm.getSignatureKeystoreMap(), threshold);
+
+ learnSilentPaymentAddresses(signingWallet, headersForm.getPsbt());
});
blockchainForm.setDynamicUpdate(this);
@@ -1156,6 +1158,11 @@ public class HeadersController extends TransactionFormController implements Init
Map<PSBTInput, WalletNode> signingNodes = unencryptedWallet.getSigningNodes(headersForm.getPsbt());
List<SilentPayment> silentPayments = unencryptedWallet.computeSilentPaymentOutputs(headersForm.getPsbt(), signingNodes);
if(!silentPayments.isEmpty()) {
+ Wallet signingWallet = headersForm.getSigningWallet();
+ for(SilentPayment silentPayment : silentPayments) {
+ signingWallet.addSilentPaymentAddress(silentPayment.getAddress(), silentPayment.getSilentPaymentAddress());
+ }
+ EventManager.get().post(new WalletSilentPaymentAddressesChangedEvent(signingWallet));
EventManager.get().post(new TransactionOutputsChangedEvent(headersForm.getTransaction()));
}
unencryptedWallet.sign(signingNodes);
@@ -1443,6 +1450,37 @@ public class HeadersController extends TransactionFormController implements Init
requestPayjoinPSBTService.start();
}
+ private void learnSilentPaymentAddresses(Wallet wallet, PSBT psbt) {
+ if(wallet == null || psbt == null) {
+ return;
+ }
+
+ Map<Address, SilentPaymentAddress> pending = new LinkedHashMap<>();
+ for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) {
+ SilentPaymentAddress spAddress = psbtOutput.getSilentPaymentAddress();
+ if(spAddress != null) {
+ Script script = psbtOutput.getScript();
+ Address address = script == null ? null : script.getToAddress();
+ if(address == null) {
+ return;
+ }
+ pending.put(address, spAddress);
+ }
+ }
+
+ boolean changed = false;
+ for(Map.Entry<Address, SilentPaymentAddress> entry : pending.entrySet()) {
+ if(!entry.getValue().equals(wallet.getSilentPaymentAddress(entry.getKey()))) {
+ wallet.addSilentPaymentAddress(entry.getKey(), entry.getValue());
+ changed = true;
+ }
+ }
+
+ if(changed) {
+ EventManager.get().post(new WalletSilentPaymentAddressesChangedEvent(wallet));
+ }
+ }
+
@Override
public void update() {
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
@@ -1664,6 +1702,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void psbtCombined(PSBTCombinedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {
+ learnSilentPaymentAddresses(headersForm.getSigningWallet(), headersForm.getPsbt());
if(headersForm.getSigningWallet() != null) {
updateSignedKeystores(headersForm.getSigningWallet());
} else if(headersForm.getPsbt().isSigned()) {
@@ -1678,6 +1717,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void psbtFinalized(PSBTFinalizedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {
+ learnSilentPaymentAddresses(headersForm.getSigningWallet(), headersForm.getPsbt());
if(headersForm.getSigningWallet() != null) {
updateSignedKeystores(headersForm.getSigningWallet());
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
index 5b0d476..20d0b36 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -1559,6 +1559,11 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void excludeUtxo(ExcludeUtxoEvent event) {
if(event.getWalletTransaction() == walletTransactionProperty.get()) {
+ BlockTransaction replacedTransaction = replacedTransactionProperty.get();
+ if(replacedTransaction != null && !getWalletForm().getWallet().isSafeToAddInputsOrOutputs(replacedTransaction)) {
+ AppServices.showErrorDialog("Cannot Exclude Input", "Removing an input from this replacement transaction could break silent payment outputs as the original output script depends on the input set.");
+ return;
+ }
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector instanceof MaxUtxoSelector) {
Collection<BlockTransactionHashIndex> utxos = event.getWalletTransaction().getSelectedUtxos().keySet();
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
index 305a193..d669ed6 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
@@ -749,6 +749,13 @@ public class WalletForm {
}
}
+ @Subscribe
+ public void walletSilentPaymentAddressesChanged(WalletSilentPaymentAddressesChangedEvent event) {
+ if(event.getWallet() == wallet) {
+ Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
+ }
+ }
+
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {
diff --git a/src/main/resources/com/sparrowwallet/sparrow/sql/V10__SilentPayments.sql b/src/main/resources/com/sparrowwallet/sparrow/sql/V10__SilentPayments.sql
index 9ed678a..2339656 100644
--- a/src/main/resources/com/sparrowwallet/sparrow/sql/V10__SilentPayments.sql
+++ b/src/main/resources/com/sparrowwallet/sparrow/sql/V10__SilentPayments.sql
@@ -1,3 +1,4 @@
alter table wallet add column birthHeight integer after birthDate;
alter table walletNode add column silentPaymentTweak varbinary(32) after addressData;
-alter table keystore add column silentPaymentScanAddress varbinary(65) after externalPaymentCode;
\ No newline at end of file
+alter table keystore add column silentPaymentScanAddress varbinary(65) after externalPaymentCode;
+create table silentPaymentAddress (address varbinary(32) primary key not null, silentPaymentAddress varbinary(67) not null);
\ No newline at end of file
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.