Compare commits

..

17 Commits

Author SHA1 Message Date
Craig Raw
3dc50682a3 upgrade lanterna to v3.1.5 2026-06-23 09:32:44 +02:00
Craig Raw
d5b119e338 bump to v2.5.3 2026-05-31 14:24:55 +02:00
Craig Raw
b457caa5d2 revise wording for non-default sighash warnings 2026-05-31 12:27:16 +02:00
Craig Raw
61ed816c87 improve verification of psbt sighash types 2026-05-31 11:55:38 +02:00
Craig Raw
9a603d7547 make date axis formatter tests locale-stable 2026-05-30 17:57:05 +02:00
PeterXMR
ac666545be
show full year on balance chart x-axis 2026-05-30 16:54:27 +02:00
Craig Raw
c4c77d84e0 use configured unit format in send to many dialog instead of jvm default 2026-05-30 14:04:09 +02:00
Craig Raw
8d126869a6 fix potential off by 1 sat rounding error on imported send to many dialog amounts 2026-05-30 12:32:05 +02:00
Craig Raw
464fade68f improve url validation for auth47 and lnurl-auth 2026-05-30 11:34:53 +02:00
Craig Raw
79517da131 fix potential npes resulting from get transactions 2026-05-28 14:51:24 +02:00
Craig Raw
086297436e followup for precomputed sp outputs 2026-05-26 15:40:31 +02:00
Craig Raw
d69e274b18 warn on loading transactions with non-zero outputs of unknown script type 2026-05-26 12:37:27 +02:00
Craig Raw
d1e67ad4a0 update lark for hid4java 2026-05-24 19:01:46 +02:00
Craig Raw
37ca98c2b0 implement dust detection for sp wallets on received utxos at a higher default limit 2026-05-24 11:23:33 +02:00
Craig Raw
287c943b44 add custom context menu to signature text area in message sign dialog 2026-05-23 15:17:08 +02:00
Craig Raw
cb92f76546 improve loaded psbt verification 2026-05-23 14:52:35 +02:00
Craig Raw
24e9c39cb8 bump to v2.5.2 2026-05-22 18:35:16 +02:00
19 changed files with 446 additions and 56 deletions

View File

