add support for bbqr encoding and decoding

This commit is contained in:
Craig Raw 2024-02-26 11:41:10 +02:00
parent 6f4d37d3ff
commit 7f3885611a
18 changed files with 730 additions and 68 deletions

View file

@ -134,6 +134,7 @@ dependencies {
implementation('net.coobird:thumbnailator:0.4.18') implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2') implementation('com.github.hervegirod:fxsvgimage:1.0b2')
implementation('com.sparrowwallet:toucan:0.9.0') implementation('com.sparrowwallet:toucan:0.9.0')
implementation('com.jcraft:jzlib:1.1.3')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0') testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0') testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
testRuntimeOnly('org.junit.platform:junit-platform-launcher') testRuntimeOnly('org.junit.platform:junit-platform-launcher')
@ -715,4 +716,7 @@ extraJavaModuleInfo {
requires('org.bouncycastle.pg') requires('org.bouncycastle.pg')
requires('org.slf4j') requires('org.slf4j')
} }
module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') {
exports('com.jcraft.jzlib')
}
} }

View file

@ -100,7 +100,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
releaseLink.setOnAction(event -> { releaseLink.setOnAction(event -> {
if(release.get() != null && release.get().exists()) { if(release.get() != null && release.get().exists()) {
if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) { if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) {
Optional<ButtonType> optType = AppServices.showAlertDialog("Close Sparrow?", "Close Sparrow before installing?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES); Optional<ButtonType> optType = AppServices.showAlertDialog("Exit Sparrow?", "Sparrow must be closed before installation. Exit?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) { if(optType.isPresent() && optType.get() == ButtonType.YES) {
javafx.application.Platform.exit(); javafx.application.Platform.exit();
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath()); AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());

View file

@ -1,22 +1,28 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
public enum QRDensity { public enum QRDensity {
NORMAL("Normal", 400), NORMAL("Normal", 400, 2000),
LOW("Low", 80); LOW("Low", 80, 1000);
private final String name; private final String name;
private final int maxFragmentLength; private final int maxUrFragmentLength;
private final int maxBbqrFragmentLength;
QRDensity(String name, int maxFragmentLength) { QRDensity(String name, int maxUrFragmentLength, int maxBbqrFragmentLength) {
this.name = name; this.name = name;
this.maxFragmentLength = maxFragmentLength; this.maxUrFragmentLength = maxUrFragmentLength;
this.maxBbqrFragmentLength = maxBbqrFragmentLength;
} }
public String getName() { public String getName() {
return name; return name;
} }
public int getMaxFragmentLength() { public int getMaxUrFragmentLength() {
return maxFragmentLength; return maxUrFragmentLength;
}
public int getMaxBbqrFragmentLength() {
return maxBbqrFragmentLength;
} }
} }

View file

@ -14,6 +14,9 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder; import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
import javafx.concurrent.ScheduledService; import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task; import javafx.concurrent.Task;
import javafx.scene.Node; import javafx.scene.Node;
@ -44,10 +47,16 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private static final int DEFAULT_QR_SIZE = 580; private static final int DEFAULT_QR_SIZE = 580;
private static final int REDUCED_QR_SIZE = 520; private static final int REDUCED_QR_SIZE = 520;
private static final BBQREncoding DEFAULT_BBQR_ENCODING = BBQREncoding.ZLIB;
private final int qrSize = getQRSize(); private final int qrSize = getQRSize();
private final UR ur; private final UR ur;
private UREncoder encoder; private UREncoder urEncoder;
private final BBQR bbqr;
private BBQREncoder bbqrEncoder;
private boolean useBbqrEncoding;
private final ImageView qrImageView; private final ImageView qrImageView;
@ -62,17 +71,26 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private static boolean initialDensityChange; private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException { public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption, false); this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
} }
public QRDisplayDialog(UR ur) { public QRDisplayDialog(UR ur) {
this(ur, false, false); this(ur, null, false, false, false);
} }
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) { public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
this.ur = ur; this.ur = ur;
this.addLegacyEncodingOption = addLegacyEncodingOption; this.bbqr = bbqr;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(selectBbqrButton) {
useBbqrEncoding = true;
}
}
final DialogPane dialogPane = new QRDisplayDialogPane(); final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane); setDialogPane(dialogPane);
@ -85,7 +103,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll()); dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
nextPart(); nextPart();
if(encoder.isSinglePart()) { if(isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart)); qrImageView.setImage(getQrCode(currentPart));
} else { } else {
createAnimateQRService(); createAnimateQRService();
@ -94,7 +112,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE); final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().add(cancelButtonType); dialogPane.getButtonTypes().add(cancelButtonType);
if(addLegacyEncodingOption) { if(this.addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT); final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType); dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else { } else {
@ -102,8 +120,13 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.getButtonTypes().add(densityButtonType); dialogPane.getButtonTypes().add(densityButtonType);
} }
if(bbqr != null) {
final ButtonType bbqrButtonType = new javafx.scene.control.ButtonType("Show BBQr", ButtonBar.ButtonData.BACK_PREVIOUS);
dialogPane.getButtonTypes().add(bbqrButtonType);
}
if(addScanButton) { if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD); final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType); dialogPane.getButtonTypes().add(scanButtonType);
} }
@ -121,7 +144,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
public QRDisplayDialog(String data, boolean addScanButton) { public QRDisplayDialog(String data, boolean addScanButton) {
this.ur = null; this.ur = null;
this.encoder = null; this.bbqr = null;
this.urEncoder = null;
this.bbqrEncoder = null;
final DialogPane dialogPane = new QRDisplayDialogPane(); final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane); setDialogPane(dialogPane);
@ -138,7 +163,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.getButtonTypes().addAll(cancelButtonType); dialogPane.getButtonTypes().addAll(cancelButtonType);
if(addScanButton) { if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD); final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType); dialogPane.getButtonTypes().add(scanButtonType);
} }
@ -163,9 +188,22 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}); });
} }
private boolean isSinglePart() {
if(useBbqrEncoding) {
return bbqrEncoder.isSinglePart();
} else if(!useLegacyEncoding) {
return urEncoder.isSinglePart();
} else {
return legacyParts.length == 1;
}
}
private void nextPart() { private void nextPart() {
if(!useLegacyEncoding) { if(useBbqrEncoding) {
String fragment = encoder.nextPart(); String fragment = bbqrEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else if(!useLegacyEncoding) {
String fragment = urEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT); currentPart = fragment.toUpperCase(Locale.ROOT);
} else { } else {
currentPart = legacyParts[legacyPartIndex]; currentPart = legacyParts[legacyPartIndex];
@ -201,37 +239,23 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
this.legacyParts = legacyEncoder.encode(); this.legacyParts = legacyEncoder.encode();
this.useLegacyEncoding = true; this.useLegacyEncoding = true;
if(legacyParts.length == 1) { restartAnimation();
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
} catch(UR.InvalidTypeException e) { } catch(UR.InvalidTypeException e) {
//Can't happen //Can't happen
} }
} else { } else {
this.useLegacyEncoding = false; this.useLegacyEncoding = false;
restartAnimation();
}
}
if(encoder.isSinglePart()) { private void setUseBbqrEncoding(boolean useBbqrEncoding) {
if(animateQRService != null) { if(useBbqrEncoding) {
animateQRService.cancel(); this.useBbqrEncoding = true;
} restartAnimation();
} else {
qrImageView.setImage(getQrCode(currentPart)); this.useBbqrEncoding = false;
} else if(animateQRService == null) { restartAnimation();
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
} }
} }
@ -240,12 +264,28 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
animateQRService.cancel(); animateQRService.cancel();
} }
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); if(bbqr != null) {
nextPart(); this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(encoder.isSinglePart()) { }
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
restartAnimation();
}
private void restartAnimation() {
if(isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart)); qrImageView.setImage(getQrCode(currentPart));
} else { } else if(animateQRService == null) {
createAnimateQRService(); createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
} }
} }
@ -290,7 +330,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData); ButtonBar.setButtonData(density, buttonData);
density.setOnAction(event -> { density.setOnAction(event -> {
if(!initialDensityChange && !encoder.isSinglePart()) { if(!initialDensityChange && !isSinglePart()) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES); Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true; initialDensityChange = true;
@ -306,12 +346,25 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
return density; return density;
} }
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) { } else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) {
Button scanButton = (Button)super.createButton(buttonType); Button scanButton = (Button)super.createButton(buttonType);
scanButton.setGraphicTextGap(5); scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA)); scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
return scanButton; return scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.BACK_PREVIOUS) {
ToggleButton bbqr = new ToggleButton(buttonType.getText());
bbqr.setGraphicTextGap(5);
bbqr.setGraphic(getGlyph(FontAwesome5.Glyph.QRCODE));
bbqr.setSelected(useBbqrEncoding);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(bbqr, buttonData);
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseBbqrEncoding(newValue);
});
return bbqr;
} }
return super.createButton(buttonType); return super.createButton(buttonType);

