package com.google.devcoin.protocols.channels; import com.google.devcoin.core.*; import com.google.devcoin.protocols.channels.PaymentChannelCloseException.CloseReason; import com.google.devcoin.utils.Threading; import com.google.common.annotations.VisibleForTesting; import com.google.protobuf.ByteString; import net.jcip.annotations.GuardedBy; import org.devcoin.paymentchannel.Protos; import org.slf4j.LoggerFactory; import java.math.BigInteger; import java.util.concurrent.locks.ReentrantLock; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; /** * <p>A class which handles most of the complexity of creating a payment channel connection by providing a * simple in/out interface which is provided with protobufs from the server and which generates protobufs which should * be sent to the server.</p> * * <p>Does all required verification of server messages and properly stores state objects in the wallet-attached * {@link StoredPaymentChannelClientStates} so that they are automatically closed when necessary and refund * transactions are not lost if the application crashes before it unlocks.</p> * * <p>Though this interface is largely designed with stateful protocols (eg simple TCP connections) in mind, it is also * possible to use it with stateless protocols (eg sending protobufs when required over HTTP headers). In this case, the * "connection" translates roughly into the server-client relationship. See the javadocs for specific functions for more * details.</p> */ public class PaymentChannelClient { private static final org.slf4j.Logger log = LoggerFactory.getLogger(PaymentChannelClient.class); protected final ReentrantLock lock = Threading.lock("channelclient"); /** * Implements the connection between this client and the server, providing an interface which allows messages to be * sent to the server, requests for the connection to the server to be closed, and a callback which occurs when the * channel is fully open. */ public interface ClientConnection { /** * <p>Requests that the given message be sent to the server. There are no blocking requirements for this method, * however the order of messages must be preserved.</p> * * <p>If the send fails, no exception should be thrown, however * {@link PaymentChannelClient#connectionClosed()} should be called immediately. In the case of messages which * are a part of initialization, initialization will simply fail and the refund transaction will be broadcasted * when it unlocks (if necessary). In the case of a payment message, the payment will be lost however if the * channel is resumed it will begin again from the channel value <i>after</i> the failed payment.</p> * * <p>Called while holding a lock on the {@link PaymentChannelClient} object - be careful about reentrancy</p> */ public void sendToServer(Protos.TwoWayChannelMessage msg); /** * <p>Requests that the connection to the server be closed. For stateless protocols, note that after this call, * no more messages should be received from the server and this object is no longer usable. A * {@link PaymentChannelClient#connectionClosed()} event should be generated immediately after this call.</p> * * <p>Called while holding a lock on the {@link PaymentChannelClient} object - be careful about reentrancy</p> * * @param reason The reason for the closure, see the individual values for more details. * It is usually safe to ignore this and treat any value below * {@link CloseReason#CLIENT_REQUESTED_CLOSE} as "unrecoverable error" and all others as * "try again once and see if it works then" */ public void destroyConnection(CloseReason reason); /** * <p>Indicates the channel has been successfully opened and * {@link PaymentChannelClient#incrementPayment(java.math.BigInteger)} may be called at will.</p> * * <p>Called while holding a lock on the {@link PaymentChannelClient} object - be careful about reentrancy</p> */ public void channelOpen(); } @GuardedBy("lock") private final ClientConnection conn; // Used to keep track of whether or not the "socket" ie connection is open and we can generate messages @VisibleForTesting @GuardedBy("lock") boolean connectionOpen = false; // The state object used to step through initialization and pay the server @GuardedBy("lock") private PaymentChannelClientState state; // The step we are at in initialization, this is partially duplicated in the state object private enum InitStep { WAITING_FOR_CONNECTION_OPEN, WAITING_FOR_VERSION_NEGOTIATION, WAITING_FOR_INITIATE, WAITING_FOR_REFUND_RETURN, WAITING_FOR_CHANNEL_OPEN, CHANNEL_OPEN } @GuardedBy("lock") private InitStep step = InitStep.WAITING_FOR_CONNECTION_OPEN; // Will either hold the StoredClientChannel of this channel or null after connectionOpen private StoredClientChannel storedChannel; // An arbitrary hash which identifies this channel (specified by the API user) private final Sha256Hash serverId; // The wallet associated with this channel private final Wallet wallet; // Information used during channel initialization to send to the server or check what the server sends to us private final ECKey myKey; private final BigInteger maxValue; /** * <p>The maximum amount of time for which we will accept the server locking up our funds for the multisig * contract.</p> * * <p>Note that though this is not final, it is in all caps because it should generally not be modified unless you * have some guarantee that the server will not request at least this (channels will fail if this is too small).</p> * * <p>24 hours is the default as it is expected that clients limit risk exposure by limiting channel size instead of * limiting lock time when dealing with potentially malicious servers.</p> */ public long MAX_TIME_WINDOW = 24*60*60; /** * Constructs a new channel manager which waits for {@link PaymentChannelClient#connectionOpen()} before acting. * * @param wallet The wallet which will be paid from, and where completed transactions will be committed. * Must already have a {@link StoredPaymentChannelClientStates} object in its extensions set. * @param myKey A freshly generated keypair used for the multisig contract and refund output. * @param maxValue The maximum value the server is allowed to request that we lock into this channel until the * refund transaction unlocks. Note that if there is a previously open channel, the refund * transaction used in this channel may be larger than maxValue. Thus, maxValue is not a method for * limiting the amount payable through this channel. * @param serverId An arbitrary hash representing this channel. This must uniquely identify the server. If an * existing stored channel exists in the wallet's {@link StoredPaymentChannelClientStates}, then an * attempt will be made to resume that channel. * @param conn A callback listener which represents the connection to the server (forwards messages we generate to * the server) */ public PaymentChannelClient(Wallet wallet, ECKey myKey, BigInteger maxValue, Sha256Hash serverId, ClientConnection conn) { this.wallet = checkNotNull(wallet); this.myKey = checkNotNull(myKey); this.maxValue = checkNotNull(maxValue); this.serverId = checkNotNull(serverId); this.conn = checkNotNull(conn); } @GuardedBy("lock") private void receiveInitiate(Protos.Initiate initiate, BigInteger contractValue) throws VerificationException, ValueOutOfRangeException { log.info("Got INITIATE message, providing refund transaction"); state = new PaymentChannelClientState(wallet, myKey, new ECKey(null, initiate.getMultisigKey().toByteArray()), contractValue, initiate.getExpireTimeSecs()); state.initiate(); step = InitStep.WAITING_FOR_REFUND_RETURN; Protos.ProvideRefund.Builder provideRefundBuilder = Protos.ProvideRefund.newBuilder() .setMultisigKey(ByteString.copyFrom(myKey.getPubKey())) .setTx(ByteString.copyFrom(state.getIncompleteRefundTransaction().bitcoinSerialize())); conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setProvideRefund(provideRefundBuilder) .setType(Protos.TwoWayChannelMessage.MessageType.PROVIDE_REFUND) .build()); } @GuardedBy("lock") private void receiveRefund(Protos.TwoWayChannelMessage msg) throws VerificationException { checkState(step == InitStep.WAITING_FOR_REFUND_RETURN && msg.hasReturnRefund()); log.info("Got RETURN_REFUND message, providing signed contract"); Protos.ReturnRefund returnedRefund = msg.getReturnRefund(); state.provideRefundSignature(returnedRefund.getSignature().toByteArray()); step = InitStep.WAITING_FOR_CHANNEL_OPEN; // Before we can send the server the contract (ie send it to the network), we must ensure that our refund // transaction is safely in the wallet - thus we store it (this also keeps it up-to-date when we pay) state.storeChannelInWallet(serverId); Protos.ProvideContract.Builder provideContractBuilder = Protos.ProvideContract.newBuilder() .setTx(ByteString.copyFrom(state.getMultisigContract().bitcoinSerialize())); conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setProvideContract(provideContractBuilder) .setType(Protos.TwoWayChannelMessage.MessageType.PROVIDE_CONTRACT) .build()); } @GuardedBy("lock") private void receiveChannelOpen() throws VerificationException { checkState(step == InitStep.WAITING_FOR_CHANNEL_OPEN || (step == InitStep.WAITING_FOR_INITIATE && storedChannel != null), step); log.info("Got CHANNEL_OPEN message, ready to pay"); if (step == InitStep.WAITING_FOR_INITIATE) state = new PaymentChannelClientState(storedChannel, wallet); step = InitStep.CHANNEL_OPEN; // channelOpen should disable timeouts, but // TODO accomodate high latency between PROVIDE_CONTRACT and here conn.channelOpen(); } /** * Called when a message is received from the server. Processes the given message and generates events based on its * content. */ public void receiveMessage(Protos.TwoWayChannelMessage msg) { lock.lock(); try { checkState(connectionOpen); // If we generate an error, we set errorBuilder and closeReason and break, otherwise we return Protos.Error.Builder errorBuilder; CloseReason closeReason; try { switch (msg.getType()) { case SERVER_VERSION: checkState(step == InitStep.WAITING_FOR_VERSION_NEGOTIATION && msg.hasServerVersion()); // Server might send back a major version lower than our own if they want to fallback to a lower version // We can't handle that, so we just close the channel if (msg.getServerVersion().getMajor() != 0) { errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.NO_ACCEPTABLE_VERSION); closeReason = CloseReason.NO_ACCEPTABLE_VERSION; break; } log.info("Got version handshake, awaiting INITIATE or resume CHANNEL_OPEN"); step = InitStep.WAITING_FOR_INITIATE; return; case INITIATE: checkState(step == InitStep.WAITING_FOR_INITIATE && msg.hasInitiate()); Protos.Initiate initiate = msg.getInitiate(); checkState(initiate.getExpireTimeSecs() > 0 && initiate.getMinAcceptedChannelSize() >= 0); if (initiate.getExpireTimeSecs() > Utils.now().getTime()/1000 + MAX_TIME_WINDOW) { errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.TIME_WINDOW_TOO_LARGE); closeReason = CloseReason.TIME_WINDOW_TOO_LARGE; break; } BigInteger minChannelSize = BigInteger.valueOf(initiate.getMinAcceptedChannelSize()); if (maxValue.compareTo(minChannelSize) < 0) { errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.CHANNEL_VALUE_TOO_LARGE); closeReason = CloseReason.SERVER_REQUESTED_TOO_MUCH_VALUE; break; } receiveInitiate(initiate, maxValue); return; case RETURN_REFUND: receiveRefund(msg); return; case CHANNEL_OPEN: receiveChannelOpen(); return; case CLOSE: conn.destroyConnection(CloseReason.SERVER_REQUESTED_CLOSE); return; case ERROR: checkState(msg.hasError()); log.error("Server sent ERROR {} with explanation {}", msg.getError().getCode().name(), msg.getError().hasExplanation() ? msg.getError().getExplanation() : ""); conn.destroyConnection(CloseReason.REMOTE_SENT_ERROR); return; default: log.error("Got unknown message type or type that doesn't apply to clients."); errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.SYNTAX_ERROR); closeReason = CloseReason.REMOTE_SENT_INVALID_MESSAGE; break; } } catch (VerificationException e) { log.error("Caught verification exception handling message from server", e); errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.BAD_TRANSACTION) .setExplanation(e.getMessage()); closeReason = CloseReason.REMOTE_SENT_INVALID_MESSAGE; } catch (ValueOutOfRangeException e) { log.error("Caught value out of range exception handling message from server", e); errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.BAD_TRANSACTION) .setExplanation(e.getMessage()); closeReason = CloseReason.REMOTE_SENT_INVALID_MESSAGE; } catch (IllegalStateException e) { log.error("Caught illegal state exception handling message from server", e); errorBuilder = Protos.Error.newBuilder() .setCode(Protos.Error.ErrorCode.SYNTAX_ERROR); closeReason = CloseReason.REMOTE_SENT_INVALID_MESSAGE; } conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setError(errorBuilder) .setType(Protos.TwoWayChannelMessage.MessageType.ERROR) .build()); conn.destroyConnection(closeReason); } finally { lock.unlock(); } } /** * <p>Called when the connection terminates. Notifies the {@link StoredClientChannel} object that we can attempt to * resume this channel in the future and stops generating messages for the server.</p> * * <p>For stateless protocols, this translates to a client not using the channel for the immediate future, but * intending to reopen the channel later. There is likely little reason to use this in a stateless protocol.</p> * * <p>Note that this <b>MUST</b> still be called even after either * {@link ClientConnection#destroyConnection(CloseReason)} or * {@link PaymentChannelClient#close()} is called to actually handle the connection close logic.</p> */ public void connectionClosed() { lock.lock(); try { connectionOpen = false; if (state != null) state.disconnectFromChannel(); } finally { lock.unlock(); } } /** * <p>Closes the connection, notifying the server it should close the channel by broadcasting the most recent payment * transaction.</p> * * <p>Note that this only generates a CLOSE message for the server and calls * {@link ClientConnection#destroyConnection(CloseReason)} to close the connection, it does not * actually handle connection close logic, and {@link PaymentChannelClient#connectionClosed()} must still be called * after the connection fully closes.</p> * * @throws IllegalStateException If the connection is not currently open (ie the CLOSE message cannot be sent) */ public void close() throws IllegalStateException { lock.lock(); try { checkState(connectionOpen); conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setType(Protos.TwoWayChannelMessage.MessageType.CLOSE) .build()); conn.destroyConnection(CloseReason.CLIENT_REQUESTED_CLOSE); } finally { lock.unlock(); } } /** * <p>Called to indicate the connection has been opened and messages can now be generated for the server.</p> * * <p>Attempts to find a channel to resume and generates a CLIENT_VERSION message for the server based on the * result.</p> */ public void connectionOpen() { lock.lock(); try { connectionOpen = true; StoredPaymentChannelClientStates channels = (StoredPaymentChannelClientStates) wallet.getExtensions().get(StoredPaymentChannelClientStates.EXTENSION_ID); if (channels != null) storedChannel = channels.getUsableChannelForServerID(serverId); step = InitStep.WAITING_FOR_VERSION_NEGOTIATION; Protos.ClientVersion.Builder versionNegotiationBuilder = Protos.ClientVersion.newBuilder() .setMajor(0).setMinor(1); if (storedChannel != null) { versionNegotiationBuilder.setPreviousChannelContractHash(ByteString.copyFrom(storedChannel.contract.getHash().getBytes())); log.info("Begun version handshake, attempting to reopen channel with contract hash {}", storedChannel.contract.getHash()); } else log.info("Begun version handshake creating new channel"); conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setType(Protos.TwoWayChannelMessage.MessageType.CLIENT_VERSION) .setClientVersion(versionNegotiationBuilder) .build()); } finally { lock.unlock(); } } /** * <p>Gets the {@link PaymentChannelClientState} object which stores the current state of the connection with the * server.</p> * * <p>Note that if you call any methods which update state directly the server will not be notified and channel * initialization logic in the connection may fail unexpectedly.</p> */ public PaymentChannelClientState state() { lock.lock(); try { return state; } finally { lock.unlock(); } } /** * Increments the total value which we pay the server. * * @param size How many satoshis to increment the payment by (note: not the new total). * @throws ValueOutOfRangeException If the size is negative or would pay more than this channel's total value * ({@link PaymentChannelClientConnection#state()}.getTotalValue()) * @throws IllegalStateException If the channel has been closed or is not yet open * (see {@link PaymentChannelClientConnection#getChannelOpenFuture()} for the second) */ public void incrementPayment(BigInteger size) throws ValueOutOfRangeException, IllegalStateException { lock.lock(); try { if (state() == null || !connectionOpen || step != InitStep.CHANNEL_OPEN) throw new IllegalStateException("Channel is not fully initialized/has already been closed"); byte[] signature = state().incrementPaymentBy(size); Protos.UpdatePayment.Builder updatePaymentBuilder = Protos.UpdatePayment.newBuilder() .setSignature(ByteString.copyFrom(signature)) .setClientChangeValue(state.getValueRefunded().longValue()); conn.sendToServer(Protos.TwoWayChannelMessage.newBuilder() .setUpdatePayment(updatePaymentBuilder) .setType(Protos.TwoWayChannelMessage.MessageType.UPDATE_PAYMENT) .build()); } finally { lock.unlock(); } } }