@@ -0,0 +1,93 @@
/**
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.bitcoin.protocols.payments.recurring;

import com.google.bitcoin.store.RecurringPaymentProtobufSerializer;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import org.bitcoin.protocols.payments.Protos;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.List;

public class Subscriptions {

private static String SUBSCRIPTIONS_FILE_NAME = "subscriptions";

private File walletDirectory;
private RecurringPaymentProtobufSerializer serializer;
private List<org.bitcoin.protocols.subscriptions.Protos.Subscription> subscriptions;

public static void storeContract(Protos.PaymentDetails paymentDetails, File walletDirectory) throws IOException {
File f = new File(walletDirectory, SUBSCRIPTIONS_FILE_NAME);
f.createNewFile();

RecurringPaymentProtobufSerializer recurringPaymentProtobufSerializer = new RecurringPaymentProtobufSerializer();
recurringPaymentProtobufSerializer.storeContract(paymentDetails, f);
}

public Subscriptions(File walletDirectory) {
this.walletDirectory = walletDirectory;
this.serializer = new RecurringPaymentProtobufSerializer();
}

public void load() throws IOException {
File f = new File(walletDirectory, SUBSCRIPTIONS_FILE_NAME);
Files.touch(f);

InputStream in = new FileInputStream(f);
try {
this.subscriptions = serializer.loadSubscriptions(in);
} finally {
in.close();
}
}

public List<Protos.PaymentDetails> getAllContracts() {
return Lists.transform(subscriptions, new Function<org.bitcoin.protocols.subscriptions.Protos.Subscription, Protos.PaymentDetails>() {
@Override
public Protos.PaymentDetails apply(org.bitcoin.protocols.subscriptions.Protos.Subscription input) {
return input.getContract();
}
});
}

public BigInteger getPaidAmountForPeriod(Protos.PaymentDetails contract) {
final String key = serializer.getUniqueKeyForContract(contract);
org.bitcoin.protocols.subscriptions.Protos.Subscription subscription = Iterables.tryFind(subscriptions, new Predicate<org.bitcoin.protocols.subscriptions.Protos.Subscription>() {
@Override
public boolean apply(org.bitcoin.protocols.subscriptions.Protos.Subscription input) {
return key.equals(serializer.getUniqueKeyForContract(input.getContract()));
}
}).get();

long paidAmountForPeriod = 0;
for (Protos.PaymentDetails pastPaymentForPeriod : subscription.getPaymentsForPeriodList()) {
for (Protos.Output output : pastPaymentForPeriod.getOutputsList()) {
paidAmountForPeriod += output.getAmount();
}
}

return BigInteger.valueOf(paidAmountForPeriod);
}
}
@@ -0,0 +1,90 @@
/**
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.bitcoin.store;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import org.bitcoin.protocols.subscriptions.Protos;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;

public class RecurringPaymentProtobufSerializer {

// New subscription
public void storeContract(org.bitcoin.protocols.payments.Protos.PaymentDetails contract, File subscriptionsFile) throws IOException {
// We are recreating the list of recurring payments in a temp file that we will atomically move upon completion
File temp = File.createTempFile(subscriptionsFile.getName(), ".tmp", subscriptionsFile.getAbsoluteFile().getParentFile());

final FileInputStream input = new FileInputStream(subscriptionsFile);
List<Protos.Subscription> allSubscriptions = loadSubscriptions(input);

List<Protos.Subscription> allUpdatedPaymentDetails = Lists.newLinkedList();
Protos.Subscription newSubscriptionForContract = Protos.Subscription.newBuilder().setContract(contract).build();
allUpdatedPaymentDetails.add(newSubscriptionForContract);

String keyForNewContract = getUniqueKeyForContract(contract);
for (Protos.Subscription subscription : allSubscriptions) {
String key = getUniqueKeyForContract(subscription.getContract());

// Consider only subscriptions for contracts other than the one we're adding or updating
if (!key.equals(keyForNewContract)) {
allUpdatedPaymentDetails.add(subscription);
}
}

final FileOutputStream output = new FileOutputStream(temp);
try {
writePaymentDetails(allUpdatedPaymentDetails, output);

// Final rename
if (!temp.renameTo(subscriptionsFile)) {
throw new IOException("Final rename failed");
}
} finally {
try {
input.close();
} finally {
output.close();
}
}
}

public List<Protos.Subscription> loadSubscriptions(InputStream input) throws IOException {
List<Protos.Subscription> result = Lists.newLinkedList();
while (input.available() > 0) {
result.add(Protos.Subscription.parseDelimitedFrom(input));
}
return result;
}

public String getUniqueKeyForContract(org.bitcoin.protocols.payments.Protos.PaymentDetails contract) {
return contract.getPaymentUrl() + contract.getTime();
}

@VisibleForTesting
void writePaymentDetails(List<Protos.Subscription> subscriptions, OutputStream output) throws IOException {
for (Protos.Subscription subscription : subscriptions) {
subscription.writeDelimitedTo(output);
}
}

}
13 changes: 13 additions & 0 deletions core/src/main/java/com/google/bitcoin/utils/Threading.java
Expand Up @@ -18,6 +18,7 @@

import com.google.common.util.concurrent.CycleDetectingLockFactory;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.ListeningScheduledExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.Uninterruptibles;
import org.slf4j.Logger;
Expand Down Expand Up @@ -188,4 +189,16 @@ public Thread newThread(Runnable r) {
}
})
);

public static ListeningScheduledExecutorService SCHEDULED_THREAD_POOL = MoreExecutors.listeningDecorator(
Executors.newScheduledThreadPool(1, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("Threading.SCHEDULED_THREAD_POOL worker");
t.setDaemon(true);
return t;
}
})
);
}
1,301 changes: 1,205 additions & 96 deletions core/src/main/java/org/bitcoin/protocols/payments/Protos.java

Large diffs are not rendered by default.

951 changes: 951 additions & 0 deletions core/src/main/java/org/bitcoin/protocols/subscriptions/Protos.java

Large diffs are not rendered by default.

27 changes: 20 additions & 7 deletions core/src/paymentrequest.proto
Expand Up @@ -40,13 +40,14 @@ message Output {
required bytes script = 2; // usually one of the standard Script forms
}
message PaymentDetails {
optional string network = 1 [default = "main"]; // "main" or "test"
repeated Output outputs = 2; // Where payment should be sent
required uint64 time = 3; // Timestamp; when payment request created
optional uint64 expires = 4; // Timestamp; when this request should be considered invalid
optional string memo = 5; // Human-readable description of request for the customer
optional string payment_url = 6; // URL to send Payment and get PaymentACK
optional bytes merchant_data = 7; // Arbitrary data to include in the Payment message
optional string network = 1 [default = "main"]; // "main" or "test"
repeated Output outputs = 2; // Where payment should be sent
required uint64 time = 3; // Timestamp; when payment request created
optional uint64 expires = 4; // Timestamp; when this request should be considered invalid
optional string memo = 5; // Human-readable description of request for the customer
optional string payment_url = 6; // URL to send Payment and get PaymentACK
optional bytes merchant_data = 7; // Arbitrary data to include in the Payment message
optional bytes serialized_recurring_payment_details = 8; // RecurringPaymentDetails
}
message PaymentRequest {
optional uint32 payment_details_version = 1 [default = 1];
Expand All @@ -67,4 +68,16 @@ message Payment {
message PaymentACK {
required Payment payment = 1; // Payment message that triggered this ACK
optional string memo = 2; // human-readable message for customer
}
enum PaymentFrequencyType {
WEEKLY = 1;
MONTHLY = 2;
QUARTERLY = 3;
ANNUAL = 4;
}
message RecurringPaymentDetails {
required string polling_url = 1; // URL to poll to get the next PaymentRequest
optional PaymentFrequencyType payment_frequency_type = 2; // Expected frequency
optional uint64 max_payment_per_period = 3; // Max payment amount within that frequency (e.g. no more than 5 BTC per month)
optional uint64 max_payment_amount = 4; // Max payment amount (e.g. no more than 3 BTC per payment)
}
28 changes: 28 additions & 0 deletions core/src/subscription.proto
@@ -0,0 +1,28 @@
/**
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Used only to store state

package payments;

option java_package = "org.bitcoin.protocols.subscriptions";
option java_outer_classname = "Protos";

import "paymentrequest.proto";

message Subscription {
required PaymentDetails contract = 1;
repeated PaymentDetails payments_for_period = 2;
}
@@ -0,0 +1,134 @@
/**
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.bitcoin.store;

import com.google.bitcoin.core.ECKey;
import com.google.bitcoin.core.NetworkParameters;
import com.google.bitcoin.core.Transaction;
import com.google.bitcoin.core.TransactionOutput;
import com.google.bitcoin.core.Utils;
import com.google.bitcoin.params.TestNet3Params;
import com.google.bitcoin.protocols.payments.recurring.Subscriptions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.protobuf.ByteString;
import org.bitcoin.protocols.payments.Protos;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.util.List;

public class RecurringPaymentProtobufSerializerTest {

private static final NetworkParameters params = TestNet3Params.get();
private static final String simplePaymentUrl = "http://a.simple.url.com/";
private static final String paymentRequestMemo = "send coinz noa plz kthx";
private static final ByteString merchantData = ByteString.copyFromUtf8("merchant data");
private static final long time = System.currentTimeMillis() / 1000L;
private ECKey serverKey;
private Transaction tx;
private TransactionOutput outputToMe;
BigInteger nanoCoins = Utils.toNanoCoins(1, 0);

@Before
public void setUp() throws Exception {
serverKey = new ECKey();
tx = new Transaction(params);
outputToMe = new TransactionOutput(params, tx, nanoCoins, serverKey);
tx.addOutput(outputToMe);
}

@Test
public void testOnePaymentDetails() throws Exception {

Protos.Output.Builder outputBuilder = Protos.Output.newBuilder()
.setAmount(nanoCoins.longValue())
.setScript(ByteString.copyFrom(outputToMe.getScriptBytes()));
Protos.RecurringPaymentDetails recurringPaymentDetails = Protos.RecurringPaymentDetails.newBuilder()
.setPollingUrl(simplePaymentUrl)
.setMaxPaymentPerPeriod(1000)
.setPaymentFrequencyType(Protos.PaymentFrequencyType.ANNUAL)
.build();
Protos.PaymentDetails paymentDetails = Protos.PaymentDetails.newBuilder()
.setNetwork("test")
.setTime(time - 10)
.setExpires(time - 1)
.setPaymentUrl(simplePaymentUrl)
.addOutputs(outputBuilder)
.setMemo(paymentRequestMemo)
.setMerchantData(merchantData)
.setSerializedRecurringPaymentDetails(recurringPaymentDetails.toByteString())
.build();

RecurringPaymentProtobufSerializer serializer = new RecurringPaymentProtobufSerializer();
ByteArrayOutputStream output = new ByteArrayOutputStream();

org.bitcoin.protocols.subscriptions.Protos.Subscription subscription = org.bitcoin.protocols.subscriptions.Protos.Subscription.newBuilder()
.setContract(paymentDetails)
.build();
serializer.writePaymentDetails(ImmutableList.of(subscription), output);

List<org.bitcoin.protocols.subscriptions.Protos.Subscription> result = serializer.loadSubscriptions(new ByteArrayInputStream(output.toByteArray()));
Assert.assertEquals(1, result.size());
Assert.assertEquals(result.get(0).getContract(), paymentDetails);
Assert.assertEquals(0, result.get(0).getPaymentsForPeriodList().size());
}

@Test
public void testMultiplePaymentDetails() throws Exception {
List<org.bitcoin.protocols.subscriptions.Protos.Subscription> allSubscriptions = Lists.newArrayList();
for (int i = 0 ; i < 12; i++) {
Protos.Output.Builder outputBuilder = Protos.Output.newBuilder()
.setAmount(nanoCoins.longValue())
.setScript(ByteString.copyFrom(outputToMe.getScriptBytes()));
Protos.RecurringPaymentDetails recurringPaymentDetails = Protos.RecurringPaymentDetails.newBuilder()
.setPollingUrl(simplePaymentUrl)
.setMaxPaymentPerPeriod(1000)
.setPaymentFrequencyType(Protos.PaymentFrequencyType.ANNUAL)
.build();
Protos.PaymentDetails paymentDetails = Protos.PaymentDetails.newBuilder()
.setNetwork("test")
.setTime(time)
.setExpires(time - i)
.setPaymentUrl(simplePaymentUrl)
.addOutputs(outputBuilder)
.setMemo(paymentRequestMemo)
.setMerchantData(merchantData)
.setSerializedRecurringPaymentDetails(recurringPaymentDetails.toByteString())
.build();

org.bitcoin.protocols.subscriptions.Protos.Subscription subscription = org.bitcoin.protocols.subscriptions.Protos.Subscription.newBuilder()
.setContract(paymentDetails)
.build();
allSubscriptions.add(subscription);
}

RecurringPaymentProtobufSerializer serializer = new RecurringPaymentProtobufSerializer();
ByteArrayOutputStream output = new ByteArrayOutputStream();
serializer.writePaymentDetails(allSubscriptions, output);

List<org.bitcoin.protocols.subscriptions.Protos.Subscription> result = serializer.loadSubscriptions(new ByteArrayInputStream(output.toByteArray()));
Assert.assertEquals(12, result.size());
for (int i = 0; i < 12; i++) {
Assert.assertEquals(time - i, result.get(i).getContract().getExpires());
Assert.assertEquals(0, result.get(i).getPaymentsForPeriodList().size());
}
}
}
102 changes: 81 additions & 21 deletions tools/src/main/java/com/google/bitcoin/tools/WalletTool.java
Expand Up @@ -25,6 +25,8 @@
import com.google.bitcoin.params.TestNet3Params;
import com.google.bitcoin.protocols.payments.PaymentRequestException;
import com.google.bitcoin.protocols.payments.PaymentSession;
import com.google.bitcoin.protocols.payments.recurring.PollingCallback;
import com.google.bitcoin.protocols.payments.recurring.RecurringPaymentSession;
import com.google.bitcoin.store.*;
import com.google.bitcoin.uri.BitcoinURI;
import com.google.bitcoin.uri.BitcoinURIParseException;
Expand All @@ -42,6 +44,7 @@
import org.slf4j.LoggerFactory;
import org.spongycastle.util.encoders.Hex;

import javax.annotation.Nullable;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
Expand All @@ -54,7 +57,9 @@
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.LogManager;

Expand Down Expand Up @@ -207,6 +212,7 @@ public static void main(String[] args) throws Exception {
parser.accepts("ignore-mandatory-extensions");
OptionSpec<String> passwordFlag = parser.accepts("password").withRequiredArg();
OptionSpec<String> paymentRequestLocation = parser.accepts("payment-request").withRequiredArg();
parser.accepts("recurring-payments");
parser.accepts("no-pki");
options = parser.parse(args);

Expand Down Expand Up @@ -330,8 +336,10 @@ public static void main(String[] args) throws Exception {
send(outputFlag.values(options), fee, lockTime, allowUnconfirmed);
} else if (options.has(paymentRequestLocation)) {
sendPaymentRequest(paymentRequestLocation.value(options), !options.has("no-pki"));
} else if (options.has("recurring-payments")) {
pollRecurringPayments();
} else {
System.err.println("You must specify a --payment-request or at least one --output=addr:value.");
System.err.println("You must specify a --payment-request or --recurring-payments or at least one --output=addr:value.");
return;
}
break;
Expand Down Expand Up @@ -541,30 +549,12 @@ private static void send(PaymentSession session) {
System.out.println("Pki-Verified Org: " + session.pkiVerificationData.orgName);
}
final Wallet.SendRequest req = session.getSendRequest();
if (password != null) {
if (!wallet.checkPassword(password)) {
System.err.println("Password is incorrect.");
return;
}
req.aesKey = wallet.getKeyCrypter().deriveKey(password);
}
wallet.completeTx(req); // may throw InsufficientMoneyException.
if (options.has("offline")) {
wallet.commitTx(req.tx);
if (!completeTransaction(req)) {
return;
}
setup();
// No refund address specified, no user-specified memo field.
ListenableFuture<PaymentSession.Ack> future = session.sendPayment(ImmutableList.of(req.tx), null, null);
if (future == null) {
// No payment_url for submission so, broadcast and wait.
peers.startAndWait();
peers.broadcastTransaction(req.tx).get();
} else {
PaymentSession.Ack ack = future.get();
wallet.commitTx(req.tx);
System.out.println("Memo from server: " + ack.getMemo());
}
fetchAck(req, future);
} catch (PaymentRequestException e) {
System.err.println("Failed to send payment " + e.getMessage());
System.exit(1);
Expand All @@ -586,6 +576,76 @@ private static void send(PaymentSession session) {
}
}

private static void pollRecurringPayments() throws IOException, InterruptedException {
final AtomicBoolean isDone = new AtomicBoolean(false);
ScheduledFuture future = RecurringPaymentSession.startPollingForRecurringPayments(walletFile.getAbsoluteFile().getParentFile(), new PollingCallback() {
@Override
public boolean preparePayment(Wallet.SendRequest sendRequest, BigInteger maxAgreedPaymentAmount, org.bitcoin.protocols.payments.Protos.PaymentFrequencyType frequencyType, BigInteger maxAgreedPaymentAmountPerPeriod, BigInteger curPaymentAmount, BigInteger curPaymentAmountPerPeriod) {
try {
return maxAgreedPaymentAmount.compareTo(curPaymentAmount) >= 0 && WalletTool.completeTransaction(sendRequest);
} catch (Exception e) {
System.err.println("Error completing the transaction: " + e.getMessage());
return false;
}
}

@Override
public void onAck(Wallet.SendRequest request, @Nullable ListenableFuture<PaymentSession.Ack> future) {
try {
WalletTool.fetchAck(request, future);
} catch (Exception e) {
onException(e, null);
}
}

@Override
public void onException(Exception e, @Nullable org.bitcoin.protocols.payments.Protos.PaymentDetails contract) {
System.err.println("Got exception " + e.getMessage());
}

@Override
public void onCompletion(int nbPaymentsSent) {
System.out.println("Processed " + nbPaymentsSent + " sessions");
isDone.set(true);
}
}, !options.has("no-pki"));

while (!isDone.get()) {
Thread.sleep(1000);
}

future.cancel(true);
}

private static boolean completeTransaction(Wallet.SendRequest req) throws InsufficientMoneyException, BlockStoreException {
if (password != null) {
if (!wallet.checkPassword(password)) {
System.err.println("Password is incorrect.");
return false;
}
req.aesKey = wallet.getKeyCrypter().deriveKey(password);
}
wallet.completeTx(req); // may throw InsufficientMoneyException.
if (options.has("offline")) {
wallet.commitTx(req.tx);
return false;
}
setup();
return true;
}

private static void fetchAck(Wallet.SendRequest request, @Nullable ListenableFuture<PaymentSession.Ack> future) throws InterruptedException, ExecutionException {
if (future == null) {
// No payment_url for submission so, broadcast and wait.
peers.startAndWait();
peers.broadcastTransaction(request.tx).get();
} else {
PaymentSession.Ack ack = future.get();
wallet.commitTx(request.tx);
System.out.println("Memo from server: " + ack.getMemo());
}
}

private static void wait(WaitForEnum waitFor) throws BlockStoreException {
final CountDownLatch latch = new CountDownLatch(1);
setup();
Expand Down