Package net.plan99.payfile.client

Source Code of net.plan99.payfile.client.PayFileClient$File

package net.plan99.payfile.client;

import com.google.bitcoin.core.*;
import com.google.bitcoin.protocols.channels.PaymentChannelClient;
import com.google.bitcoin.protocols.channels.PaymentChannelCloseException;
import com.google.bitcoin.protocols.channels.StoredPaymentChannelClientStates;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import net.plan99.payfile.Payfile;
import net.plan99.payfile.ProtocolException;
import org.bitcoin.paymentchannel.Protos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import java.io.*;
import java.math.BigInteger;
import java.net.Socket;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static net.plan99.payfile.utils.Exceptions.evalUnchecked;
import static net.plan99.payfile.utils.Exceptions.runUnchecked;

public class PayFileClient {
    private static final Logger log = LoggerFactory.getLogger(PayFileClient.class);

    public static final int PORT = 18754;

    private final DataInputStream input;
    private final Socket socket;
    private final DataOutputStream output;
    private final Wallet wallet;
    private CompletableFuture<List<File>> currentQuery;
    private CompletableFuture currentFuture;
    private int chunkSize;
    private List<File> currentDownloads = new CopyOnWriteArrayList<>();
    private PaymentChannelClient paymentChannelClient;
    private volatile boolean running;
    private Consumer<Long> onPaymentMade;
    private boolean freshChannel;
    private long numPurchasedChunks;

    private boolean settling;
    private CompletableFuture<Void> settlementFuture;

    public PayFileClient(Socket socket, Wallet wallet) {
        this.socket = socket;
        this.input = new DataInputStream(evalUnchecked(socket::getInputStream));
        this.output = new DataOutputStream(evalUnchecked(socket::getOutputStream));
        this.wallet = wallet;

        ClientThread thread = new ClientThread();
        thread.setName(socket.toString());
        thread.setDaemon(true);
        thread.start();
    }

    public void disconnect() {
        running = false;
        if (paymentChannelClient != null)
            paymentChannelClient.connectionClosed();
        runUnchecked(input::close);
        runUnchecked(output::close);
    }

    public CompletableFuture<Void> settlePaymentChannel() {
        // Tell it to terminate the payment relationship and thus broadcast the micropayment transactions. We will
        // resume control in destroyConnection below.
        settling = true;
        currentFuture = settlementFuture = new CompletableFuture<Void>();
        if (paymentChannelClient == null) {
            // Have to connect first.
            return initializePayments().thenCompose((v) -> {
                paymentChannelClient.settle();
                return settlementFuture;
            });
        } else {
            paymentChannelClient.settle();
            return settlementFuture;
        }
    }