@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless"))
group = 'com.sparrowwallet'
version = '2.5.1'
version = '2.5.3'
repositories {
mavenCentral()
@ -104,7 +104,7 @@ dependencies {
implementation('org.apache.commons:commons-lang3:3.20.0')
implementation('org.apache.commons:commons-compress:1.28.0')
implementation('com.github.librepdf:openpdf:1.3.43')
implementation('com.googlecode.lanterna:lanterna:3.1.3')
implementation('com.googlecode.lanterna:lanterna:3.1.5')
implementation('net.coobird:thumbnailator:0.4.21')
implementation('com.github.hervegirod:fxsvgimage:1.1')
implementation('com.sparrowwallet:toucan:0.9.0')

View File

@ -56,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.5.0"
GIT_TAG="2.5.2"
```
The project can then be initially cloned as follows:

2
drongo

@ -1 +1 @@
Subproject commit 377931738885c58d750005f2dfb61d3283820395
Subproject commit 077d2142cc3aad84f6f58868cf8f17fc61027fdc

2
lark

@ -1 +1 @@
Subproject commit 2a8c73c1314eb395d5fcf0c4bdc92033efef06bf
Subproject commit e9c6f35fe66aee105ef3c532fcefeb7130dab169

View File

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.1</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->

View File

@ -1511,7 +1511,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
sendToManyDialog = new SendToManyDialog(bitcoinUnit, Config.get().getUnitFormat(), initialPayments);
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
@ -2058,6 +2058,34 @@ public class AppController implements Initializable {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
return;
}
try {
psbt.verifySigHashes();
} catch(PSBTSignatureException e) {
Optional<ButtonType> result = AppServices.showWarningDialog("Non-Default Sighash",
e.getMessage() + "\n\nReview this PSBT carefully before signing.\n\nOpen the transaction?", ButtonType.YES, ButtonType.NO);
if(result.isEmpty() || result.get() != ButtonType.YES) {
return;
}
}
}
//Skip the warning for already-confirmed transactions loaded for inspection
if(blockTransaction == null) {
List<TransactionOutput> unknownScriptOutputs = new ArrayList<>();
for(int i = 0; i < transaction.getOutputs().size(); i++) {
TransactionOutput txOutput = transaction.getOutputs().get(i);
if(txOutput.getValue() > 0 && txOutput.getScript().getToAddress() == null) {
//Silent payment outputs have an empty script and non-zero value until the recipient script is computed
if(psbt != null && i < psbt.getPsbtOutputs().size() && psbt.getPsbtOutputs().get(i).getSilentPaymentAddress() != null) {
continue;
}
unknownScriptOutputs.add(txOutput);
}
}
if(!unknownScriptOutputs.isEmpty() && !confirmUnknownScriptOutputs(unknownScriptOutputs)) {
return;
}
}
try {
@ -2164,6 +2192,23 @@ public class AppController implements Initializable {
return result.isPresent() && result.get() == ButtonType.YES;
}
private boolean confirmUnknownScriptOutputs(List<TransactionOutput> unknownScriptOutputs) {
long totalAmount = unknownScriptOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = totalAmount >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS;
}
String amount = unit.equals(BitcoinUnit.BTC) ? format.formatBtcValue(totalAmount) + " BTC" : format.formatSatsValue(totalAmount) + " sats";
String outputDesc = unknownScriptOutputs.size() == 1 ? "an output" : unknownScriptOutputs.size() + " outputs";
Optional<ButtonType> result = AppServices.showWarningDialog("Unknown Script Type",
"This transaction contains " + outputDesc + " of a non-standard or unrecognised script type, totalling " + amount + ".\n\n" +
"Sparrow cannot resolve these outputs to addresses, so they will not appear in the transaction diagram. " +
"Review the individual output(s) in the transaction tree carefully before signing or broadcasting.\n\n" +
"Open the transaction?", ButtonType.YES, ButtonType.NO);
return result.isPresent() && result.get() == ButtonType.YES;
}
private String getTabName(Tab tab) {
return ((Label)tab.getGraphic()).getText();
}

View File

@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "2.5.1";
public static final String APP_VERSION = "2.5.3";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";

View File

@ -11,7 +11,7 @@ import java.util.Date;
public class DateAxisFormatter extends StringConverter<Number> {
private static final DateFormat HOUR_FORMAT = new SimpleDateFormat("HH:mm");
private static final DateFormat DAY_FORMAT = new SimpleDateFormat("d MMM");
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yy");
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yyyy");
private final DateFormat dateFormat;
private int oddCounter;

View File

@ -160,6 +160,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
signature.setStyle("-fx-pref-height: 80px");
signature.setWrapText(true);
signature.setOnMouseClicked(event -> signature.selectAll());
ContextMenu signatureMenu = new ContextMenu();
MenuItem copyItem = new MenuItem("Copy");
copyItem.setOnAction(e -> signature.copy());
MenuItem pasteItem = new MenuItem("Paste");
pasteItem.setOnAction(e -> signature.paste());
MenuItem clearItem = new MenuItem("Clear");
clearItem.setOnAction(e -> signature.clear());
signatureMenu.getItems().addAll(copyItem, pasteItem, clearItem);
signature.setContextMenu(signatureMenu);
signatureField.getInputs().add(signature);
Field formatField = new Field();

View File

@ -9,13 +9,13 @@ import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
@ -28,6 +28,8 @@ import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
@ -35,20 +37,26 @@ import org.controlsfx.control.spreadsheet.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final UnitFormat unitFormat;
private final UnitFormatDoubleCellType amountCellType;
private final SpreadsheetView spreadsheetView;
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
public SendToManyDialog(BitcoinUnit bitcoinUnit, UnitFormat unitFormat, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat);
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
@ -119,11 +127,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)sendToPayment.payment().getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
long rawAmount = sendToPayment.payment().getAmount();
Double amount = rawAmount < 0 ? null : bitcoinUnit.getValue(rawAmount);
SpreadsheetCell amountCell = amountCellType.createCell(row, 1, 1, 1, amount);
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
amountCell.getStyleClass().add("number-value");
if(OsType.getCurrent() == OsType.MACOS) {
@ -177,7 +183,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
if(sendToAddress != null && sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
return true;
}
}
@ -216,12 +222,15 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
try {
String rawAmount = csvReader.get(1).trim();
String groupingStripped = rawAmount.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
long amount;
if(bitcoinUnit == BitcoinUnit.BTC) {
double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN);
String normalised = groupingStripped.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
double doubleAmount = Double.parseDouble(normalised);
amount = bitcoinUnit.getSatsValue(doubleAmount);
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
amount = Long.parseLong(groupingStripped);
}
String label = csvReader.get(2);
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
@ -359,6 +368,160 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
};
private static class UnitFormatDoubleCellType extends SpreadsheetCellType<Double> {
private final UnitFormat unitFormat;
UnitFormatDoubleCellType(UnitFormat unitFormat) {
super(new UnitFormatDoubleConverter(unitFormat));
this.unitFormat = unitFormat;
}
@Override
public String toString() {
return "double";
}
public SpreadsheetCell createCell(int row, int column, int rowSpan, int columnSpan, Double value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
}
@Override
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
return new UnitFormatDoubleEditor(view, unitFormat);
}
@Override
public boolean match(Object value, Object... options) {
if(value == null || value instanceof Number) {
return true;
}
try {
String s = value.toString();
return s == null || s.isEmpty() || converter.fromString(s) != null;
} catch(Exception e) {
return false;
}
}
@Override
public Double convertValue(Object value) {
if(value instanceof Double d) {
return d;
}
if(value instanceof Number n) {
return n.doubleValue();
}
return converter.fromString(value == null ? null : value.toString());
}
@Override
public String toString(Double item) {
return converter.toString(item);
}
@Override
public String toString(Double item, String format) {
return ((StringConverterWithFormat<Double>)converter).toStringFormat(item, format);
}
}
private static class UnitFormatDoubleConverter extends StringConverterWithFormat<Double> {
private final UnitFormat unitFormat;
UnitFormatDoubleConverter(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
}
@Override
public Double fromString(String str) {
if(str == null || str.isEmpty()) {
return null;
}
String normalised = str.trim()
.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "")
.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
try {
return Double.valueOf(normalised);
} catch(NumberFormatException e) {
return null;
}
}
@Override
public String toString(Double item) {
return toStringFormat(item, "");
}
@Override
public String toStringFormat(Double item, String format) {
if(item == null || item.isNaN()) {
return "";
}
if(format == null || format.isEmpty()) {
return Double.toString(item);
}
return new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(item);
}
}
private static class UnitFormatDoubleEditor extends SpreadsheetCellEditor {
private final UnitFormat unitFormat;
private final TextField textField;
UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat) {
super(view);
this.unitFormat = unitFormat;
this.textField = new TextField();
this.textField.setTextFormatter(new CoinTextFormatter(unitFormat));
}
@Override
public void startEdit(Object item, String format, Object... options) {
if(item instanceof Double d && !d.isNaN()) {
String text = (format == null || format.isEmpty())
? Double.toString(d)
: new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(d);
textField.setText(text);
} else {
textField.setText("");
}
textField.getStyleClass().removeAll("error");
textField.setOnKeyPressed(this::onKeyPressed);
textField.requestFocus();
textField.selectAll();
}
@Override
public void end() {
textField.setOnKeyPressed(null);
textField.setOnKeyReleased(null);
textField.getStyleClass().removeAll("error");
}
@Override
public TextField getEditor() {
return textField;
}
@Override
public String getControlValue() {
String raw = textField.getText();
return raw == null ? "" : raw.trim();
}
private void onKeyPressed(KeyEvent event) {
if(event.getCode() == KeyCode.ENTER) {
endEdit(true);
event.consume();
} else if(event.getCode() == KeyCode.ESCAPE) {
endEdit(false);
event.consume();
}
}
}
public static class SendToAddress {
private final String hrn;
private final Address address;
@ -487,11 +650,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
if(sendToAddress != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(sendToAddress.toPayment(label, value.longValue(), false));
payments.add(sendToAddress.toPayment(label, bitcoinUnit.getSatsValue(value), false));
}
}

View File

@ -18,12 +18,12 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.ENUMERATE_HW_PERIOD_SECS;
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SATS;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SP_SATS;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@ -64,6 +64,7 @@ public class Config {
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private long dustAttackThresholdSp = DUST_ATTACK_THRESHOLD_SP_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private QREncoding qrEncoding;
@ -448,6 +449,10 @@ public class Config {
return dustAttackThreshold;
}
public long getDustAttackThresholdSp() {
return dustAttackThresholdSp;
}
public int getEnumerateHwPeriod() {
return enumerateHwPeriod;
}

View File

@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
@ -69,19 +70,17 @@ public class Auth47 {
this.srbnName = srbnUrl.getUserInfo();
this.callback = new URI(HTTPS_PROTOCOL + srbnUrl.getHost()).toURL();
} else {
this.callback = new URI(strCallback).toURL();
URI callbackUri = new URI(strCallback);
if(!Utils.isSecureUrl(callbackUri)) {
throw new IllegalArgumentException("Invalid callback parameter (not https, http .onion or srbn): " + strCallback);
}
this.callback = callbackUri.toURL();
}
this.expiry = parameterMap.get("e");
this.resource = parameterMap.get("r");
if(resource == null) {
if(srbn) {
this.resource = "srbn";
} else if(strCallback.startsWith("http")) {
this.resource = strCallback;
} else {
throw new IllegalArgumentException("Invalid callback parameter (not http/s or srbn): " + strCallback);
}
this.resource = srbn ? "srbn" : strCallback;
}
}

View File

@ -877,6 +877,11 @@ public class ElectrumServer {
for(BlockTransactionHash reference : references.keySet()) {
Transaction transaction = references.get(reference);
if(transaction == null) {
transactionMap.put(reference.getHash(), UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(reference.getHash()));
continue;
}
Date blockDate = null;
if(reference.getHeight() > 0) {
@ -890,7 +895,7 @@ public class ElectrumServer {
}
Long fee = reference.getFee();
if(fee == null) {
if(fee == null && wallet != null) {
BlockTransaction cached = wallet.getWalletTransaction(reference.getHash());
if(cached != null && cached.getFee() != null) {
fee = cached.getFee();

View File

@ -41,9 +41,17 @@ public class LnurlAuth {
public LnurlAuth(URI uri) throws MalformedURLException, URISyntaxException {
String lnurl = uri.getSchemeSpecificPart();
Bech32.Bech32Data bech32 = Bech32.decode(lnurl, 2000);
if(!"lnurl".equals(bech32.hrp)) {
throw new IllegalArgumentException("LNURL-auth bech32 prefix must be lnurl");
}
byte[] urlBytes = Bech32.convertBits(bech32.data, 0, bech32.data.length, 5, 8, false);
String strUrl = new String(urlBytes, StandardCharsets.UTF_8);
this.url = new URI(strUrl).toURL();
URI decodedUri = new URI(strUrl);
if(!Utils.isSecureUrl(decodedUri)) {
throw new IllegalArgumentException("LNURL-auth URL must be https or http .onion");
}
this.url = decodedUri.toURL();
Map<String, String> parameterMap = new LinkedHashMap<>();
String query = url.getQuery();

View File

@ -1455,21 +1455,10 @@ public class HeadersController extends TransactionFormController implements Init
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);
}
}
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
boolean changed = false;
for(Map.Entry<Address, SilentPaymentAddress> entry : pending.entrySet()) {
for(Map.Entry<Address, SilentPaymentAddress> entry : verified.entrySet()) {
if(!entry.getValue().equals(wallet.getSilentPaymentAddress(entry.getKey()))) {
wallet.addSilentPaymentAddress(entry.getKey(), entry.getValue());
changed = true;

View File

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Config;
@ -9,6 +10,7 @@ import java.util.stream.Collectors;
public class WalletUtxosEntry extends Entry {
public static final int DUST_ATTACK_THRESHOLD_SATS = 1000;
public static final int DUST_ATTACK_THRESHOLD_SP_SATS = 5000;
public WalletUtxosEntry(Wallet wallet) {
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
@ -50,14 +52,22 @@ public class WalletUtxosEntry extends Entry {
}
protected void calculateDust() {
long dustAttackThreshold = Config.get().getDustAttackThreshold();
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
if(getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
long dustAttackThreshold = Config.get().getDustAttackThresholdSp();
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
}
} else {
long dustAttackThreshold = Config.get().getDustAttackThreshold();
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
}
}
}

View File

@ -0,0 +1,64 @@
package com.sparrowwallet.sparrow.control;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
public class DateAxisFormatterTest {
private static final long HOUR = 60 * 60 * 1000L;
private static final long DAY = 24 * HOUR;
private static final long YEAR = 365 * DAY;
private static String thirdLabel(DateAxisFormatter formatter, long timestamp) {
formatter.toString(timestamp);
formatter.toString(timestamp);
return formatter.toString(timestamp);
}
private static long timestamp(int year, int month, int day) {
Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
cal.clear();
cal.set(year, month, day);
return cal.getTimeInMillis();
}
@Test
public void multiYearDurationIncludesFourDigitYear() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
String actual = thirdLabel(formatter, ts);
String fourDigitYear = new SimpleDateFormat("yyyy").format(new Date(ts));
Assertions.assertTrue(actual.contains(fourDigitYear),
"Expected 4-digit year " + fourDigitYear + " in label, got: " + actual);
}
@Test
public void subYearDurationUsesDayMonth() {
DateAxisFormatter formatter = new DateAxisFormatter(30 * DAY);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals(new SimpleDateFormat("d MMM").format(new Date(ts)),
thirdLabel(formatter, ts));
}
@Test
public void subDayDurationUsesHourMinute() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * HOUR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals(new SimpleDateFormat("HH:mm").format(new Date(ts)),
thirdLabel(formatter, ts));
}
@Test
public void everyThirdLabelIsRendered() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals("", formatter.toString(ts));
Assertions.assertEquals("", formatter.toString(ts));
Assertions.assertNotEquals("", formatter.toString(ts));
}
}

View File

@ -0,0 +1,38 @@
package com.sparrowwallet.sparrow.net;
import org.junit.jupiter.api.Test;
import java.net.URI;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class Auth47Test {
@Test
public void acceptsHttpsCallbacksWithResource() throws Exception {
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=https://example.com/auth&r=example"));
assertEquals("https", auth47.getCallback().getProtocol());
assertEquals("example.com", auth47.getCallback().getHost());
}
@Test
public void acceptsSrbnCallbacks() throws Exception {
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=srbn://alice@relay.example.com"));
assertEquals("https", auth47.getCallback().getProtocol());
assertEquals("relay.example.com", auth47.getCallback().getHost());
}
@Test
public void rejectsHttpClearnetCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new Auth47(new URI("auth47://nonce?c=http://example.com/auth&r=example")));
}
@Test
public void rejectsNonHttpCallbacksWithResource() {
assertThrows(IllegalArgumentException.class, () ->
new Auth47(new URI("auth47://nonce?c=file:///tmp/auth47&r=example")));
}
}

View File

@ -0,0 +1,57 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.protocol.Bech32;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class LnurlAuthTest {
private static final String K1 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
public void acceptsHttpsCallbacks() throws Exception {
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("https://example.com/lnurl-auth?tag=login&k1=" + K1));
assertEquals("example.com", lnurlAuth.getDomain());
assertEquals("login to example.com", lnurlAuth.getLoginMessage());
}
@Test
public void acceptsHttpOnionCallbacks() throws Exception {
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/lnurl-auth?tag=login&k1=" + K1));
assertEquals("abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion", lnurlAuth.getDomain());
}
@Test
public void rejectsHttpClearnetCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("http://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
@Test
public void rejectsNonHttpCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("ftp://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
@Test
public void rejectsNonLnurlBech32Prefix() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("lnurlx", "https://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
private static URI lightningUri(String url) throws Exception {
return lightningUri("lnurl", url);
}
private static URI lightningUri(String prefix, String url) throws Exception {
byte[] urlBytes = url.getBytes(StandardCharsets.UTF_8);
byte[] lnurlData = Bech32.convertBits(urlBytes, 0, urlBytes.length, 8, 5, true);
return new URI("lightning:" + Bech32.encode(prefix, Bech32.Encoding.BECH32, lnurlData));
}
}