Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc50682a3 | ||
|
|
d5b119e338 | ||
|
|
b457caa5d2 | ||
|
|
61ed816c87 | ||
|
|
9a603d7547 | ||
|
|
ac666545be | ||
|
|
c4c77d84e0 | ||
|
|
8d126869a6 | ||
|
|
464fade68f | ||
|
|
79517da131 | ||
|
|
086297436e | ||
|
|
d69e274b18 | ||
|
|
d1e67ad4a0 | ||
|
|
37ca98c2b0 | ||
|
|
287c943b44 | ||
|
|
cb92f76546 | ||
|
|
24e9c39cb8 | ||
|
|
754ebf7bbf | ||
|
|
bc7a0be87e | ||
|
|
87af1ed9f5 | ||
|
|
da476c9d77 |
@ -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.0'
|
||||
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')
|
||||
|
||||
@ -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.4.2"
|
||||
GIT_TAG="2.5.2"
|
||||
```
|
||||
|
||||
The project can then be initially cloned as follows:
|
||||
|
||||
2
drongo
2
drongo
@ -1 +1 @@
|
||||
Subproject commit cc55b5f13a6543ac47667ff5ae4cfaa83824d06d
|
||||
Subproject commit 077d2142cc3aad84f6f58868cf8f17fc61027fdc
|
||||
2
lark
2
lark
@ -1 +1 @@
|
||||
Subproject commit 2a8c73c1314eb395d5fcf0c4bdc92033efef06bf
|
||||
Subproject commit e9c6f35fe66aee105ef3c532fcefeb7130dab169
|
||||
@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.5.0</string>
|
||||
<string>2.5.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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.0";
|
||||
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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
@ -532,6 +543,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
|
||||
private String extractBip322Signature(PSBT signedPsbt) {
|
||||
String psbtMessage = signedPsbt.getGenericSignedMessage();
|
||||
if(psbtMessage != null && !psbtMessage.equals(message.getText().trim())) {
|
||||
Optional<ButtonType> response = AppServices.showWarningDialog("Message mismatch",
|
||||
"The message in the signed PSBT does not match the message in this dialog.\n\nPSBT message: " + psbtMessage +
|
||||
"\n\nContinue extracting the signature?", ButtonType.NO, ButtonType.YES);
|
||||
if(response.isEmpty() || response.get() != ButtonType.YES) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Wallet signingWallet = walletNode.getWallet();
|
||||
if(signingWallet.getPolicyType() == PolicyType.SINGLE_SP) {
|
||||
return Bip322.getBip322SignatureFromPsbtSp(signedPsbt);
|
||||
@ -565,8 +586,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
if(result.psbt != null) {
|
||||
try {
|
||||
String sig = extractBip322Signature(result.psbt);
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
if(sig != null) {
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Error extracting BIP-322 signature from PSBT", e);
|
||||
AppServices.showErrorDialog("Error extracting signature", e.getMessage());
|
||||
@ -666,8 +689,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
byte[] psbtBytes = Files.readAllBytes(file.toPath());
|
||||
PSBT signedPsbt = new PSBT(psbtBytes, false);
|
||||
String sig = extractBip322Signature(signedPsbt);
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
if(sig != null) {
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
}
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
if(file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt")) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -125,9 +125,8 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
walletForm.getWallet().setPolicyType(policyType);
|
||||
|
||||
scriptType.setItems(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(policyType)));
|
||||
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
|
||||
|
||||
if(!initialising) {
|
||||
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
|
||||
clearKeystoreTabs();
|
||||
}
|
||||
initialising = false;
|
||||
|
||||
@ -245,8 +245,11 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
|
||||
|
||||
public Long getVSizeFromTip() {
|
||||
if(!AppServices.getMempoolHistogram().isEmpty()) {
|
||||
Double feeRate = blockTransaction.getFeeRate();
|
||||
if(feeRate == null) {
|
||||
return null;
|
||||
}
|
||||
Set<MempoolRateSize> rateSizes = AppServices.getMempoolHistogram().get(AppServices.getMempoolHistogram().lastKey());
|
||||
double feeRate = blockTransaction.getFeeRate();
|
||||
return rateSizes.stream().filter(rateSize -> rateSize.getFee() > feeRate).mapToLong(MempoolRateSize::getVSize).sum();
|
||||
}
|
||||
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
38
src/test/java/com/sparrowwallet/sparrow/net/Auth47Test.java
Normal file
38
src/test/java/com/sparrowwallet/sparrow/net/Auth47Test.java
Normal 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")));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user