From 7f3885611a476fe0a7d3db77372a98af3b5b7416 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Mon, 26 Feb 2024 11:41:10 +0200 Subject: [PATCH] add support for bbqr encoding and decoding --- build.gradle | 4 + .../control/DownloadVerifierDialog.java | 2 +- .../sparrow/control/QRDensity.java | 20 +- .../sparrow/control/QRDisplayDialog.java | 141 +++++++++----- .../sparrow/control/QRScanDialog.java | 54 ++++-- .../sparrowwallet/sparrow/io/bbqr/BBQR.java | 3 + .../sparrow/io/bbqr/BBQRDecoder.java | 180 ++++++++++++++++++ .../sparrow/io/bbqr/BBQREncoder.java | 71 +++++++ .../sparrow/io/bbqr/BBQREncoding.java | 104 ++++++++++ .../io/bbqr/BBQREncodingException.java | 11 ++ .../sparrow/io/bbqr/BBQRException.java | 11 ++ .../sparrow/io/bbqr/BBQRHeader.java | 40 ++++ .../sparrow/io/bbqr/BBQRType.java | 25 +++ .../transaction/HeadersController.java | 15 +- src/main/java/module-info.java | 1 + .../sparrow/io/bbqr/BBQRDecoderTest.java | 37 ++++ .../sparrow/io/bbqr/BBQREncoderTest.java | 34 ++++ .../sparrow/io/bbqr/BBQREncodingTest.java | 45 +++++ 18 files changed, 730 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQR.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoder.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoding.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingException.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRException.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRHeader.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRType.java create mode 100644 src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoderTest.java create mode 100644 src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoderTest.java create mode 100644 src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingTest.java diff --git a/build.gradle b/build.gradle index fb184830..30f36db6 100644 --- a/build.gradle +++ b/build.gradle @@ -134,6 +134,7 @@ dependencies { implementation('net.coobird:thumbnailator:0.4.18') implementation('com.github.hervegirod:fxsvgimage:1.0b2') implementation('com.sparrowwallet:toucan:0.9.0') + implementation('com.jcraft:jzlib:1.1.3') testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0') testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0') testRuntimeOnly('org.junit.platform:junit-platform-launcher') @@ -715,4 +716,7 @@ extraJavaModuleInfo { requires('org.bouncycastle.pg') requires('org.slf4j') } + module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') { + exports('com.jcraft.jzlib') + } } \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java index b684b7b4..8c4695e2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java @@ -100,7 +100,7 @@ public class DownloadVerifierDialog extends Dialog { releaseLink.setOnAction(event -> { if(release.get() != null && release.get().exists()) { if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) { - Optional optType = AppServices.showAlertDialog("Close Sparrow?", "Close Sparrow before installing?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES); + Optional 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) { javafx.application.Platform.exit(); AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath()); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRDensity.java b/src/main/java/com/sparrowwallet/sparrow/control/QRDensity.java index a4b92dc3..c5fed3f4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRDensity.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRDensity.java @@ -1,22 +1,28 @@ package com.sparrowwallet.sparrow.control; public enum QRDensity { - NORMAL("Normal", 400), - LOW("Low", 80); + NORMAL("Normal", 400, 2000), + LOW("Low", 80, 1000); 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.maxFragmentLength = maxFragmentLength; + this.maxUrFragmentLength = maxUrFragmentLength; + this.maxBbqrFragmentLength = maxBbqrFragmentLength; } public String getName() { return name; } - public int getMaxFragmentLength() { - return maxFragmentLength; + public int getMaxUrFragmentLength() { + return maxUrFragmentLength; + } + + public int getMaxBbqrFragmentLength() { + return maxBbqrFragmentLength; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRDisplayDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRDisplayDialog.java index 27bb071a..01bc20df 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRDisplayDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRDisplayDialog.java @@ -14,6 +14,9 @@ import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.hummingbird.UR; 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.Task; import javafx.scene.Node; @@ -44,10 +47,16 @@ public class QRDisplayDialog extends Dialog { private static final int DEFAULT_QR_SIZE = 580; private static final int REDUCED_QR_SIZE = 520; + private static final BBQREncoding DEFAULT_BBQR_ENCODING = BBQREncoding.ZLIB; + private final int qrSize = getQRSize(); private final UR ur; - private UREncoder encoder; + private UREncoder urEncoder; + + private final BBQR bbqr; + private BBQREncoder bbqrEncoder; + private boolean useBbqrEncoding; private final ImageView qrImageView; @@ -62,17 +71,26 @@ public class QRDisplayDialog extends Dialog { private static boolean initialDensityChange; 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) { - 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.addLegacyEncodingOption = addLegacyEncodingOption; - this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); + this.bbqr = bbqr; + 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(); setDialogPane(dialogPane); @@ -85,7 +103,7 @@ public class QRDisplayDialog extends Dialog { dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll()); nextPart(); - if(encoder.isSinglePart()) { + if(isSinglePart()) { qrImageView.setImage(getQrCode(currentPart)); } else { createAnimateQRService(); @@ -94,7 +112,7 @@ public class QRDisplayDialog extends Dialog { final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE); 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); dialogPane.getButtonTypes().add(legacyEncodingButtonType); } else { @@ -102,8 +120,13 @@ public class QRDisplayDialog extends Dialog { 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) { - 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); } @@ -121,7 +144,9 @@ public class QRDisplayDialog extends Dialog { public QRDisplayDialog(String data, boolean addScanButton) { this.ur = null; - this.encoder = null; + this.bbqr = null; + this.urEncoder = null; + this.bbqrEncoder = null; final DialogPane dialogPane = new QRDisplayDialogPane(); setDialogPane(dialogPane); @@ -138,7 +163,7 @@ public class QRDisplayDialog extends Dialog { dialogPane.getButtonTypes().addAll(cancelButtonType); 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); } @@ -163,9 +188,22 @@ public class QRDisplayDialog extends Dialog { }); } + private boolean isSinglePart() { + if(useBbqrEncoding) { + return bbqrEncoder.isSinglePart(); + } else if(!useLegacyEncoding) { + return urEncoder.isSinglePart(); + } else { + return legacyParts.length == 1; + } + } + private void nextPart() { - if(!useLegacyEncoding) { - String fragment = encoder.nextPart(); + if(useBbqrEncoding) { + String fragment = bbqrEncoder.nextPart(); + currentPart = fragment.toUpperCase(Locale.ROOT); + } else if(!useLegacyEncoding) { + String fragment = urEncoder.nextPart(); currentPart = fragment.toUpperCase(Locale.ROOT); } else { currentPart = legacyParts[legacyPartIndex]; @@ -201,37 +239,23 @@ public class QRDisplayDialog extends Dialog { this.legacyParts = legacyEncoder.encode(); this.useLegacyEncoding = true; - if(legacyParts.length == 1) { - if(animateQRService != null) { - animateQRService.cancel(); - } - - nextPart(); - qrImageView.setImage(getQrCode(currentPart)); - } else if(animateQRService == null) { - createAnimateQRService(); - } else if(!animateQRService.isRunning()) { - animateQRService.reset(); - animateQRService.start(); - } + restartAnimation(); } catch(UR.InvalidTypeException e) { //Can't happen } } else { this.useLegacyEncoding = false; + restartAnimation(); + } + } - if(encoder.isSinglePart()) { - if(animateQRService != null) { - animateQRService.cancel(); - } - - qrImageView.setImage(getQrCode(currentPart)); - } else if(animateQRService == null) { - createAnimateQRService(); - } else if(!animateQRService.isRunning()) { - animateQRService.reset(); - animateQRService.start(); - } + private void setUseBbqrEncoding(boolean useBbqrEncoding) { + if(useBbqrEncoding) { + this.useBbqrEncoding = true; + restartAnimation(); + } else { + this.useBbqrEncoding = false; + restartAnimation(); } } @@ -240,12 +264,28 @@ public class QRDisplayDialog extends Dialog { animateQRService.cancel(); } - this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); - nextPart(); - if(encoder.isSinglePart()) { + if(bbqr != null) { + this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0); + } + + 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)); - } else { + } else if(animateQRService == null) { createAnimateQRService(); + } else if(!animateQRService.isRunning()) { + animateQRService.reset(); + animateQRService.start(); } } @@ -290,7 +330,7 @@ public class QRDisplayDialog extends Dialog { final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); ButtonBar.setButtonData(density, buttonData); density.setOnAction(event -> { - if(!initialDensityChange && !encoder.isSinglePart()) { + if(!initialDensityChange && !isSinglePart()) { Optional 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) { initialDensityChange = true; @@ -306,12 +346,25 @@ public class QRDisplayDialog extends Dialog { return density; } - } else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) { + } else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) { Button scanButton = (Button)super.createButton(buttonType); scanButton.setGraphicTextGap(5); scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA)); 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); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index c395a373..c4db84c6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -30,6 +30,8 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; 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.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; @@ -63,8 +65,9 @@ import java.util.stream.IntStream; public class QRScanDialog extends Dialog { private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class); - private final URDecoder decoder; - private final LegacyURDecoder legacyDecoder; + private final URDecoder urDecoder; + private final LegacyURDecoder legacyUrDecoder; + private final BBQRDecoder bbqrDecoder; private final WebcamService webcamService; private List parts; @@ -80,8 +83,9 @@ public class QRScanDialog extends Dialog { private final ObjectProperty webcamDeviceProperty = new SimpleObjectProperty<>(); public QRScanDialog() { - this.decoder = new URDecoder(); - this.legacyDecoder = new LegacyURDecoder(); + this.urDecoder = new URDecoder(); + this.legacyUrDecoder = new LegacyURDecoder(); + this.bbqrDecoder = new BBQRDecoder(); if(Config.get().isHdCapture()) { webcamResolutionProperty.set(WebcamResolution.HD); @@ -192,23 +196,23 @@ public class QRScanDialog extends Dialog { if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) { if(LegacyURDecoder.isLegacyURFragment(qrtext)) { - legacyDecoder.receivePart(qrtext); - Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete())); + legacyUrDecoder.receivePart(qrtext); + Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete())); - if(legacyDecoder.isComplete()) { + if(legacyUrDecoder.isComplete()) { try { - UR ur = legacyDecoder.decode(); + UR ur = legacyUrDecoder.decode(); result = extractResultFromUR(ur); } catch(Exception e) { result = new Result(new URException(e.getMessage())); } } } else { - decoder.receivePart(qrtext); - Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0)); + urDecoder.receivePart(qrtext); + Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0)); - if(decoder.getResult() != null) { - URDecoder.Result urResult = decoder.getResult(); + if(urDecoder.getResult() != null) { + URDecoder.Result urResult = urDecoder.getResult(); if(urResult.type == ResultType.SUCCESS) { result = extractResultFromUR(urResult.ur); Platform.runLater(() -> setResult(result)); @@ -217,6 +221,19 @@ public class QRScanDialog extends Dialog { } } } + } 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()) { int m = Integer.parseInt(partMatcher.group(1)); int n = Integer.parseInt(partMatcher.group(2)); @@ -651,6 +668,19 @@ public class QRScanDialog extends Dialog { 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 { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQR.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQR.java new file mode 100644 index 00000000..8d690eb0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQR.java @@ -0,0 +1,3 @@ +package com.sparrowwallet.sparrow.io.bbqr; + +public record BBQR(BBQRType type, byte[] data) {} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java new file mode 100644 index 00000000..2a92ca12 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java @@ -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 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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoder.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoder.java new file mode 100644 index 00000000..591f46f1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoder.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoding.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoding.java new file mode 100644 index 00000000..293fa183 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoding.java @@ -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(); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingException.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingException.java new file mode 100644 index 00000000..28b19f9b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingException.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRException.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRException.java new file mode 100644 index 00000000..982ed2ab --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRException.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRHeader.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRHeader.java new file mode 100644 index 00000000..4689ba91 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRHeader.java @@ -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(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRType.java b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRType.java new file mode 100644 index 00000000..31806ac2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRType.java @@ -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); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index a59018a4..fd98e6a6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -19,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.io.Config; 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.io.Storage; import com.sparrowwallet.sparrow.payjoin.Payjoin; @@ -902,16 +904,21 @@ public class HeadersController extends TransactionFormController implements Init ToggleButton toggleButton = (ToggleButton)event.getSource(); 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 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 boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType()); - CryptoPSBT cryptoPSBT = new CryptoPSBT(headersForm.getPsbt().serialize(true, includeNonWitnessUtxos)); - QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption, true); + byte[] psbtBytes = headersForm.getPsbt().serialize(true, includeNonWitnessUtxos); + + 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()); Optional 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); } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 2d99a749..eb7168b0 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -63,4 +63,5 @@ open module com.sparrowwallet.sparrow { requires com.github.hervegirod; requires com.sparrowwallet.toucan; requires java.smartcardio; + requires com.jcraft.jzlib; } \ No newline at end of file diff --git a/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoderTest.java b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoderTest.java new file mode 100644 index 00000000..315bb402 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoderTest.java @@ -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()); + } + } + } + } +} diff --git a/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoderTest.java b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoderTest.java new file mode 100644 index 00000000..f3f9c375 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncoderTest.java @@ -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); + } + } + } + } + } + } +} diff --git a/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingTest.java b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingTest.java new file mode 100644 index 00000000..e238fcb4 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/io/bbqr/BBQREncodingTest.java @@ -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); + } +}