    /**
     * Returns balance of the wallet plus whatever is left in the current channel, i.e. how much money is spendable
     * after a clean disconnect.
     */
    public BigInteger getRemainingBalance() {
        final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet);
        checkNotNull(extension);
        BigInteger valueRefunded = extension.getBalanceForServer(getServerID());
        return wallet.getBalance().add(valueRefunded);
    }

    /**
     * Returns how much money is still stuck in a channel with the given server. Does NOT include wallet balance.
     */
    public static BigInteger getBalanceForServer(String serverName, int port, Wallet wallet) {
        final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet);
        checkNotNull(extension);
        return extension.getBalanceForServer(getServerID(serverName, port));
    }

    /**
     * Returns how long you have to wait until this channel will either be settled by the server, or can be auto-settled
     * by the client (us).
     */
    public static long getSecondsUntilExpiry(String serverName, int port, Wallet wallet) {
        final StoredPaymentChannelClientStates extension = StoredPaymentChannelClientStates.getFromWallet(wallet);
        checkNotNull(extension);
        return extension.getSecondsUntilExpiry(getServerID(serverName, port));
    }

    public void setOnPaymentMade(Consumer<Long> onPaymentMade) {
        this.onPaymentMade = onPaymentMade;
    }

    public class File {
        private String fileName;
        private String description;
        private int handle;
        private long size;
        private long pricePerChunk;

        private long bytesDownloaded;
        private long nextChunk;
        private OutputStream downloadStream;
        private CompletableFuture<Void> completionFuture;

        public File(String fileName, String description, int handle, long size, long pricePerChunk) {
            this.fileName = fileName;
            this.description = description;
            this.handle = handle;
            this.size = size;
            this.pricePerChunk = pricePerChunk;
        }

        @Override
        public String toString() {
            return String.format("%s\nPrice: %s BTC", getFileName(), Utils.bitcoinValueToFriendlyString(BigInteger.valueOf(getPrice())));
        }

        public long getBytesDownloaded() {
            return bytesDownloaded;
        }

        public String getFileName() {
            return fileName;
        }

        public String getDescription() {
            return description;
        }

        public int getHandle() {
            return handle;
        }

        public long getSize() {
            return size;
        }

        public void reset() {
            bytesDownloaded = 0;
            nextChunk = 0;
            downloadStream = null;
        }

        public boolean isAffordable() {
            long totalPrice = getPrice();
            if (totalPrice == 0)
                return true;
            long balance = getRemainingBalance().longValue();
            return totalPrice <= balance;
        }

        public long getPrice() {
            return pricePerChunk * (size / chunkSize);
        }
    }

    public CompletableFuture<List<File>> queryFiles() {
        if (currentQuery != null)
            throw new IllegalStateException("Already running a query");
        CompletableFuture<List<File>> future = new CompletableFuture<>();
        currentFuture = currentQuery = future;
        final Payfile.QueryFiles.Builder queryFiles = Payfile.QueryFiles.newBuilder()
                .setUserAgent("Basic client v1.0")
                .setBitcoinNetwork(wallet.getParams().getId());
        final Payfile.PayFileMessage.Builder msg = Payfile.PayFileMessage.newBuilder()
                .setType(Payfile.PayFileMessage.Type.QUERY_FILES)
                .setQueryFiles(queryFiles);
        try {
            writeMessage(msg.build());
        } catch (IOException e) {
            future.completeExceptionally(e);
        }
        return future;
    }

    public CompletableFuture<Void> downloadFile(File file, OutputStream outputStream) throws IOException, InsufficientMoneyException {
        if (file.downloadStream != null)
            throw new IllegalStateException("Already downloading this file");
        file.downloadStream = outputStream;

        currentFuture = file.completionFuture = new CompletableFuture<>();
        file.completionFuture.whenComplete((v, exception) -> { file.reset(); });

        // Set up payments and then start the download.
        if (file.getPrice() > 0) {
            if (!file.isAffordable())
                throw new InsufficientMoneyException(BigInteger.valueOf(file.getPrice() - getRemainingBalance().longValue()), "Cannot afford this file");
            log.info("Price is {}, ensuring payments are initialised ... ", file.getPrice());
            initializePayments().handle((v, ex) -> {
                if (ex == null) {
                    log.info("Payments initialised. Downloading file {} {}", file.getHandle(), file.getFileName());
                    currentDownloads.add(file);
                    runUnchecked(() -> downloadNextChunk(file));
                } else {
                    currentFuture.completeExceptionally(ex);
                }
                return null;
            });
        } else {
            log.info("Downloading file {} {}", file.getHandle(), file.getFileName());
            currentDownloads.add(file);
            downloadNextChunk(file);
        }
        return file.completionFuture;
    }

    private CompletableFuture<Void> initializePayments() {
        if (paymentChannelClient != null)
            return CompletableFuture.completedFuture(null);
        log.info("{}: Init payments", socket);
        Sha256Hash serverID = getServerID();
        // Lock up our entire balance into the channel for this server, minus the reference tx fee.
        final BigInteger channelSize = wallet.getBalance().subtract(Transaction.REFERENCE_DEFAULT_MIN_TX_FEE);

        final CompletableFuture<Void> future = new CompletableFuture<>();
        paymentChannelClient = new PaymentChannelClient(wallet, wallet.getKeys().get(0), channelSize,
                serverID, new PaymentChannelClient.ClientConnection() {
            @Override
            public void sendToServer(Protos.TwoWayChannelMessage paymentMsg) {
                Payfile.PayFileMessage msg = Payfile.PayFileMessage.newBuilder()
                        .setType(Payfile.PayFileMessage.Type.PAYMENT)
                        .setPayment(paymentMsg.toByteString())
                        .build();
                runUnchecked(() -> writeMessage(msg));
            }

            @Override
            public void destroyConnection(PaymentChannelCloseException.CloseReason reason) {
                if (reason != PaymentChannelCloseException.CloseReason.CLIENT_REQUESTED_CLOSE) {
                    log.warn("{}: Payment channel terminating with reason {}", socket, reason);
                    if (reason == PaymentChannelCloseException.CloseReason.SERVER_REQUESTED_TOO_MUCH_VALUE) {
                        future.completeExceptionally(new InsufficientMoneyException(paymentChannelClient.getMissing()));
                    } else {
                        if (currentFuture != null)
                            currentFuture.completeExceptionally(new PaymentChannelCloseException("Unexpected payment channel termination", reason));
                    }
                } else {
                    checkState(settling);
                    log.info("{}: Payment channel settled successfully.", socket);
                    settlementFuture.complete(null);
                }
                paymentChannelClient.connectionClosed();
                paymentChannelClient = null;
            }

            @Override
            public void channelOpen(boolean wasInitiated) {
                log.info("{}: Payment channel negotiated{}", socket, wasInitiated ? ", was initiated" : "");
                freshChannel = wasInitiated;
                future.complete(null);
            }
        });
        paymentChannelClient.connectionOpen();
        return future;
    }

    private Sha256Hash getServerID() {
        return getServerID(socket.getInetAddress().getHostName(), socket.getPort());
    }

    private static Sha256Hash getServerID(String host, int port) {
        return Sha256Hash.create(String.format("%s:%d", host, port).getBytes());
    }

    private void downloadNextChunk(File file) throws IOException {
        if (currentFuture.isCompletedExceptionally())
            return;
        if (paymentChannelClient != null) {
            // Write two messages, one after the other: possibly add to our balance, then spend it.
            if (freshChannel) {
                freshChannel = false;
                // If we opened a fresh channel, we have automatically made a min payment on the channel equal
                // to the dust limit. Divide to find out how many chunks that's worth here. We might end up
                // overpaying slightly this way: a smarter approach would handle the remainder from the division.
                numPurchasedChunks = paymentChannelClient.state().getValueSpent().longValue() / file.pricePerChunk;
                log.info("New channel, have pre-paid {} chunks", numPurchasedChunks);
            }
            if (numPurchasedChunks == 0) {
                /* ValueOutOfRangeException */ runUnchecked(() ->
                    paymentChannelClient.incrementPayment(BigInteger.valueOf(file.pricePerChunk))
                );
                numPurchasedChunks++;
                if (onPaymentMade != null)
                    onPaymentMade.accept(file.pricePerChunk);
            }
            numPurchasedChunks--;
        }
        Payfile.DownloadChunk.Builder downloadChunk = Payfile.DownloadChunk.newBuilder();
        downloadChunk.setHandle(file.getHandle());
        // For now do one chunk at a time, although the protocol allows for more.
        downloadChunk.setChunkId(file.nextChunk++);
        Payfile.PayFileMessage.Builder msg = Payfile.PayFileMessage.newBuilder();
        msg.setType(Payfile.PayFileMessage.Type.DOWNLOAD_CHUNK);
        msg.setDownloadChunk(downloadChunk);
        writeMessage(msg.build());
    }

    private void writeMessage(Payfile.PayFileMessage msg) throws IOException {
        byte[] bits = msg.toByteArray();
        output.writeInt(bits.length);
        output.write(bits);
    }

    private class ClientThread extends Thread {
        @Override
        public void run() {
            try {
                running = true;
                while (true) {
                    int len = input.readInt();
                    if (len < 0 || len > 1024*1024)
                        throw new ProtocolException("Server sent message that's too large: " + len);
                    byte[] bits = new byte[len];
                    input.readFully(bits);
                    Payfile.PayFileMessage msg = Payfile.PayFileMessage.parseFrom(bits);
                    handle(msg);
                }
            } catch (EOFException | SocketException e) {
                if (running)
                    e.printStackTrace();
            } catch (Throwable t) {
                // Server flagged an error.
                if (currentFuture != null)
                    currentFuture.completeExceptionally(t);
                else
                    t.printStackTrace();
            }
        }
    }

    private void handle(Payfile.PayFileMessage msg) throws ProtocolException, IOException {
        switch (msg.getType()) {
            case MANIFEST:
                handleManifest(msg.getManifest());
                break;
            case DATA:
                handleData(msg.getData());
                break;
            case ERROR:
                handleError(msg.getError());
                break;
            case PAYMENT:
                handlePayment(msg.getPayment());
                break;
            default:
                throw new ProtocolException("Unhandled message");
        }
    }

    private void handleError(Payfile.Error error) throws ProtocolException {
        ProtocolException.Code code;
        try {
            code = ProtocolException.Code.valueOf(error.getCode());
        } catch (IllegalArgumentException e) {
            log.error("{}: Unknown error code: {}", socket, error.getCode());
            code = ProtocolException.Code.GENERIC;
        }
        throw new ProtocolException(code, error.getExplanation());
    }

    private void handlePayment(ByteString payment) throws ProtocolException {
        try {
            Protos.TwoWayChannelMessage paymentMessage = Protos.TwoWayChannelMessage.parseFrom(payment);
            paymentChannelClient.receiveMessage(paymentMessage);
        } catch (InvalidProtocolBufferException e) {
            throw new ProtocolException("Could not parse payment message: " + e.getMessage());
        } catch (InsufficientMoneyException e) {
            // This shouldn't happen as we shouldn't try to open a channel larger than what we can afford.
            throw new RuntimeException(e);
        }
    }

    @Nullable
    private File handleToFile(int handle) {
        for (File file : currentDownloads) {
            if (file.getHandle() == handle) {
                return file;
            }
        }
        return null;
    }

    private void handleData(Payfile.Data data) throws IOException, ProtocolException {
        File file = handleToFile(data.getHandle());
        if (file == null)
            throw new ProtocolException("Unknown handle");
        if (data.getChunkId() == file.nextChunk - 1) {
            final byte[] bits = data.getData().toByteArray();
            file.bytesDownloaded += bits.length;
            file.downloadStream.write(bits);
            if ((data.getChunkId() + 1) * chunkSize >= file.getSize()) {
                // File is done.
                file.downloadStream.close();
                currentDownloads.remove(file);
                file.completionFuture.complete(null);
                currentFuture = null;
            } else {
                downloadNextChunk(file);
            }
        } else {
            throw new ProtocolException("Server sent wrong part of file");
        }
    }

    private void handleManifest(Payfile.Manifest manifest) throws ProtocolException {
        if (currentQuery == null)
            throw new ProtocolException("Got MANIFEST before QUERY_FILES");
        List<File> files = new ArrayList<>(manifest.getFilesCount());
        for (Payfile.File f : manifest.getFilesList()) {
            File file = new File(f.getFileName(), f.getDescription(), f.getHandle(), f.getSize(), f.getPricePerChunk());
            files.add(file);
        }
        chunkSize = manifest.getChunkSize();
        currentFuture = null;
        currentQuery.complete(files);
    }
}
TOP

Related Classes of net.plan99.payfile.client.PayFileClient$File

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.