├── .gitignore ├── README.md ├── lib └── asg.cliche-110413.jar ├── pom.xml └── src └── main ├── java └── net │ └── plan99 │ └── payfile │ ├── Payfile.java │ ├── ProtocolException.java │ ├── client │ ├── CLI.java │ └── PayFileClient.java │ ├── gui │ ├── BitcoinAddressValidator.java │ ├── ConnectServerController.java │ ├── Controller.java │ ├── Main.java │ ├── ProgressOutputStream.java │ ├── SendMoneyController.java │ ├── Settings.java │ ├── controls │ │ ├── BitcoinAddressValidator.java │ │ └── ClickableBitcoinAddress.java │ └── utils │ │ ├── AlertWindowController.java │ │ ├── GuiUtils.java │ │ ├── TextFieldValidator.java │ │ └── ThrottledRunLater.java │ ├── server │ └── Server.java │ └── utils │ └── Exceptions.java ├── payfile.proto └── resources └── net └── plan99 └── payfile └── gui ├── bitcoin_logo_plain.png ├── checkpoints ├── connect_server.fxml ├── controls └── bitcoin_address.fxml ├── main.fxml ├── send_money.fxml └── utils ├── alert.fxml └── text-validation.css /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.wallet 3 | *.spvchain 4 | target/ 5 | *.iml 6 | *.DS_Store 7 | *.tmp 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PayFile 2 | ======= 3 | 4 | Mike Hearn 5 | 6 | PayFile is a set of three apps showing how to build a simple file server and download client that uses Bitcoin micropayment channels to incrementally pay for each chunk of data. 7 | 8 | It provides a server, a simple console client and a JavaFX2 based GUI. The GUI is designed to act as both a very simple file browser/downloader and also a standalone wallet that money can be loaded into/out of. It's based on the "wallet template" app found inside the source code for the bitcoinj library. 9 | 10 | Current status: incomplete and not yet ready for public announcement. This repository is just acting as a backup area until the project is further along. There is a TODO list in the Main.java file of the GUI client. 11 | -------------------------------------------------------------------------------- /lib/asg.cliche-110413.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikehearn/PayFile/d12ca14250d71656b5c5699aa3ab8f342a2e419c/lib/asg.cliche-110413.jar -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | net.plan99 8 | payfile 9 | 1.0 10 | 11 | 12 | 13 | 14 | org.apache.maven.plugins 15 | maven-compiler-plugin 16 | 3.1 17 | 18 | 1.8 19 | 1.8 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | sonatype-nexus-snapshots 28 | https://oss.sonatype.org/content/repositories/snapshots 29 | 30 | 31 | 32 | 33 | 34 | 35 | com.google 36 | bitcoinj 37 | 0.11-SNAPSHOT 38 | 39 | 40 | org.slf4j 41 | slf4j-jdk14 42 | 1.7.5 43 | 44 | 45 | org.slf4j 46 | slf4j-api 47 | 1.7.5 48 | 49 | 50 | com.google.guava 51 | guava 52 | 13.0.1 53 | 54 | 55 | com.google.protobuf 56 | protobuf-java 57 | 2.5.0 58 | 59 | 60 | com.aquafx-project 61 | aquafx 62 | 0.1 63 | 64 | 65 | de.jensd 66 | fontawesomefx 67 | 8.0.0 68 | 69 | 70 | net.glxn 71 | qrgen 72 | 1.3 73 | 74 | 75 | net.sf.jopt-simple 76 | jopt-simple 77 | 4.5 78 | 79 | 80 | junit 81 | junit 82 | 4.8.1 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/ProtocolException.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile; 2 | 3 | public class ProtocolException extends Exception { 4 | public static enum Code { 5 | GENERIC, 6 | NETWORK_MISMATCH, 7 | INTERNAL_ERROR, 8 | } 9 | 10 | private Code code; 11 | 12 | public ProtocolException(String msg) { 13 | super(msg); 14 | code = Code.GENERIC; 15 | } 16 | 17 | public ProtocolException(Code code, String msg) { 18 | super(msg); 19 | this.code = code; 20 | } 21 | 22 | public Code getCode() { 23 | return code; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/client/CLI.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.client; 18 | 19 | import asg.cliche.Command; 20 | import asg.cliche.Param; 21 | import asg.cliche.ShellFactory; 22 | import com.google.bitcoin.core.*; 23 | import com.google.bitcoin.kits.WalletAppKit; 24 | import com.google.bitcoin.params.MainNetParams; 25 | import com.google.bitcoin.params.RegTestParams; 26 | import com.google.bitcoin.params.TestNet3Params; 27 | import com.google.bitcoin.protocols.channels.StoredPaymentChannelClientStates; 28 | import com.google.bitcoin.utils.BriefLogFormatter; 29 | import joptsimple.BuiltinHelpFormatter; 30 | import joptsimple.OptionException; 31 | import joptsimple.OptionParser; 32 | import joptsimple.OptionSet; 33 | 34 | import java.io.File; 35 | import java.io.FileOutputStream; 36 | import java.io.IOException; 37 | import java.math.BigInteger; 38 | import java.net.Socket; 39 | import java.util.List; 40 | 41 | import static joptsimple.util.RegexMatcher.regex; 42 | 43 | public class CLI { 44 | public static NetworkParameters params; 45 | private static String filePrefix; 46 | 47 | private PayFileClient client; 48 | private List files; 49 | private WalletAppKit appkit; 50 | 51 | public CLI(Socket socket) throws IOException { 52 | appkit = new WalletAppKit(params, new File("."), filePrefix + "payfile-cli") { 53 | @Override 54 | protected void addWalletExtensions() throws Exception { 55 | super.addWalletExtensions(); 56 | wallet().addExtension(new StoredPaymentChannelClientStates(wallet(), peerGroup())); 57 | } 58 | }; 59 | 60 | if (params == RegTestParams.get()) { 61 | appkit.connectToLocalHost(); // You should run a regtest mode bitcoind locally. 62 | } else if (params == MainNetParams.get()) { 63 | // Checkpoints are block headers that ship inside our app: for a new user, we pick the last header 64 | // in the checkpoints file and then download the rest from the network. It makes things much faster. 65 | // Checkpoint files are made using the BuildCheckpoints tool and usually we have to download the 66 | // last months worth or more (takes a few seconds). 67 | appkit.setCheckpoints(getClass().getResourceAsStream("checkpoints")); 68 | } 69 | 70 | appkit.setBlockingStartup(false) 71 | .setUserAgent("Payfile CLI","1.0") 72 | .startAndWait(); 73 | 74 | appkit.wallet().allowSpendingUnconfirmedTransactions(); 75 | appkit.wallet().addEventListener(new AbstractWalletEventListener() { 76 | @Override 77 | public void onCoinsReceived(Wallet wallet, Transaction tx, BigInteger prevBalance, BigInteger newBalance) { 78 | System.out.println("Received money: " + Utils.bitcoinValueToFriendlyString(tx.getValueSentToMe(appkit.wallet()))); 79 | } 80 | }); 81 | System.out.println("Send coins to " + appkit.wallet().getKeys().get(0).toAddress(params)); 82 | System.out.println("Your balance is " + Utils.bitcoinValueToFriendlyString(appkit.wallet().getBalance())); 83 | client = new PayFileClient(socket, appkit.wallet()); 84 | } 85 | 86 | public void shutdown() { 87 | client.disconnect(); 88 | appkit.stopAndWait(); 89 | } 90 | 91 | @Command(description = "Show the files advertised by the remote server") 92 | public void ls() throws Exception { 93 | files = client.queryFiles().get(); 94 | for (PayFileClient.File file : files) { 95 | String priceMessage = Utils.bitcoinValueToFriendlyString(BigInteger.valueOf(file.getPrice())); 96 | String affordability = file.isAffordable() ? "" : ", unaffordable"; 97 | String str = String.format("%d) [%d bytes, %s%s] \"%s\" : %s", file.getHandle(), 98 | file.getSize(), priceMessage, affordability, file.getFileName(), file.getDescription()); 99 | System.out.println(str); 100 | } 101 | } 102 | 103 | @Command(description = "Download the given file ID to the given directory") 104 | public void get( 105 | @Param(name="handle", description="Numeric ID of the file") int handle, 106 | @Param(name="directory", description="Directory to save the file to") String directory) throws Exception { 107 | if (files == null) { 108 | System.out.println("Fetching file list ..."); 109 | files = client.queryFiles().get(); 110 | } 111 | File dir = new File(directory); 112 | if (!dir.isDirectory()) { 113 | System.out.println(directory + " is not a directory"); 114 | return; 115 | } 116 | String fileName = null; 117 | PayFileClient.File serverFile = null; 118 | for (PayFileClient.File f : files) { 119 | if (f.getHandle() == handle) { 120 | fileName = f.getFileName(); 121 | serverFile = f; 122 | break; 123 | } 124 | } 125 | if (serverFile == null) { 126 | System.out.println("Unknown file handle " + handle); 127 | return; 128 | } 129 | File output = new File(dir, fileName); 130 | final PayFileClient.File fServerFile = serverFile; 131 | FileOutputStream stream = new FileOutputStream(output) { 132 | @Override 133 | public void write(byte[] b) throws IOException { 134 | super.write(b); 135 | final long bytesDownloaded = fServerFile.getBytesDownloaded(); 136 | double percentDone = bytesDownloaded / (double) fServerFile.getSize() * 100; 137 | System.out.println(String.format("Downloaded %d kilobytes [%.2f%% done]", bytesDownloaded / 1024, percentDone)); 138 | } 139 | }; 140 | client.downloadFile(serverFile, stream).get(); 141 | System.out.println(String.format("Downloaded %s successfully.", fileName)); 142 | System.out.println(String.format("You have %s remaining.", Utils.bitcoinValueToFriendlyString(client.getRemainingBalance()))); 143 | } 144 | 145 | @Command(description = "Print info about your wallet") 146 | public void wallet() { 147 | System.out.println(appkit.wallet().toString(false, true, true, appkit.chain())); 148 | System.out.println("Total remaining: " + Utils.bitcoinValueToFriendlyString(client.getRemainingBalance())); 149 | } 150 | 151 | public static void main(String[] args) throws Exception { 152 | BriefLogFormatter.init(); 153 | //Logger.getLogger("").setLevel(Level.OFF); 154 | // allow client to choose another network for testing by passing through an argument. 155 | OptionParser parser = new OptionParser(); 156 | parser.accepts("network").withRequiredArg().withValuesConvertedBy(regex("(mainnet)|(testnet)|(regtest)")).defaultsTo("mainnet"); 157 | parser.accepts("server").withRequiredArg().required(); 158 | parser.accepts("help").forHelp(); 159 | parser.formatHelpWith(new BuiltinHelpFormatter(120, 10)); 160 | OptionSet options; 161 | 162 | try { 163 | options = parser.parse(args); 164 | } catch (OptionException e) { 165 | System.err.println(e.getMessage()); 166 | System.err.println(""); 167 | parser.printHelpOn(System.err); 168 | return; 169 | } 170 | 171 | if (options.has("help")) { 172 | parser.printHelpOn(System.out); 173 | return; 174 | } 175 | 176 | if (options.valueOf("network").equals(("testnet"))) { 177 | params = TestNet3Params.get(); 178 | filePrefix = "testnet-"; 179 | } else if (options.valueOf("network").equals(("mainnet"))) { 180 | params = MainNetParams.get(); 181 | filePrefix = ""; 182 | } else if (options.valueOf("network").equals(("regtest"))) { 183 | params = RegTestParams.get(); 184 | filePrefix = "regtest-"; 185 | } 186 | 187 | String server = options.valueOf("server").toString(); 188 | System.out.println("Connecting to " + server); 189 | Socket socket = new Socket(server, 18754); 190 | final CLI cli = new CLI(socket); 191 | ShellFactory.createConsoleShell(server, "PayFile", cli).commandLoop(); 192 | cli.shutdown(); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/client/PayFileClient.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.client; 2 | 3 | import com.google.bitcoin.core.*; 4 | import com.google.bitcoin.protocols.channels.PaymentChannelClient; 5 | import com.google.bitcoin.protocols.channels.PaymentChannelCloseException; 6 | import com.google.bitcoin.protocols.channels.StoredPaymentChannelClientStates; 7 | import com.google.protobuf.ByteString; 8 | import com.google.protobuf.InvalidProtocolBufferException; 9 | import net.plan99.payfile.Payfile; 10 | import net.plan99.payfile.ProtocolException; 11 | import org.bitcoin.paymentchannel.Protos; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | import javax.annotation.Nullable; 16 | import java.io.*; 17 | import java.math.BigInteger; 18 | import java.net.Socket; 19 | import java.net.SocketException; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | import java.util.concurrent.CompletableFuture; 23 | import java.util.concurrent.CopyOnWriteArrayList; 24 | import java.util.function.Consumer; 25 | 26 | import static com.google.common.base.Preconditions.checkNotNull; 27 | import static com.google.common.base.Preconditions.checkState; 28 | import static net.plan99.payfile.utils.Exceptions.evalUnchecked; 29 | import static net.plan99.payfile.utils.Exceptions.runUnchecked; 30 | 31 | public class PayFileClient { 32 | private static final Logger log = LoggerFactory.getLogger(PayFileClient.class); 33 | 34 | public static final int PORT = 18754; 35 | 36 | private final DataInputStream input; 37 | private final Socket socket; 38 | private final DataOutputStream output; 39 | private final Wallet wallet; 40 | private CompletableFuture> currentQuery; 41 | private CompletableFuture currentFuture; 42 | private int chunkSize; 43 | private List currentDownloads = new CopyOnWriteArrayList<>(); 44 | private PaymentChannelClient paymentChannelClient; 45 | private volatile boolean running; 46 | private Consumer onPaymentMade; 47 | private boolean freshChannel; 48 | private long numPurchasedChunks; 49 | 50 | private boolean settling; 51 | private CompletableFuture settlementFuture; 52 | 53 | public PayFileClient(Socket socket, Wallet wallet) { 54 | this.socket = socket; 55 | this.input = new DataInputStream(evalUnchecked(socket::getInputStream)); 56 | this.output = new DataOutputStream(evalUnchecked(socket::getOutputStream)); 57 | this.wallet = wallet; 58 | 59 | ClientThread thread = new ClientThread(); 60 | thread.setName(socket.toString()); 61 | thread.setDaemon(true); 62 | thread.start(); 63 | } 64 | 65 | public void disconnect() { 66 | running = false; 67 | if (paymentChannelClient != null) 68 | paymentChannelClient.connectionClosed(); 69 | runUnchecked(input::close); 70 | runUnchecked(output::close); 71 | } 72 | 73 | public CompletableFuture settlePaymentChannel() { 74 | // Tell it to terminate the payment relationship and thus broadcast the micropayment transactions. We will 75 | // resume control in destroyConnection below. 76 | settling = true; 77 | currentFuture = settlementFuture = new CompletableFuture(); 78 | if (paymentChannelClient == null) { 79 | // Have to connect first. 80 | return initializePayments().thenCompose((v) -> { 81 | paymentChannelClient.settle(); 82 | return settlementFuture; 83 | }); 84 | } else { 85 | paymentChannelClient.settle(); 86 | return settlementFuture; 87 | } 88 | } 89 | 90 | /** 91 | * Returns balance of the wallet plus whatever is left in the current channel, i.e. how much money is spendable 92 | * after a clean disconnect. 93 | */ 94 | public BigInteger getRemainingBalance() { 95 | final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet); 96 | checkNotNull(extension); 97 | BigInteger valueRefunded = extension.getBalanceForServer(getServerID()); 98 | return wallet.getBalance().add(valueRefunded); 99 | } 100 | 101 | /** 102 | * Returns how much money is still stuck in a channel with the given server. Does NOT include wallet balance. 103 | */ 104 | public static BigInteger getBalanceForServer(String serverName, int port, Wallet wallet) { 105 | final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet); 106 | checkNotNull(extension); 107 | return extension.getBalanceForServer(getServerID(serverName, port)); 108 | } 109 | 110 | /** 111 | * Returns how long you have to wait until this channel will either be settled by the server, or can be auto-settled 112 | * by the client (us). 113 | */ 114 | public static long getSecondsUntilExpiry(String serverName, int port, Wallet wallet) { 115 | final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet); 116 | checkNotNull(extension); 117 | return extension.getSecondsUntilExpiry(getServerID(serverName, port)); 118 | } 119 | 120 | public void setOnPaymentMade(Consumer onPaymentMade) { 121 | this.onPaymentMade = onPaymentMade; 122 | } 123 | 124 | public class File { 125 | private String fileName; 126 | private String description; 127 | private int handle; 128 | private long size; 129 | private long pricePerChunk; 130 | 131 | private long bytesDownloaded; 132 | private long nextChunk; 133 | private OutputStream downloadStream; 134 | private CompletableFuture completionFuture; 135 | 136 | public File(String fileName, String description, int handle, long size, long pricePerChunk) { 137 | this.fileName = fileName; 138 | this.description = description; 139 | this.handle = handle; 140 | this.size = size; 141 | this.pricePerChunk = pricePerChunk; 142 | } 143 | 144 | @Override 145 | public String toString() { 146 | return String.format("%s\nPrice: %s BTC", getFileName(), Utils.bitcoinValueToFriendlyString(BigInteger.valueOf(getPrice()))); 147 | } 148 | 149 | public long getBytesDownloaded() { 150 | return bytesDownloaded; 151 | } 152 | 153 | public String getFileName() { 154 | return fileName; 155 | } 156 | 157 | public String getDescription() { 158 | return description; 159 | } 160 | 161 | public int getHandle() { 162 | return handle; 163 | } 164 | 165 | public long getSize() { 166 | return size; 167 | } 168 | 169 | public void reset() { 170 | bytesDownloaded = 0; 171 | nextChunk = 0; 172 | downloadStream = null; 173 | } 174 | 175 | public boolean isAffordable() { 176 | long totalPrice = getPrice(); 177 | if (totalPrice == 0) 178 | return true; 179 | long balance = getRemainingBalance().longValue(); 180 | return totalPrice <= balance; 181 | } 182 | 183 | public long getPrice() { 184 | return pricePerChunk * (size / chunkSize); 185 | } 186 | } 187 | 188 | public CompletableFuture> queryFiles() { 189 | if (currentQuery != null) 190 | throw new IllegalStateException("Already running a query"); 191 | CompletableFuture> future = new CompletableFuture<>(); 192 | currentFuture = currentQuery = future; 193 | final Payfile.QueryFiles.Builder queryFiles = Payfile.QueryFiles.newBuilder() 194 | .setUserAgent("Basic client v1.0") 195 | .setBitcoinNetwork(wallet.getParams().getId()); 196 | final Payfile.PayFileMessage.Builder msg = Payfile.PayFileMessage.newBuilder() 197 | .setType(Payfile.PayFileMessage.Type.QUERY_FILES) 198 | .setQueryFiles(queryFiles); 199 | try { 200 | writeMessage(msg.build()); 201 | } catch (IOException e) { 202 | future.completeExceptionally(e); 203 | } 204 | return future; 205 | } 206 | 207 | public CompletableFuture downloadFile(File file, OutputStream outputStream) throws IOException, InsufficientMoneyException { 208 | if (file.downloadStream != null) 209 | throw new IllegalStateException("Already downloading this file"); 210 | file.downloadStream = outputStream; 211 | 212 | currentFuture = file.completionFuture = new CompletableFuture<>(); 213 | file.completionFuture.whenComplete((v, exception) -> { file.reset(); }); 214 | 215 | // Set up payments and then start the download. 216 | if (file.getPrice() > 0) { 217 | if (!file.isAffordable()) 218 | throw new InsufficientMoneyException(BigInteger.valueOf(file.getPrice() - getRemainingBalance().longValue()), "Cannot afford this file"); 219 | log.info("Price is {}, ensuring payments are initialised ... ", file.getPrice()); 220 | initializePayments().handle((v, ex) -> { 221 | if (ex == null) { 222 | log.info("Payments initialised. Downloading file {} {}", file.getHandle(), file.getFileName()); 223 | currentDownloads.add(file); 224 | runUnchecked(() -> downloadNextChunk(file)); 225 | } else { 226 | currentFuture.completeExceptionally(ex); 227 | } 228 | return null; 229 | }); 230 | } else { 231 | log.info("Downloading file {} {}", file.getHandle(), file.getFileName()); 232 | currentDownloads.add(file); 233 | downloadNextChunk(file); 234 | } 235 | return file.completionFuture; 236 | } 237 | 238 | private CompletableFuture initializePayments() { 239 | if (paymentChannelClient != null) 240 | return CompletableFuture.completedFuture(null); 241 | log.info("{}: Init payments", socket); 242 | Sha256Hash serverID = getServerID(); 243 | // Lock up our entire balance into the channel for this server, minus the reference tx fee. 244 | final BigInteger channelSize = wallet.getBalance().subtract(Transaction.REFERENCE_DEFAULT_MIN_TX_FEE); 245 | 246 | final CompletableFuture future = new CompletableFuture<>(); 247 | paymentChannelClient = new PaymentChannelClient(wallet, wallet.getKeys().get(0), channelSize, 248 | serverID, new PaymentChannelClient.ClientConnection() { 249 | @Override 250 | public void sendToServer(Protos.TwoWayChannelMessage paymentMsg) { 251 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.newBuilder() 252 | .setType(Payfile.PayFileMessage.Type.PAYMENT) 253 | .setPayment(paymentMsg.toByteString()) 254 | .build(); 255 | runUnchecked(() -> writeMessage(msg)); 256 | } 257 | 258 | @Override 259 | public void destroyConnection(PaymentChannelCloseException.CloseReason reason) { 260 | if (reason != PaymentChannelCloseException.CloseReason.CLIENT_REQUESTED_CLOSE) { 261 | log.warn("{}: Payment channel terminating with reason {}", socket, reason); 262 | if (reason == PaymentChannelCloseException.CloseReason.SERVER_REQUESTED_TOO_MUCH_VALUE) { 263 | future.completeExceptionally(new InsufficientMoneyException(paymentChannelClient.getMissing())); 264 | } else { 265 | if (currentFuture != null) 266 | currentFuture.completeExceptionally(new PaymentChannelCloseException("Unexpected payment channel termination", reason)); 267 | } 268 | } else { 269 | checkState(settling); 270 | log.info("{}: Payment channel settled successfully.", socket); 271 | settlementFuture.complete(null); 272 | } 273 | paymentChannelClient.connectionClosed(); 274 | paymentChannelClient = null; 275 | } 276 | 277 | @Override 278 | public void channelOpen(boolean wasInitiated) { 279 | log.info("{}: Payment channel negotiated{}", socket, wasInitiated ? ", was initiated" : ""); 280 | freshChannel = wasInitiated; 281 | future.complete(null); 282 | } 283 | }); 284 | paymentChannelClient.connectionOpen(); 285 | return future; 286 | } 287 | 288 | private Sha256Hash getServerID() { 289 | return getServerID(socket.getInetAddress().getHostName(), socket.getPort()); 290 | } 291 | 292 | private static Sha256Hash getServerID(String host, int port) { 293 | return Sha256Hash.create(String.format("%s:%d", host, port).getBytes()); 294 | } 295 | 296 | private void downloadNextChunk(File file) throws IOException { 297 | if (currentFuture.isCompletedExceptionally()) 298 | return; 299 | if (paymentChannelClient != null) { 300 | // Write two messages, one after the other: possibly add to our balance, then spend it. 301 | if (freshChannel) { 302 | freshChannel = false; 303 | // If we opened a fresh channel, we have automatically made a min payment on the channel equal 304 | // to the dust limit. Divide to find out how many chunks that's worth here. We might end up 305 | // overpaying slightly this way: a smarter approach would handle the remainder from the division. 306 | numPurchasedChunks = paymentChannelClient.state().getValueSpent().longValue() / file.pricePerChunk; 307 | log.info("New channel, have pre-paid {} chunks", numPurchasedChunks); 308 | } 309 | if (numPurchasedChunks == 0) { 310 | /* ValueOutOfRangeException */ runUnchecked(() -> 311 | paymentChannelClient.incrementPayment(BigInteger.valueOf(file.pricePerChunk)) 312 | ); 313 | numPurchasedChunks++; 314 | if (onPaymentMade != null) 315 | onPaymentMade.accept(file.pricePerChunk); 316 | } 317 | numPurchasedChunks--; 318 | } 319 | Payfile.DownloadChunk.Builder downloadChunk = Payfile.DownloadChunk.newBuilder(); 320 | downloadChunk.setHandle(file.getHandle()); 321 | // For now do one chunk at a time, although the protocol allows for more. 322 | downloadChunk.setChunkId(file.nextChunk++); 323 | Payfile.PayFileMessage.Builder msg = Payfile.PayFileMessage.newBuilder(); 324 | msg.setType(Payfile.PayFileMessage.Type.DOWNLOAD_CHUNK); 325 | msg.setDownloadChunk(downloadChunk); 326 | writeMessage(msg.build()); 327 | } 328 | 329 | private void writeMessage(Payfile.PayFileMessage msg) throws IOException { 330 | byte[] bits = msg.toByteArray(); 331 | output.writeInt(bits.length); 332 | output.write(bits); 333 | } 334 | 335 | private class ClientThread extends Thread { 336 | @Override 337 | public void run() { 338 | try { 339 | running = true; 340 | while (true) { 341 | int len = input.readInt(); 342 | if (len < 0 || len > 1024*1024) 343 | throw new ProtocolException("Server sent message that's too large: " + len); 344 | byte[] bits = new byte[len]; 345 | input.readFully(bits); 346 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.parseFrom(bits); 347 | handle(msg); 348 | } 349 | } catch (EOFException | SocketException e) { 350 | if (running) 351 | e.printStackTrace(); 352 | } catch (Throwable t) { 353 | // Server flagged an error. 354 | if (currentFuture != null) 355 | currentFuture.completeExceptionally(t); 356 | else 357 | t.printStackTrace(); 358 | } 359 | } 360 | } 361 | 362 | private void handle(Payfile.PayFileMessage msg) throws ProtocolException, IOException { 363 | switch (msg.getType()) { 364 | case MANIFEST: 365 | handleManifest(msg.getManifest()); 366 | break; 367 | case DATA: 368 | handleData(msg.getData()); 369 | break; 370 | case ERROR: 371 | handleError(msg.getError()); 372 | break; 373 | case PAYMENT: 374 | handlePayment(msg.getPayment()); 375 | break; 376 | default: 377 | throw new ProtocolException("Unhandled message"); 378 | } 379 | } 380 | 381 | private void handleError(Payfile.Error error) throws ProtocolException { 382 | ProtocolException.Code code; 383 | try { 384 | code = ProtocolException.Code.valueOf(error.getCode()); 385 | } catch (IllegalArgumentException e) { 386 | log.error("{}: Unknown error code: {}", socket, error.getCode()); 387 | code = ProtocolException.Code.GENERIC; 388 | } 389 | throw new ProtocolException(code, error.getExplanation()); 390 | } 391 | 392 | private void handlePayment(ByteString payment) throws ProtocolException { 393 | try { 394 | Protos.TwoWayChannelMessage paymentMessage = Protos.TwoWayChannelMessage.parseFrom(payment); 395 | paymentChannelClient.receiveMessage(paymentMessage); 396 | } catch (InvalidProtocolBufferException e) { 397 | throw new ProtocolException("Could not parse payment message: " + e.getMessage()); 398 | } catch (InsufficientMoneyException e) { 399 | // This shouldn't happen as we shouldn't try to open a channel larger than what we can afford. 400 | throw new RuntimeException(e); 401 | } 402 | } 403 | 404 | @Nullable 405 | private File handleToFile(int handle) { 406 | for (File file : currentDownloads) { 407 | if (file.getHandle() == handle) { 408 | return file; 409 | } 410 | } 411 | return null; 412 | } 413 | 414 | private void handleData(Payfile.Data data) throws IOException, ProtocolException { 415 | File file = handleToFile(data.getHandle()); 416 | if (file == null) 417 | throw new ProtocolException("Unknown handle"); 418 | if (data.getChunkId() == file.nextChunk - 1) { 419 | final byte[] bits = data.getData().toByteArray(); 420 | file.bytesDownloaded += bits.length; 421 | file.downloadStream.write(bits); 422 | if ((data.getChunkId() + 1) * chunkSize >= file.getSize()) { 423 | // File is done. 424 | file.downloadStream.close(); 425 | currentDownloads.remove(file); 426 | file.completionFuture.complete(null); 427 | currentFuture = null; 428 | } else { 429 | downloadNextChunk(file); 430 | } 431 | } else { 432 | throw new ProtocolException("Server sent wrong part of file"); 433 | } 434 | } 435 | 436 | private void handleManifest(Payfile.Manifest manifest) throws ProtocolException { 437 | if (currentQuery == null) 438 | throw new ProtocolException("Got MANIFEST before QUERY_FILES"); 439 | List files = new ArrayList<>(manifest.getFilesCount()); 440 | for (Payfile.File f : manifest.getFilesList()) { 441 | File file = new File(f.getFileName(), f.getDescription(), f.getHandle(), f.getSize(), f.getPricePerChunk()); 442 | files.add(file); 443 | } 444 | chunkSize = manifest.getChunkSize(); 445 | currentFuture = null; 446 | currentQuery.complete(files); 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/BitcoinAddressValidator.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui; 2 | 3 | import com.google.bitcoin.core.Address; 4 | import com.google.bitcoin.core.AddressFormatException; 5 | import com.google.bitcoin.core.NetworkParameters; 6 | import javafx.scene.Node; 7 | import javafx.scene.control.TextField; 8 | import net.plan99.payfile.gui.utils.TextFieldValidator; 9 | 10 | /** 11 | * Given a text field, some network params and optionally some nodes, will make the text field an angry red colour 12 | * if the address is invalid for those params, and enable/disable the nodes. 13 | */ 14 | public class BitcoinAddressValidator { 15 | private NetworkParameters params; 16 | private Node[] nodes; 17 | 18 | public BitcoinAddressValidator(NetworkParameters params, TextField field, Node... nodes) { 19 | this.params = params; 20 | this.nodes = nodes; 21 | 22 | // Handle the red highlighting, but don't highlight in red just when the field is empty because that makes 23 | // the example/prompt address hard to read. 24 | new TextFieldValidator(field, text -> text.isEmpty() || testAddr(text)); 25 | // However we do want the buttons to be disabled when empty so we apply a different test there. 26 | field.textProperty().addListener((observableValue, prev, current) -> { 27 | toggleButtons(current); 28 | }); 29 | toggleButtons(field.getText()); 30 | } 31 | 32 | private void toggleButtons(String current) { 33 | boolean valid = testAddr(current); 34 | for (Node n : nodes) n.setDisable(!valid); 35 | } 36 | 37 | private boolean testAddr(String text) { 38 | try { 39 | new Address(params, text); 40 | return true; 41 | } catch (AddressFormatException e) { 42 | return false; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/ConnectServerController.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui; 18 | 19 | import com.google.common.base.Throwables; 20 | import com.google.common.net.HostAndPort; 21 | import javafx.application.Platform; 22 | import javafx.event.ActionEvent; 23 | import javafx.scene.control.Button; 24 | import javafx.scene.control.Label; 25 | import javafx.scene.control.TextField; 26 | import net.plan99.payfile.ProtocolException; 27 | import net.plan99.payfile.client.PayFileClient; 28 | import org.slf4j.Logger; 29 | import org.slf4j.LoggerFactory; 30 | 31 | import java.math.BigInteger; 32 | import java.net.ConnectException; 33 | import java.net.SocketTimeoutException; 34 | import java.net.UnknownHostException; 35 | import java.util.List; 36 | import java.util.concurrent.CompletableFuture; 37 | 38 | import static net.plan99.payfile.gui.utils.GuiUtils.*; 39 | 40 | /** A class that manages the connect to server screen. */ 41 | public class ConnectServerController { 42 | private final static Logger log = LoggerFactory.getLogger(ConnectServerController.class); 43 | private final static int REFUND_CONNECT_TIMEOUT_MSEC = 1000; 44 | 45 | public Button connectBtn; 46 | public TextField server; 47 | public Label titleLabel; 48 | public Main.OverlayUI overlayUi; 49 | private String defaultTitle; 50 | 51 | // Called by FXMLLoader 52 | public void initialize() { 53 | server.textProperty().addListener((observableValue, prev, current) -> connectBtn.setDisable(current.trim().isEmpty())); 54 | defaultTitle = titleLabel.getText(); 55 | // Restore the server used last time, minus the port part if it was the default. 56 | HostAndPort lastServer = Settings.getLastServer(); 57 | if (lastServer != null) 58 | server.setText(lastServer.getPort() == PayFileClient.PORT ? lastServer.getHostText() : lastServer.toString()); 59 | } 60 | 61 | public void connect(ActionEvent event) { 62 | final String serverName = server.getText().trim(); 63 | HostAndPort hostPort = verifyServerName(serverName); 64 | if (hostPort == null) 65 | return; 66 | connectBtn.setDisable(true); 67 | 68 | maybeSettleLastServer(hostPort).thenRun(() -> { 69 | titleLabel.setText(String.format("Connecting to %s...", serverName)); 70 | 71 | Main.connect(hostPort).handle((client, ex) -> { 72 | if (ex != null) { 73 | Platform.runLater(() -> handleConnectError(Throwables.getRootCause(ex), serverName)); 74 | return null; 75 | } 76 | Main.client = client; 77 | return client.queryFiles().whenCompleteAsync((files, ex2) -> { 78 | if (ex2 != null) { 79 | handleQueryFilesError(ex2); 80 | } else { 81 | Settings.setLastServer(hostPort); 82 | showUIWithFiles(files); 83 | } 84 | }, Platform::runLater); 85 | }); 86 | }); 87 | } 88 | 89 | /** 90 | * Possibly reconnect to the last paid server and ask it to give us back the money. Note that after 24 hours this 91 | * channel will expire anyway, so if the server is gone, it's not the end of the world, we'll still get the money 92 | * back. The returned future completes immediately if nothing needs to be done. 93 | */ 94 | private CompletableFuture maybeSettleLastServer(HostAndPort newServerName) { 95 | final HostAndPort lastPaidServer = Settings.getLastPaidServer(); 96 | // If we didn't have a payment channel, or we did but it's with the same server we're connecting to, ignore. 97 | if (lastPaidServer == null || newServerName.equals(lastPaidServer)) 98 | return CompletableFuture.completedFuture(null); 99 | BigInteger amountInLastServer = PayFileClient.getBalanceForServer( 100 | lastPaidServer.getHostText(), lastPaidServer.getPort(), Main.bitcoin.wallet()); 101 | // If the last server we paid was already settled, ignore. 102 | if (amountInLastServer.compareTo(BigInteger.ZERO) == 0) 103 | return CompletableFuture.completedFuture(null); 104 | // Otherwise we have some money locked up with the last server. Ask for it back. 105 | final CompletableFuture future = new CompletableFuture<>(); 106 | titleLabel.setText(String.format("Contacting %s to request early settlement ...", lastPaidServer)); 107 | log.info("Connecting to {}", lastPaidServer); 108 | Main.connect(lastPaidServer, REFUND_CONNECT_TIMEOUT_MSEC).whenCompleteAsync((client, ex) -> { 109 | if (ex == null) { 110 | log.info("Connected. Requesting early settlement."); 111 | titleLabel.setText("Requesting early settlement ..."); 112 | client.settlePaymentChannel().whenCompleteAsync((v, settleEx) -> { 113 | if (settleEx == null) { 114 | log.info("Settled. Proceeding ..."); 115 | client.disconnect(); 116 | future.complete(null); 117 | } else { 118 | crashAlert(settleEx); 119 | } 120 | }, Platform::runLater); 121 | } else { 122 | log.error("Failed to connect", ex); 123 | titleLabel.setText(defaultTitle); 124 | informUserTheyMustWait(lastPaidServer); 125 | } 126 | }, Platform::runLater); 127 | return future; 128 | } 129 | 130 | private HostAndPort verifyServerName(String serverName) { 131 | try { 132 | return HostAndPort.fromString(serverName).withDefaultPort(PayFileClient.PORT); 133 | } catch (IllegalArgumentException e) { 134 | informationalAlert("Invalid server name", 135 | "Could not understand server name '%s'. Try something like 'riker.plan99.net'.", serverName); 136 | return null; 137 | } 138 | } 139 | 140 | private void showUIWithFiles(List files) { 141 | checkGuiThread(); 142 | Main.instance.controller.prepareForDisplay(files); 143 | fadeIn(Main.instance.mainUI); 144 | Main.instance.mainUI.setVisible(true); 145 | overlayUi.done(); 146 | } 147 | 148 | private void handleConnectError(Throwable ex, String serverName) { 149 | checkGuiThread(); 150 | titleLabel.setText(defaultTitle); 151 | connectBtn.setDisable(false); 152 | String message = ex.toString(); 153 | // More friendly message for the most common failure kinds. 154 | if (ex instanceof UnknownHostException) { 155 | message = "No server with that name found."; 156 | } else if (ex instanceof SocketTimeoutException) { 157 | message = "Connection timed out: server did not respond quickly enough"; 158 | } else if (ex instanceof ConnectException) { 159 | message = "Connection refused: there's no PayFile server running at that address"; 160 | } 161 | informationalAlert("Failed to connect to '" + serverName + "'", message); 162 | } 163 | 164 | private void handleQueryFilesError(Throwable ex2) { 165 | if (ex2 instanceof ProtocolException && ((ProtocolException) ex2).getCode() == ProtocolException.Code.NETWORK_MISMATCH) { 166 | informationalAlert("Network mismatch", "The remote server is not using the same crypto-currency as you.%n%s", 167 | ex2.getMessage()); 168 | return; 169 | } 170 | crashAlert(ex2); 171 | } 172 | 173 | private void informUserTheyMustWait(HostAndPort lastPaidServer) { 174 | int seconds = (int) PayFileClient.getSecondsUntilExpiry(lastPaidServer.getHostText(), lastPaidServer.getPort(), 175 | Main.bitcoin.wallet()); 176 | 177 | StringBuilder time = new StringBuilder(); 178 | int minutes = seconds / 60; 179 | int hours = minutes / 60; 180 | if (hours > 0) { 181 | if (hours == 1) 182 | time.append("1 hour and "); 183 | else 184 | time.append(hours).append(" hours and "); 185 | minutes %= 60; 186 | } 187 | time.append(minutes).append(" minutes"); 188 | 189 | informationalAlert("Connection failed", "Could not contact '%s' to request an early settlement of your " 190 | + "previous payments. You must wait approximately %s until automatic " 191 | + "settlement is possible. At that time all refunds will be returned.", lastPaidServer, time); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/Controller.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui; 18 | 19 | import com.google.bitcoin.core.*; 20 | import javafx.animation.*; 21 | import javafx.application.Platform; 22 | import javafx.beans.property.ReadOnlyObjectProperty; 23 | import javafx.collections.FXCollections; 24 | import javafx.collections.ObservableList; 25 | import javafx.event.ActionEvent; 26 | import javafx.scene.Node; 27 | import javafx.scene.control.Button; 28 | import javafx.scene.control.Label; 29 | import javafx.scene.control.ListView; 30 | import javafx.scene.control.ProgressBar; 31 | import javafx.scene.input.MouseButton; 32 | import javafx.scene.input.MouseEvent; 33 | import javafx.scene.layout.HBox; 34 | import javafx.scene.layout.VBox; 35 | import javafx.stage.DirectoryChooser; 36 | import javafx.util.Duration; 37 | import net.plan99.payfile.client.PayFileClient; 38 | import net.plan99.payfile.gui.controls.ClickableBitcoinAddress; 39 | 40 | import java.io.File; 41 | import java.io.FileOutputStream; 42 | import java.math.BigInteger; 43 | import java.util.Date; 44 | import java.util.List; 45 | import java.util.concurrent.CancellationException; 46 | import java.util.concurrent.CompletableFuture; 47 | 48 | import static com.google.common.base.Preconditions.checkNotNull; 49 | import static javafx.beans.binding.Bindings.isNull; 50 | import static net.plan99.payfile.gui.Main.bitcoin; 51 | import static net.plan99.payfile.gui.utils.GuiUtils.*; 52 | 53 | /** 54 | * Gets created auto-magically by FXMLLoader via reflection. The widget fields are set to the GUI controls they're named 55 | * after. This class handles all the updates and event handling for the main UI. 56 | */ 57 | public class Controller { 58 | public ProgressBar progressBar; 59 | public Label progressBarLabel; 60 | public VBox syncBox; 61 | public HBox controlsBox; 62 | public Label balance; 63 | public Button sendMoneyOutBtn; 64 | public ClickableBitcoinAddress addressControl; 65 | 66 | // PayFile specific stuff 67 | public Button downloadBtn, cancelBtn; 68 | public ListView filesList; 69 | private ObservableList files; 70 | private ReadOnlyObjectProperty selectedFile; 71 | private CompletableFuture downloadFuture; 72 | 73 | // Called by FXMLLoader. 74 | public void initialize() { 75 | progressBar.setProgress(-1); 76 | 77 | cancelBtn.setVisible(false); 78 | 79 | // The PayFileClient.File.toString() method is good enough for rendering list cells for now. 80 | files = FXCollections.observableArrayList(); 81 | filesList.setItems(files); 82 | selectedFile = filesList.getSelectionModel().selectedItemProperty(); 83 | // Don't allow the user to press download unless an item is selected. 84 | downloadBtn.disableProperty().bind(isNull(selectedFile)); 85 | } 86 | 87 | public void onBitcoinSetup() { 88 | bitcoin.wallet().addEventListener(new BalanceUpdater()); 89 | addressControl.setAddress(bitcoin.wallet().getKeys().get(0).toAddress(Main.params).toString()); 90 | refreshBalanceLabel(); 91 | } 92 | 93 | public void sendMoneyOut(ActionEvent event) { 94 | // Free up the users money, if any is suspended in a payment channel for this server. 95 | // 96 | // The UI races the broadcast here - we could/should throw up a spinner until the server finishes settling 97 | // the channel and we know we've got the money back. TODO: Make a spinner. 98 | Main.client.settlePaymentChannel(); 99 | // Hide this UI and show the send money UI. This UI won't be clickable until the user dismisses send_money. 100 | Main.instance.overlayUI("send_money.fxml"); 101 | } 102 | 103 | public void disconnect(ActionEvent event) { 104 | Main.client.disconnect(); 105 | Main.client = null; 106 | fadeOut(Main.instance.mainUI); 107 | files.clear(); 108 | Main.instance.overlayUI("connect_server.fxml"); 109 | } 110 | 111 | public void download(ActionEvent event) throws Exception { 112 | File destination = null; 113 | try { 114 | final PayFileClient.File downloadingFile = checkNotNull(selectedFile.get()); 115 | if (downloadingFile.getPrice() > getBalance().longValue()) 116 | throw new InsufficientMoneyException(BigInteger.valueOf(downloadingFile.getPrice() - getBalance().longValue())); 117 | // Ask the user where to put it. 118 | DirectoryChooser chooser = new DirectoryChooser(); 119 | chooser.setTitle("Select download directory"); 120 | File directory = chooser.showDialog(Main.instance.mainWindow); 121 | if (directory == null) 122 | return; 123 | destination = new File(directory, downloadingFile.getFileName()); 124 | FileOutputStream fileStream = new FileOutputStream(destination); 125 | final long startTime = System.currentTimeMillis(); 126 | cancelBtn.setVisible(true); 127 | progressBarLabel.setText("Downloading " + downloadingFile.getFileName()); 128 | // Make the UI update whilst the download is in progress: progress bar and balance label. 129 | ProgressOutputStream stream = new ProgressOutputStream(fileStream, downloadingFile.getSize()); 130 | progressBar.progressProperty().bind(stream.progressProperty()); 131 | Main.client.setOnPaymentMade((amt) -> Platform.runLater(this::refreshBalanceLabel)); 132 | // Swap in the progress bar with an animation. 133 | animateSwap(); 134 | // ... and start the download. 135 | Settings.setLastPaidServer(Main.serverAddress); 136 | downloadFuture = Main.client.downloadFile(downloadingFile, stream); 137 | final File fDestination = destination; 138 | // When we're done ... 139 | downloadFuture.handleAsync((ok, exception) -> { 140 | animateSwap(); // ... swap widgets back out again 141 | if (exception != null) { 142 | if (!(exception instanceof CancellationException)) 143 | crashAlert(exception); 144 | } else { 145 | // Otherwise inform the user we're finished and let them open the file. 146 | int secondsTaken = (int) (System.currentTimeMillis() - startTime) / 1000; 147 | runAlert((stage, controller) -> 148 | controller.withOpenFile(stage, downloadingFile, fDestination, secondsTaken)); 149 | } 150 | return null; 151 | }, Platform::runLater); 152 | } catch (InsufficientMoneyException e) { 153 | if (destination != null) 154 | destination.delete(); 155 | final String price = Utils.bitcoinValueToFriendlyString(BigInteger.valueOf(selectedFile.get().getPrice())); 156 | final String missing = String.valueOf(e.missing); 157 | informationalAlert("Insufficient funds", 158 | "This file costs %s BTC but you can't afford that. You need %s satoshis to complete the transaction.", price, missing); 159 | } 160 | } 161 | 162 | public void fileEntryClicked(MouseEvent event) throws Exception { 163 | if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { 164 | // Double click on a file: shortcut for downloading. 165 | download(null); 166 | } 167 | } 168 | 169 | public void cancelOperation(ActionEvent event) { 170 | downloadFuture.cancel(true); 171 | } 172 | 173 | public void readyToGoAnimation() { 174 | // Sync progress bar slides out ... 175 | TranslateTransition leave = new TranslateTransition(Duration.millis(600), syncBox); 176 | leave.setByY(80.0); 177 | // Buttons slide in and clickable address appears simultaneously. 178 | TranslateTransition arrive = new TranslateTransition(Duration.millis(600), controlsBox); 179 | arrive.setToY(0.0); 180 | FadeTransition reveal = new FadeTransition(Duration.millis(500), addressControl); 181 | reveal.setToValue(1.0); 182 | ParallelTransition group = new ParallelTransition(arrive, reveal); 183 | // Slide out happens then slide in/fade happens. 184 | SequentialTransition both = new SequentialTransition(leave, group); 185 | both.setCycleCount(1); 186 | both.setInterpolator(Interpolator.EASE_BOTH); 187 | both.play(); 188 | } 189 | 190 | private boolean controlsBoxOnScreen = true; 191 | 192 | /** Swap the download/disconnect buttons for a progress bar + cancel button */ 193 | public void animateSwap() { 194 | Node n1 = controlsBoxOnScreen ? controlsBox : syncBox; 195 | Node n2 = controlsBoxOnScreen ? syncBox : controlsBox; 196 | TranslateTransition leave = new TranslateTransition(Duration.millis(600), n1); 197 | leave.setByY(80.0); 198 | TranslateTransition arrive = new TranslateTransition(Duration.millis(600), n2); 199 | arrive.setToY(0.0); 200 | SequentialTransition both = new SequentialTransition(leave, arrive); 201 | both.setCycleCount(1); 202 | both.setInterpolator(Interpolator.EASE_BOTH); 203 | both.play(); 204 | controlsBoxOnScreen = !controlsBoxOnScreen; 205 | } 206 | 207 | private class BlockChainSyncListener extends DownloadListener { 208 | @Override 209 | protected void progress(double pct, int blocksSoFar, Date date) { 210 | super.progress(pct, blocksSoFar, date); 211 | Platform.runLater(() -> progressBar.setProgress(pct / 100.0)); 212 | } 213 | 214 | @Override 215 | protected void doneDownload() { 216 | super.doneDownload(); 217 | Platform.runLater(Controller.this::readyToGoAnimation); 218 | } 219 | } 220 | 221 | public BlockChainSyncListener progressBarUpdater() { 222 | return new BlockChainSyncListener(); 223 | } 224 | 225 | private class BalanceUpdater extends AbstractWalletEventListener { 226 | @Override 227 | public void onWalletChanged(Wallet wallet) { 228 | refreshBalanceLabel(); 229 | } 230 | } 231 | 232 | public void refreshBalanceLabel() { 233 | checkGuiThread(); 234 | BigInteger amount = getBalance(); 235 | balance.setText(Utils.bitcoinValueToFriendlyString(amount)); 236 | } 237 | 238 | private BigInteger getBalance() { 239 | BigInteger amount; 240 | if (Main.client != null) 241 | amount = Main.client.getRemainingBalance(); 242 | else 243 | amount = bitcoin.wallet().getBalance(); 244 | return amount; 245 | } 246 | 247 | public void prepareForDisplay(List files) { 248 | this.files.setAll(files); 249 | refreshBalanceLabel(); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/Main.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui; 18 | 19 | import com.google.bitcoin.core.NetworkParameters; 20 | import com.google.bitcoin.kits.WalletAppKit; 21 | import com.google.bitcoin.params.MainNetParams; 22 | import com.google.bitcoin.params.RegTestParams; 23 | import com.google.bitcoin.params.TestNet3Params; 24 | import com.google.bitcoin.protocols.channels.StoredPaymentChannelClientStates; 25 | import com.google.bitcoin.store.BlockStoreException; 26 | import com.google.bitcoin.utils.BriefLogFormatter; 27 | import com.google.bitcoin.utils.Threading; 28 | import com.google.common.base.Throwables; 29 | import com.google.common.net.HostAndPort; 30 | import javafx.application.Application; 31 | import javafx.application.Platform; 32 | import javafx.fxml.FXMLLoader; 33 | import javafx.scene.Node; 34 | import javafx.scene.Scene; 35 | import javafx.scene.layout.Pane; 36 | import javafx.scene.layout.StackPane; 37 | import javafx.stage.Stage; 38 | import joptsimple.BuiltinHelpFormatter; 39 | import joptsimple.OptionException; 40 | import joptsimple.OptionParser; 41 | import joptsimple.OptionSet; 42 | import net.plan99.payfile.client.PayFileClient; 43 | import net.plan99.payfile.gui.utils.TextFieldValidator; 44 | 45 | import java.io.File; 46 | import java.io.IOException; 47 | import java.net.InetSocketAddress; 48 | import java.net.Socket; 49 | import java.net.URL; 50 | import java.util.concurrent.CompletableFuture; 51 | 52 | import static joptsimple.util.RegexMatcher.regex; 53 | import static net.plan99.payfile.gui.utils.GuiUtils.*; 54 | import static net.plan99.payfile.utils.Exceptions.evalUnchecked; 55 | import static net.plan99.payfile.utils.Exceptions.runUnchecked; 56 | 57 | // To do list: 58 | // 59 | // Payments: 60 | // - Progress indicator for negotiating a payment channel? 61 | // - Bug: If the server fails to broadcast the contract tx then the client gets out of sync with the server. 62 | // 63 | // Misc code quality: 64 | // - Consider switching to P2Proto (question: how to do SSL with that?). Simplifies the core protocol. 65 | // - SSL support 66 | // 67 | // Generic UI: 68 | // - Solve the Mac menubar issue. Port the Mac specific tweaks to wallet-template. 69 | // - Write a test plan that exercises every reasonable path through the app and test it. 70 | // - Get an Apple developer ID and a Windows codesigning cert. 71 | // - Find/beg/buy/borrow/steal a nice icon. 72 | // - Find a way to dual boot Windows on my laptop. 73 | // - Build, sign and test native packages! 74 | // 75 | // Future ideas: 76 | // - Merkle tree validators for files, to avoid a server maliciously serving junk instead of the real deal. 77 | 78 | 79 | public class Main extends Application { 80 | public static final String APP_NAME = "PayFile"; 81 | public static final int CONNECT_TIMEOUT_MSEC = 2000; 82 | 83 | public static NetworkParameters params; 84 | 85 | public static WalletAppKit bitcoin; 86 | public static Main instance; 87 | public static PayFileClient client; 88 | public static HostAndPort serverAddress; 89 | private static String filePrefix; 90 | 91 | private StackPane uiStack; 92 | public Pane mainUI; 93 | public Controller controller; 94 | public Stage mainWindow; 95 | 96 | @Override 97 | public void start(Stage mainWindow) throws Exception { 98 | instance = this; 99 | // Show the crash dialog for any exceptions that we don't handle and that hit the main loop. 100 | handleCrashesOnThisThread(); 101 | try { 102 | init(mainWindow); 103 | } catch (Throwable t) { 104 | // Nicer message for the case where the block store file is locked. 105 | if (Throwables.getRootCause(t) instanceof BlockStoreException) { 106 | informationalAlert("Already running", "This application is already running and cannot be started twice."); 107 | } else { 108 | throw t; 109 | } 110 | } 111 | } 112 | 113 | private void init(Stage mainWindow) throws IOException { 114 | this.mainWindow = mainWindow; 115 | 116 | // commented out for now as Modena looks better, but might want to bring this back. 117 | /* if (System.getProperty("os.name").toLowerCase().contains("mac")) { 118 | AquaFx.style(); 119 | } */ 120 | 121 | // Load the GUI. The Controller class will be automagically created and wired up. 122 | URL location = getClass().getResource("main.fxml"); 123 | FXMLLoader loader = new FXMLLoader(location); 124 | mainUI = loader.load(); 125 | controller = loader.getController(); 126 | // Configure the window with a StackPane so we can overlay things on top of the main UI. 127 | uiStack = new StackPane(mainUI); 128 | mainWindow.setTitle(APP_NAME); 129 | final Scene scene = new Scene(uiStack); 130 | TextFieldValidator.configureScene(scene); // Add CSS that we need. 131 | mainWindow.setScene(scene); 132 | 133 | // Make log output concise. 134 | BriefLogFormatter.init(); 135 | // Tell bitcoinj to run event handlers on the JavaFX UI thread. This keeps things simple and means 136 | // we cannot forget to switch threads when adding event handlers. Unfortunately, the DownloadListener 137 | // we give to the app kit is currently an exception and runs on a library thread. It'll get fixed in 138 | // a future version. Also note that this doesn't affect the default executor for ListenableFutures. 139 | // That must be specified each time. 140 | Threading.USER_THREAD = Platform::runLater; 141 | // Create the app kit. It won't do any heavyweight initialization until after we start it. 142 | bitcoin = new WalletAppKit(params, new File("."), filePrefix + APP_NAME ) { 143 | @Override 144 | protected void addWalletExtensions() throws Exception { 145 | super.addWalletExtensions(); 146 | wallet().addExtension(new StoredPaymentChannelClientStates(wallet(), peerGroup())); 147 | } 148 | }; 149 | if (params == RegTestParams.get()) { 150 | bitcoin.connectToLocalHost(); // You should run a regtest mode bitcoind locally. 151 | } else if (params == MainNetParams.get()) { 152 | // Checkpoints are block headers that ship inside our app: for a new user, we pick the last header 153 | // in the checkpoints file and then download the rest from the network. It makes things much faster. 154 | // Checkpoint files are made using the BuildCheckpoints tool and usually we have to download the 155 | // last months worth or more (takes a few seconds). 156 | bitcoin.setCheckpoints(getClass().getResourceAsStream("checkpoints")); 157 | } 158 | 159 | // Now configure and start the appkit. It won't block for very long. 160 | bitcoin.setDownloadListener(controller.progressBarUpdater()) 161 | .setBlockingStartup(false) 162 | .setUserAgent("PayFile Client", "1.0") 163 | .startAndWait(); 164 | // Don't make the user wait for confirmations for now, as the intention is they're sending it their own money! 165 | bitcoin.wallet().allowSpendingUnconfirmedTransactions(); 166 | System.out.println(bitcoin.wallet()); 167 | controller.onBitcoinSetup(); 168 | overlayUI("connect_server.fxml"); 169 | mainUI.setVisible(false); 170 | mainWindow.show(); 171 | } 172 | 173 | public class OverlayUI { 174 | public Node ui; 175 | public T controller; 176 | 177 | public OverlayUI(Node ui, T controller) { 178 | this.ui = ui; 179 | this.controller = controller; 180 | } 181 | 182 | public void show() { 183 | blurOut(mainUI); 184 | uiStack.getChildren().add(ui); 185 | fadeIn(ui); 186 | } 187 | 188 | public void done() { 189 | checkGuiThread(); 190 | fadeOutAndRemove(ui, uiStack); 191 | blurIn(mainUI); 192 | this.ui = null; 193 | this.controller = null; 194 | } 195 | } 196 | 197 | public OverlayUI overlayUI(Node node, T controller) { 198 | checkGuiThread(); 199 | OverlayUI pair = new OverlayUI<>(node, controller); 200 | // Auto-magically set the overlayUi member, if it's there. 201 | runUnchecked(() -> controller.getClass().getDeclaredField("overlayUi").set(controller, pair)); 202 | pair.show(); 203 | return pair; 204 | } 205 | 206 | /** Loads the FXML file with the given name, blurs out the main UI and puts this one on top. */ 207 | public OverlayUI overlayUI(String name) { 208 | return evalUnchecked(() -> { 209 | checkGuiThread(); 210 | // Load the UI from disk. 211 | URL location = getClass().getResource(name); 212 | FXMLLoader loader = new FXMLLoader(location); 213 | Pane ui = loader.load(); 214 | T controller = loader.getController(); 215 | OverlayUI pair = new OverlayUI<>(ui, controller); 216 | // Auto-magically set the overlayUi member, if it's there. 217 | controller.getClass().getDeclaredField("overlayUi").set(controller, pair); 218 | pair.show(); 219 | return pair; 220 | }); 221 | } 222 | 223 | public static CompletableFuture connect(HostAndPort server) { 224 | serverAddress = server; 225 | return connect(serverAddress, CONNECT_TIMEOUT_MSEC); 226 | } 227 | 228 | public static CompletableFuture connect(HostAndPort server, int timeoutMsec) { 229 | return CompletableFuture.supplyAsync(() -> 230 | evalUnchecked(() -> { 231 | final InetSocketAddress address = new InetSocketAddress(server.getHostText(), server.getPort()); 232 | final Socket socket = new Socket(); 233 | socket.connect(address, timeoutMsec); 234 | return new PayFileClient(socket, bitcoin.wallet()); 235 | }) 236 | ); 237 | } 238 | 239 | @Override 240 | public void stop() throws Exception { 241 | bitcoin.stopAndWait(); 242 | super.stop(); 243 | } 244 | 245 | public static void main(String[] args) throws Exception { 246 | // allow client to choose another network for testing by passing through an argument. 247 | OptionParser parser = new OptionParser(); 248 | parser.accepts("network").withRequiredArg().withValuesConvertedBy(regex("(mainnet)|(testnet)|(regtest)")).defaultsTo("mainnet"); 249 | parser.accepts("help").forHelp(); 250 | parser.formatHelpWith(new BuiltinHelpFormatter(120, 10)); 251 | OptionSet options; 252 | 253 | try { 254 | options = parser.parse(args); 255 | } catch (OptionException e) { 256 | System.err.println(e.getMessage()); 257 | System.err.println(""); 258 | parser.printHelpOn(System.err); 259 | return; 260 | } 261 | 262 | if (options.has("help")) { 263 | parser.printHelpOn(System.out); 264 | return; 265 | } 266 | 267 | if (options.valueOf("network").equals(("testnet"))) { 268 | params = TestNet3Params.get(); 269 | filePrefix = "testnet-"; 270 | } else if (options.valueOf("network").equals(("mainnet"))) { 271 | params = MainNetParams.get(); 272 | filePrefix = ""; 273 | } else if (options.valueOf("network").equals(("regtest"))) { 274 | params = RegTestParams.get(); 275 | filePrefix = "regtest-"; 276 | } 277 | 278 | launch(args); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/ProgressOutputStream.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui; 18 | 19 | import javafx.beans.property.DoubleProperty; 20 | import javafx.beans.property.ReadOnlyDoubleProperty; 21 | import javafx.beans.property.SimpleDoubleProperty; 22 | import net.plan99.payfile.gui.utils.ThrottledRunLater; 23 | 24 | import javax.annotation.Nonnull; 25 | import java.io.*; 26 | import java.util.concurrent.atomic.AtomicLong; 27 | 28 | /** 29 | * An output stream that forwards to another, but keeps track of how many bytes were written and exposes the progress 30 | * as a JavaFX bindable property which is guaranteed to update at a sane rate on the UI thread. 31 | */ 32 | public class ProgressOutputStream extends FilterOutputStream { 33 | private final DoubleProperty progress; 34 | private final ThrottledRunLater throttler; 35 | private final AtomicLong bytesSoFar; 36 | 37 | public ProgressOutputStream(OutputStream sink, long expectedSize) { 38 | this(sink, 0, expectedSize); 39 | } 40 | 41 | public ProgressOutputStream(OutputStream sink, long bytesSoFar, long expectedSize) { 42 | super(sink); 43 | this.bytesSoFar = new AtomicLong(bytesSoFar); 44 | this.progress = new SimpleDoubleProperty(); 45 | this.throttler = new ThrottledRunLater(() -> progress.set(this.bytesSoFar.get() / (double) expectedSize)); 46 | } 47 | 48 | @Override 49 | public void write(@Nonnull byte[] b) throws IOException { 50 | super.write(b); 51 | bytesSoFar.addAndGet(b.length); 52 | throttler.runLater(); 53 | } 54 | 55 | public ReadOnlyDoubleProperty progressProperty() { 56 | return progress; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/SendMoneyController.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui; 2 | 3 | import com.google.bitcoin.core.*; 4 | import com.google.common.util.concurrent.FutureCallback; 5 | import com.google.common.util.concurrent.Futures; 6 | import javafx.application.Platform; 7 | import javafx.event.ActionEvent; 8 | import javafx.scene.control.Button; 9 | import javafx.scene.control.Label; 10 | import javafx.scene.control.TextField; 11 | import net.plan99.payfile.gui.controls.BitcoinAddressValidator; 12 | 13 | import static net.plan99.payfile.gui.utils.GuiUtils.crashAlert; 14 | import static net.plan99.payfile.gui.utils.GuiUtils.informationalAlert; 15 | import static net.plan99.payfile.utils.Exceptions.evalUnchecked; 16 | 17 | public class SendMoneyController { 18 | public Button sendBtn; 19 | public Button cancelBtn; 20 | public TextField address; 21 | public Label titleLabel; 22 | 23 | public Main.OverlayUI overlayUi; 24 | 25 | private Wallet.SendResult sendResult; 26 | 27 | // Called by FXMLLoader 28 | public void initialize() { 29 | new BitcoinAddressValidator(Main.params, address, sendBtn); 30 | } 31 | 32 | public void cancel(ActionEvent event) { 33 | overlayUi.done(); 34 | } 35 | 36 | public void send(ActionEvent event) { 37 | Address destination = evalUnchecked(() -> new Address(Main.params, address.getText())); 38 | Wallet.SendRequest req = Wallet.SendRequest.emptyWallet(destination); 39 | try { 40 | sendResult = Main.bitcoin.wallet().sendCoins(req); 41 | } catch (InsufficientMoneyException e) { 42 | // We couldn't empty the wallet for some reason. 43 | informationalAlert("Could not empty the wallet", 44 | "You may have too little money left in the wallet to make a transaction."); 45 | overlayUi.done(); 46 | return; 47 | } 48 | 49 | Futures.addCallback(sendResult.broadcastComplete, new FutureCallback() { 50 | @Override 51 | public void onSuccess(Transaction result) { 52 | Platform.runLater(overlayUi::done); 53 | } 54 | 55 | @Override 56 | public void onFailure(Throwable t) { 57 | // We died trying to empty the wallet. 58 | crashAlert(t); 59 | } 60 | }); 61 | sendResult.tx.getConfidence().addEventListener((tx, reason) -> { 62 | if (reason == TransactionConfidence.Listener.ChangeReason.SEEN_PEERS) 63 | updateTitleForBroadcast(); 64 | }); 65 | sendBtn.setDisable(true); 66 | address.setDisable(true); 67 | updateTitleForBroadcast(); 68 | } 69 | 70 | private void updateTitleForBroadcast() { 71 | final int peers = sendResult.tx.getConfidence().numBroadcastPeers(); 72 | titleLabel.setText(String.format("Broadcasting ... seen by %d peers", peers)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/Settings.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui; 2 | 3 | import com.google.common.net.HostAndPort; 4 | import net.plan99.payfile.client.PayFileClient; 5 | 6 | import java.util.prefs.Preferences; 7 | 8 | public class Settings { 9 | private static Preferences preferences = Preferences.userNodeForPackage(Settings.class); 10 | 11 | public static void setLastServer(HostAndPort serverName) { 12 | preferences.put("lastServer", serverName.toString()); 13 | } 14 | 15 | public static HostAndPort getLastServer() { 16 | return HostAndPort.fromString(preferences.get("lastServer", "")).withDefaultPort(PayFileClient.PORT); 17 | } 18 | 19 | public static void setLastPaidServer(HostAndPort serverName) { 20 | preferences.put("lastPaidServer", serverName.toString()); 21 | } 22 | 23 | public static HostAndPort getLastPaidServer() { 24 | final String str = preferences.get("lastPaidServer", ""); 25 | return str == null ? null : HostAndPort.fromString(str).withDefaultPort(PayFileClient.PORT); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/controls/BitcoinAddressValidator.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui.controls; 2 | 3 | import com.google.bitcoin.core.Address; 4 | import com.google.bitcoin.core.AddressFormatException; 5 | import com.google.bitcoin.core.NetworkParameters; 6 | import javafx.scene.Node; 7 | import javafx.scene.control.TextField; 8 | import net.plan99.payfile.gui.utils.TextFieldValidator; 9 | 10 | /** 11 | * Given a text field, some network params and optionally some nodes, will make the text field an angry red colour 12 | * if the address is invalid for those params, and enable/disable the nodes. 13 | */ 14 | public class BitcoinAddressValidator { 15 | private NetworkParameters params; 16 | private Node[] nodes; 17 | 18 | public BitcoinAddressValidator(NetworkParameters params, TextField field, Node... nodes) { 19 | this.params = params; 20 | this.nodes = nodes; 21 | 22 | // Handle the red highlighting, but don't highlight in red just when the field is empty because that makes 23 | // the example/prompt address hard to read. 24 | new TextFieldValidator(field, text -> text.isEmpty() || testAddr(text)); 25 | // However we do want the buttons to be disabled when empty so we apply a different test there. 26 | field.textProperty().addListener((observableValue, prev, current) -> { 27 | toggleButtons(current); 28 | }); 29 | toggleButtons(field.getText()); 30 | } 31 | 32 | private void toggleButtons(String current) { 33 | boolean valid = testAddr(current); 34 | for (Node n : nodes) n.setDisable(!valid); 35 | } 36 | 37 | private boolean testAddr(String text) { 38 | try { 39 | new Address(params, text); 40 | return true; 41 | } catch (AddressFormatException e) { 42 | return false; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/controls/ClickableBitcoinAddress.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui.controls; 2 | 3 | import com.google.bitcoin.uri.BitcoinURI; 4 | import de.jensd.fx.fontawesome.AwesomeDude; 5 | import de.jensd.fx.fontawesome.AwesomeIcon; 6 | import javafx.beans.property.StringProperty; 7 | import javafx.event.ActionEvent; 8 | import javafx.event.EventHandler; 9 | import javafx.fxml.FXML; 10 | import javafx.fxml.FXMLLoader; 11 | import javafx.scene.control.ContextMenu; 12 | import javafx.scene.control.Label; 13 | import javafx.scene.control.Tooltip; 14 | import javafx.scene.effect.DropShadow; 15 | import javafx.scene.image.Image; 16 | import javafx.scene.image.ImageView; 17 | import javafx.scene.input.Clipboard; 18 | import javafx.scene.input.ClipboardContent; 19 | import javafx.scene.input.MouseButton; 20 | import javafx.scene.input.MouseEvent; 21 | import javafx.scene.layout.AnchorPane; 22 | import javafx.scene.layout.Pane; 23 | import net.glxn.qrgen.QRCode; 24 | import net.glxn.qrgen.image.ImageType; 25 | import net.plan99.payfile.gui.Main; 26 | import net.plan99.payfile.gui.utils.GuiUtils; 27 | 28 | import java.awt.*; 29 | import java.io.ByteArrayInputStream; 30 | import java.io.IOException; 31 | import java.net.URI; 32 | 33 | // This control can be used with Scene Builder as long as we don't use any Java 8 features yet. Once Oracle release 34 | // a new Scene Builder compiled against Java 8, we'll be able to use lambdas and so on here. Until that day comes, 35 | // this file specifically must be recompiled against Java 7 for main.fxml to be editable visually. 36 | // 37 | // From the java directory: 38 | // 39 | // javac -cp $HOME/.m2/repository/com/google/bitcoinj/0.11-SNAPSHOT/bitcoinj-0.11-SNAPSHOT.jar:$HOME/.m2/repository/net/glxn/qrgen/1.3/qrgen-1.3.jar:$HOME/.m2/repository/de/jensd/fontawesomefx/8.0.0/fontawesomefx-8.0.0.jar:../../../target/classes -d ../../../target/classes/ -source 1.7 -target 1.7 net/plan99/payfile/gui/controls/ClickableBitcoinAddress.java 40 | 41 | 42 | /** 43 | * A custom control that implements a clickable, copyable Bitcoin address. Clicking it opens a local wallet app. The 44 | * address looks like a blue hyperlink. Next to it there are two icons, one that copies to the clipboard and another 45 | * that shows a QRcode. 46 | */ 47 | public class ClickableBitcoinAddress extends AnchorPane { 48 | @FXML protected Label addressLabel; 49 | @FXML protected ContextMenu addressMenu; 50 | @FXML protected Label copyWidget; 51 | @FXML protected Label qrCode; 52 | 53 | public ClickableBitcoinAddress() { 54 | try { 55 | FXMLLoader loader = new FXMLLoader(getClass().getResource("bitcoin_address.fxml")); 56 | loader.setRoot(this); 57 | loader.setController(this); 58 | // The following line is supposed to help Scene Builder, although it doesn't seem to be needed for me. 59 | loader.setClassLoader(getClass().getClassLoader()); 60 | loader.load(); 61 | 62 | AwesomeDude.setIcon(copyWidget, AwesomeIcon.COPY); 63 | Tooltip.install(copyWidget, new Tooltip("Copy address to clipboard")); 64 | 65 | AwesomeDude.setIcon(qrCode, AwesomeIcon.QRCODE); 66 | Tooltip.install(qrCode, new Tooltip("Show a barcode scannable with a mobile phone for this address")); 67 | } catch (IOException e) { 68 | throw new RuntimeException(e); 69 | } 70 | } 71 | 72 | public String uri() { 73 | return BitcoinURI.convertToBitcoinURI(getAddress(), null, Main.APP_NAME, null); 74 | } 75 | 76 | public String getAddress() { 77 | return addressLabel.getText(); 78 | } 79 | 80 | public void setAddress(String address) { 81 | addressLabel.setText(address); 82 | } 83 | 84 | public StringProperty addressProperty() { 85 | return addressLabel.textProperty(); 86 | } 87 | 88 | @FXML 89 | protected void copyAddress(ActionEvent event) { 90 | // User clicked icon or menu item. 91 | Clipboard clipboard = Clipboard.getSystemClipboard(); 92 | ClipboardContent content = new ClipboardContent(); 93 | content.putString(getAddress()); 94 | content.putHtml(String.format("%s", uri(), getAddress())); 95 | clipboard.setContent(content); 96 | } 97 | 98 | @FXML 99 | protected void requestMoney(MouseEvent event) { 100 | if (event.getButton() == MouseButton.SECONDARY || (event.getButton() == MouseButton.PRIMARY && event.isMetaDown())) { 101 | // User right clicked or the Mac equivalent. Show the context menu. 102 | addressMenu.show(addressLabel, event.getScreenX(), event.getScreenY()); 103 | } else { 104 | // User left clicked. 105 | try { 106 | Desktop.getDesktop().browse(URI.create(uri())); 107 | } catch (IOException e) { 108 | GuiUtils.informationalAlert("Opening wallet app failed", "Perhaps you don't have one installed?"); 109 | } 110 | } 111 | } 112 | 113 | @FXML 114 | protected void copyWidgetClicked(MouseEvent event) { 115 | copyAddress(null); 116 | } 117 | 118 | @FXML 119 | protected void showQRCode(MouseEvent event) { 120 | // Serialize to PNG and back into an image. Pretty lame but it's the shortest code to write and I'm feeling 121 | // lazy tonight. 122 | final byte[] imageBytes = QRCode 123 | .from(uri()) 124 | .withSize(320, 240) 125 | .to(ImageType.PNG) 126 | .stream() 127 | .toByteArray(); 128 | Image qrImage = new Image(new ByteArrayInputStream(imageBytes)); 129 | ImageView view = new ImageView(qrImage); 130 | view.setEffect(new DropShadow()); 131 | // Embed the image in a pane to ensure the drop-shadow interacts with the fade nicely, otherwise it looks weird. 132 | // Then fix the width/height to stop it expanding to fill the parent, which would result in the image being 133 | // non-centered on the screen. Finally fade/blur it in. 134 | Pane pane = new Pane(view); 135 | pane.setMaxSize(qrImage.getWidth(), qrImage.getHeight()); 136 | final Main.OverlayUI overlay = Main.instance.overlayUI(pane, this); 137 | view.setOnMouseClicked(new EventHandler() { 138 | @Override 139 | public void handle(MouseEvent event) { 140 | overlay.done(); 141 | } 142 | }); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/utils/AlertWindowController.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui.utils; 18 | 19 | import javafx.scene.control.Button; 20 | import javafx.scene.control.Label; 21 | import javafx.stage.Stage; 22 | import net.plan99.payfile.client.PayFileClient; 23 | 24 | import java.awt.*; 25 | import java.io.File; 26 | import java.io.IOException; 27 | 28 | public class AlertWindowController { 29 | public Label messageLabel; 30 | public Label detailsLabel; 31 | public Button okButton; 32 | public Button cancelButton; 33 | public Button actionButton; 34 | 35 | /** Initialize this alert dialog for information about a crash. */ 36 | public void crashAlert(Stage stage, String crashMessage) { 37 | messageLabel.setText("Unfortunately, we screwed up and the app crashed. Sorry about that!"); 38 | detailsLabel.setText(crashMessage); 39 | 40 | cancelButton.setVisible(false); 41 | actionButton.setVisible(false); 42 | okButton.setOnAction(actionEvent -> stage.close()); 43 | } 44 | 45 | /** Initialize this alert for general information: OK button only, nothing happens on dismissal. */ 46 | public void informational(Stage stage, String message, String details) { 47 | messageLabel.setText(message); 48 | detailsLabel.setText(details); 49 | cancelButton.setVisible(false); 50 | actionButton.setVisible(false); 51 | okButton.setOnAction(actionEvent -> stage.close()); 52 | } 53 | 54 | public void withOpenFile(Stage stage, PayFileClient.File file, File destination, int secondsTaken) { 55 | messageLabel.setText("Download complete"); 56 | detailsLabel.setText(String.format("'%s' was successfully downloaded in %d seconds.", file.getFileName(), secondsTaken)); 57 | cancelButton.setVisible(false); 58 | actionButton.setText("Open ..."); 59 | actionButton.setVisible(true); 60 | actionButton.setOnAction((event) -> { 61 | stage.close(); 62 | try { 63 | Desktop.getDesktop().open(destination); 64 | } catch (IOException e) { 65 | GuiUtils.crashAlert(e); 66 | } 67 | }); 68 | okButton.setOnAction((event) -> stage.close()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/utils/GuiUtils.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui.utils; 18 | 19 | import com.google.common.base.Throwables; 20 | import javafx.animation.*; 21 | import javafx.application.Platform; 22 | import javafx.fxml.FXMLLoader; 23 | import javafx.scene.Node; 24 | import javafx.scene.Scene; 25 | import javafx.scene.effect.GaussianBlur; 26 | import javafx.scene.layout.Pane; 27 | import javafx.stage.Modality; 28 | import javafx.stage.Stage; 29 | import javafx.util.Duration; 30 | 31 | import java.util.function.BiConsumer; 32 | 33 | import static com.google.common.base.Preconditions.checkState; 34 | import static net.plan99.payfile.utils.Exceptions.evalUnchecked; 35 | 36 | public class GuiUtils { 37 | public static void runAlert(BiConsumer setup) { 38 | // JavaFX doesn't actually have a standard alert template. Instead the Scene Builder app will create FXML 39 | // files for an alert window for you, and then you customise it as you see fit. I guess it makes sense in 40 | // an odd sort of way. 41 | Stage dialogStage = new Stage(); 42 | dialogStage.initModality(Modality.APPLICATION_MODAL); 43 | FXMLLoader loader = new FXMLLoader(GuiUtils.class.getResource("alert.fxml")); 44 | Pane pane = evalUnchecked(() -> (Pane) loader.load()); 45 | AlertWindowController controller = loader.getController(); 46 | setup.accept(dialogStage, controller); 47 | dialogStage.setScene(new Scene(pane)); 48 | dialogStage.showAndWait(); 49 | } 50 | 51 | public static void crashAlert(Throwable t) { 52 | t.printStackTrace(); 53 | Throwable rootCause = Throwables.getRootCause(t); 54 | Runnable r = () -> { 55 | runAlert((stage, controller) -> controller.crashAlert(stage, rootCause.toString())); 56 | Platform.exit(); 57 | }; 58 | if (Platform.isFxApplicationThread()) 59 | r.run(); 60 | else 61 | Platform.runLater(r); 62 | } 63 | 64 | /** Show a GUI alert box for any unhandled exceptions that propagate out of this thread. */ 65 | public static void handleCrashesOnThisThread() { 66 | Thread.currentThread().setUncaughtExceptionHandler( 67 | (thread, exception) -> GuiUtils.crashAlert(Throwables.getRootCause(exception))); 68 | } 69 | 70 | public static void informationalAlert(String message, String details, Object... args) { 71 | String formattedDetails = String.format(details, args); 72 | Runnable r = () -> runAlert((stage, controller) -> controller.informational(stage, message, formattedDetails)); 73 | if (Platform.isFxApplicationThread()) 74 | r.run(); 75 | else 76 | Platform.runLater(r); 77 | } 78 | 79 | private static final int UI_ANIMATION_TIME_MSEC = 350; 80 | 81 | public static void fadeIn(Node ui) { 82 | FadeTransition ft = new FadeTransition(Duration.millis(UI_ANIMATION_TIME_MSEC), ui); 83 | ft.setFromValue(0.0); 84 | ft.setToValue(1.0); 85 | ft.play(); 86 | } 87 | 88 | public static Animation fadeOut(Node ui) { 89 | FadeTransition ft = new FadeTransition(Duration.millis(UI_ANIMATION_TIME_MSEC), ui); 90 | ft.setFromValue(ui.getOpacity()); 91 | ft.setToValue(0.0); 92 | ft.play(); 93 | return ft; 94 | } 95 | 96 | public static Animation fadeOutAndRemove(Node ui, Pane parentPane) { 97 | Animation animation = fadeOut(ui); 98 | animation.setOnFinished(actionEvent -> parentPane.getChildren().remove(ui)); 99 | return animation; 100 | } 101 | 102 | public static void blurOut(Node node) { 103 | GaussianBlur blur = new GaussianBlur(0.0); 104 | node.setEffect(blur); 105 | Timeline timeline = new Timeline(); 106 | KeyValue kv = new KeyValue(blur.radiusProperty(), 10.0); 107 | KeyFrame kf = new KeyFrame(Duration.millis(UI_ANIMATION_TIME_MSEC), kv); 108 | timeline.getKeyFrames().add(kf); 109 | timeline.play(); 110 | } 111 | 112 | public static void blurIn(Node node) { 113 | GaussianBlur blur = (GaussianBlur) node.getEffect(); 114 | Timeline timeline = new Timeline(); 115 | KeyValue kv = new KeyValue(blur.radiusProperty(), 0.0); 116 | KeyFrame kf = new KeyFrame(Duration.millis(UI_ANIMATION_TIME_MSEC), kv); 117 | timeline.getKeyFrames().add(kf); 118 | timeline.setOnFinished(actionEvent -> node.setEffect(null)); 119 | timeline.play(); 120 | } 121 | 122 | public static void checkGuiThread() { 123 | checkState(Platform.isFxApplicationThread()); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/utils/TextFieldValidator.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.gui.utils; 2 | 3 | import javafx.scene.Scene; 4 | import javafx.scene.control.TextField; 5 | 6 | import java.util.function.Predicate; 7 | 8 | public class TextFieldValidator { 9 | private boolean valid; 10 | 11 | public TextFieldValidator(TextField textField, Predicate validator) { 12 | this.valid = validator.test(textField.getText()); 13 | apply(textField, valid); 14 | textField.textProperty().addListener((observableValue, prev, current) -> { 15 | boolean nowValid = validator.test(current); 16 | if (nowValid == valid) return; 17 | apply(textField, nowValid); 18 | valid = nowValid; 19 | }); 20 | } 21 | 22 | private static void apply(TextField textField, boolean nowValid) { 23 | if (nowValid) { 24 | textField.getStyleClass().remove("validation_error"); 25 | } else { 26 | textField.getStyleClass().add("validation_error"); 27 | } 28 | } 29 | 30 | public static void configureScene(Scene scene) { 31 | final String file = TextFieldValidator.class.getResource("text-validation.css").toString(); 32 | scene.getStylesheets().add(file); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/gui/utils/ThrottledRunLater.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.gui.utils; 18 | 19 | import javafx.application.Platform; 20 | 21 | import java.util.concurrent.atomic.AtomicBoolean; 22 | 23 | /** 24 | * A simple wrapper around {@link Platform#runLater(Runnable)} which will do nothing if the previous 25 | * invocation of runLater didn't execute on the JavaFX UI thread yet. In this way you can avoid flooding 26 | * the event loop if you have a background thread that for whatever reason wants to update the UI very 27 | * frequently. Without this class you could end up bloating up memory usage and causing the UI to stutter 28 | * if the UI thread couldn't keep up with your background worker. 29 | */ 30 | public class ThrottledRunLater implements Runnable { 31 | private final Runnable runnable; 32 | private final AtomicBoolean pending = new AtomicBoolean(); 33 | 34 | /** Created this way, the no-args runLater will execute this classes run method. */ 35 | public ThrottledRunLater() { 36 | this.runnable = null; 37 | } 38 | 39 | /** Created this way, the no-args runLater will execute the given runnable. */ 40 | public ThrottledRunLater(Runnable runnable) { 41 | this.runnable = runnable; 42 | } 43 | 44 | public void runLater(Runnable runnable) { 45 | if (!pending.getAndSet(true)) { 46 | Platform.runLater(() -> { 47 | pending.set(false); 48 | runnable.run(); 49 | }); 50 | } 51 | } 52 | 53 | public void runLater() { 54 | runLater(runnable != null ? runnable : this); 55 | } 56 | 57 | @Override 58 | public void run() { 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/server/Server.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Mike Hearn 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package net.plan99.payfile.server; 18 | 19 | import com.google.bitcoin.core.NetworkParameters; 20 | import com.google.bitcoin.core.Sha256Hash; 21 | import com.google.bitcoin.core.TransactionBroadcaster; 22 | import com.google.bitcoin.core.Wallet; 23 | import com.google.bitcoin.kits.WalletAppKit; 24 | import com.google.bitcoin.params.MainNetParams; 25 | import com.google.bitcoin.params.RegTestParams; 26 | import com.google.bitcoin.params.TestNet3Params; 27 | import com.google.bitcoin.protocols.channels.PaymentChannelCloseException; 28 | import com.google.bitcoin.protocols.channels.PaymentChannelServer; 29 | import com.google.bitcoin.protocols.channels.PaymentChannelServerState; 30 | import com.google.bitcoin.protocols.channels.StoredPaymentChannelServerStates; 31 | import com.google.bitcoin.utils.BriefLogFormatter; 32 | import com.google.protobuf.ByteString; 33 | import com.google.protobuf.InvalidProtocolBufferException; 34 | import joptsimple.*; 35 | import net.plan99.payfile.Payfile; 36 | import net.plan99.payfile.ProtocolException; 37 | import org.bitcoin.paymentchannel.Protos; 38 | import org.slf4j.Logger; 39 | import org.slf4j.LoggerFactory; 40 | 41 | import javax.annotation.Nullable; 42 | import java.io.*; 43 | import java.math.BigInteger; 44 | import java.net.ServerSocket; 45 | import java.net.Socket; 46 | import java.util.ArrayList; 47 | import java.util.Arrays; 48 | 49 | import static joptsimple.util.RegexMatcher.regex; 50 | import static net.plan99.payfile.utils.Exceptions.runUnchecked; 51 | 52 | /** 53 | * An instance of Server handles one client. The static main method opens up a listening socket and starts a thread 54 | * that runs a new Server for each client that connects. This one thread per connection model is simple and 55 | * easy to understand, but for lots of clients you'd need to possibly minimise the stack size. 56 | */ 57 | public class Server implements Runnable { 58 | private static final Logger log = LoggerFactory.getLogger(Server.class); 59 | // 50kb chunk size. If we swapped in a faster ECDSA implementation then we could decrease this a lot, but 60 | // bouncy castle is really slow. bitcoinj has some basic support for sipa's libsecp256k1 which would let 61 | // us speed up the download significantly. 62 | private static final int CHUNK_SIZE = 1024*50; 63 | private static final int PORT = 18754; 64 | private static final int MIN_ACCEPTED_CHUNKS = 5; // Require download of at least this many chunks. 65 | private static File directoryToServe; 66 | private static int defaultPricePerChunk = 100; // Satoshis 67 | private static ArrayList manifest; 68 | private static NetworkParameters params; 69 | // The client socket that we're talking to. 70 | private final Socket socket; 71 | private final Wallet wallet; 72 | private final TransactionBroadcaster transactionBroadcaster; 73 | private final String peerName; 74 | private DataInputStream input; 75 | private DataOutputStream output; 76 | @Nullable private PaymentChannelServer payments; 77 | private static String filePrefix; 78 | 79 | public Server(Wallet wallet, TransactionBroadcaster transactionBroadcaster, Socket socket) { 80 | this.socket = socket; 81 | this.peerName = socket.getInetAddress().getHostAddress(); 82 | this.wallet = wallet; 83 | this.transactionBroadcaster = transactionBroadcaster; 84 | } 85 | 86 | public static void main(String[] args) throws Exception { 87 | BriefLogFormatter.init(); 88 | 89 | // Usage: --file-directory= [--network=[mainnet|testnet|regtest]] [--port=]" 90 | OptionParser parser = new OptionParser(); 91 | OptionSpec fileDir = parser.accepts("file-directory").withRequiredArg().required().ofType(File.class); 92 | parser.accepts("network").withRequiredArg().withValuesConvertedBy(regex("(mainnet)|(testnet)|(regtest)")).defaultsTo("mainnet"); 93 | parser.accepts("port").withRequiredArg().ofType(Integer.class).defaultsTo(PORT); 94 | parser.accepts("help").forHelp(); 95 | parser.formatHelpWith(new BuiltinHelpFormatter(120, 10)); 96 | 97 | OptionSet options; 98 | 99 | try { 100 | options = parser.parse(args); 101 | } catch (OptionException e) { 102 | System.err.println(e.getMessage()); 103 | System.err.println(""); 104 | parser.printHelpOn(System.err); 105 | return; 106 | } 107 | 108 | if (options.has("help")) { 109 | parser.printHelpOn(System.out); 110 | return; 111 | } 112 | 113 | directoryToServe = options.valueOf(fileDir); 114 | if (!buildFileList()) 115 | return; 116 | 117 | if (options.valueOf("network").equals(("testnet"))) { 118 | params = TestNet3Params.get(); 119 | filePrefix = "testnet-"; 120 | } else if (options.valueOf("network").equals(("mainnet"))) { 121 | params = MainNetParams.get(); 122 | filePrefix = ""; 123 | } else if (options.valueOf("network").equals(("regtest"))) { 124 | params = RegTestParams.get(); 125 | filePrefix = "regtest-"; 126 | } 127 | 128 | final int port = Integer.parseInt(options.valueOf("port").toString()); 129 | 130 | WalletAppKit appkit = new WalletAppKit(params, new File("."), filePrefix + "payfile-server-" + port) { 131 | @Override 132 | protected void addWalletExtensions() throws Exception { 133 | super.addWalletExtensions(); 134 | wallet().addExtension(new StoredPaymentChannelServerStates(wallet(), peerGroup())); 135 | } 136 | }; 137 | if (params == RegTestParams.get()) { 138 | appkit.connectToLocalHost(); 139 | } 140 | appkit.setUserAgent("PayFile Server", "1.0").startAndWait(); 141 | 142 | System.out.println(appkit.wallet().toString(false, true, true, appkit.chain())); 143 | 144 | ServerSocket socket = new ServerSocket(port); 145 | Socket clientSocket; 146 | do { 147 | clientSocket = socket.accept(); 148 | final Server server = new Server(appkit.wallet(), appkit.peerGroup(), clientSocket); 149 | Thread clientThread = new Thread(server, clientSocket.toString()); 150 | clientThread.start(); 151 | } while (true); 152 | } 153 | 154 | private static boolean buildFileList() { 155 | final File[] files = directoryToServe.listFiles(); 156 | if (files == null) { 157 | log.error("{} is not a directory", directoryToServe); 158 | return false; 159 | } 160 | manifest = new ArrayList<>(); 161 | int counter = 0; 162 | for (File f : files) { 163 | if (f.isDirectory() || f.isHidden()) continue; 164 | Payfile.File file = Payfile.File.newBuilder() 165 | .setFileName(f.getName()) 166 | .setDescription("Some cool file") 167 | .setHandle(counter++) 168 | .setSize(f.length()) 169 | .setPricePerChunk(defaultPricePerChunk) 170 | .build(); 171 | manifest.add(file); 172 | } 173 | if (counter == 0) { 174 | log.error("{} contains no files", directoryToServe); 175 | return false; 176 | } 177 | log.info("Serving {} files", counter); 178 | return true; 179 | } 180 | 181 | @Override 182 | public void run() { 183 | try { 184 | log.info("Got new connection from {}", peerName); 185 | input = new DataInputStream(socket.getInputStream()); 186 | output = new DataOutputStream(socket.getOutputStream()); 187 | 188 | while (true) { 189 | int len = input.readInt(); 190 | if (len < 0 || len > 64 * 1024) { 191 | log.error("Client sent over-sized message of {} bytes", len); 192 | return; 193 | } 194 | byte[] bits = new byte[len]; 195 | input.readFully(bits); 196 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.parseFrom(bits); 197 | handle(msg); 198 | } 199 | } catch (EOFException ignored) { 200 | log.info("Client {} disconnected", peerName); 201 | } catch (IOException e) { 202 | throw new RuntimeException(e); 203 | } catch (ProtocolException e) { 204 | try { 205 | sendError(e); 206 | } catch (IOException ignored) {} 207 | } catch (Throwable t) { 208 | // Internal server error. 209 | try { 210 | sendError(new ProtocolException(ProtocolException.Code.INTERNAL_ERROR, "Internal server error: " + t.toString())); 211 | } catch (IOException ignored) {} 212 | } finally { 213 | forceClose(); 214 | } 215 | } 216 | 217 | private void forceClose() { 218 | runUnchecked(socket::close); 219 | } 220 | 221 | private void sendError(ProtocolException e) throws IOException { 222 | Payfile.Error error = Payfile.Error.newBuilder() 223 | .setCode(e.getCode().name()) 224 | .setExplanation(e.getMessage()) 225 | .build(); 226 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.newBuilder() 227 | .setType(Payfile.PayFileMessage.Type.ERROR) 228 | .setError(error) 229 | .build(); 230 | writeMessage(msg); 231 | } 232 | 233 | private void handle(Payfile.PayFileMessage msg) throws IOException, ProtocolException { 234 | switch (msg.getType()) { 235 | case QUERY_FILES: 236 | queryFiles(msg.getQueryFiles()); 237 | break; 238 | case PAYMENT: 239 | payment(msg.getPayment()); 240 | break; 241 | case DOWNLOAD_CHUNK: 242 | downloadChunk(msg.getDownloadChunk()); 243 | break; 244 | default: 245 | throw new ProtocolException("Unknown message"); 246 | } 247 | } 248 | 249 | private void queryFiles(Payfile.QueryFiles queryFiles) throws IOException, ProtocolException { 250 | log.info("{}: File query request from '{}'", peerName, queryFiles.getUserAgent()); 251 | checkForNetworkMismatch(queryFiles); 252 | Payfile.Manifest manifestMsg = Payfile.Manifest.newBuilder() 253 | .addAllFiles(manifest) 254 | .setChunkSize(CHUNK_SIZE) 255 | .build(); 256 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.newBuilder() 257 | .setType(Payfile.PayFileMessage.Type.MANIFEST) 258 | .setManifest(manifestMsg) 259 | .build(); 260 | writeMessage(msg); 261 | } 262 | 263 | private void checkForNetworkMismatch(Payfile.QueryFiles queryFiles) throws ProtocolException { 264 | final String theirNetwork = queryFiles.getBitcoinNetwork(); 265 | final String myNetwork = wallet.getParams().getId(); 266 | if (!theirNetwork.equals(myNetwork)) { 267 | final String msg = String.format("Client is using '%s' and server is '%s'", theirNetwork, myNetwork); 268 | throw new ProtocolException(ProtocolException.Code.NETWORK_MISMATCH, msg); 269 | } 270 | } 271 | 272 | private void writeMessage(Payfile.PayFileMessage msg) { 273 | try { 274 | byte[] bits = msg.toByteArray(); 275 | output.writeInt(bits.length); 276 | output.write(bits); 277 | } catch (IOException e) { 278 | log.error("{}: Failed writing message: {}", peerName, e); 279 | forceClose(); 280 | } 281 | } 282 | 283 | private void payment(ByteString payment) { 284 | try { 285 | Protos.TwoWayChannelMessage msg = Protos.TwoWayChannelMessage.parseFrom(payment); 286 | maybeInitPayments().receiveMessage(msg); 287 | } catch (InvalidProtocolBufferException e) { 288 | log.error("{}: Got an unreadable payment message: {}", peerName, e); 289 | forceClose(); 290 | } 291 | } 292 | 293 | private PaymentChannelServer maybeInitPayments() { 294 | if (payments != null) 295 | return payments; 296 | BigInteger minPayment = BigInteger.valueOf(defaultPricePerChunk * MIN_ACCEPTED_CHUNKS); 297 | payments = new PaymentChannelServer(transactionBroadcaster, wallet, minPayment, new PaymentChannelServer.ServerConnection() { 298 | @Override 299 | public void sendToClient(Protos.TwoWayChannelMessage msg) { 300 | Payfile.PayFileMessage.Builder m = Payfile.PayFileMessage.newBuilder(); 301 | m.setPayment(msg.toByteString()); 302 | m.setType(Payfile.PayFileMessage.Type.PAYMENT); 303 | writeMessage(m.build()); 304 | } 305 | 306 | @Override 307 | public void destroyConnection(PaymentChannelCloseException.CloseReason reason) { 308 | if (reason != PaymentChannelCloseException.CloseReason.CLIENT_REQUESTED_CLOSE) { 309 | log.error("{}: Payments terminated abnormally: {}", peerName, reason); 310 | } 311 | payments = null; 312 | } 313 | 314 | @Override 315 | public void channelOpen(Sha256Hash contractHash) { 316 | log.info("{}: Payments negotiated: {}", peerName, contractHash); 317 | } 318 | 319 | @Override 320 | public void paymentIncrease(BigInteger by, BigInteger to) { 321 | log.info("{}: Increased balance by {} to {}", peerName, by, to); 322 | } 323 | }); 324 | payments.connectionOpen(); 325 | return payments; 326 | } 327 | 328 | private void downloadChunk(Payfile.DownloadChunk downloadChunk) throws ProtocolException { 329 | try { 330 | Payfile.File file = null; 331 | for (Payfile.File f : manifest) { 332 | if (f.getHandle() == downloadChunk.getHandle()) { 333 | file = f; 334 | break; 335 | } 336 | } 337 | if (file == null) 338 | throw new ProtocolException("DOWNLOAD_CHUNK specified invalid file handle " + downloadChunk.getHandle()); 339 | if (downloadChunk.getNumChunks() <= 0) 340 | throw new ProtocolException("DOWNLOAD_CHUNK: num_chunks must be >= 1"); 341 | if (file.getPricePerChunk() > 0) { 342 | // How many chunks can the client afford with their current balance? 343 | PaymentChannelServerState state = payments == null ? null : payments.state(); 344 | if (state == null) 345 | throw new ProtocolException("Payment channel not initiated but this file is not free"); 346 | long balance = state.getBestValueToMe().longValue(); 347 | long affordableChunks = balance / file.getPricePerChunk(); 348 | if (affordableChunks < downloadChunk.getNumChunks()) 349 | throw new ProtocolException("Insufficient payment received for requested amount of data: got " + balance); 350 | balance -= downloadChunk.getNumChunks(); 351 | } 352 | for (int i = 0; i < downloadChunk.getNumChunks(); i++) { 353 | long chunkId = downloadChunk.getChunkId() + i; 354 | if (chunkId == 0) 355 | log.info("{}: Starting download of {}", peerName, file.getFileName()); 356 | // This is super inefficient. 357 | File diskFile = new File(directoryToServe, file.getFileName()); 358 | FileInputStream fis = new FileInputStream(diskFile); 359 | final long offset = chunkId * CHUNK_SIZE; 360 | if (fis.skip(offset) != offset) 361 | throw new IOException("Bogus seek"); 362 | byte[] chunk = new byte[CHUNK_SIZE]; 363 | final int bytesActuallyRead = fis.read(chunk); 364 | if (bytesActuallyRead < 0) { 365 | log.debug("Reached EOF"); 366 | } else if (bytesActuallyRead > 0 && bytesActuallyRead < chunk.length) { 367 | chunk = Arrays.copyOf(chunk, bytesActuallyRead); 368 | } 369 | Payfile.PayFileMessage msg = Payfile.PayFileMessage.newBuilder() 370 | .setType(Payfile.PayFileMessage.Type.DATA) 371 | .setData(Payfile.Data.newBuilder() 372 | .setChunkId(downloadChunk.getChunkId()) 373 | .setHandle(file.getHandle()) 374 | .setData(ByteString.copyFrom(chunk)) 375 | .build() 376 | ).build(); 377 | writeMessage(msg); 378 | } 379 | } catch (IOException e) { 380 | throw new ProtocolException("Error reading from disk: " + e.getMessage()); 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/main/java/net/plan99/payfile/utils/Exceptions.java: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile.utils; 2 | 3 | import java.util.concurrent.Callable; 4 | 5 | /** 6 | *

