From 687f247d0691d6568c9cfcb3cdcad52cf63ba1d0 Mon Sep 17 00:00:00 2001 From: Alva Swanson Date: Thu, 7 Nov 2024 20:46:37 +0000 Subject: [PATCH] core: Implement transaction offset signing The BisqTransactionSigner signs the inputs of a transaction after the given offset using the LocalOffsetTransactionSigner. The LocalOffsetTransactionSigner is identical to BitcoinJ's LocalTransactionSigner. The only difference is that the LocalOffsetTransactionSigner accepts an offset in its constructor. All inputs below this offset are skipped during signing. This is needed for Bisq's BSQ implementation because the mining fees of BSQ transactions come from Bisq's BTC wallet. BitcoinJ's LocalTransactionSigner expects all inputs to be part of the same wallet. --- .../btc/wallet/BisqTransactionSigner.java | 50 +++++++ .../wallet/LocalOffsetTransactionSigner.java | 135 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 core/src/main/java/bisq/core/btc/wallet/BisqTransactionSigner.java create mode 100644 core/src/main/java/bisq/core/btc/wallet/LocalOffsetTransactionSigner.java diff --git a/core/src/main/java/bisq/core/btc/wallet/BisqTransactionSigner.java b/core/src/main/java/bisq/core/btc/wallet/BisqTransactionSigner.java new file mode 100644 index 0000000000..788c4a8079 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/BisqTransactionSigner.java @@ -0,0 +1,50 @@ +package bisq.core.btc.wallet; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script; +import org.bitcoinj.signers.MissingSigResolutionSigner; +import org.bitcoinj.signers.TransactionSigner; +import org.bitcoinj.wallet.DecryptingKeyBag; +import org.bitcoinj.wallet.KeyBag; +import org.bitcoinj.wallet.RedeemData; +import org.bitcoinj.wallet.Wallet; + +import java.util.Objects; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class BisqTransactionSigner { + public static void sign(Wallet wallet, Transaction tx, int inputOffset) { + var localOffsetTransactionSigner = new LocalOffsetTransactionSigner(inputOffset); + KeyBag maybeDecryptingKeyBag = new DecryptingKeyBag(wallet, null); + + int numInputs = tx.getInputs().size(); + for (int i = 0; i < numInputs; i++) { + if (i < inputOffset) { + continue; + } + + TransactionInput txIn = tx.getInput(i); + TransactionOutput connectedOutput = txIn.getConnectedOutput(); + Objects.requireNonNull(connectedOutput, "Connected output of transaction input #" + i + " is null"); + + Script scriptPubKey = connectedOutput.getScriptPubKey(); + + RedeemData redeemData = txIn.getConnectedRedeemData(maybeDecryptingKeyBag); + Objects.requireNonNull(redeemData, "Transaction exists in wallet that we cannot redeem: " + txIn.getOutpoint().getHash()); + txIn.setScriptSig(scriptPubKey.createEmptyInputScript(redeemData.keys.get(0), redeemData.redeemScript)); + txIn.setWitness(scriptPubKey.createEmptyWitness(redeemData.keys.get(0))); + } + + TransactionSigner.ProposedTransaction proposal = new TransactionSigner.ProposedTransaction(tx); + if (!localOffsetTransactionSigner.signInputs(proposal, maybeDecryptingKeyBag)) { + log.info("{} returned false for the tx", localOffsetTransactionSigner.getClass().getName()); + } + + // resolve missing sigs if any + new MissingSigResolutionSigner(Wallet.MissingSigsMode.THROW).signInputs(proposal, maybeDecryptingKeyBag); + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/LocalOffsetTransactionSigner.java b/core/src/main/java/bisq/core/btc/wallet/LocalOffsetTransactionSigner.java new file mode 100644 index 0000000000..c926ef499b --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/LocalOffsetTransactionSigner.java @@ -0,0 +1,135 @@ +package bisq.core.btc.wallet; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.TransactionWitness; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.script.ScriptException; +import org.bitcoinj.script.ScriptPattern; +import org.bitcoinj.signers.TransactionSigner; +import org.bitcoinj.wallet.KeyBag; +import org.bitcoinj.wallet.RedeemData; + +import java.util.Arrays; +import java.util.EnumSet; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LocalOffsetTransactionSigner implements TransactionSigner { + private static final Logger log = LoggerFactory.getLogger(LocalOffsetTransactionSigner.class); + + /** + * Verify flags that are safe to use when testing if an input is already + * signed. + */ + private static final EnumSet MINIMUM_VERIFY_FLAGS = EnumSet.of(Script.VerifyFlag.P2SH, + Script.VerifyFlag.NULLDUMMY); + + private final int startOffset; + + public LocalOffsetTransactionSigner(int startOffset) { + this.startOffset = startOffset; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public boolean signInputs(ProposedTransaction propTx, KeyBag keyBag) { + Transaction tx = propTx.partialTx; + int numInputs = tx.getInputs().size(); + for (int i = 0; i < numInputs; i++) { + if (i < startOffset) { + continue; + } + + TransactionInput txIn = tx.getInput(i); + final TransactionOutput connectedOutput = txIn.getConnectedOutput(); + if (connectedOutput == null) { + log.warn("Missing connected output, assuming input {} is already signed.", i); + continue; + } + Script scriptPubKey = connectedOutput.getScriptPubKey(); + + try { + // We assume if its already signed, its hopefully got a SIGHASH type that will not invalidate when + // we sign missing pieces (to check this would require either assuming any signatures are signing + // standard output types or a way to get processed signatures out of script execution) + txIn.getScriptSig().correctlySpends(tx, i, txIn.getWitness(), connectedOutput.getValue(), + connectedOutput.getScriptPubKey(), MINIMUM_VERIFY_FLAGS); + log.warn("Input {} already correctly spends output, assuming SIGHASH type used will be safe and skipping signing.", i); + continue; + } catch (ScriptException e) { + // Expected. + } + + RedeemData redeemData = txIn.getConnectedRedeemData(keyBag); + + // For P2SH inputs we need to share derivation path of the signing key with other signers, so that they + // use correct key to calculate their signatures. + // Married keys all have the same derivation path, so we can safely just take first one here. + ECKey pubKey = redeemData.keys.get(0); + if (pubKey instanceof DeterministicKey) + propTx.keyPaths.put(scriptPubKey, (((DeterministicKey) pubKey).getPath())); + + ECKey key; + // locate private key in redeem data. For P2PKH and P2PK inputs RedeemData will always contain + // only one key (with private bytes). For P2SH inputs RedeemData will contain multiple keys, one of which MAY + // have private bytes + if ((key = redeemData.getFullKey()) == null) { + log.warn("No local key found for input {}", i); + continue; + } + + Script inputScript = txIn.getScriptSig(); + // script here would be either a standard CHECKSIG program for P2PKH or P2PK inputs or + // a CHECKMULTISIG program for P2SH inputs + byte[] script = redeemData.redeemScript.getProgram(); + try { + if (ScriptPattern.isP2PK(scriptPubKey) || ScriptPattern.isP2PKH(scriptPubKey) + || ScriptPattern.isP2SH(scriptPubKey)) { + TransactionSignature signature = tx.calculateSignature(i, key, script, Transaction.SigHash.ALL, + false); + + // at this point we have incomplete inputScript with OP_0 in place of one or more signatures. We + // already have calculated the signature using the local key and now need to insert it in the + // correct place within inputScript. For P2PKH and P2PK script there is only one signature and it + // always goes first in an inputScript (sigIndex = 0). In P2SH input scripts we need to figure out + // our relative position relative to other signers. Since we don't have that information at this + // point, and since we always run first, we have to depend on the other signers rearranging the + // signatures as needed. Therefore, always place as first signature. + int sigIndex = 0; + inputScript = scriptPubKey.getScriptSigWithSignature(inputScript, signature.encodeToBitcoin(), + sigIndex); + txIn.setScriptSig(inputScript); + txIn.setWitness(null); + } else if (ScriptPattern.isP2WPKH(scriptPubKey)) { + Script scriptCode = ScriptBuilder.createP2PKHOutputScript(key); + Coin value = txIn.getValue(); + TransactionSignature signature = tx.calculateWitnessSignature(i, key, scriptCode, value, + Transaction.SigHash.ALL, false); + txIn.setScriptSig(ScriptBuilder.createEmpty()); + txIn.setWitness(TransactionWitness.redeemP2WPKH(signature, key)); + } else { + throw new IllegalStateException(Arrays.toString(script)); + } + } catch (ECKey.KeyIsEncryptedException e) { + throw e; + } catch (ECKey.MissingPrivateKeyException e) { + log.warn("No private key in keypair for input {}", i); + } + + } + return true; + } + +}