View file

@ -30,6 +30,8 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.DoubleProperty; import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
@ -63,8 +65,9 @@ import java.util.stream.IntStream;
public class QRScanDialog extends Dialog<QRScanDialog.Result> { public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class); private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
private final URDecoder decoder; private final URDecoder urDecoder;
private final LegacyURDecoder legacyDecoder; private final LegacyURDecoder legacyUrDecoder;
private final BBQRDecoder bbqrDecoder;
private final WebcamService webcamService; private final WebcamService webcamService;
private List<String> parts; private List<String> parts;
@ -80,8 +83,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>(); private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
public QRScanDialog() { public QRScanDialog() {
this.decoder = new URDecoder(); this.urDecoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder(); this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) { if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD); webcamResolutionProperty.set(WebcamResolution.HD);
@ -192,23 +196,23 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) { if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) { if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext); legacyUrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete())); Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete()));
if(legacyDecoder.isComplete()) { if(legacyUrDecoder.isComplete()) {
try { try {
UR ur = legacyDecoder.decode(); UR ur = legacyUrDecoder.decode();
result = extractResultFromUR(ur); result = extractResultFromUR(ur);
} catch(Exception e) { } catch(Exception e) {
result = new Result(new URException(e.getMessage())); result = new Result(new URException(e.getMessage()));
} }
} }
} else { } else {
decoder.receivePart(qrtext); urDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0)); Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) { if(urDecoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult(); URDecoder.Result urResult = urDecoder.getResult();
if(urResult.type == ResultType.SUCCESS) { if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur); result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result)); Platform.runLater(() -> setResult(result));
@ -217,6 +221,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} }
} }
} }
} else if(BBQRDecoder.isBBQRFragment(qrtext)) {
bbqrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(bbqrDecoder.getPercentComplete()));
if(bbqrDecoder.getResult() != null) {
BBQRDecoder.Result bbqrResult = bbqrDecoder.getResult();
if(bbqrResult.getResultType() == BBQRDecoder.ResultType.SUCCESS) {
result = extractResultFromBBQR(bbqrResult);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new BBQRException(bbqrResult.getError()));
}
}
} else if(partMatcher.matches()) { } else if(partMatcher.matches()) {
int m = Integer.parseInt(partMatcher.group(1)); int m = Integer.parseInt(partMatcher.group(1));
int n = Integer.parseInt(partMatcher.group(2)); int n = Integer.parseInt(partMatcher.group(2));
@ -651,6 +668,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return wallets; return wallets;
} }
private Result extractResultFromBBQR(BBQRDecoder.Result result) {
if(result.getPsbt() != null) {
return new Result(result.getPsbt());
} else if(result.getTransaction() != null) {
return new Result(result.getTransaction());
} else if(result.toString() != null) {
return new Result(result.toString());
} else {
log.error("Unsupported BBQR type " + result.getBbqrType());
return new Result(new URException("BBQR type " + result.getBbqrType() + " is not supported"));
}
}
} }
private class QRScanListener implements WebcamListener { private class QRScanListener implements WebcamListener {

View file

@ -0,0 +1,3 @@
package com.sparrowwallet.sparrow.io.bbqr;
public record BBQR(BBQRType type, byte[] data) {}

View file

@ -0,0 +1,180 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;
public class BBQRDecoder {
private static final Logger log = LoggerFactory.getLogger(BBQRDecoder.class);
private final Map<Integer, byte[]> receivedParts = new TreeMap<>();
private int totalParts;
private BBQRType type;
private Result result;
public static boolean isBBQRFragment(String part) {
try {
BBQRHeader.fromString(part);
return true;
} catch(Exception e) {
return false;
}
}
public void receivePart(String part) {
try {
BBQRHeader header = BBQRHeader.fromString(part);
totalParts = header.seqTotal();
type = header.type();
byte[] partData = header.decode(part);
receivedParts.put(header.seqNumber(), partData);
if(receivedParts.size() == totalParts) {
byte[] data = concatParts();
if(type == BBQRType.PSBT) {
result = new Result(new PSBT(data));
} else if(type == BBQRType.TXN) {
result = new Result(new Transaction(data));
} else if(type == BBQRType.JSON || type == BBQRType.UNICODE) {
result = new Result(type, new String(data, StandardCharsets.UTF_8));
} else {
result = new Result(type, data);
}
}
} catch(Exception e) {
log.error("Could not parse received QR of type " + type, e);
result = new Result(ResultType.FAILURE, e.getMessage());
}
}
private byte[] concatParts() {
int totalLength = 0;
for(byte[] part : receivedParts.values()) {
totalLength += part.length;
}
byte[] data = new byte[totalLength];
int index = 0;
for(byte[] part : receivedParts.values()) {
System.arraycopy(part, 0, data, index, part.length);
index += part.length;
}
return data;
}
public int getProcessedPartsCount() {
return receivedParts.size();
}
public double getPercentComplete() {
if(totalParts == 0) {
return 0d;
}
return (double)getProcessedPartsCount() / totalParts;
}
public Result getResult() {
return result;
}
public static class Result {
private final ResultType resultType;
private final BBQRType bbqrType;
private final PSBT psbt;
private final Transaction transaction;
private final String strData;
private final byte[] data;
private final String error;
public Result(ResultType resultType, String error) {
this.resultType = resultType;
this.bbqrType = null;
this.psbt = null;
this.transaction = null;
this.strData = null;
this.data = null;
this.error = error;
}
public Result(PSBT psbt) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = BBQRType.PSBT;
this.psbt = psbt;
this.transaction = null;
this.strData = null;
this.data = null;
this.error = null;
}
public Result(Transaction transaction) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = BBQRType.TXN;
this.psbt = null;
this.transaction = transaction;
this.strData = null;
this.data = null;
this.error = null;
}
public Result(BBQRType bbqrType, String strData) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = bbqrType;
this.psbt = null;
this.transaction = null;
this.strData = strData;
this.data = null;
this.error = null;
}
public Result(BBQRType bbqrType, byte[] data) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = bbqrType;
this.psbt = null;
this.transaction = null;
this.strData = null;
this.data = data;
this.error = null;
}
public ResultType getResultType() {
return resultType;
}
public BBQRType getBbqrType() {
return bbqrType;
}
public PSBT getPsbt() {
return psbt;
}
public Transaction getTransaction() {
return transaction;
}
public byte[] getData() {
return data;
}
public String toString() {
return strData;
}
public String getError() {
return error;
}
}
public enum ResultType {
SUCCESS, FAILURE;
}
}