Simple wrapper class that should really be in Java 8 library. Makes checked exceptions less painful. Statically 7 | * import it then use like this:

8 | * 9 | *
10 |  *    runUnchecked(() -> foo.canGoWrong());   // Any checked exceptions are wrapped in RuntimeException and rethrown.
11 |  *    runUnchecked(foo::canGoWrong);          // Even easier.
12 |  *    bar = evalUnchecked(() -> foo.calculate());
13 |  * 
14 | */ 15 | public class Exceptions { 16 | public interface ExceptionWrapper { 17 | E wrap(Exception e); 18 | } 19 | 20 | // Why does this not exist in the SDK? 21 | public interface CodeThatCanThrow { 22 | public void run() throws Exception; 23 | } 24 | 25 | public static T evalUnchecked(Callable callable) throws RuntimeException { 26 | return evalUnchecked(callable, RuntimeException::new); 27 | } 28 | 29 | public static T evalUnchecked(Callable callable, ExceptionWrapper wrapper) throws E { 30 | try { 31 | return callable.call(); 32 | } catch (RuntimeException e) { 33 | throw e; 34 | } catch (Exception e) { 35 | throw wrapper.wrap(e); 36 | } 37 | } 38 | 39 | public static void runUnchecked(CodeThatCanThrow runnable) throws RuntimeException { 40 | runUnchecked(runnable, RuntimeException::new); 41 | } 42 | 43 | public static void runUnchecked(CodeThatCanThrow runnable, ExceptionWrapper wrapper) throws E { 44 | try { 45 | runnable.run(); 46 | } catch (RuntimeException e) { 47 | throw e; 48 | } catch (Exception e) { 49 | throw wrapper.wrap(e); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/payfile.proto: -------------------------------------------------------------------------------- 1 | package net.plan99.payfile; 2 | 3 | message PayFileMessage { 4 | enum Type { 5 | // Protocol goes like this: firstly you find out what files are available by having the client 6 | // send a QUERY_FILES message to the server. 7 | QUERY_FILES = 1; 8 | // The server sends a MANIFEST telling what it's got and how much it's gonna cost you ... 9 | MANIFEST = 2; 10 | // Client sets up a micropayment channel to the server for however much money it wants. 11 | // These messages just pass opaque byte arrays back and forth, the actual payment channels 12 | // protocol is embedded inside this one. 13 | PAYMENT = 3; 14 | // Client sends a DOWNLOAD_CHUNK message asking for a part of the given file. The client should 15 | // have sent a micropayment for the chunk beforehand. 16 | DOWNLOAD_CHUNK = 4; 17 | // Server sends back a DATA message containing the requested chunk. 18 | DATA = 5; 19 | 20 | // Either side can send this. 21 | ERROR = 6; 22 | } 23 | required Type type = 1; 24 | 25 | optional QueryFiles query_files = 2; 26 | optional Manifest manifest = 3; 27 | optional bytes payment = 4; 28 | optional DownloadChunk download_chunk = 5; 29 | optional Data data = 6; 30 | optional Error error = 7; 31 | } 32 | 33 | message QueryFiles { 34 | // The program that's gonna do the downloading. 35 | required string user_agent = 1; 36 | 37 | // Verify up-front that we're on the same Bitcoin network (main, test, regtest, litecoin, etc). 38 | // If this doesn't match the server network we'll get an ERROR after QUERY_FILES. 39 | // The strings are from the ID field of the bitcoinj NetworkParameters objects: 40 | // 41 | // org.bitcoin.production 42 | // org.bitcoin.test (this is used for both testnet and regtest mode but that may change in future) 43 | required string bitcoin_network = 2; 44 | } 45 | 46 | message File { 47 | // A unique file name. Can contain spaces, arbitrary UTF-8, any extension etc. 48 | required string file_name = 1; 49 | required int64 size = 2; // In bytes. 50 | optional string description = 3; 51 | // Satoshis per chunk charged for this file. 52 | required int32 price_per_chunk = 4; 53 | // Number that will be used to refer to this file later. 54 | required int32 handle = 5; 55 | } 56 | 57 | message Manifest { 58 | repeated File files = 1; 59 | // Size in bytes of each chunk. 60 | required int32 chunk_size = 2; 61 | } 62 | 63 | message DownloadChunk { 64 | // File handle we're downloading. 65 | required int32 handle = 1; 66 | // Offset into the file in terms of chunks. 67 | required int64 chunk_id = 2; 68 | // Number of chunks to download at once. 69 | optional int32 num_chunks = 3 [default = 1]; 70 | } 71 | 72 | // Sent back from the server to the client. 73 | message Data { 74 | required int32 handle = 1; 75 | required int64 chunk_id = 2; 76 | required bytes data = 3; 77 | } 78 | 79 | message Error { 80 | // From ProtocolException.Code, one of: 81 | // 82 | // GENERIC 83 | // NETWORK_MISMATCH 84 | // INTERNAL_ERROR 85 | // 86 | // Note that the micropayment protocol has its own internal error messages, so this is just for the non payments 87 | // part of the system. 88 | required string code = 1; 89 | optional string explanation = 2; 90 | } -------------------------------------------------------------------------------- /src/main/resources/net/plan99/payfile/gui/bitcoin_logo_plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikehearn/PayFile/d12ca14250d71656b5c5699aa3ab8f342a2e419c/src/main/resources/net/plan99/payfile/gui/bitcoin_logo_plain.png -------------------------------------------------------------------------------- /src/main/resources/net/plan99/payfile/gui/checkpoints: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikehearn/PayFile/d12ca14250d71656b5c5699aa3ab8f342a2e419c/src/main/resources/net/plan99/payfile/gui/checkpoints -------------------------------------------------------------------------------- /src/main/resources/net/plan99/payfile/gui/connect_server.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 74 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 54 | 55 | 56 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/main/resources/net/plan99/payfile/gui/utils/text-validation.css: -------------------------------------------------------------------------------- 1 | .text-field.validation_error { 2 | -fx-background-color: red, 3 | linear-gradient( 4 | to bottom, 5 | derive(red,70%) 5%, 6 | derive(red,90%) 40% 7 | ); 8 | } 9 | 10 | .text-field.validation_warning { 11 | -fx-background-color: orange, 12 | linear-gradient( 13 | to bottom, 14 | derive(orange,70%) 5%, 15 | derive(orange,90%) 40% 16 | ); 17 | } 18 | --------------------------------------------------------------------------------