package lighthouse.wallet; import com.google.common.collect.*; import com.google.common.util.concurrent.*; import com.google.protobuf.*; import kotlin.*; import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function2; import lighthouse.protocol.*; import net.jcip.annotations.*; import org.bitcoinj.core.*; import org.bitcoinj.crypto.*; import org.bitcoinj.params.*; import org.bitcoinj.script.*; import org.bitcoinj.store.*; import org.bitcoinj.utils.*; import org.bitcoinj.wallet.*; import org.slf4j.*; import org.spongycastle.crypto.params.*; import javax.annotation.*; import java.util.*; import java.util.concurrent.*; import java.util.function.*; import java.util.stream.*; import static com.google.common.base.Preconditions.*; import static lighthouse.protocol.LHUtils.*; /** * A pledging wallet is a customization of the normal Wallet class that knows how to form, track, serialize and undo * pledges to various projects. */ public class PledgingWallet extends Wallet { private static final Logger log = LoggerFactory.getLogger(PledgingWallet.class); @GuardedBy("this") private final BiMap<TransactionOutput, LHProtos.Pledge> pledges; @GuardedBy("this") private final BiMap<Project, LHProtos.Pledge> projects; // See the wallet-extension.proto file for a discussion of why we track these. @GuardedBy("this") private final Map<Sha256Hash, LHProtos.Pledge> revokedPledges; public interface OnRevokeHandler { void onRevoke(LHProtos.Pledge pledge); } public interface OnClaimHandler { void onClaim(LHProtos.Pledge pledge, Transaction claimTX); } private CopyOnWriteArrayList<ListenerRegistration<Function2<Project, LHProtos.Pledge, Unit>>> onPledgeHandlers = new CopyOnWriteArrayList<>(); private CopyOnWriteArrayList<ListenerRegistration<Function1<LHProtos.Pledge, Unit>>> onRevokeHandlers = new CopyOnWriteArrayList<>(); private CopyOnWriteArrayList<ListenerRegistration<Function2<LHProtos.Pledge, Transaction, Unit>>> onClaimedHandlers = new CopyOnWriteArrayList<>(); public PledgingWallet(NetworkParameters params, KeyChainGroup keyChainGroup) { super(params, keyChainGroup); setCoinSelector(new IgnorePledgedCoinSelector()); pledges = HashBiMap.create(); projects = HashBiMap.create(); revokedPledges = new HashMap<>(); addExtension(new PledgeStorage(this)); } public PledgingWallet(NetworkParameters params) { this(params, new KeyChainGroup(params)); } // This has to be a static class, as it must be instantiated BEFORE the wallet is created so it can be passed // to the deserializer. public static class PledgeStorage implements WalletExtension { public PledgingWallet wallet; public PledgeStorage(@Nullable PledgingWallet wallet) { // Can be null if this was created prior to deserialization: we'll be given the wallet later in that case. this.wallet = wallet; } @Override public String getWalletExtensionID() { return "com.vinumeris.lighthouse"; } @Override public boolean isWalletExtensionMandatory() { // Allow other apps to open these wallets. Of course those wallets will be downgraded automatically and // may have their pledges messed up/revoked, but in a pinch it may be a good way to recover money from // a bad install (e.g. if app crashes at startup for some reason). return false; } @Override public byte[] serializeWalletExtension() { LHWalletProtos.Extension.Builder ext = LHWalletProtos.Extension.newBuilder(); wallet.populateExtensionProto(ext); return ext.build().toByteArray(); } @SuppressWarnings("FieldAccessNotGuarded") // No need to synchronize when deserializing a new wallet. @Override public void deserializeWalletExtension(Wallet containingWallet, byte[] data) throws Exception { wallet = (PledgingWallet) containingWallet; LHWalletProtos.Extension ext = LHWalletProtos.Extension.parseFrom(data); log.info("Wallet has {} pledges in it", ext.getPledgesCount()); Map<TransactionOutput, LHProtos.Pledge> contractOuts = new HashMap<>(); for (LHProtos.Pledge pledge : ext.getPledgesList()) { final List<ByteString> txns = pledge.getTransactionsList(); // The pledge must be the only tx. Transaction pledgeTx = new Transaction(wallet.params, txns.get(txns.size() - 1).toByteArray()); if (pledgeTx.getInputs().size() != 1) { log.error("Pledge TX does not seem to have the right form: {}", pledgeTx); continue; } // Find the stub output that the pledge spends. final TransactionOutPoint op = pledgeTx.getInput(0).getOutpoint(); final Transaction transaction = wallet.transactions.get(op.getHash()); // TODO: if transaction == null here then the wallet has been reset and this is an orphan pledge. checkNotNull(transaction); TransactionOutput output = transaction.getOutput((int) op.getIndex()); checkNotNull(output); // Record the contract output it pledges to. contractOuts.put(pledgeTx.getOutput(0).duplicateDetached(), pledge); log.info("Loaded pledge {}", LHUtils.hashFromPledge(pledge)); wallet.pledges.put(output, pledge); } for (LHProtos.Project project : ext.getProjectsList()) { Project p = new Project(project); TransactionOutput output = p.getOutputs().get(0).duplicateDetached(); LHProtos.Pledge pledgeForProject = contractOuts.get(output); if (pledgeForProject != null) wallet.projects.put(p, pledgeForProject); } for (LHProtos.Pledge pledge : ext.getRevokedPledgesList()) { wallet.revokedPledges.put(hashFromPledge(pledge), pledge); } } @Override public String toString() { return wallet.pledgesToString(); } } private synchronized String pledgesToString() { StringBuilder builder = new StringBuilder(); BiMap<LHProtos.Pledge, Project> mapPledgeProject = projects.inverse(); for (LHProtos.Pledge pledge : pledges.values()) { builder.append(String.format("Pledge:%n%s%nTotal input value: %d%nFor project: %s%n%n", new Transaction(params, pledge.getTransactions(0).toByteArray()), pledge.getPledgeDetails().getTotalInputValue(), mapPledgeProject.get(pledge))); } for (Project project : projects.keySet()) { builder.append(String.format("Project: %s%n", project.getTitle())); } return builder.toString(); } public interface PledgeSupplier { LHProtos.Pledge getData(); LHProtos.Pledge commit(boolean andBroadcastDeps); } public class PendingPledge implements PledgeSupplier { @Nullable public final Transaction dependency; public final Transaction pledge; public final long feesRequired; public final Project project; public final LHProtos.PledgeDetails details; public final long timestamp; private boolean committed = false; public PendingPledge(Project project, @Nullable Transaction dependency, Transaction pledge, long feesRequired, LHProtos.PledgeDetails details) { this.project = project; this.dependency = dependency; this.pledge = pledge; this.feesRequired = feesRequired; this.details = details; this.timestamp = Utils.currentTimeSeconds(); } public LHProtos.Pledge getData() { final TransactionOutput stub = pledge.getInput(0).getConnectedOutput(); checkNotNull(stub); LHProtos.Pledge.Builder proto = LHProtos.Pledge.newBuilder(); if (dependency != null) proto.addTransactions(ByteString.copyFrom(dependency.bitcoinSerialize())); proto.addTransactions(ByteString.copyFrom(pledge.bitcoinSerialize())); proto.getPledgeDetailsBuilder().mergeFrom(details); proto.getPledgeDetailsBuilder().setTotalInputValue(stub.getValue().longValue()); proto.getPledgeDetailsBuilder().setTimestamp(timestamp); proto.getPledgeDetailsBuilder().setProjectId(project.getID()); return proto.build(); } public LHProtos.Pledge commit(boolean andBroadcastDependencies) { // Commit and broadcast the dependency. LHProtos.Pledge data = getData(); final TransactionOutput stub = pledge.getInput(0).getConnectedOutput(); checkNotNull(stub); checkState(!committed); log.info("Committing pledge for stub: {}", stub); committed = true; if (dependency != null) { // It's possible that by the time we arrive here, the dependency is already committed, thus we use the // maybe variant. The reason is, for a server-assisted project the server will broadcast the dependency // for us to avoid races where the server thinks a pledge is invalid because it can't see the stub // output. If the server responds to our HTTP upload request and we get here *slower* that the p2p // network manages to propagate the transaction back to us, we might have already processed the // dependency tx! if (maybeCommitTx(dependency) && andBroadcastDependencies) { log.info("Broadcasting dependency"); vTransactionBroadcaster.broadcastTransaction(dependency); } } log.info("Pledge has {} txns", data.getTransactionsCount()); Coin prevBalance = getBalance(BalanceType.AVAILABLE_SPENDABLE); updateForPledge(data, project, stub); saveNow(); onPledgeHandlers.forEach(it -> it.executor.execute(() -> it.listener.invoke(project, data))); lock.lock(); try { queueOnCoinsSent(pledge, prevBalance, getBalance(BalanceType.AVAILABLE_SPENDABLE)); maybeQueueOnWalletChanged(); } finally { lock.unlock(); } return data; } } public org.bitcoinj.wallet.Protos.Wallet serialize() { WalletProtobufSerializer serializer = new WalletProtobufSerializer(); return serializer.walletToProto(this); } public static PledgingWallet deserialize(org.bitcoinj.wallet.Protos.Wallet proto) throws UnreadableWalletException { WalletProtobufSerializer serializer = new WalletProtobufSerializer(PledgingWallet::new); NetworkParameters params = NetworkParameters.fromID(proto.getNetworkIdentifier()); return (PledgingWallet) serializer.readWallet(params, new WalletExtension[]{new PledgeStorage(null)}, proto); } private synchronized void populateExtensionProto(LHWalletProtos.Extension.Builder ext) { ext.addAllPledges(pledges.values()); ext.addAllProjects(projects.keySet().stream().map(Project::getProto).collect(Collectors.toList())); ext.addAllRevokedPledges(revokedPledges.values()); } private synchronized void updateForPledge(LHProtos.Pledge data, Project project, TransactionOutput stub) { pledges.put(stub, data); projects.put(project, data); } public PendingPledge createPledge(Project project, long satoshis, @Nullable KeyParameter aesKey) throws InsufficientMoneyException { return createPledge(project, Coin.valueOf(satoshis), aesKey, LHProtos.PledgeDetails.getDefaultInstance()); } public PendingPledge createPledge(Project project, Coin value, @Nullable KeyParameter aesKey, LHProtos.PledgeDetails details) throws InsufficientMoneyException { checkNotNull(project); // Attempt to find a single output that can satisfy this given pledge, because pledges cannot have change // outputs, and submitting multiple inputs is unfriendly (increases fees paid by the pledge claimer). // This process takes into account outputs that are already pledged, to ignore them. We call a pledged output // the "stub" and the tx that spends it using SIGHASH_ANYONECANPAY the "pledge". The template tx outputs are // the "contract". TransactionOutput stub = findAvailableStub(value); log.info("First attempt to find a stub yields: {}", stub); // If no such output exists, we must create a tx that creates an output of the right size and then try again. Coin totalFees = Coin.ZERO; Transaction dependency = null; if (stub == null) { final Address stubAddr = currentReceiveKey().toAddress(getParams()); SendRequest req; if (value.equals(getBalance(BalanceType.AVAILABLE_SPENDABLE))) req = SendRequest.emptyWallet(stubAddr); else req = SendRequest.to(stubAddr, value); if (params == UnitTestParams.get()) req.shuffleOutputs = false; req.aesKey = aesKey; completeTx(req); dependency = req.tx; totalFees = req.fee; log.info("Created dependency tx {}", dependency.getHash()); // The change is in a random output position so we have to search for it. It's possible that there are // two outputs of the same size, in that case it doesn't matter which we use. stub = findOutputOfValue(value, dependency.getOutputs()); if (stub == null) { // We created a dependency tx to make a stub, and now we can't find it. This can only happen // if we are sending the entire balance and thus had to subtract the miner fee from the value. checkState(req.emptyWallet); checkState(dependency.getOutputs().size() == 1); stub = dependency.getOutput(0); } } checkNotNull(stub); Transaction pledge = new Transaction(getParams()); // TODO: Support submitting multiple inputs in a single pledge tx here. TransactionInput input = pledge.addInput(stub); project.getOutputs().forEach(pledge::addOutput); ECKey key = input.getOutpoint().getConnectedKey(this); checkNotNull(key); Script script = stub.getScriptPubKey(); if (aesKey != null) key = key.maybeDecrypt(aesKey); TransactionSignature signature = pledge.calculateSignature(0, key, script, Transaction.SigHash.ALL, true /* anyone can pay! */); if (script.isSentToAddress()) { input.setScriptSig(ScriptBuilder.createInputScript(signature, key)); } else if (script.isSentToRawPubKey()) { // This branch will never be taken with the current design of the app because the only way to get money // in is via an address, but in future we might support direct-to-key payments via the payment protocol. input.setScriptSig(ScriptBuilder.createInputScript(signature)); } input.setScriptSig(ScriptBuilder.createInputScript(signature, key)); pledge.setPurpose(Transaction.Purpose.ASSURANCE_CONTRACT_PLEDGE); log.info("Paid {} satoshis in fees to create pledge tx {}", totalFees, pledge); return new PendingPledge(project, dependency, pledge, totalFees.longValue(), details); } @Nullable public synchronized LHProtos.Pledge getPledgeFor(Project project) { return projects.get(project); } public long getPledgedAmountFor(Project project) { LHProtos.Pledge pledge = getPledgeFor(project); return pledge == null ? 0 : pledge.getPledgeDetails().getTotalInputValue(); } // Returns a spendable output of exactly the given value. @Nullable private TransactionOutput findAvailableStub(Coin value) { CoinSelection selection = coinSelector.select(value, calculateAllSpendCandidates()); if (selection.valueGathered.compareTo(value) < 0) return null; return findOutputOfValue(value, selection.gathered); } private TransactionOutput findOutputOfValue(Coin value, Collection<TransactionOutput> outputs) { return outputs.stream() .filter(out -> out.getValue().equals(value)) .findFirst() .orElse(null); } public synchronized Set<LHProtos.Pledge> getPledges() { return new HashSet<>(pledges.values()); } public synchronized boolean wasPledgeRevoked(LHProtos.Pledge pledge) { final Sha256Hash hash = hashFromPledge(pledge); return revokedPledges.containsKey(hash); } @GuardedBy("this") private Set<Transaction> revokeInProgress = new HashSet<>(); public class Revocation { public final TransactionBroadcast broadcast; public final Transaction tx; public Revocation(TransactionBroadcast broadcast, Transaction tx) { this.broadcast = broadcast; this.tx = tx; } } /** * Given a pledge protobuf, double spends the stub so the pledge can no longer be claimed. The pledge is * removed from the wallet once the double spend propagates successfully. * * @throws org.bitcoinj.core.InsufficientMoneyException if we can't afford to revoke. */ public Revocation revokePledge(LHProtos.Pledge proto, @Nullable KeyParameter aesKey) throws InsufficientMoneyException { TransactionOutput stub; synchronized (this) { stub = pledges.inverse().get(proto); } checkArgument(stub != null, "Given pledge not found: %s", proto); Transaction revocation = new Transaction(params); revocation.addInput(stub); // Send all pledged amount back to a fresh address minus the fee amount. revocation.addOutput(stub.getValue().subtract(Transaction.REFERENCE_DEFAULT_MIN_TX_FEE), freshReceiveKey().toAddress(params)); SendRequest request = SendRequest.forTx(revocation); request.aesKey = aesKey; completeTx(request); synchronized (this) { revokeInProgress.add(request.tx); } log.info("Broadcasting revocation of pledge for {} satoshis", stub.getValue()); log.info("Stub: {}", stub); log.info("Revocation tx: {}", revocation); TransactionBroadcast broadcast = vTransactionBroadcaster.broadcastTransaction(revocation); Futures.addCallback(broadcast.future(), new FutureCallback<Transaction>() { @Override public void onSuccess(@Nullable Transaction result) { log.info("Broadcast of revocation was successful, marking pledge {} as revoked in wallet", hashFromPledge(proto)); log.info("Pledge has {} txns", proto.getTransactionsCount()); updateForRevoke(result, proto, stub); saveNow(); onRevokeHandlers.forEach(it -> it.executor.execute(() -> it.listener.invoke(proto))); lock.lock(); try { maybeQueueOnWalletChanged(); } finally { lock.unlock(); } } @Override public void onFailure(Throwable t) { log.error("Failed to broadcast pledge revocation: {}", t); } }); return new Revocation(broadcast, revocation); } private synchronized void updateForRevoke(Transaction tx, LHProtos.Pledge proto, TransactionOutput stub) { revokeInProgress.remove(tx); revokedPledges.put(LHUtils.hashFromPledge(proto), proto); pledges.remove(stub); projects.inverse().remove(proto); } /** * Runs completeContract to get a feeless contract, then attaches an extra input of size MIN_FEE, potentially * creating and broadcasting a tx to create an output of the right size first (as we cannot add change outputs * to an assurance contract). The returned future completes once both dependency and contract are broadcast OK. */ public void completeContractWithFee(Project project, Set<LHProtos.Pledge> pledges, @Nullable KeyParameter aesKey, TransactionBroadcast.ProgressCallback progress, Consumer<Throwable> error, Executor callbackExecutor) throws InsufficientMoneyException { // The chances of having a fee shaped output are minimal, so we always create a dependency tx here. // We x2 it to avoid problems with sitting right on the dust threshold for older peers. final Coin feeSize = Transaction.REFERENCE_DEFAULT_MIN_TX_FEE.multiply(2); log.info("Completing contract with fee: sending dependency tx"); Transaction contract = project.completeContract(pledges); Wallet.SendRequest request = Wallet.SendRequest.to(freshReceiveKey().toAddress(params), feeSize); request.aesKey = aesKey; Wallet.SendResult result = sendCoins(vTransactionBroadcaster, request); TransactionBroadcast.ProgressCallback mergingCallback = new TransactionBroadcast.ProgressCallback() { double total, last; @Override public void onBroadcastProgress(double val) { // Progress will increment from 0.0 to 1.0 for the dep tx, and then reset to 0.0 and do it again. // But we want to present the user with a smooth 0.0 to 1.0 progress bar. So we aggregate them here. if (val < last) last = 0.0; total += val - last; last = val; progress.onBroadcastProgress(total / 2.0); } }; result.broadcast.setProgressCallback(mergingCallback, callbackExecutor); // The guava API is better for this than what the Java 8 guys produced, sigh. Futures.addCallback(result.broadcastComplete, new FutureCallback<Transaction>() { @Override public void onSuccess(@Nullable Transaction tx) { // Runs on a bitcoinj thread when the dependency was broadcast. // Find the right output size and add it as a regular input (that covers the rest). log.info("Dependency broadcast complete"); TransactionOutput feeOut = tx.getOutputs().stream() .filter(output -> output.getValue().equals(feeSize)).findAny().get(); contract.addInput(feeOut); // Sign the final output we added. SendRequest req = SendRequest.forTx(contract); req.aesKey = aesKey; signTransaction(req); log.info("Prepared final contract: {}", contract); TransactionBroadcast broadcast = vTransactionBroadcaster.broadcastTransaction(contract); broadcast.setProgressCallback(mergingCallback, callbackExecutor); Futures.addCallback(broadcast.future(), new FutureCallback<Transaction>() { @Override public void onSuccess(@Nullable Transaction result) { log.info("Successfully broadcast claim"); } @Override public void onFailure(Throwable t) { log.error("Error broadcasting claim!", t); error.accept(t); } }, callbackExecutor); } @Override public void onFailure(Throwable t) { log.error("Dependency broadcast failed!", t); callbackExecutor.execute(() -> error.accept(t)); } }); } public boolean isProjectMine(Project project) { return getAuthKeyFromIndexOrPubKey(project.getAuthKey(), project.getAuthKeyIndex()) != null; } public void addOnPledgeHandler(Executor executor, Function2<Project, LHProtos.Pledge, Unit> onPledgeHandler) { onPledgeHandlers.add(new ListenerRegistration<>(onPledgeHandler, executor)); } public void addOnRevokeHandler(Executor executor, Function1<LHProtos.Pledge, Unit> onRevokeHandler) { onRevokeHandlers.add(new ListenerRegistration<>(onRevokeHandler, executor)); } public void addOnClaimHandler(Executor executor, Function2<LHProtos.Pledge, Transaction, Unit> onClaimHandler) { onClaimedHandlers.add(new ListenerRegistration<>(onClaimHandler, executor)); } private class IgnorePledgedCoinSelector extends DefaultCoinSelector { @Override protected boolean shouldSelect(Transaction tx) { return true; // Allow spending of pending transactions. } @Override public CoinSelection select(Coin target, List<TransactionOutput> candidates) { // Remove all the outputs pledged already before selecting. synchronized (PledgingWallet.this) { //noinspection FieldAccessNotGuarded candidates.removeAll(pledges.keySet()); } // Search for a perfect match first, to see if we can avoid creating a dependency transaction. for (TransactionOutput op : candidates) if (op.getValue().equals(target)) return new CoinSelection(op.getValue(), ImmutableList.of(op)); // Delegate back to the default behaviour. return super.select(target, candidates); } } @Override protected void queueOnCoinsSent(Transaction tx, Coin prevBalance, Coin newBalance) { super.queueOnCoinsSent(tx, prevBalance, newBalance); // Check to see if we just saw a pledge get spent. synchronized (this) { // Copy the entry set so we can modify in the loop. for (Map.Entry<TransactionOutput, LHProtos.Pledge> entry : new HashSet<>(pledges.entrySet())) { TransactionInput spentBy = entry.getKey().getSpentBy(); if (spentBy != null && tx.equals(spentBy.getParentTransaction())) { if (!revokeInProgress.contains(tx)) { log.info("Saw spend of our pledge that we didn't revoke ... "); LHProtos.Pledge pledge = entry.getValue(); Project project = projects.inverse().get(pledge); checkNotNull(project); if (compareOutputsStructurally(tx, project)) { log.info("... by a tx matching the project's outputs: claimed!"); onClaimedHandlers.forEach(it -> it.executor.execute(() -> it.listener.invoke(pledge, tx))); } else { log.warn("... by a tx we don't recognise: cloned wallet? Deleting pledge."); updateForRevoke(tx, pledge, entry.getKey()); onRevokeHandlers.forEach(it -> it.executor.execute(() -> it.listener.invoke(pledge))); saveNow(); } } } } } } /** Returns new key that can be used to prove we created a particular project. */ public DeterministicKey freshAuthKey() { // We use a fresh key here each time for a bit of extra privacy so there's no way to link projects together. // However this does yield a problem: if we were to create more projects than exist in the lookahead zone, // and then the user restores their wallet from a seed, they might not notice they own the project in question. // We could fix this by including the key index into the project file, but then we're back to leaking personal // data (this time: how many projects you created, even though they're somewhat unlinkable). The best solution // would be to store the key path but encrypted under a constant key e.g. the first internal key. But I don't // have time to implement this now: people who create lots of projects and then restore from seed (not the // recommended way to back up your wallet) may need manual rescue return freshKey(KeyChain.KeyPurpose.AUTHENTICATION); } /** * Given pubkey/index pair, returns the given DeterministicKey object. Index will typically be -1 unless the * generated auth key (above) was beyond the lookahead threshold, in which case we must record the index in the * project file and use it here to find the right key in case the user restored from wallet seed words and thus * we cannot find pubkey in our hashmaps. */ @Nullable public DeterministicKey getAuthKeyFromIndexOrPubKey(byte[] pubkey, int index) { DeterministicKey key = (DeterministicKey) findKeyFromPubKey(pubkey); if (key == null) { if (index == -1) return null; List<ChildNumber> path = checkNotNull(freshAuthKey().getParent()).getPath(); key = getKeyByPath(HDUtils.append(path, new ChildNumber(index))); if (!Arrays.equals(key.getPubKey(), pubkey)) return null; } return key; } }