View file

@ -0,0 +1,71 @@
package com.sparrowwallet.sparrow.io.bbqr;
import java.util.ArrayList;
import java.util.List;
public class BBQREncoder {
private final String[] parts;
private int partIndex;
public BBQREncoder(BBQRType bbqrType, BBQREncoding bbqrEncoding, byte[] data, int maxFragmentLength, int firstSeqNum) {
this.parts = encode(bbqrType, bbqrEncoding, data, maxFragmentLength).toArray(new String[0]);
this.partIndex = firstSeqNum;
}
public boolean isSinglePart() {
return parts.length == 1;
}
public String nextPart() {
String currentPart = parts[partIndex];
partIndex++;
if(partIndex > parts.length - 1) {
partIndex = 0;
}
return currentPart;
}
public int getNumParts() {
return parts.length;
}
private List<String> encode(BBQRType type, BBQREncoding desiredEncoding, byte[] data, int desiredChunkSize) {
String encoded;
BBQREncoding encoding = desiredEncoding;
try {
encoded = encoding.encode(data);
if(encoding == BBQREncoding.ZLIB) {
String uncompressed = BBQREncoding.BASE32.encode(data);
if(encoded.length() > uncompressed.length()) {
throw new BBQREncodingException("Compressed data was larger than uncompressed data");
}
}
} catch(BBQREncodingException e) {
encoding = BBQREncoding.BASE32;
encoded = BBQREncoding.BASE32.encode(data);
}
int inputLength = encoded.length();
int numChunks = (inputLength + desiredChunkSize - 1) / desiredChunkSize;
int chunkSize = numChunks == 1 ? desiredChunkSize : (int)Math.ceil((double)inputLength / numChunks);
int modulo = chunkSize % encoding.getPartModulo();
if(modulo > 0) {
chunkSize += (encoding.getPartModulo() - modulo);
}
List<String> chunks = new ArrayList<>();
int startIndex = 0;
for(int i = 0; i < numChunks; i ++) {
int endIndex = Math.min(startIndex + chunkSize, encoded.length());
BBQRHeader bbqrHeader = new BBQRHeader(encoding, type, numChunks, i);
String chunk = bbqrHeader + encoded.substring(startIndex, endIndex);
startIndex = endIndex;
chunks.add(chunk);
}
return chunks;
}
}

