From af532e7fc99eb9313662a40098a7b3ecbeaf46cd Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Mon, 27 Mar 2023 11:00:32 +0200 Subject: [PATCH] import seed via border wallets grid pattern --- build.gradle | 2 + .../sparrow/control/MnemonicGridDialog.java | 196 ++++++++++++++++++ .../sparrow/control/MnemonicKeystorePane.java | 45 ++++ .../sparrowwallet/sparrow/io/PdfUtils.java | 34 ++- .../com/sparrowwallet/sparrow/grid.css | 5 + src/main/resources/image/border-wallets.png | Bin 0 -> 2698 bytes .../resources/image/border-wallets@2x.png | Bin 0 -> 3790 bytes .../resources/image/border-wallets@3x.png | Bin 0 -> 6240 bytes 8 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/grid.css create mode 100644 src/main/resources/image/border-wallets.png create mode 100644 src/main/resources/image/border-wallets@2x.png create mode 100644 src/main/resources/image/border-wallets@3x.png diff --git a/build.gradle b/build.gradle index e212e58a..6907be64 100644 --- a/build.gradle +++ b/build.gradle @@ -160,6 +160,7 @@ run { "--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", + "--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"] @@ -208,6 +209,7 @@ jlink { "--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", + "--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java new file mode 100644 index 00000000..fe8133d6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java @@ -0,0 +1,196 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.io.PdfUtils; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import org.controlsfx.control.spreadsheet.*; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.tools.Platform; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + + +public class MnemonicGridDialog extends Dialog> { + private final SpreadsheetView spreadsheetView; + + private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false); + private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false); + + public MnemonicGridDialog() { + DialogPane dialogPane = new MnemonicGridDialogPane(); + setDialogPane(dialogPane); + setTitle("Border Wallets Grid"); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm()); + dialogPane.setHeaderText("Load a Border Wallets PDF, and select 11 or 23 words in the grid.\nThe order of selection is important!"); + javafx.scene.image.Image image = new Image("/image/border-wallets.png"); + dialogPane.setGraphic(new ImageView(image)); + + String[][] emptyWordGrid = new String[128][16]; + Grid grid = getGrid(emptyWordGrid); + + spreadsheetView = new SpreadsheetView(grid); + spreadsheetView.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { + try { + Field f = event.getClass().getDeclaredField(Platform.getCurrent() == Platform.OSX ? "metaDown" : "controlDown"); + f.setAccessible(true); + f.set(event, true); + } catch(IllegalAccessException | NoSuchFieldException e) { + //ignore + } + }); + spreadsheetView.setId("grid"); + spreadsheetView.setEditable(false); + spreadsheetView.setFixingColumnsAllowed(false); + spreadsheetView.setFixingRowsAllowed(false); + + spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() { + @Override + public void onChanged(Change c) { + int numWords = c.getList().size(); + wordsSelectedProperty.set(numWords == 11 || numWords == 23); + } + }); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().add(spreadsheetView); + dialogPane.setContent(stackPane); + + stackPane.widthProperty().addListener((observable, oldValue, newValue) -> { + if(newValue != null) { + for(SpreadsheetColumn column : spreadsheetView.getColumns()) { + column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17); + } + } + }); + + dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF", ButtonBar.ButtonData.LEFT); + dialogPane.getButtonTypes().add(loadCsvButtonType); + + Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK); + okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty))); + + setResultConverter((dialogButton) -> { + ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData(); + return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null; + }); + + dialogPane.setPrefWidth(850); + dialogPane.setPrefHeight(500); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.moveToActiveWindowScreen(this); + } + + private Grid getGrid(String[][] wordGrid) { + int rowCount = wordGrid.length; + int columnCount = wordGrid[0].length; + GridBase grid = new GridBase(rowCount, columnCount); + ObservableList> rows = FXCollections.observableArrayList(); + grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P"); + for(int i = 0; i < rowCount; i++) { + final ObservableList list = FXCollections.observableArrayList(); + for(int j = 0; j < columnCount; j++) { + list.add(createCell(i, j, wordGrid[i][j])); + } + rows.add(list); + grid.getRowHeaders().add(String.format("%03d", i + 1)); + } + grid.setRows(rows); + + return grid; + } + + private SpreadsheetCell createCell(int row, int column, String word) { + return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word); + } + + private List getSelectedWords() { + List abbreviations = spreadsheetView.getSelectionModel().getSelectedCells().stream() + .map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList()); + + List words = new ArrayList<>(); + for(String abbreviation : abbreviations) { + for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) { + if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) { + words.add(word); + break; + } + } + } + + if(words.size() != abbreviations.size()) { + abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr))); + throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations); + } + + return words; + } + + private class MnemonicGridDialogPane extends DialogPane { + @Override + protected Node createButton(ButtonType buttonType) { + Node button; + if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) { + Button loadButton = new Button(buttonType.getText()); + loadButton.setGraphicTextGap(5); + loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP)); + final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); + ButtonBar.setButtonData(loadButton, buttonData); + loadButton.setOnAction(event -> { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Open PDF"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"), + new FileChooser.ExtensionFilter("PDF", "*.pdf") + ); + + AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450); + File file = fileChooser.showOpenDialog(this.getScene().getWindow()); + if(file != null) { + try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) { + String[][] wordGrid = PdfUtils.getWordGrid(inputStream); + spreadsheetView.setGrid(getGrid(wordGrid)); + initializedProperty.set(true); + } catch(Exception e) { + AppServices.showErrorDialog("Cannot load PDF", e.getMessage()); + } + } + }); + + button = loadButton; + } else { + button = super.createButton(buttonType); + } + + return button; + } + + private Glyph getGlyph(FontAwesome5.Glyph glyphName) { + Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName); + glyph.setFontSize(11); + return glyph; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java index 35dc2568..7ef944c8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java @@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.geometry.Orientation; import javafx.geometry.Pos; @@ -20,6 +22,7 @@ import javafx.scene.control.*; import javafx.scene.input.Clipboard; import javafx.scene.layout.*; import javafx.util.Callback; +import javafx.util.Duration; import org.controlsfx.control.textfield.AutoCompletionBinding; import org.controlsfx.control.textfield.TextFields; import org.controlsfx.glyphfont.Glyph; @@ -78,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane { enterMnemonicButton.getItems().add(item); } enterMnemonicButton.getItems().add(new SeparatorMenuItem()); + MenuItem gridItem = new MenuItem("Border Wallets..."); + gridItem.setOnAction(event -> { + showGrid(); + }); + enterMnemonicButton.getItems().add(gridItem); + MenuItem scanItem = new MenuItem("Scan QR..."); scanItem.setOnAction(event -> { scanQR(); @@ -86,6 +95,42 @@ public class MnemonicKeystorePane extends TitledDescriptionPane { enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty()); } + protected void showGrid() { + MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog(); + Optional> optWords = mnemonicGridDialog.showAndWait(); + if(optWords.isPresent()) { + List words = optWords.get(); + setContent(getMnemonicWordsEntry(words.size() + 1, true)); + setExpanded(true); + + for(int i = 0; i < wordsPane.getChildren().size(); i++) { + WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i); + if(i < words.size()) { + wordEntry.getEditor().setText(words.get(i)); + wordEntry.getEditor().setEditable(false); + } else { + ScheduledService service = new ScheduledService<>() { + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Void call() { + return null; + } + }; + } + }; + service.setDelay(Duration.millis(500)); + service.setOnSucceeded(event1 -> { + service.cancel(); + Platform.runLater(() -> wordEntry.getEditor().requestFocus()); + }); + service.start(); + } + } + } + } + protected void scanQR() { QRScanDialog qrScanDialog = new QRScanDialog(); Optional optionalResult = qrScanDialog.showAndWait(); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java b/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java index 67b730be..636422e9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java @@ -13,6 +13,7 @@ import com.lowagie.text.pdf.PdfReader; import com.lowagie.text.pdf.PdfWriter; import com.lowagie.text.pdf.parser.PdfTextExtractor; import com.sparrowwallet.drongo.OutputDescriptor; +import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UREncoder; @@ -25,8 +26,8 @@ import org.slf4j.LoggerFactory; import java.awt.*; import java.io.*; -import java.util.Locale; -import java.util.Scanner; +import java.util.*; +import java.util.List; public class PdfUtils { private static final Logger log = LoggerFactory.getLogger(PdfUtils.class); @@ -108,4 +109,33 @@ public class PdfUtils { ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); return new javafx.scene.image.Image(bais); } + + public static String[][] getWordGrid(InputStream inputStream) { + try { + PdfReader pdfReader = new PdfReader(inputStream); + String allText = ""; + for(int page = 1; page <= pdfReader.getNumberOfPages(); page++) { + PdfTextExtractor textExtractor = new PdfTextExtractor(pdfReader); + allText += textExtractor.getTextFromPage(page) + "\n"; + } + + List rows = new ArrayList<>(); + Scanner scanner = new Scanner(allText); + while(scanner.hasNextLine()) { + String line = scanner.nextLine().trim(); + String[] words = line.split(" "); + if(words.length > 16 && Utils.isNumber(words[0])) { + rows.add(Arrays.copyOfRange(words, 1, 17)); + } + } + + if(rows.size() < 128) { + throw new IllegalArgumentException("Not a valid Border Wallets PDF"); + } + + return rows.toArray(new String[][]{new String[0]}); + } catch(Exception e) { + throw new IllegalArgumentException("Not a valid Border Wallets PDF"); + } + } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/grid.css b/src/main/resources/com/sparrowwallet/sparrow/grid.css new file mode 100644 index 00000000..58fb44b8 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/grid.css @@ -0,0 +1,5 @@ +#grid .spreadsheet-cell:selected, +#grid .spreadsheet-cell:focused:selected, +#grid .spreadsheet-cell:focused:selected:hover { + -fx-background-color: rgb(238, 210, 2); +} \ No newline at end of file diff --git a/src/main/resources/image/border-wallets.png b/src/main/resources/image/border-wallets.png new file mode 100644 index 0000000000000000000000000000000000000000..660f466ca794c813060ac60aab1e1051ad65df19 GIT binary patch literal 2698 zcmZ`)2|SeR8vn-DG?p+_mYYdgqFH5Wj4>F?B${I#I`$dY#4Nut&QL;SDM})ggia(a zBHS*rP1m7Q*)z9D99yN*V#|&0H#p@u_0I2o-)ElZ|9_VEecs=9g6eE94^xK$03c6s zAiID&PCTJ9;JU@~csT$-a+x+ZREmuag399rGs74Fpns+)tsxX1u(`#OayExXX_8A; zV?!0v_c)lF<IS(oT3BsBR^>VTrhJhNh$pMV^4 z6RDrmo4uO<(BzQghCBOeE7TVPL?5FVd#fCdCCRJ%o!SeROeSEs(HobrB zSIH3y$W0@<%%PoX_oYV{p659qGTc`F!jzoYo%dd5Alcrt-sw<@r(&$k{z=xe&IJ!L zuKz&OTiu_pHI23_9^&^3?o_48$5P#oSr(b(O)ccU>lG!BOZ5y&_ zwZ9p@6}O5=`w?-KVHFW93va_?h+D>u!wDmPuY(BawWu`?oWSd*2k-;P0el7#hrt;k zF-Az7sT&r9$C=@A7()yWj{&O?yZH-tl^^ke;At+5a86i+_!^0(I1D(@Sn*0g|INIX z`i<$%X7Y)+70Md*T9(&0`}YE_Hm{}b<1ravGcM(dU5U0@w}L02#r?RdKbM<-NegyA z5e6R1pD&3BGq;V-00609C}b(Gh=`^smsI2QKOiG4ARK1Sh3(TVNHfo|5 zEL!r7{u?wNw25Jt!wDWRnOqou6m8^~buLg51KlhoCm{nl7H8Fn@K>BGP-+dXNS&1j z?z9aJ4EPGnG)o2&&S+LfxRDwwx^#(l(C^wEblzp0%`A#2yu zaZd($)r1pdZS|C9W$R+IR}&MVgf07S%8iYU1;XL*(=US=H#QM= zAd$#EaCpip6FKGIePYye=V*N!nuV~iuxKkSMSYo?n))@h{Gfuux=^^K z={kSco&H#z)MB$&0cd85>S1*JeVWy2@^M=NrDv9!4^3j}<=tohNbYLaC2Z z$6YIo;M`9CaN+2+oi!Sp{R$QmvL{Oeuv|en)>HhK$2fHGH+gY^(28%ZGg-qsK1EGhfMr&gu33k{^X@7$3 zkV>)0HU1`PXC2QOdRR|Tf|p32%9u!Vzvl3O_zE5kwW-V;drGRQt(DySK_^2BIic#k z&Z>1m&&|rNC7ESwE`>^@5#+&E3p%}bx4%rv#19HnLFC)en09@~=}HQXvP zGMEX~oZr`R>vrrHH4TjmB5Hjz3^tKjvoJE}JFs=b+}5@LW|p~#Xc8kiWpuJ($L+kP z&PnKuvf->Gq-55ldWYYLJP&$Ng>~lDXQ~D?ab()WaXPfh0N(uGlCp0{LUw;g2UTL8 zH8a4e^uyQ(xO1nlkj2ytQT1Z}F;DqN+4;fggE2*ZSmUZAX`)S7-CK1vcd>n^;u*%BY+|zSO6Ng3%c*h0E!6E$yzpDP`R)%y#ScI`cHAcj+e$O!kVv_r3PH$0FWo YxmFd&UAciw5`W(*w$9{IYyYGF2bZ-jPyhe` literal 0 HcmV?d00001 diff --git a/src/main/resources/image/border-wallets@2x.png b/src/main/resources/image/border-wallets@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3093c461a22840a1a1961c8175c53461a29e7c GIT binary patch literal 3790 zcmb_fdpy(YAK%<9%B{KA%$?Xw$U+!nGPhiYlZ?En0JF&`lm`Zdf_TuWByt210MN_6oYiOOKvU@2Lpo_y*lu6f zn9X{v#oQf3r3bM zv3|7inb2Ql^9PGBctknB55i^aTJ-!qV4R4p%O09b%{#`*uJvli;1dm+4kbL}9P?7g zy1O}3=m=C6UhLk<+HNaMJ$ElQ<6z>_+OBxij^O&!+Sh4v^_NW`>iZN*+cisrhwNBU zipA%i_I|j?2FGuTtDBc>X$pbWi#xsWytBVqWq&=U_>gNR!iJ`M}buzqVJJ)u5n_}He|C%UWe=CQ=EI)?D2tx7kO)}#-C zDHDt0GwAr#q)2QM|BecYI1CAk1sL#k34j<-4j|0efcyslQ~`*7(g6Tppz5D`5K!-n zjS%0)1i)VbTltfeMh1v{u}|Pj!EokJzNgR9gB(kYpx~KQx}XyvrpBhm2tFoMBJ?MZ zP$K+Q=VO4EM)w2wLNdzPp8)`Dk{3)Mps+{@01%EOd*hfmtSd5L3viHF2eA|e^cSZnAf3nX6`Y{Q_CPbSQ8YbXxu0YOpeM2NXD+!zkE0YM-T zD>{LM^m0Idq4VFYp@*2vC?pIP6BA<`V`@yLAB34$T3W*32p9rk#J4bF#6>dku||;$ z&CgE$=f{D_2&I#wm}F`sMBo=6LXBoxL!p8|e~!<25@X3Ul0<3og>e0s-d_7{7i2^)K-E)UQmR zNHWs~@rCl;_V+CRulU~u{1*P6>P{yU`R({ASCcQ%zTNv`Zv_){_A{Q3yz=EVXZykv*zs2RbV6nC_dPZ^PXJY5+ee&GORo+4<7ZE39xfWcY_du68*l zXSV)7ZsSx|MBCG17|(nx)ikPrM;a))z`NK~)RZ78`APxkXe;X>^6!Y*<4IR)Y6dJ| zl7*HALeDDHdTUE0cD-80VzKP>^z_9iKT{Rstk}7@hxeD(&+2GvN1Iz%9Jo@AxwFzj zBofC$LqkP<)t7a7Z7<*QkdGffMnk(Xy@P`X@(T(IHfg)Lx=MK-bK1MN@7DBkTF>03 z=^o8K1@^RR+L4>h&CNZg+jK5cxLxL>HpBb;{dtduhK3@~Ev21X9Q)ynI_fbI@UgnJ z^&|ZGDZOF&jP&%NWd2uQs{WN;@Mhavv8~Tq^)8?JP0G*1qqdM6b1Ff(H-+#{X6C_& ziOuWz`3hYtlzv=XoU*>Y{*ufs^?*&<_AkoV)`1mxf;SOcPvX&Mr27xdQ z$$N4lcq&0B4?anD&>!UFjykt!YirM}-rfGZQsRyUaQ;L6_3PoU78V@G&c9VqRJ>(l zY1wr4)>p{6LgR=p{^7xsA?5yQ_6%3JJt1+#LV zXtRmuxf<2}zgcBc�Zsm_NN$E(@9$D3$MWDLbj*75AKNN3`s5cwgDw$;zrKIkjOU z4fA6L3#iW3_qJ@Gk*SFG7p;^X zuKUE;SV2~v9n4m?)>cxz_#?#?Bbt(ul8@TQ@Z3M3qw2&N>F)}`YV@6OFz56vQsm)e{AxE zv}#+)!E_%_kaFN=u?LDt#CzO#(qjH{g#ArjsWkm%CBsxS@51%7mvY5iJJv>P?w{#| zy1RC#0pDv*&opWE^Z@5=|jL(^&8>Y_UP;o;#HH9OU}U4YAWgd(;#Q9Y*V=V>C;P>hzt>GwG4PDuqgDXs8To(0`_YP+0hLQ zLV@=P@ z8X6|o;ZMi(pBitzx3RnD;PGGc`*#dD{joY9msYIj0Afj4T_Uq$dd!7z+iPTv%GZ!A zf?p}XB!%T<*|2m&gx-^P_jdVsR~lpG{iSmQvF8;HE+)nnsaU(69HN+I=JIYQZB9*}FvzwxtmU#JDU_oMlO0fc-zmBu zl(DIG5Ze^_fI2`wG>9XSNROJ}-nDi1)7|Qhi$dHjGSH0Dg~+yjKwhYM@jzW2LOUp= zN^zoFnN?$LZS9ZK9fX+a^unuNN0ifoUWn%<*>||)u)1PtKdG%3l^{AbJ8&A6m6eF4 z-mAA`X)CzjU5_03y~IaDfqq_goNyGD3@b*x(o~<4S~byGb4&<5Xhl@sd)kPz`X5r# zhU(--(&pN|+i#>3&aQ3=AJywG~Y{&uCiEv(Kquj%-J<6C)1EKskf@V zc5`6umj{M^H76yvtKClJG+!>0AwSYk3EULT2!r%WQmTt|)B>-Q`YWretC@$V7Ya!0 z?Hdu^T5k~CUpB;;vX{@>e!w8cs8ZQ;;k_GcHF|Rz^8LH>Rak0wBIY*_Px_*h)tDy? z?if56oP7;$eXD}*jw;MT}kF>T#&9ls;lbhQ}7s{asWi1s$ z2e3OR?^(T)OE!-OQyIq(m3x3+&IpBZJ)uMJv=H?*RL;4wBnX3b00_&nqR~!jB-`-!6-<)=%Aqy7n#r2%_z3!a}Es@X@N>BQiL~W|<`z`gR W{mt_ScEAOH7ch=)4mI|{$^Qi{ED?hM literal 0 HcmV?d00001 diff --git a/src/main/resources/image/border-wallets@3x.png b/src/main/resources/image/border-wallets@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..0e3d1149ae224246049433cbbeb9e1766fd3b627 GIT binary patch literal 6240 zcmeHL2SZa?77is;L8=Hw3B5@|ub~HNL5iVD@4W_r&@`ZcNEH<%lmS6{ks?isw4lqoN@8 z73P&W007B-cMT0g9SsezVSulTyO%QnAedU1qNJ^E&vM7MUhGXu5yOT=S?g3lK?t3&}zBspAfM1DQe}3MJ$N760aB&e8#|+0AOLd>MmMMm^3bI(d zUt6HfAc1r>4QJlaLxz(#i6=jPY2{^8-S!frJS})JA8FU-Cr5sFa|Xmo5Zgkpty*5c zke&3tQ0Gkc6N={2?TO9rMQ#SXv`ls$JeMA2_yl4Vy1uFBfX4O;hT;Gppp1VdoNqjV#u<;ykK2G129C>;sxIr-eUWehj`5WpF9UWhmTskYinDy!~-Z-(e5f{K#& z&bITjb-LxLrq%S_z9Gd5nn5II$E7z)VuR9&Z-ULc{+nXTCeLWIyS z0<6`ziL3P3*<8m(UmqY$q^STDBuoG@B1J;n0VEs%;7=L=FeSP02W>+l_)CVAC?gIa z_5fC5bBT2akpGeoCyu9^Hn9;k{XQDHhd6tA+oOB~PHO=y36X?I5P76|q<`RWd1Qam zL>|C;KsJduQ2S|H1OfoG%%_b6@ECg@03h>mN1CI|^$`k=zTOafCtn9=NQk%JDGC4! zQ6Q4u&M13uh_{zdphAce_fH80B7Hgymrh#0pr4HyiD z1vt4V7{jmpf)me_xZO}FKLsc>I5-#*ED7-qaD|G?%gaN>B%l%!qC^SNz)&BQeTb+} zAkXhg{;db@9OxL}?uT;s^#PyiwRi9hLMd@`pBnmO``u6H5cfYV`2_w7ix?pElmiuq zh(Z4eMhx~3|Na9NV(<4qK`3{Z|6%Kt^E>QkGJf|FcItt`&qcI=yL&tP1QO#>mJkzz z{q*g>IDba>7o_?BME-^I7bGz-1&sjb)1pE8`g$q*~_83;_R?jb~(&gXmsiF12=bmLp15cZ>?6fF8UXBnGli1e%C9hotUT|-V#7$7}leA zJ)$%v+z(+vAd1HfnKF?3zxpRkqWift^V7Hdou@#=-S4(*a z`x@EgI+P=d&FxxsEL|PQG8>}h>{y5ymJMt@A9@TFCh+3Pb@5Ce4?UCGHMo-fq0s1M z+k2P42P*dUfGGFx3zbg|ImS3L6LOdL57m#}q-Sox%w4RTQ6H@&-b8e?B&Z?=SyR$g zHhGUORNEFg9bV|m$ymOo9tXDrY=`_)(rGE z4Q|)wweK3Ml)MYm*QEQX}RqVc~*Abx6s*G*;ddrxun}!f@^OxuDEm=mL|T zPTsnLoe)_TQ#-P!F|~OMG{ZO~{;(z+KNfCs)jvu$Xmeg1L83!_cZYI}hKZkUVjx2L zu0NmFz{uzhbE4Dc+QFLO05gI_9v76-DY`P#4eu~1&ro$?EU4;-r>l=i7ujXWwI{!S zRE*K&Fzl+(XQw8=#-Kao;1c|hQOB8d#g9`LU0lyPvQB% zH}Y}T@GfG43X*)zLVJRvnW^%@*NPCK*-)HbjPg21{1WPAXdYwkBa=tT z@m1z_F-C>WG-nd6;T&|3NrU#>a8fQwFG8htOM^|&go&+VXg4#WASW49e2ZhH%;d7i zOMc)83uA?6ih4U^7Ot8JlEuVGRRX@NK195(>O!;=+??+Fl$u8?<#e{HFbuO!H%{K* zRa&68Jx2=RqQrc(uQ5|BSlHOl)7DwGu~iYK3V%v4?XPf3k1Sh{wDmpy9#+dDq;FXz0fn~VkX6ah>bC(+R?2sNCW<7B1v zbrV}#m1t4dO1p&-z8P_z&Rw+0(_Y3wO{awma%UB^sba?-@51EN!m}@vcCt8)73rIl znN~DcHZ<(voti!SQ8VnYnxKP)YWLu7<2OAD2`X9A8}b%!JAY$GfHN?#y=lip1TsA4 z-Zy$!1wR@6UW;*+N&QTR+p(|IMw2nu6TSsC7I@+uk5ZrA&ma5k6S9^6%X}a{T3_yEX|B9~3wz5u+CLwJpp1W$bF3*TllN{R~2$_3xkdR6vQWSI)I8ZZ{WsuZ0bkCCMr?` z>FCi%1t1;cjNDF^7>_H#K!Ssi^rsqvBsfH1PfQonhE?p@A9k61p)ydanHU{X8n4Ya zO>V^SAU(tUWaqKn4I@mEwN>RS6mEAw@U^~RzlLCcEvz=piBxwwG(|>x6U1}eapLuI zIV)yqeGNO6$9p!9*Nm4-m0sfpDKX7KUIWrEuhTsYwImpx94O%((pOCNU$Y-f2dAln zwORF23l6FiMNc;Qc*1Y(;l96Zox6&9E?{r6k913SxKh=|YvyT5e{!*JhBt-y-oRC9 z9Y*+iEV7|?l9jd#b~JPnii>Oy2R@DPRL6L0+~s6*v%E@**Hlw8Cc#I=q}oiGNsR!D z92gb+(HR-R=cAmiIo6e?bcfL#UL=hWmC;t`R?%h+K0o~3sW*I%e(SEBrjX$v8@$!3 zbpI^1>-dHEvv%Wg-5;;wpYChUp!qpYGMN0Oj@Dw5di;$fD#c3C7H)0BmM595D*3iE z3#_sS%+*J_bwVugd>+>q#sq9n#FMiU+RW$juS~90V+1(MzNqX!J0fzloKd z^Y6RZB^1A>oTJ)V%M!fVmHTdPg~mlEm$J-(M0IxrITTz~R;CE=O~Y8(j3VO~Yb~Di zWFNs$oVL9hah%WAs?lR;*)qe~vf+DWrDMi3ev7v4k;k>DQBe1ya{EE4$$>#Zx<<>I zzykTT6lu@NVyTg2l=199*+;H&PjEfs$?*u>`}|ffuA{Idsk@^?L_ggf z+Tp=*FVZ05w@UeBJ$VLMG=g}KIh-uu6(^f_T?<(o*0>~c>207b!Z>iyg%B|;_#{LZh?}o>kYZ?i&J?t+uyp* zPUR{DeJT*_^(H@}gTr~o;|Kg+*o#bjz3czfS@RXo!1y}`uh>_1G|vJ)ywbahmGf6A z*%L44h%Wg$V7Twiq_cD$=<8XW0(5@@z1~n>RA0aA^6A?F8f%th$auaY;3If=f5h#4 zG<7DN!!b1eQ<$Eh0r3ss+PnbM`i2p`uces!tJFpdN+SXN$4}HI8yIb>_O-srYhJpp z5lP!%1&+g@P^fW+ZwbQ6z(BxFqe)w^*L-t*_L=I78&$Y(Ad|*&M=;?m=&-2v%;vMW z7B!Zb7y;`t3=YCwsS_qW1!bsvyQ@-DVkvA$gA@eG^UDZx=Rh&Y!J$6>FA2by>zCEWaUMf>^b6>Op$mgyl zG$YsGHpWAYS3YMkv;x^Pz*6&aB**-o>u{GCzYJ8M}$eGw<3%<|QKzc6L9^xk2IG zy>D`q!Z_yBRd_I#*9}Xq*e2L^z7yXbdrf?3C|V=bJxN9zyl44ADv_;8RXWn`4OuZ( z$6h=5>5Xcx+3>!UA4=&*Y=|FGUv{T#H@R$#?-BCF2m#pS#4!|CFmUfAU^8hGs*UYe zFr0LOa25*Zr5V~{ZW-WPEnlGPOF@~hqmunz#0pOfvAFQ9)o!z}Pc)&eaPh6d;OU_ z-(Yd|fYz{cK&#k*Gwk<&6S17Aq?_IYNo+@yf8-IsM+n7)iD*-mO7-?56>&&m>Szv$ zG>$R>Qgr^MCmOgZHm~Z`?aj5&avDsl*^Qn$o zp2!>jJ=l(a^RZO0}jWvpH(?F}1)FKQM~ z*0kP!5BCDK7J?M37s0iq)vssXs!riOC0*W3$K{ubO5Ad!vn2^ax+&;G%AmO!CC`0X z=i2m#7Hg_>MTXRIW68=kZ2i@3qT(+E08aj8EEIdYp{KmTCDC zcgOn?!=2atA(FV2P*LAiC7TW9ftm+99+OD6Zc1p7sHZs&eWq{ zg=m#vh0k>-`Uf==&eap>S09K7?oE*+I{ttBa|T9rmZCn*(?qv`nl7FGzSYsZ3a?bR G!~6?A4#|E1 literal 0 HcmV?d00001