From 967a2c2026f15a6b458d055e33ac488e53df65fd Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Fri, 2 Jul 2021 10:43:26 +0200 Subject: [PATCH] add support for sending to taproot addresses --- .../sparrowwallet/drongo/address/Address.java | 20 +++- .../drongo/address/P2PKAddress.java | 2 +- .../drongo/address/P2TRAddress.java | 46 ++++++++ .../sparrowwallet/drongo/crypto/ECKey.java | 13 +- .../drongo/crypto/LazyECPoint.java | 16 ++- .../sparrowwallet/drongo/protocol/Bech32.java | 55 +++++++-- .../sparrowwallet/drongo/protocol/Script.java | 21 +++- .../drongo/protocol/ScriptType.java | 111 +++++++++++++++++- .../sparrowwallet/drongo/wallet/Wallet.java | 2 +- .../drongo/address/AddressTest.java | 17 +++ 10 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/drongo/address/P2TRAddress.java diff --git a/src/main/java/com/sparrowwallet/drongo/address/Address.java b/src/main/java/com/sparrowwallet/drongo/address/Address.java index 90bf9ca..e557ada 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/Address.java +++ b/src/main/java/com/sparrowwallet/drongo/address/Address.java @@ -108,15 +108,29 @@ public abstract class Address { Bech32.Bech32Data data = Bech32.decode(address); if(data.hrp.equals(network.getBech32AddressHRP())) { int witnessVersion = data.data[0]; - if (witnessVersion == 0) { + if(witnessVersion == 0) { + if(data.encoding != Bech32.Encoding.BECH32) { + throw new InvalidAddressException("Invalid address - witness version is 0 but encoding is " + data.encoding); + } + byte[] convertedProgram = Arrays.copyOfRange(data.data, 1, data.data.length); byte[] witnessProgram = Bech32.convertBits(convertedProgram, 0, convertedProgram.length, 5, 8, false); - if (witnessProgram.length == 20) { + if(witnessProgram.length == 20) { return new P2WPKHAddress(witnessProgram); } - if (witnessProgram.length == 32) { + if(witnessProgram.length == 32) { return new P2WSHAddress(witnessProgram); } + } else if(witnessVersion == 1) { + if(data.encoding != Bech32.Encoding.BECH32M) { + throw new InvalidAddressException("Invalid address - witness version is 1 but encoding is " + data.encoding); + } + + byte[] convertedProgram = Arrays.copyOfRange(data.data, 1, data.data.length); + byte[] witnessProgram = Bech32.convertBits(convertedProgram, 0, convertedProgram.length, 5, 8, false); + if(witnessProgram.length == 32) { + return new P2TRAddress(witnessProgram); + } } } } catch (Exception e) { diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java index 35df236..aaa956b 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java @@ -6,7 +6,7 @@ import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.ScriptType; public class P2PKAddress extends Address { - private byte[] pubKey; + private final byte[] pubKey; public P2PKAddress(byte[] pubKey) { super(Utils.sha256hash160(pubKey)); diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2TRAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2TRAddress.java new file mode 100644 index 0000000..a263762 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/address/P2TRAddress.java @@ -0,0 +1,46 @@ +package com.sparrowwallet.drongo.address; + +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Bech32; +import com.sparrowwallet.drongo.protocol.Script; +import com.sparrowwallet.drongo.protocol.ScriptType; + +public class P2TRAddress extends Address { + private final byte[] pubKey; + + public P2TRAddress(byte[] pubKey) { + super(Utils.sha256hash160(pubKey)); + this.pubKey = pubKey; + } + + @Override + public int getVersion(Network network) { + return 1; + } + + @Override + public String getAddress(Network network) { + return Bech32.encode(network.getBech32AddressHRP(), getVersion(), pubKey); + } + + @Override + public ScriptType getScriptType() { + return ScriptType.P2TR; + } + + @Override + public Script getOutputScript() { + return getScriptType().getOutputScript(pubKey); + } + + @Override + public byte[] getOutputScriptData() { + return pubKey; + } + + @Override + public String getOutputScriptDataType() { + return "Taproot"; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java index 1075ef3..d62f852 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java @@ -340,6 +340,13 @@ public class ECKey implements EncryptableItem { return pub.getEncoded(); } + /** + * Gets the x coordinate of the raw public key value. This appears in transaction scriptPubKeys for Taproot outputs. + */ + public byte[] getPubKeyXCoord() { + return pub.getEncodedXCoord(); + } + /** Gets the public key in the form of an elliptic curve point object from Bouncy Castle. */ public ECPoint getPubKeyPoint() { return pub.get(); @@ -625,8 +632,10 @@ public class ECKey implements EncryptableItem { * Returns true if the given pubkey is canonical, i.e. the correct length taking into account compression. */ public static boolean isPubKeyCanonical(byte[] pubkey) { - if (pubkey.length < 33) + if (pubkey.length < 32) return false; + if (pubkey.length == 32) + return true; if (pubkey[0] == 0x04) { // Uncompressed pubkey if (pubkey.length != 65) @@ -644,7 +653,7 @@ public class ECKey implements EncryptableItem { * Returns true if the given pubkey is in its compressed form. */ public static boolean isPubKeyCompressed(byte[] encoded) { - if (encoded.length == 33 && (encoded[0] == 0x02 || encoded[0] == 0x03)) + if (encoded.length == 32 || (encoded.length == 33 && (encoded[0] == 0x02 || encoded[0] == 0x03))) return true; else if (encoded.length == 65 && encoded[0] == 0x04) return false; diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/LazyECPoint.java b/src/main/java/com/sparrowwallet/drongo/crypto/LazyECPoint.java index 981f935..1eee6bc 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/LazyECPoint.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/LazyECPoint.java @@ -20,7 +20,7 @@ public class LazyECPoint { public LazyECPoint(ECCurve curve, byte[] bits) { this.curve = curve; - this.bits = bits; + this.bits = (bits != null && bits.length == 32 ? addYCoord(bits) : bits); this.compressed = ECKey.isPubKeyCompressed(bits); } @@ -61,6 +61,13 @@ public class LazyECPoint { return get().getEncoded(compressed); } + public byte[] getEncodedXCoord() { + byte[] compressed = getEncoded(true); + byte[] xcoord = new byte[32]; + System.arraycopy(compressed, 1, xcoord, 0, 32); + return xcoord; + } + public String toString() { return Hex.toHexString(getEncoded()); } @@ -80,4 +87,11 @@ public class LazyECPoint { private byte[] getCanonicalEncoding() { return getEncoded(true); } + + private static byte[] addYCoord(byte[] xcoord) { + byte[] compressed = new byte[33]; + compressed[0] = 0x02; + System.arraycopy(xcoord, 0, compressed, 1, 32); + return compressed; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java b/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java index da5ad0a..7ed7f16 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java @@ -39,10 +39,18 @@ public class Bech32 { public static class Bech32Data { public final String hrp; public final byte[] data; + public final Encoding encoding; private Bech32Data(final String hrp, final byte[] data) { this.hrp = hrp; this.data = data; + this.encoding = (data[0] == 0x00 ? Encoding.BECH32 : Encoding.BECH32M); + } + + public Bech32Data(String hrp, byte[] data, Encoding encoding) { + this.hrp = hrp; + this.data = data; + this.encoding = encoding; } } @@ -64,7 +72,7 @@ public class Bech32 { /** Expand a HRP for use in checksum computation. */ private static byte[] expandHrp(final String hrp) { int hrpLength = hrp.length(); - byte ret[] = new byte[hrpLength * 2 + 1]; + byte[] ret = new byte[hrpLength * 2 + 1]; for (int i = 0; i < hrpLength; ++i) { int c = hrp.charAt(i) & 0x7f; // Limit to standard 7-bit ASCII ret[i] = (byte) ((c >>> 5) & 0x07); @@ -75,21 +83,29 @@ public class Bech32 { } /** Verify a checksum. */ - private static boolean verifyChecksum(final String hrp, final byte[] values) { + private static Encoding verifyChecksum(final String hrp, final byte[] values) { byte[] hrpExpanded = expandHrp(hrp); byte[] combined = new byte[hrpExpanded.length + values.length]; System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.length); System.arraycopy(values, 0, combined, hrpExpanded.length, values.length); - return polymod(combined) == 1; + + int check = polymod(combined); + for(Encoding encoding : Encoding.values()) { + if(check == encoding.checksumConstant) { + return encoding; + } + } + + return null; } /** Create a checksum. */ - private static byte[] createChecksum(final String hrp, final byte[] values) { + private static byte[] createChecksum(final String hrp, Encoding encoding, final byte[] values) { byte[] hrpExpanded = expandHrp(hrp); byte[] enc = new byte[hrpExpanded.length + values.length + 6]; System.arraycopy(hrpExpanded, 0, enc, 0, hrpExpanded.length); System.arraycopy(values, 0, enc, hrpExpanded.length, values.length); - int mod = polymod(enc) ^ 1; + int mod = polymod(enc) ^ encoding.checksumConstant; byte[] ret = new byte[6]; for (int i = 0; i < 6; ++i) { ret[i] = (byte) ((mod >>> (5 * (5 - i))) & 31); @@ -99,16 +115,17 @@ public class Bech32 { /** Encode a Bech32 string. */ public static String encode(final Bech32Data bech32) { - return encode(bech32.hrp, bech32.data); + return encode(bech32.hrp, bech32.encoding, bech32.data); } /** Encode a Bech32 string. */ public static String encode(String hrp, int version, final byte[] values) { - return encode(hrp, encode(0, values)); + Encoding encoding = (version == 0 ? Encoding.BECH32 : Encoding.BECH32M); + return encode(hrp, encoding, encode(version, values)); } /** Encode a Bech32 string. */ - public static String encode(String hrp, final byte[] values) { + public static String encode(String hrp, Encoding encoding, final byte[] values) { if(hrp.length() < 1) { throw new ProtocolException("Human-readable part is too short"); } @@ -118,7 +135,7 @@ public class Bech32 { } hrp = hrp.toLowerCase(Locale.ROOT); - byte[] checksum = createChecksum(hrp, values); + byte[] checksum = createChecksum(hrp, encoding, values); byte[] combined = new byte[values.length + checksum.length]; System.arraycopy(values, 0, combined, 0, values.length); System.arraycopy(checksum, 0, combined, values.length, checksum.length); @@ -163,14 +180,18 @@ public class Bech32 { values[i] = CHARSET_REV[c]; } String hrp = str.substring(0, pos).toLowerCase(Locale.ROOT); - if (!verifyChecksum(hrp, values)) throw new ProtocolException("Invalid checksum"); - return new Bech32Data(hrp, Arrays.copyOfRange(values, 0, values.length - 6)); + Encoding encoding = verifyChecksum(hrp, values); + if(encoding == null) { + throw new ProtocolException("Invalid checksum"); + } + + return new Bech32Data(hrp, Arrays.copyOfRange(values, 0, values.length - 6), encoding); } private static byte[] encode(int witnessVersion, byte[] witnessProgram) { byte[] convertedProgram = convertBits(witnessProgram, 0, witnessProgram.length, 8, 5, true); byte[] bytes = new byte[1 + convertedProgram.length]; - bytes[0] = (byte) (Script.encodeToOpN(witnessVersion) & 0xff); + bytes[0] = (byte)(witnessVersion & 0xff); System.arraycopy(convertedProgram, 0, bytes, 1, convertedProgram.length); return bytes; } @@ -206,4 +227,14 @@ public class Bech32 { } return out.toByteArray(); } + + public enum Encoding { + BECH32(1), BECH32M(0x2bc830a3); + + private final int checksumConstant; + + Encoding(int checksumConstant) { + this.checksumConstant = checksumConstant; + } + } } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Script.java b/src/main/java/com/sparrowwallet/drongo/protocol/Script.java index 05435f4..36f5d41 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Script.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Script.java @@ -135,6 +135,21 @@ public class Script { return false; } + /** + *

If the program somehow pays to a pubkey, returns the pubkey.

+ * + *

Otherwise this method throws a ScriptException.

+ */ + public ECKey getPubKey() throws ProtocolException { + for(ScriptType scriptType : SINGLE_KEY_TYPES) { + if(scriptType.isScriptType(this)) { + return scriptType.getPublicKeyFromScript(this); + } + } + + throw new ProtocolException("Script not a standard form that contains a single key"); + } + /** *

If the program somehow pays to a hash, returns the hash.

* @@ -160,8 +175,10 @@ public class Script { } } - if(P2PK.isScriptType(this)) { - return new Address[] { P2PK.getAddress(P2PK.getPublicKeyFromScript(this).getPubKey()) }; + for(ScriptType scriptType : SINGLE_KEY_TYPES) { + if(scriptType.isScriptType(this)) { + return new Address[] { scriptType.getAddress(scriptType.getPublicKeyFromScript(this)) }; + } } if(MULTISIG.isScriptType(this)) { diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java index 2a0a7c0..8e07b8c 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java @@ -974,6 +974,106 @@ public enum ScriptType { public List getAllowedPolicyTypes() { return List.of(MULTI, CUSTOM); } + }, + P2TR("P2TR", "Taproot (P2TR)", "m/6789'/0'/0'") { + @Override + public Address getAddress(byte[] pubKey) { + return new P2TRAddress(pubKey); + } + + @Override + public Address getAddress(ECKey key) { + return getAddress(key.getPubKeyXCoord()); + } + + @Override + public Address getAddress(Script script) { + throw new ProtocolException("Cannot create a taproot address without a keypath"); + } + + @Override + public Script getOutputScript(byte[] pubKey) { + List chunks = new ArrayList<>(); + chunks.add(new ScriptChunk(OP_1, null)); + chunks.add(new ScriptChunk(pubKey.length, pubKey)); + + return new Script(chunks); + } + + @Override + public Script getOutputScript(ECKey key) { + return getOutputScript(key.getPubKeyXCoord()); + } + + @Override + public Script getOutputScript(Script script) { + throw new ProtocolException("Cannot create a taproot output script without a keypath"); + } + + @Override + public String getOutputDescriptor(ECKey key) { + return getDescriptor() + Utils.bytesToHex(key.getPubKeyXCoord()) + getCloseDescriptor(); + } + + @Override + public String getOutputDescriptor(Script script) { + throw new ProtocolException("Cannot create a taproot output descriptor without a keypath"); + } + + @Override + public String getDescriptor() { + return "tr("; + } + + @Override + public boolean isScriptType(Script script) { + List chunks = script.chunks; + if (chunks.size() != 2) + return false; + if (!chunks.get(0).equalsOpCode(OP_1)) + return false; + byte[] chunk1data = chunks.get(1).data; + if (chunk1data == null) + return false; + if (chunk1data.length != 32) + return false; + return true; + } + + @Override + public byte[] getHashFromScript(Script script) { + throw new ProtocolException("P2TR script does not contain a hash, use getPublicKeyFromScript(script) to retrieve public key"); + } + + @Override + public ECKey getPublicKeyFromScript(Script script) { + return ECKey.fromPublicOnly(script.chunks.get(1).data); + } + + @Override + public Script getScriptSig(Script scriptPubKey, ECKey pubKey, TransactionSignature signature) { + throw new UnsupportedOperationException("Constructing Taproot inputs is not yet supported"); + } + + @Override + public TransactionInput addSpendingInput(Transaction transaction, TransactionOutput prevOutput, ECKey pubKey, TransactionSignature signature) { + throw new UnsupportedOperationException("Constructing Taproot inputs is not yet supported"); + } + + @Override + public Script getMultisigScriptSig(Script scriptPubKey, int threshold, Map pubKeySignatures) { + throw new UnsupportedOperationException("Constructing Taproot inputs is not yet supported"); + } + + @Override + public TransactionInput addMultisigSpendingInput(Transaction transaction, TransactionOutput prevOutput, int threshold, Map pubKeySignatures) { + throw new UnsupportedOperationException("Constructing Taproot inputs is not yet supported"); + } + + @Override + public List getAllowedPolicyTypes() { + return Collections.emptyList(); + } }; private final String name; @@ -1087,18 +1187,22 @@ public enum ScriptType { public abstract TransactionInput addMultisigSpendingInput(Transaction transaction, TransactionOutput prevOutput, int threshold, Map pubKeySignatures); + public static final ScriptType[] SINGLE_KEY_TYPES = {P2PK, P2TR}; + public static final ScriptType[] SINGLE_HASH_TYPES = {P2PKH, P2SH, P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH}; + public static final ScriptType[] ADDRESSABLE_TYPES = {P2PKH, P2SH, P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH, P2TR}; + public static final ScriptType[] NON_WITNESS_TYPES = {P2PK, P2PKH, P2SH}; - public static final ScriptType[] WITNESS_TYPES = {P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH}; + public static final ScriptType[] WITNESS_TYPES = {P2SH_P2WPKH, P2SH_P2WSH, P2WPKH, P2WSH, P2TR}; public static List getScriptTypesForPolicyType(PolicyType policyType) { return Arrays.stream(values()).filter(scriptType -> scriptType.isAllowed(policyType)).collect(Collectors.toList()); } public static List getAddressableScriptTypes(PolicyType policyType) { - return Arrays.stream(values()).filter(scriptType -> scriptType.isAllowed(policyType) && Arrays.asList(SINGLE_HASH_TYPES).contains(scriptType)).collect(Collectors.toList()); + return Arrays.stream(ADDRESSABLE_TYPES).filter(scriptType -> scriptType.isAllowed(policyType)).collect(Collectors.toList()); } public static ScriptType getType(Script script) { @@ -1166,6 +1270,9 @@ public enum ScriptType { return (32 + 4 + 1 + 13 + (107 / WITNESS_SCALE_FACTOR) + 4); } else if(P2SH_P2WSH.equals(this)) { return (32 + 4 + 1 + 35 + (107 / WITNESS_SCALE_FACTOR) + 4); + } else if(P2TR.equals(this)) { + //Assume a default keypath spend + return (32 + 4 + 1 + (66 / WITNESS_SCALE_FACTOR) + 4); } else if(Arrays.asList(WITNESS_TYPES).contains(this)) { //Return length of spending input with 75% discount to script size return (32 + 4 + 1 + (107 / WITNESS_SCALE_FACTOR) + 4); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 6deee5b..1d8eac9 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -443,7 +443,7 @@ public class Wallet extends Persistable { public int getNoInputsWeightUnits(List payments) { Transaction transaction = new Transaction(); if(Arrays.asList(ScriptType.WITNESS_TYPES).contains(getScriptType())) { - transaction.setSegwitVersion(0); + transaction.setSegwitVersion(1); } for(Payment payment : payments) { transaction.addOutput(payment.getAmount(), payment.getAddress()); diff --git a/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java b/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java index 9978803..fd4b799 100644 --- a/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java +++ b/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java @@ -49,6 +49,14 @@ public class AddressTest { Address address10 = Address.fromString(Network.SIGNET, "2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A"); Assert.assertTrue(address10 instanceof P2SHAddress); Assert.assertEquals("2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A", address10.toString(Network.SIGNET)); + + Address address11 = Address.fromString("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"); + Assert.assertTrue(address11 instanceof P2TRAddress); + Assert.assertEquals("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", address11.toString()); + + Address address12 = Address.fromString(Network.TESTNET, "tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c"); + Assert.assertTrue(address12 instanceof P2TRAddress); + Assert.assertEquals("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c", address12.toString(Network.TESTNET)); } @Test @@ -74,6 +82,10 @@ public class AddressTest { Address address9 = Address.fromString("2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A"); Assert.assertTrue(address9 instanceof P2SHAddress); Assert.assertEquals("2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A", address9.toString()); + + Address address12 = Address.fromString("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c"); + Assert.assertTrue(address12 instanceof P2TRAddress); + Assert.assertEquals("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c", address12.toString()); } @Test @@ -119,6 +131,11 @@ public class AddressTest { Address address1 = Address.fromString("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmb3"); } + @Test(expected = InvalidAddressException.class) + public void invalidEncodingAddressTest() throws InvalidAddressException { + Address address1 = Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh"); + } + @After public void tearDown() throws Exception { Network.set(null);