View file

@ -0,0 +1,104 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.google.common.io.BaseEncoding;
import com.jcraft.jzlib.*;
import com.sparrowwallet.drongo.Utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
public enum BBQREncoding {
HEX("H") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
return Utils.bytesToHex(data).toUpperCase(Locale.ROOT);
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
return Utils.hexToBytes(part);
}
@Override
public int getPartModulo() {
return 2;
}
}, BASE32("2") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
return BaseEncoding.base32().encode(data).replaceAll("=+$", "");
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
return BaseEncoding.base32().decode(part);
}
@Override
public int getPartModulo() {
return 8;
}
}, ZLIB("Z") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
try {
Deflater deflater = new Deflater(JZlib.Z_BEST_COMPRESSION, 10, true);
ByteArrayOutputStream out = new ByteArrayOutputStream();
DeflaterOutputStream zOut = new DeflaterOutputStream(out, deflater);
zOut.write(data);
zOut.close();
return BASE32.encode(out.toByteArray());
} catch(Exception e) {
throw new BBQREncodingException("Error deflating with zlib", e);
}
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
try {
Inflater inflater = new Inflater(10, true);
ByteArrayInputStream in = new ByteArrayInputStream(BASE32.decode(part));
InflaterInputStream zIn = new InflaterInputStream(in, inflater);
byte[] decoded = zIn.readAllBytes();
zIn.close();
return decoded;
} catch(Exception e) {
throw new BBQREncodingException("Error inflating with zlib", e);
}
}
@Override
public int getPartModulo() {
return 8;
}
};
public static BBQREncoding fromString(String code) {
for(BBQREncoding encoding : values()) {
if(encoding.getCode().equals(code)) {
return encoding;
}
}
throw new IllegalArgumentException("Could not find encoding for code " + code);
}
private final String code;
BBQREncoding(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public abstract String encode(byte[] data) throws BBQREncodingException;
public abstract byte[] decode(String part) throws BBQREncodingException;
public abstract int getPartModulo();
}

View file

@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.io.bbqr;
public class BBQREncodingException extends BBQRException {
public BBQREncodingException(String message) {
super(message);
}
public BBQREncodingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.io.bbqr;
public class BBQRException extends RuntimeException {
public BBQRException(String message) {
super(message);
}
public BBQRException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,40 @@
package com.sparrowwallet.sparrow.io.bbqr;
import java.math.BigInteger;
public record BBQRHeader(BBQREncoding encoding, BBQRType type, int seqTotal, int seqNumber) {
public static final String HEADER = "B$";
public String toString() {
return HEADER + encoding.getCode() + type.getCode() + encodeToBase36(seqTotal) + encodeToBase36(seqNumber);
}
public byte[] decode(String part) {
return encoding.decode(part.substring(8));
}
public static BBQRHeader fromString(String part) {
if(part.length() < 8) {
throw new IllegalArgumentException("Part too short");
}
if(!HEADER.equals(part.substring(0, 2))) {
throw new IllegalArgumentException("Part does not start with " + HEADER);
}
BBQREncoding e = BBQREncoding.fromString(part.substring(2, 3));
BBQRType t = BBQRType.fromString(part.substring(3, 4));
return new BBQRHeader(e, t, decodeFromBase36(part.substring(4, 6)), decodeFromBase36(part.substring(6, 8)));
}
private String encodeToBase36(int number) {
String base36Encoded = new BigInteger(String.valueOf(number)).toString(36);
return String.format("%2s", base36Encoded).replace(' ', '0');
}
private static int decodeFromBase36(String base36) {
BigInteger bigInteger = new BigInteger(base36, 36);
return bigInteger.intValue();
}
}

View file

@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow.io.bbqr;
public enum BBQRType {
PSBT("P"), TXN("T"), JSON("J"), CBOR("C"), UNICODE("U"), BINARY("B"), EXECUTABLE("X");
private final String code;
BBQRType(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public static BBQRType fromString(String code) {
for(BBQRType type : values()) {
if(type.getCode().equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Could not find type for code " + code);
}
}

View file

@ -19,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.payjoin.Payjoin; import com.sparrowwallet.sparrow.payjoin.Payjoin;
@ -902,16 +904,21 @@ public class HeadersController extends TransactionFormController implements Init
ToggleButton toggleButton = (ToggleButton)event.getSource(); ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false); toggleButton.setSelected(false);
//TODO: Remove once Cobo Vault has upgraded to UR2.0 //TODO: Remove once Cobo Vault support has been removed
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT)); boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT));
boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD) || keystore.getSource().equals(KeystoreSource.SW_WATCH));
boolean selectBbqrOption = headersForm.getSigningWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD));
//Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning //Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning
boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType()); boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType());
CryptoPSBT cryptoPSBT = new CryptoPSBT(headersForm.getPsbt().serialize(true, includeNonWitnessUtxos)); byte[] psbtBytes = headersForm.getPsbt().serialize(true, includeNonWitnessUtxos);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption, true);
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.PSBT, psbtBytes) : null;
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, addLegacyEncodingOption, true, selectBbqrOption);
qrDisplayDialog.initOwner(toggleButton.getScene().getWindow()); qrDisplayDialog.initOwner(toggleButton.getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait(); Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) { if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
scanPSBT(event); scanPSBT(event);
} }
} }

View file

@ -63,4 +63,5 @@ open module com.sparrowwallet.sparrow {
requires com.github.hervegirod; requires com.github.hervegirod;
requires com.sparrowwallet.toucan; requires com.sparrowwallet.toucan;
requires java.smartcardio; requires java.smartcardio;
requires com.jcraft.jzlib;
} }

View file

@ -0,0 +1,37 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.sparrowwallet.sparrow.control.QRDensity;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Random;
public class BBQRDecoderTest {
@Test
public void testDecoder() {
Random random = new Random();
for(QRDensity qrDensity : QRDensity.values()) {
for(BBQREncoding encoding : BBQREncoding.values()) {
for(int length = qrDensity.getMaxBbqrFragmentLength() / 2; length < qrDensity.getMaxBbqrFragmentLength() * 2.5d; length++) {
byte[] data = new byte[length];
random.nextBytes(data);
BBQREncoder encoder = new BBQREncoder(BBQRType.BINARY, encoding, data, qrDensity.getMaxBbqrFragmentLength() + 13, 0);
BBQRDecoder decoder = new BBQRDecoder();
while(decoder.getPercentComplete() < 1d) {
String part = encoder.nextPart();
Assertions.assertTrue(BBQRDecoder.isBBQRFragment(part));
if(random.nextDouble() < 0.7) {
decoder.receivePart(part);
}
}
Assertions.assertNotNull(decoder.getResult(), "Result was null for encoding " + encoding + " " + qrDensity + " " + length);
Assertions.assertArrayEquals(data, decoder.getResult().getData());
}
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.sparrowwallet.sparrow.control.QRDensity;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Random;
public class BBQREncoderTest {
@Test
public void testEncoding() {
Random random = new Random();
for(QRDensity qrDensity : QRDensity.values()) {
for(BBQREncoding encoding : BBQREncoding.values()) {
for(int length = qrDensity.getMaxBbqrFragmentLength() / 2; length < qrDensity.getMaxBbqrFragmentLength() * 2.5d; length++) {
byte[] data = new byte[length];
random.nextBytes(data);
BBQREncoder encoder = new BBQREncoder(BBQRType.BINARY, encoding, data, qrDensity.getMaxBbqrFragmentLength(), 0);
int partLength = encoder.nextPart().length();
for(int i = 1; i < encoder.getNumParts(); i++) {
int nextPartLength = encoder.nextPart().length();
if(i < encoder.getNumParts() - 1) {
Assertions.assertEquals(0, nextPartLength % encoding.getPartModulo(), "Modulo test failed for " + length + " in encoding " + encoding + " on part " + (i+1) + " of " + encoder.getNumParts());
Assertions.assertEquals(partLength, nextPartLength);
} else {
Assertions.assertTrue(nextPartLength <= partLength);
}
}
}
}
}
}
}

View file

@ -0,0 +1,45 @@
package com.sparrowwallet.sparrow.io.bbqr;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Random;
public class BBQREncodingTest {
@Test
public void testHex() {
byte[] data = new byte[1000];
Random random = new Random();
random.nextBytes(data);
String deflated = BBQREncoding.HEX.encode(data);
byte[] inflated = BBQREncoding.HEX.decode(deflated);
Assertions.assertArrayEquals(data, inflated);
}
@Test
public void testBase32() {
byte[] data = new byte[1000];
Random random = new Random();
random.nextBytes(data);
String deflated = BBQREncoding.BASE32.encode(data);
byte[] inflated = BBQREncoding.BASE32.decode(deflated);
Assertions.assertArrayEquals(data, inflated);
}
@Test
public void testZlib() {
byte[] data = new byte[1000];
Random random = new Random();
random.nextBytes(data);
String deflated = BBQREncoding.ZLIB.encode(data);
byte[] inflated = BBQREncoding.ZLIB.decode(deflated);
Assertions.assertArrayEquals(data, inflated);
}
}