From 7bb07ab39eafc0de54d3dc2e19a444d39f9a1fc3 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 22 Feb 2022 12:02:40 +0200 Subject: [PATCH] add bip47 keystores --- .../sparrowwallet/drongo/KeyDerivation.java | 8 + .../com/sparrowwallet/drongo/KeyPurpose.java | 5 + .../drongo/OutputDescriptor.java | 18 +- .../bip47/InvalidPaymentCodeException.java | 19 + .../drongo/bip47/NotSecp256k1Exception.java | 19 + .../drongo/bip47/PaymentAddress.java | 104 +++++ .../drongo/bip47/PaymentCode.java | 380 ++++++++++++++++++ .../drongo/bip47/SecretPoint.java | 53 +++ .../drongo/crypto/HDKeyDerivation.java | 4 + .../drongo/protocol/TransactionOutPoint.java | 13 + .../com/sparrowwallet/drongo/psbt/PSBT.java | 4 +- .../sparrowwallet/drongo/wallet/Keystore.java | 112 +++++- .../drongo/wallet/KeystoreSource.java | 8 +- .../drongo/wallet/PresetUtxoSelector.java | 16 + .../drongo/wallet/UtxoSelector.java | 3 + .../sparrowwallet/drongo/wallet/Wallet.java | 236 ++++++++--- src/main/java/module-info.java | 1 + .../drongo/OutputDescriptorTest.java | 6 +- .../drongo/bip47/PaymentCodeTest.java | 171 ++++++++ .../sparrowwallet/drongo/psbt/PSBTTest.java | 6 - .../drongo/wallet/WalletTest.java | 26 +- 21 files changed, 1116 insertions(+), 96 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/drongo/bip47/InvalidPaymentCodeException.java create mode 100644 src/main/java/com/sparrowwallet/drongo/bip47/NotSecp256k1Exception.java create mode 100644 src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java create mode 100644 src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java create mode 100644 src/main/java/com/sparrowwallet/drongo/bip47/SecretPoint.java create mode 100644 src/test/java/com/sparrowwallet/drongo/bip47/PaymentCodeTest.java diff --git a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java index 2f811ec..2db24be 100644 --- a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java +++ b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java @@ -11,6 +11,10 @@ public class KeyDerivation { private final String derivationPath; private transient List derivation; + public KeyDerivation(String masterFingerprint, List derivation) { + this(masterFingerprint, writePath(derivation)); + } + public KeyDerivation(String masterFingerprint, String derivationPath) { this.masterFingerprint = masterFingerprint == null ? null : masterFingerprint.toLowerCase(); this.derivationPath = derivationPath; @@ -91,6 +95,10 @@ public class KeyDerivation { return true; } + public static List getBip47Derivation(int account) { + return List.of(new ChildNumber(47, true), new ChildNumber(Network.get() == Network.MAINNET ? 0 : 1, true), new ChildNumber(account, true)); + } + public KeyDerivation copy() { return new KeyDerivation(masterFingerprint, derivationPath); } diff --git a/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java b/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java index 0af33fb..5c14396 100644 --- a/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java +++ b/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java @@ -9,6 +9,11 @@ public enum KeyPurpose { public static final List DEFAULT_PURPOSES = List.of(RECEIVE, CHANGE); + //The receive derivation is also used for BIP47 notifications + public static final KeyPurpose NOTIFICATION = RECEIVE; + //The change derivation is reused for the send chain in BIP47 wallets + public static final KeyPurpose SEND = CHANGE; + private final ChildNumber pathIndex; KeyPurpose(ChildNumber pathIndex) { diff --git a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java index 2e3f416..71823cb 100644 --- a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java +++ b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java @@ -500,18 +500,24 @@ public class OutputDescriptor { Utils.LexicographicByteArrayComparator lexicographicByteArrayComparator = new Utils.LexicographicByteArrayComparator(); sortedKeys.sort((o1, o2) -> { - List derivation1 = getDerivations(mapChildrenDerivations.get(o1)).get(0); - derivation1.add(0, o1.getKeyChildNumber()); - ECKey key1 = o1.getKey(derivation1); - List derivation2 = getDerivations(mapChildrenDerivations.get(o2)).get(0); - derivation2.add(0, o2.getKeyChildNumber()); - ECKey key2 = o2.getKey(derivation2); + ECKey key1 = getChildKeyForExtendedPubKey(o1); + ECKey key2 = getChildKeyForExtendedPubKey(o2); return lexicographicByteArrayComparator.compare(key1.getPubKey(), key2.getPubKey()); }); return sortedKeys; } + private ECKey getChildKeyForExtendedPubKey(ExtendedKey extendedKey) { + if(mapChildrenDerivations.get(extendedKey) == null) { + return extendedKey.getKey(); + } + + List derivation = getDerivations(mapChildrenDerivations.get(extendedKey)).get(0); + derivation.add(0, extendedKey.getKeyChildNumber()); + return extendedKey.getKey(derivation); + } + private List> getDerivations(String childDerivation) { Matcher matcher = MULTIPATH_PATTERN.matcher(childDerivation); if(matcher.find()) { diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/InvalidPaymentCodeException.java b/src/main/java/com/sparrowwallet/drongo/bip47/InvalidPaymentCodeException.java new file mode 100644 index 0000000..336f465 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/InvalidPaymentCodeException.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.drongo.bip47; + +public class InvalidPaymentCodeException extends Exception { + public InvalidPaymentCodeException() { + super(); + } + + public InvalidPaymentCodeException(String msg) { + super(msg); + } + + public InvalidPaymentCodeException(Throwable cause) { + super(cause); + } + + public InvalidPaymentCodeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/NotSecp256k1Exception.java b/src/main/java/com/sparrowwallet/drongo/bip47/NotSecp256k1Exception.java new file mode 100644 index 0000000..51d4860 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/NotSecp256k1Exception.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.drongo.bip47; + +public class NotSecp256k1Exception extends Exception { + public NotSecp256k1Exception() { + super(); + } + + public NotSecp256k1Exception(String msg) { + super(msg); + } + + public NotSecp256k1Exception(Throwable cause) { + super(cause); + } + + public NotSecp256k1Exception(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java new file mode 100644 index 0000000..fc9054d --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentAddress.java @@ -0,0 +1,104 @@ +package com.sparrowwallet.drongo.bip47; + +import com.sparrowwallet.drongo.crypto.ECKey; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.math.ec.ECPoint; + +import java.math.BigInteger; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.spec.InvalidKeySpecException; + +public class PaymentAddress { + private static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1"); + private static final ECDomainParameters CURVE = new ECDomainParameters(CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), CURVE_PARAMS.getH()); + + private final PaymentCode paymentCode; + private final int index; + private final byte[] privKey; + + public PaymentAddress(PaymentCode paymentCode, int index, byte[] privKey) { + this.paymentCode = paymentCode; + this.index = index; + this.privKey = privKey; + } + + public ECKey getSendECKey() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, IllegalStateException, InvalidKeySpecException, NotSecp256k1Exception { + return getSendECKey(getSecretPoint()); + } + + public ECKey getReceiveECKey() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, IllegalStateException, InvalidKeySpecException, NotSecp256k1Exception { + return getReceiveECKey(getSecretPoint()); + } + + public SecretPoint getSharedSecret() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, IllegalStateException, InvalidKeySpecException { + return sharedSecret(); + } + + public BigInteger getSecretPoint() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, IllegalStateException, InvalidKeySpecException, NotSecp256k1Exception { + return secretPoint(); + } + + public ECPoint getECPoint() { + ECKey ecKey = ECKey.fromPublicOnly(paymentCode.getKey(index).getPubKey()); + return ecKey.getPubKeyPoint(); + } + + public byte[] hashSharedSecret() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, IllegalStateException, InvalidKeySpecException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(getSharedSecret().ECDHSecretAsBytes()); + } + + private ECPoint get_sG(BigInteger s) { + return CURVE_PARAMS.getG().multiply(s); + } + + private ECKey getSendECKey(BigInteger s) throws IllegalStateException { + ECPoint ecPoint = getECPoint(); + ECPoint sG = get_sG(s); + return ECKey.fromPublicOnly(ecPoint.add(sG).getEncoded(true)); + } + + private ECKey getReceiveECKey(BigInteger s) { + BigInteger privKeyValue = ECKey.fromPrivate(privKey).getPrivKey(); + return ECKey.fromPrivate(addSecp256k1(privKeyValue, s)); + } + + private BigInteger addSecp256k1(BigInteger b1, BigInteger b2) { + BigInteger ret = b1.add(b2); + + if(ret.bitLength() > CURVE.getN().bitLength()) { + return ret.mod(CURVE.getN()); + } + + return ret; + } + + private SecretPoint sharedSecret() throws InvalidKeySpecException, InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, NoSuchProviderException { + return new SecretPoint(privKey, paymentCode.getKey(index).getPubKey()); + } + + private boolean isSecp256k1(BigInteger b) { + return b.compareTo(BigInteger.ONE) > 0 && b.bitLength() <= CURVE.getN().bitLength(); + } + + private BigInteger secretPoint() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException, NotSecp256k1Exception { + // + // convert hash to value 's' + // + BigInteger s = new BigInteger(1, hashSharedSecret()); + // + // check that 's' is on the secp256k1 curve + // + if(!isSecp256k1(s)) { + throw new NotSecp256k1Exception("Secret point not on Secp256k1 curve"); + } + + return s; + } +} + diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java new file mode 100644 index 0000000..ea881e0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java @@ -0,0 +1,380 @@ +package com.sparrowwallet.drongo.bip47; + +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.DeterministicKey; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.HDKeyDerivation; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.wallet.Keystore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class PaymentCode { + private static final Logger log = LoggerFactory.getLogger(PaymentCode.class); + + private static final int PUBLIC_KEY_Y_OFFSET = 2; + private static final int PUBLIC_KEY_X_OFFSET = 3; + private static final int CHAIN_OFFSET = 35; + private static final int PUBLIC_KEY_X_LEN = 32; + private static final int PUBLIC_KEY_Y_LEN = 1; + private static final int CHAIN_LEN = 32; + private static final int PAYLOAD_LEN = 80; + + private static final int SAMOURAI_FEATURE_BYTE = 79; + private static final int SAMOURAI_SEGWIT_BIT = 0; + + private final String strPaymentCode; + private final byte[] pubkey; + private final byte[] chain; + + private PaymentCode(String strPaymentCode, byte[] pubkey, byte[] chain) { + this.strPaymentCode = strPaymentCode; + this.pubkey = pubkey; + this.chain = chain; + } + + public PaymentCode(String payment_code) throws InvalidPaymentCodeException { + strPaymentCode = payment_code; + Map.Entry pubKeyChain = parse().entrySet().iterator().next(); + this.pubkey = pubKeyChain.getKey(); + this.chain = pubKeyChain.getValue(); + } + + public PaymentCode(byte[] payload) { + if(payload.length != 80) { + throw new IllegalArgumentException("Payment code must be 80 bytes"); + } + + pubkey = new byte[PUBLIC_KEY_Y_LEN + PUBLIC_KEY_X_LEN]; + chain = new byte[CHAIN_LEN]; + + System.arraycopy(payload, PUBLIC_KEY_Y_OFFSET, pubkey, 0, PUBLIC_KEY_Y_LEN + PUBLIC_KEY_X_LEN); + System.arraycopy(payload, CHAIN_OFFSET, chain, 0, CHAIN_LEN); + + strPaymentCode = makeV1(); + } + + public PaymentCode(byte[] pubkey, byte[] chain) { + this.pubkey = pubkey; + this.chain = chain; + strPaymentCode = makeV1(); + } + + public ECKey getNotificationKey() { + DeterministicKey masterPubKey = createMasterPubKeyFromBytes(); + return HDKeyDerivation.deriveChildKey(masterPubKey, ChildNumber.ZERO); + } + + public Address getNotificationAddress() { + return ScriptType.P2PKH.getAddress(getNotificationKey()); + } + + public ECKey getKey(int index) { + DeterministicKey masterPubKey = createMasterPubKeyFromBytes(); + return HDKeyDerivation.deriveChildKey(masterPubKey, new ChildNumber(index)); + } + + public byte[] getPayload() { + byte[] pcBytes = Base58.decodeChecked(strPaymentCode); + byte[] payload = new byte[PAYLOAD_LEN]; + System.arraycopy(pcBytes, 1, payload, 0, payload.length); + + return payload; + } + + public int getType() throws InvalidPaymentCodeException { + byte[] payload = getPayload(); + ByteBuffer bb = ByteBuffer.wrap(payload); + return bb.get(); + } + + public boolean isSegwitEnabled() { + return isBitSet(getPayload()[SAMOURAI_FEATURE_BYTE], SAMOURAI_SEGWIT_BIT); + } + + public String toString() { + return strPaymentCode; + } + + public static PaymentCode getPaymentCode(Transaction transaction, Keystore keystore) throws InvalidPaymentCodeException { + try { + TransactionInput txInput = getDesignatedInput(transaction); + ECKey pubKey = getDesignatedPubKey(txInput); + + ECKey notificationPrivKey = keystore.getBip47ExtendedPrivateKey().getKey(List.of(ChildNumber.ZERO_HARDENED, new ChildNumber(0))); + SecretPoint secretPoint = new SecretPoint(notificationPrivKey.getPrivKeyBytes(), pubKey.getPubKey()); + byte[] blindingMask = getMask(secretPoint.ECDHSecretAsBytes(), txInput.getOutpoint().bitcoinSerialize()); + byte[] blindedPaymentCode = getOpReturnData(transaction); + return new PaymentCode(PaymentCode.blind(blindedPaymentCode, blindingMask)); + } catch(Exception e) { + throw new InvalidPaymentCodeException("Could not determine payment code from transaction", e); + } + } + + public static TransactionInput getDesignatedInput(Transaction transaction) { + for(TransactionInput txInput : transaction.getInputs()) { + if(getDesignatedPubKey(txInput) != null) { + return txInput; + } + } + + throw new IllegalArgumentException("Cannot find designated input in notification transaction"); + } + + private static ECKey getDesignatedPubKey(TransactionInput txInput) { + for(ScriptChunk scriptChunk : txInput.getScriptSig().getChunks()) { + if(scriptChunk.isPubKey()) { + return scriptChunk.getPubKey(); + } + } + + for(ScriptChunk scriptChunk : txInput.getWitness().asScriptChunks()) { + if(scriptChunk.isPubKey()) { + return scriptChunk.getPubKey(); + } + } + + return null; + } + + public static byte[] getOpReturnData(Transaction transaction) { + for(TransactionOutput txOutput : transaction.getOutputs()) { + List scriptChunks = getOpReturnChunks(txOutput); + if(scriptChunks == null) { + continue; + } + + return scriptChunks.get(1).getData(); + } + + throw new IllegalArgumentException("Cannot find OP_RETURN output in notification transaction"); + } + + private static List getOpReturnChunks(TransactionOutput txOutput) { + List scriptChunks = txOutput.getScript().getChunks(); + if(scriptChunks.size() != 2) { + return null; + } + if(scriptChunks.get(0).getOpcode() != ScriptOpCodes.OP_RETURN) { + return null; + } + if(scriptChunks.get(1).getData() != null && scriptChunks.get(1).getData().length != 80) { + return null; + } + return scriptChunks; + } + + public static byte[] getMask(byte[] sPoint, byte[] oPoint) { + Mac sha512_HMAC; + byte[] mac_data = null; + + try { + sha512_HMAC = Mac.getInstance("HmacSHA512"); + SecretKeySpec secretkey = new SecretKeySpec(oPoint, "HmacSHA512"); + sha512_HMAC.init(secretkey); + mac_data = sha512_HMAC.doFinal(sPoint); + } catch(InvalidKeyException | NoSuchAlgorithmException ignored) { + //ignore + } + + return mac_data; + } + + public static byte[] blind(byte[] payload, byte[] mask) throws InvalidPaymentCodeException { + byte[] ret = new byte[PAYLOAD_LEN]; + byte[] pubkey = new byte[PUBLIC_KEY_X_LEN]; + byte[] chain = new byte[CHAIN_LEN]; + byte[] buf0 = new byte[PUBLIC_KEY_X_LEN]; + byte[] buf1 = new byte[CHAIN_LEN]; + + System.arraycopy(payload, 0, ret, 0, PAYLOAD_LEN); + + System.arraycopy(payload, PUBLIC_KEY_X_OFFSET, pubkey, 0, PUBLIC_KEY_X_LEN); + System.arraycopy(payload, CHAIN_OFFSET, chain, 0, CHAIN_LEN); + System.arraycopy(mask, 0, buf0, 0, PUBLIC_KEY_X_LEN); + System.arraycopy(mask, PUBLIC_KEY_X_LEN, buf1, 0, CHAIN_LEN); + + System.arraycopy(xor(pubkey, buf0), 0, ret, PUBLIC_KEY_X_OFFSET, PUBLIC_KEY_X_LEN); + System.arraycopy(xor(chain, buf1), 0, ret, CHAIN_OFFSET, CHAIN_LEN); + + return ret; + } + + private Map parse() throws InvalidPaymentCodeException { + byte[] pcBytes = Base58.decodeChecked(strPaymentCode); + + ByteBuffer bb = ByteBuffer.wrap(pcBytes); + if(bb.get() != 0x47) { + throw new InvalidPaymentCodeException("Invalid payment code version"); + } + + byte[] chain = new byte[CHAIN_LEN]; + byte[] pub = new byte[PUBLIC_KEY_X_LEN + PUBLIC_KEY_Y_LEN]; + + // type: + bb.get(); + // features: + bb.get(); + + bb.get(pub); + if(pub[0] != 0x02 && pub[0] != 0x03) { + throw new InvalidPaymentCodeException("Invalid public key"); + } + + bb.get(chain); + + return Map.of(pub, chain); + } + + private String makeV1() { + return make(0x01); + } + + private String make(int type) { + byte[] payload = new byte[PAYLOAD_LEN]; + byte[] payment_code = new byte[PAYLOAD_LEN + 1]; + + for(int i = 0; i < payload.length; i++) { + payload[i] = (byte) 0x00; + } + + // byte 0: type. + payload[0] = (byte) type; + // byte 1: features bit field. All bits must be zero except where specified elsewhere in this specification + // bit 0: Bitmessage notification + // bits 1-7: reserved + payload[1] = (byte) 0x00; + + // replace sign & x code (33 bytes) + System.arraycopy(pubkey, 0, payload, PUBLIC_KEY_Y_OFFSET, pubkey.length); + // replace chain code (32 bytes) + System.arraycopy(chain, 0, payload, CHAIN_OFFSET, chain.length); + + // add version byte + payment_code[0] = (byte) 0x47; + System.arraycopy(payload, 0, payment_code, 1, payload.length); + + // append checksum + return base58EncodeChecked(payment_code); + } + + public String makeSamouraiPaymentCode() throws InvalidPaymentCodeException { + byte[] payload = getPayload(); + // set bit0 = 1 in 'Samourai byte' for segwit. Can send/receive P2PKH, P2SH-P2WPKH, P2WPKH (bech32) + payload[SAMOURAI_FEATURE_BYTE] = setBit(payload[SAMOURAI_FEATURE_BYTE], SAMOURAI_SEGWIT_BIT); + byte[] payment_code = new byte[PAYLOAD_LEN + 1]; + // add version byte + payment_code[0] = (byte) 0x47; + System.arraycopy(payload, 0, payment_code, 1, payload.length); + + // append checksum + return base58EncodeChecked(payment_code); + } + + private String base58EncodeChecked(byte[] buf) { + byte[] checksum = Arrays.copyOfRange(Sha256Hash.hashTwice(buf), 0, 4); + byte[] bufChecked = new byte[buf.length + checksum.length]; + System.arraycopy(buf, 0, bufChecked, 0, buf.length); + System.arraycopy(checksum, 0, bufChecked, bufChecked.length - 4, checksum.length); + + return Base58.encode(bufChecked); + } + + private boolean isBitSet(byte b, int pos) { + byte test = 0; + return (setBit(test, pos) & b) > 0; + } + + private byte setBit(byte b, int pos) { + return (byte) (b | (1 << pos)); + } + + private DeterministicKey createMasterPubKeyFromBytes() { + return HDKeyDerivation.createMasterPubKeyFromBytes(pubkey, chain); + } + + private static byte[] xor(byte[] a, byte[] b) { + if(a.length != b.length) { + log.error("Invalid length for xor: " + a.length + " vs " + b.length); + return null; + } + + byte[] ret = new byte[a.length]; + + for(int i = 0; i < a.length; i++) { + ret[i] = (byte) ((int) b[i] ^ (int) a[i]); + } + + return ret; + } + + public boolean isValid() { + try { + byte[] pcodeBytes = Base58.decodeChecked(strPaymentCode); + + ByteBuffer byteBuffer = ByteBuffer.wrap(pcodeBytes); + if(byteBuffer.get() != 0x47) { + throw new InvalidPaymentCodeException("Invalid version: " + strPaymentCode); + } else { + byte[] chain = new byte[32]; + byte[] pub = new byte[33]; + // type: + byteBuffer.get(); + // feature: + byteBuffer.get(); + byteBuffer.get(pub); + byteBuffer.get(chain); + + ByteBuffer pubBytes = ByteBuffer.wrap(pub); + int firstByte = pubBytes.get(); + return firstByte == 0x02 || firstByte == 0x03; + } + } catch(BufferUnderflowException | InvalidPaymentCodeException bue) { + return false; + } + } + + public static PaymentCode fromString(String strPaymentCode) { + try { + return new PaymentCode(strPaymentCode); + } catch(InvalidPaymentCodeException e) { + log.error("Invalid payment code", e); + } + + return null; + } + + public PaymentCode copy() { + return new PaymentCode(strPaymentCode, pubkey, chain); + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + + PaymentCode that = (PaymentCode) o; + return strPaymentCode.equals(that.strPaymentCode); + } + + @Override + public int hashCode() { + return strPaymentCode.hashCode(); + } +} + diff --git a/src/main/java/com/sparrowwallet/drongo/bip47/SecretPoint.java b/src/main/java/com/sparrowwallet/drongo/bip47/SecretPoint.java new file mode 100644 index 0000000..60e3054 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/SecretPoint.java @@ -0,0 +1,53 @@ +package com.sparrowwallet.drongo.bip47; + +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.bouncycastle.jce.spec.ECPrivateKeySpec; +import org.bouncycastle.jce.spec.ECPublicKeySpec; + +import javax.crypto.KeyAgreement; +import javax.crypto.SecretKey; +import java.math.BigInteger; +import java.security.*; +import java.security.spec.InvalidKeySpecException; + +public class SecretPoint { + private static final ECParameterSpec params = ECNamedCurveTable.getParameterSpec("secp256k1"); + private static final String KEY_PROVIDER = "BC"; + + private final PrivateKey privKey; + private final PublicKey pubKey; + private final KeyFactory kf; + + static { + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + } + + public SecretPoint(byte[] dataPrv, byte[] dataPub) throws InvalidKeySpecException, InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, NoSuchProviderException { + kf = KeyFactory.getInstance("ECDH", KEY_PROVIDER); + privKey = loadPrivateKey(dataPrv); + pubKey = loadPublicKey(dataPub); + } + + public byte[] ECDHSecretAsBytes() throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, NoSuchProviderException { + return ECDHSecret().getEncoded(); + } + + private SecretKey ECDHSecret() throws InvalidKeyException, IllegalStateException, NoSuchAlgorithmException, NoSuchProviderException { + KeyAgreement ka = KeyAgreement.getInstance("ECDH", KEY_PROVIDER); + ka.init(privKey); + ka.doPhase(pubKey, true); + return ka.generateSecret("AES"); + } + + private PublicKey loadPublicKey(byte[] data) throws InvalidKeySpecException { + ECPublicKeySpec pubKey = new ECPublicKeySpec(params.getCurve().decodePoint(data), params); + return kf.generatePublic(pubKey); + } + + private PrivateKey loadPrivateKey(byte[] data) throws InvalidKeySpecException { + ECPrivateKeySpec prvkey = new ECPrivateKeySpec(new BigInteger(1, data), params); + return kf.generatePrivate(prvkey); + } +} + diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/HDKeyDerivation.java b/src/main/java/com/sparrowwallet/drongo/crypto/HDKeyDerivation.java index f926dee..5f93f04 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/HDKeyDerivation.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/HDKeyDerivation.java @@ -38,6 +38,10 @@ public class HDKeyDerivation { return new DeterministicKey(childNumberPath, chainCode, priv, null); } + public static DeterministicKey createMasterPubKeyFromBytes(byte[] pubKeyBytes, byte[] chainCode) { + return new DeterministicKey(List.of(), chainCode, new LazyECPoint(ECKey.CURVE.getCurve(), pubKeyBytes), null, null); + } + public static DeterministicKey deriveChildKey(DeterministicKey parent, ChildNumber childNumber) throws HDDerivationException { if(parent.isPubKeyOnly()) { RawKeyBytes rawKey = deriveChildKeyBytesFromPublic(parent, childNumber); diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionOutPoint.java b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionOutPoint.java index 935673f..ffe3e02 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionOutPoint.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionOutPoint.java @@ -3,6 +3,7 @@ package com.sparrowwallet.drongo.protocol; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Objects; @@ -51,6 +52,18 @@ public class TransactionOutPoint extends ChildMessage { this.addresses = addresses; } + public byte[] bitcoinSerialize() { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bitcoinSerializeToStream(outputStream); + return outputStream.toByteArray(); + } catch (IOException e) { + //can't happen + } + + return null; + } + @Override protected void bitcoinSerializeToStream(OutputStream stream) throws IOException { stream.write(hash.getReversedBytes()); diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index e77a165..51c6f39 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -136,8 +136,8 @@ public class PSBT { outputNodes.add(wallet.getWalletAddresses().getOrDefault(address, null)); } } catch(NonStandardScriptException e) { - //Should never happen - throw new IllegalArgumentException(e); + //Ignore, likely OP_RETURN output + outputNodes.add(null); } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java index db40a68..b4079e5 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java @@ -4,11 +4,20 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.bip47.PaymentAddress; +import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.crypto.*; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; public class Keystore extends Persistable { + private static final Logger log = LoggerFactory.getLogger(Keystore.class); + public static final String DEFAULT_LABEL = "Keystore 1"; public static final int MAX_LABEL_LENGTH = 16; @@ -17,9 +26,13 @@ public class Keystore extends Persistable { private WalletModel walletModel = WalletModel.SPARROW; private KeyDerivation keyDerivation; private ExtendedKey extendedPublicKey; + private PaymentCode externalPaymentCode; private MasterPrivateExtendedKey masterPrivateExtendedKey; private DeterministicSeed seed; + //For BIP47 keystores - not persisted but must be unencrypted to generate keys + private ExtendedKey bip47ExtendedPrivateKey; + public Keystore() { this(DEFAULT_LABEL); } @@ -72,6 +85,14 @@ public class Keystore extends Persistable { this.extendedPublicKey = extendedPublicKey; } + public PaymentCode getExternalPaymentCode() { + return externalPaymentCode; + } + + public void setExternalPaymentCode(PaymentCode paymentCode) { + this.externalPaymentCode = paymentCode; + } + public boolean hasMasterPrivateExtendedKey() { return masterPrivateExtendedKey != null; } @@ -96,10 +117,34 @@ public class Keystore extends Persistable { this.seed = seed; } - public boolean hasPrivateKey() { + public boolean hasMasterPrivateKey() { return hasSeed() || hasMasterPrivateExtendedKey(); } + public boolean hasPrivateKey() { + return hasMasterPrivateKey() || (source == KeystoreSource.SW_PAYMENT_CODE && bip47ExtendedPrivateKey != null); + } + + public PaymentCode getPaymentCode() { + DeterministicKey bip47Key = bip47ExtendedPrivateKey.getKey(); + return new PaymentCode(bip47Key.getPubKey(), bip47Key.getChainCode()); + } + + public ExtendedKey getBip47ExtendedPrivateKey() { + return bip47ExtendedPrivateKey; + } + + public void setBip47ExtendedPrivateKey(ExtendedKey bip47ExtendedPrivateKey) { + this.bip47ExtendedPrivateKey = bip47ExtendedPrivateKey; + } + + public PaymentAddress getPaymentAddress(KeyPurpose keyPurpose, int index) { + List derivation = keyDerivation.getDerivation(); + ChildNumber derivationStart = keyDerivation.getDerivation().isEmpty() ? ChildNumber.ZERO_HARDENED : keyDerivation.getDerivation().get(derivation.size() - 1); + DeterministicKey privateKey = bip47ExtendedPrivateKey.getKey(List.of(derivationStart, new ChildNumber(keyPurpose == KeyPurpose.SEND ? 0 : index))); + return new PaymentAddress(externalPaymentCode, keyPurpose == KeyPurpose.SEND ? index : 0, privateKey.getPrivKeyBytes()); + } + public DeterministicKey getMasterPrivateKey() throws MnemonicException { if(seed == null && masterPrivateExtendedKey == null) { throw new IllegalArgumentException("Keystore does not contain a master private key, or seed to derive one from"); @@ -136,22 +181,44 @@ public class Keystore extends Persistable { return ExtendedKey.fromDescriptor(xprv.toString()); } - public DeterministicKey getKey(WalletNode walletNode) throws MnemonicException { - return getKey(walletNode.getKeyPurpose(), walletNode.getIndex()); - } + public ECKey getKey(WalletNode walletNode) throws MnemonicException { + if(source == KeystoreSource.SW_PAYMENT_CODE) { + try { + if(walletNode.getKeyPurpose() != KeyPurpose.RECEIVE) { + throw new IllegalArgumentException("Cannot get private key for non-receive chain"); + } + + PaymentAddress paymentAddress = getPaymentAddress(walletNode.getKeyPurpose(), walletNode.getIndex()); + return paymentAddress.getReceiveECKey(); + } catch(IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid payment code " + externalPaymentCode, e); + } catch(Exception e) { + log.error("Cannot get receive private key at index " + walletNode.getIndex() + " for payment code " + externalPaymentCode, e); + } + } - public DeterministicKey getKey(KeyPurpose keyPurpose, int keyIndex) throws MnemonicException { ExtendedKey extendedPrivateKey = getExtendedPrivateKey(); - List derivation = List.of(extendedPrivateKey.getKeyChildNumber(), keyPurpose.getPathIndex(), new ChildNumber(keyIndex)); + List derivation = new ArrayList<>(); + derivation.add(extendedPrivateKey.getKeyChildNumber()); + derivation.addAll(walletNode.getDerivation()); return extendedPrivateKey.getKey(derivation); } - public DeterministicKey getPubKey(WalletNode walletNode) { - return getPubKey(walletNode.getKeyPurpose(), walletNode.getIndex()); - } + public ECKey getPubKey(WalletNode walletNode) { + if(source == KeystoreSource.SW_PAYMENT_CODE) { + try { + PaymentAddress paymentAddress = getPaymentAddress(walletNode.getKeyPurpose(), walletNode.getIndex()); + return walletNode.getKeyPurpose() == KeyPurpose.RECEIVE ? ECKey.fromPublicOnly(paymentAddress.getReceiveECKey()) : paymentAddress.getSendECKey(); + } catch(IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid payment code " + externalPaymentCode, e); + } catch(Exception e) { + log.error("Cannot get receive private key at index " + walletNode.getIndex() + " for payment code " + externalPaymentCode, e); + } + } - public DeterministicKey getPubKey(KeyPurpose keyPurpose, int keyIndex) { - List derivation = List.of(extendedPublicKey.getKeyChildNumber(), keyPurpose.getPathIndex(), new ChildNumber(keyIndex)); + List derivation = new ArrayList<>(); + derivation.add(extendedPublicKey.getKeyChildNumber()); + derivation.addAll(walletNode.getDerivation()); return extendedPublicKey.getKey(derivation); } @@ -225,6 +292,16 @@ public class Keystore extends Persistable { } } } + + if(source == KeystoreSource.SW_PAYMENT_CODE) { + if(externalPaymentCode == null) { + throw new InvalidKeystoreException("Source of " + source + " but no payment code is present"); + } + + if(bip47ExtendedPrivateKey == null) { + throw new InvalidKeystoreException("Source of " + source + " but no extended private key is present"); + } + } } public Keystore copy() { @@ -244,6 +321,12 @@ public class Keystore extends Persistable { if(seed != null) { copy.setSeed(seed.copy()); } + if(externalPaymentCode != null) { + copy.setExternalPaymentCode(externalPaymentCode.copy()); + } + if(bip47ExtendedPrivateKey != null) { + copy.setBip47ExtendedPrivateKey(bip47ExtendedPrivateKey.copy()); + } return copy; } @@ -274,6 +357,13 @@ public class Keystore extends Persistable { keystore.setWalletModel(WalletModel.SPARROW); keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(derivation))); keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub.toString())); + + int account = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream() + .mapToInt(scriptType -> scriptType.getAccount(keystore.getKeyDerivation().getDerivationPath())).filter(idx -> idx > -1).findFirst().orElse(0); + List bip47Derivation = KeyDerivation.getBip47Derivation(account); + DeterministicKey bip47Key = xprv.getKey(bip47Derivation); + ExtendedKey bip47ExtendedPrivateKey = new ExtendedKey(bip47Key, bip47Key.getParentFingerprint(), bip47Derivation.get(bip47Derivation.size() - 1)); + keystore.setBip47ExtendedPrivateKey(ExtendedKey.fromDescriptor(bip47ExtendedPrivateKey.toString())); } public boolean isEncrypted() { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/KeystoreSource.java b/src/main/java/com/sparrowwallet/drongo/wallet/KeystoreSource.java index b3c4b31..a98bbe0 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/KeystoreSource.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/KeystoreSource.java @@ -1,9 +1,13 @@ package com.sparrowwallet.drongo.wallet; public enum KeystoreSource { - HW_USB("Connected Hardware Wallet"), HW_AIRGAPPED("Airgapped Hardware Wallet"), SW_SEED("Software Wallet"), SW_WATCH("Watch Only Wallet"); + HW_USB("Connected Hardware Wallet"), + HW_AIRGAPPED("Airgapped Hardware Wallet"), + SW_SEED("Software Wallet"), + SW_WATCH("Watch Only Wallet"), + SW_PAYMENT_CODE("Payment Code Wallet"); - private String displayName; + private final String displayName; KeystoreSource(String displayName) { this.displayName = displayName; diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java index 2ad8147..090014c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java @@ -7,9 +7,16 @@ import java.util.stream.Collectors; public class PresetUtxoSelector extends SingleSetUtxoSelector { private final Collection presetUtxos; + private final boolean maintainOrder; public PresetUtxoSelector(Collection presetUtxos) { this.presetUtxos = presetUtxos; + this.maintainOrder = false; + } + + public PresetUtxoSelector(Collection presetUtxos, boolean maintainOrder) { + this.presetUtxos = presetUtxos; + this.maintainOrder = maintainOrder; } @Override @@ -26,10 +33,19 @@ public class PresetUtxoSelector extends SingleSetUtxoSelector { } } + if(maintainOrder && utxos.containsAll(presetUtxos)) { + return presetUtxos; + } + return utxos; } public Collection getPresetUtxos() { return presetUtxos; } + + @Override + public boolean shuffleInputs() { + return !maintainOrder; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java index 0051ba0..4d6efa5 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java @@ -5,4 +5,7 @@ import java.util.List; public interface UtxoSelector { List> selectSets(long targetValue, Collection candidates); + default boolean shuffleInputs() { + return true; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java index 5b19bff..6e2ea79 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -1,11 +1,10 @@ package com.sparrowwallet.drongo.wallet; -import com.sparrowwallet.drongo.BitcoinUnit; -import com.sparrowwallet.drongo.KeyDerivation; -import com.sparrowwallet.drongo.KeyPurpose; -import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.*; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.DeterministicKey; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.crypto.Key; import com.sparrowwallet.drongo.policy.Policy; @@ -109,7 +108,7 @@ public class Wallet extends Persistable implements Comparable { throw new IllegalStateException("Cannot add child wallet to existing child wallet"); } - if(childWallet.containsPrivateKeys() && childWallet.isEncrypted()) { + if(childWallet.containsMasterPrivateKeys() && childWallet.isEncrypted()) { throw new IllegalStateException("Cannot derive child wallet xpub from encrypted wallet"); } @@ -136,7 +135,7 @@ public class Wallet extends Persistable implements Comparable { childDerivation.add(standardAccount.getChildNumber()); } - if(keystore.hasPrivateKey()) { + if(keystore.hasMasterPrivateKey()) { try { Keystore derivedKeystore = keystore.hasSeed() ? Keystore.fromSeed(keystore.getSeed(), childDerivation) : Keystore.fromMasterPrivateExtendedKey(keystore.getMasterPrivateExtendedKey(), childDerivation); keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); @@ -167,6 +166,65 @@ public class Wallet extends Persistable implements Comparable { return null; } + public Wallet addChildWallet(PaymentCode externalPaymentCode, ScriptType childScriptType, BlockTransactionHashIndex notificationOutput, BlockTransaction notificationTransaction) { + Wallet bip47Wallet = addChildWallet(externalPaymentCode, childScriptType); + WalletNode notificationNode = bip47Wallet.getNode(KeyPurpose.NOTIFICATION); + notificationNode.getTransactionOutputs().add(notificationOutput); + bip47Wallet.updateTransactions(Map.of(notificationTransaction.getHash(), notificationTransaction)); + + return bip47Wallet; + } + + public Wallet addChildWallet(PaymentCode externalPaymentCode, ScriptType childScriptType) { + if(policyType != PolicyType.SINGLE) { + throw new IllegalStateException("Cannot add payment code wallet to " + policyType.getName() + " wallet"); + } + + if(scriptType != P2PKH && scriptType != P2WPKH) { + throw new IllegalStateException("Cannot add payment code wallet to " + scriptType.getName() + " wallet"); + } + + Keystore masterKeystore = getKeystores().get(0); + if(masterKeystore.getBip47ExtendedPrivateKey() == null) { + throw new IllegalStateException("Cannot add payment code wallet, BIP47 extended private key not present"); + } + + Wallet childWallet = new Wallet(childScriptType + "-" + externalPaymentCode.toString()); + childWallet.setPolicyType(PolicyType.SINGLE); + childWallet.setScriptType(childScriptType); + childWallet.setGapLimit(5); + + Keystore keystore = new Keystore("BIP47"); + keystore.setSource(KeystoreSource.SW_PAYMENT_CODE); + keystore.setWalletModel(WalletModel.SPARROW); + List derivation = KeyDerivation.getBip47Derivation(getAccountIndex()); + keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), derivation)); + keystore.setExternalPaymentCode(externalPaymentCode); + keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey()); + DeterministicKey pubKey = keystore.getBip47ExtendedPrivateKey().getKey().dropPrivateBytes().dropParent(); + keystore.setExtendedPublicKey(new ExtendedKey(pubKey, keystore.getBip47ExtendedPrivateKey().getParentFingerprint(), derivation.get(derivation.size() - 1))); + + childWallet.getKeystores().add(keystore); + childWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, childWallet.getKeystores(), 1)); + + childWallet.setMasterWallet(this); + getChildWallets().add(childWallet); + return childWallet; + } + + public Wallet getChildWallet(PaymentCode externalPaymentCode, ScriptType childScriptType) { + for(Wallet childWallet : getChildWallets()) { + if(childWallet.getKeystores().size() == 1 && externalPaymentCode != null && childWallet.getScriptType() == childScriptType && + childWallet.getKeystores().get(0).getExternalPaymentCode() != null && + (externalPaymentCode.equals(childWallet.getKeystores().get(0).getExternalPaymentCode()) || + externalPaymentCode.getNotificationAddress().equals(childWallet.getKeystores().get(0).getExternalPaymentCode().getNotificationAddress()))) { + return childWallet; + } + } + + return null; + } + public List getAllWallets() { List allWallets = new ArrayList<>(); Wallet masterWallet = isMasterWallet() ? this : getMasterWallet(); @@ -175,6 +233,70 @@ public class Wallet extends Persistable implements Comparable { return allWallets; } + public boolean hasPaymentCode() { + return getKeystores().size() == 1 && getKeystores().get(0).getBip47ExtendedPrivateKey() != null; + } + + public PaymentCode getPaymentCode() { + if(hasPaymentCode()) { + return getKeystores().get(0).getPaymentCode(); + } + + return null; + } + + public Wallet getNotificationWallet() { + if(isMasterWallet() && hasPaymentCode()) { + Wallet notificationWallet = new Wallet(); + notificationWallet.setPolicyType(PolicyType.SINGLE); + notificationWallet.setScriptType(ScriptType.P2PKH); + notificationWallet.setGapLimit(0); + + Keystore masterKeystore = getKeystores().get(0); + + Keystore keystore = new Keystore(); + keystore.setSource(KeystoreSource.SW_WATCH); + keystore.setWalletModel(WalletModel.SPARROW); + keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), KeyDerivation.getBip47Derivation(getAccountIndex()))); + keystore.setExtendedPublicKey(masterKeystore.getBip47ExtendedPrivateKey()); + keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey()); + + notificationWallet.getKeystores().add(keystore); + notificationWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, notificationWallet.getKeystores(), 1)); + + return notificationWallet; + } + + return null; + } + + public Map getNotificationTransaction(PaymentCode externalPaymentCode) { + Address notificationAddress = externalPaymentCode.getNotificationAddress(); + for(Map.Entry txoEntry : getWalletTxos().entrySet()) { + if(txoEntry.getKey().isSpent()) { + BlockTransaction blockTransaction = transactions.get(txoEntry.getKey().getSpentBy().getHash()); + if(blockTransaction != null) { + for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) { + if(notificationAddress.equals(txOutput.getScript().getToAddress())) { + try { + PaymentCode.getOpReturnData(blockTransaction.getTransaction()); + return Map.of(blockTransaction, txoEntry.getValue()); + } catch(Exception e) { + //ignore + } + } + } + } + } + } + + return Collections.emptyMap(); + } + + public boolean isBip47() { + return !isMasterWallet() && getKeystores().size() == 1 && getKeystores().get(0).getSource() == KeystoreSource.SW_PAYMENT_CODE; + } + public StandardAccount getStandardAccountType() { int accountIndex = getAccountIndex(); return Arrays.stream(StandardAccount.values()).filter(standardAccount -> standardAccount.getChildNumber().num() == accountIndex).findFirst().orElse(null); @@ -454,10 +576,6 @@ public class Wallet extends Persistable implements Comparable { } public ECKey getPubKey(WalletNode node) { - return getPubKey(node.getKeyPurpose(), node.getIndex()); - } - - public ECKey getPubKey(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.MULTI) { throw new IllegalStateException("Attempting to retrieve a single key for a multisig policy wallet"); } else if(policyType == PolicyType.CUSTOM) { @@ -465,33 +583,25 @@ public class Wallet extends Persistable implements Comparable { } Keystore keystore = getKeystores().get(0); - return keystore.getPubKey(keyPurpose, index); + return keystore.getPubKey(node); } public List getPubKeys(WalletNode node) { - return getPubKeys(node.getKeyPurpose(), node.getIndex()); - } - - public List getPubKeys(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { throw new IllegalStateException("Attempting to retrieve multiple keys for a singlesig policy wallet"); } else if(policyType == PolicyType.CUSTOM) { throw new UnsupportedOperationException("Cannot determine public keys for a custom policy"); } - return getKeystores().stream().map(keystore -> keystore.getPubKey(keyPurpose, index)).collect(Collectors.toList()); + return getKeystores().stream().map(keystore -> keystore.getPubKey(node)).collect(Collectors.toList()); } public Address getAddress(WalletNode node) { - return getAddress(node.getKeyPurpose(), node.getIndex()); - } - - public Address getAddress(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - ECKey pubKey = getPubKey(keyPurpose, index); + ECKey pubKey = getPubKey(node); return scriptType.getAddress(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = getPubKeys(node); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getAddress(script); } else { @@ -500,15 +610,11 @@ public class Wallet extends Persistable implements Comparable { } public Script getOutputScript(WalletNode node) { - return getOutputScript(node.getKeyPurpose(), node.getIndex()); - } - - public Script getOutputScript(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - ECKey pubKey = getPubKey(keyPurpose, index); + ECKey pubKey = getPubKey(node); return scriptType.getOutputScript(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = getPubKeys(node); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputScript(script); } else { @@ -517,15 +623,11 @@ public class Wallet extends Persistable implements Comparable { } public String getOutputDescriptor(WalletNode node) { - return getOutputDescriptor(node.getKeyPurpose(), node.getIndex()); - } - - public String getOutputDescriptor(KeyPurpose keyPurpose, int index) { if(policyType == PolicyType.SINGLE) { - ECKey pubKey = getPubKey(keyPurpose, index); + ECKey pubKey = getPubKey(node); return scriptType.getOutputDescriptor(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = getPubKeys(node); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputDescriptor(script); } else { @@ -533,10 +635,20 @@ public class Wallet extends Persistable implements Comparable { } } + public List getWalletKeyPurposes() { + return isBip47() ? List.of(KeyPurpose.RECEIVE) : KeyPurpose.DEFAULT_PURPOSES; + } + + public KeyPurpose getChangeKeyPurpose() { + return isBip47() ? KeyPurpose.RECEIVE : KeyPurpose.CHANGE; + } + public Map> getWalletNodes() { Map> walletNodes = new LinkedHashMap<>(); - getNode(KeyPurpose.RECEIVE).getChildren().forEach(childNode -> walletNodes.put(childNode, childNode.getTransactionOutputs())); - getNode(KeyPurpose.CHANGE).getChildren().forEach(childNode -> walletNodes.put(childNode, childNode.getTransactionOutputs())); + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { + getNode(keyPurpose).getChildren().forEach(childNode -> walletNodes.put(childNode, childNode.getTransactionOutputs())); + } + return walletNodes; } @@ -546,8 +658,10 @@ public class Wallet extends Persistable implements Comparable { public Map getWalletAddresses() { Map walletAddresses = new LinkedHashMap<>(); - getWalletAddresses(walletAddresses, getNode(KeyPurpose.RECEIVE)); - getWalletAddresses(walletAddresses, getNode(KeyPurpose.CHANGE)); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getWalletAddresses(walletAddresses, getNode(keyPurpose)); + } + return walletAddresses; } @@ -562,10 +676,18 @@ public class Wallet extends Persistable implements Comparable { } public Map getWalletOutputScripts() { - return getWalletOutputScripts(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); + return getWalletOutputScripts(getWalletKeyPurposes()); } - public Map getWalletOutputScripts(KeyPurpose... keyPurposes) { + public Map getWalletOutputScripts(KeyPurpose keyPurpose) { + if(!getWalletKeyPurposes().contains(keyPurpose)) { + return Collections.emptyMap(); + } + + return getWalletOutputScripts(List.of(keyPurpose)); + } + + private Map getWalletOutputScripts(List keyPurposes) { Map walletOutputScripts = new LinkedHashMap<>(); for(KeyPurpose keyPurpose : keyPurposes) { getWalletOutputScripts(walletOutputScripts, getNode(keyPurpose)); @@ -593,8 +715,10 @@ public class Wallet extends Persistable implements Comparable { public Map getWalletTxos() { Map walletTxos = new TreeMap<>(); - getWalletTxos(walletTxos, getNode(KeyPurpose.RECEIVE)); - getWalletTxos(walletTxos, getNode(KeyPurpose.CHANGE)); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getWalletTxos(walletTxos, getNode(keyPurpose)); + } + return walletTxos; } @@ -612,8 +736,10 @@ public class Wallet extends Persistable implements Comparable { public Map getWalletUtxos(boolean includeSpentMempoolOutputs) { Map walletUtxos = new TreeMap<>(); - getWalletUtxos(walletUtxos, getNode(KeyPurpose.RECEIVE), includeSpentMempoolOutputs); - getWalletUtxos(walletUtxos, getNode(KeyPurpose.CHANGE), includeSpentMempoolOutputs); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getWalletUtxos(walletUtxos, getNode(keyPurpose), includeSpentMempoolOutputs); + } + return walletUtxos; } @@ -785,7 +911,7 @@ public class Wallet extends Persistable implements Comparable { } for(int i = 1; i < numSets; i+=2) { - WalletNode mixNode = getFreshNode(KeyPurpose.CHANGE); + WalletNode mixNode = getFreshNode(getChangeKeyPurpose()); txExcludedChangeNodes.add(mixNode); Payment fakeMixPayment = new Payment(getAddress(mixNode), ".." + mixNode + " (Fake Mix)", totalPaymentAmount, false); fakeMixPayment.setType(Payment.Type.FAKE_MIX); @@ -842,9 +968,9 @@ public class Wallet extends Persistable implements Comparable { long costOfChangeAmt = getCostOfChange(noChangeFeeRate, longTermFeeRate); if(setChangeAmts.stream().allMatch(amt -> amt > costOfChangeAmt) || (numSets > 1 && differenceAmt / transaction.getVirtualSize() > noChangeFeeRate * 2)) { //Change output is required, determine new fee once change output has been added - WalletNode changeNode = getFreshNode(KeyPurpose.CHANGE); + WalletNode changeNode = getFreshNode(getChangeKeyPurpose()); while(txExcludedChangeNodes.contains(changeNode)) { - changeNode = getFreshNode(KeyPurpose.CHANGE, changeNode); + changeNode = getFreshNode(getChangeKeyPurpose(), changeNode); } TransactionOutput changeOutput = new TransactionOutput(transaction, setChangeAmts.iterator().next(), getOutputScript(changeNode)); double changeVSize = noChangeVSize + changeOutput.getLength() * numSets; @@ -860,7 +986,7 @@ public class Wallet extends Persistable implements Comparable { for(Long setChangeAmt : setChangeAmts) { transaction.addOutput(setChangeAmt, getOutputScript(changeNode)); changeMap.put(changeNode, setChangeAmt); - changeNode = getFreshNode(KeyPurpose.CHANGE, changeNode); + changeNode = getFreshNode(getChangeKeyPurpose(), changeNode); } if(setChangeAmts.stream().anyMatch(amt -> amt < costOfChangeAmt)) { @@ -958,7 +1084,9 @@ public class Wallet extends Persistable implements Comparable { total += selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); Map selectedInputsMap = new LinkedHashMap<>(); List shuffledInputs = new ArrayList<>(selectedInputs); - Collections.shuffle(shuffledInputs); + if(utxoSelector.shuffleInputs()) { + Collections.shuffle(shuffledInputs); + } for(BlockTransactionHashIndex shuffledInput : shuffledInputs) { selectedInputsMap.put(shuffledInput, utxos.get(shuffledInput)); } @@ -976,8 +1104,10 @@ public class Wallet extends Persistable implements Comparable { private List getGroupedUtxos(List utxoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) { List outputGroups = new ArrayList<>(); - getGroupedUtxos(outputGroups, getNode(KeyPurpose.RECEIVE), utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); - getGroupedUtxos(outputGroups, getNode(KeyPurpose.CHANGE), utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getGroupedUtxos(outputGroups, getNode(keyPurpose), utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + } + return outputGroups; } @@ -1527,9 +1657,9 @@ public class Wallet extends Persistable implements Comparable { return copy; } - public boolean containsPrivateKeys() { + public boolean containsMasterPrivateKeys() { for(Keystore keystore : keystores) { - if(keystore.hasPrivateKey()) { + if(keystore.hasMasterPrivateKey()) { return true; } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 825652f..49bb37b 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -14,5 +14,6 @@ open module com.sparrowwallet.drongo { exports com.sparrowwallet.drongo.wallet; exports com.sparrowwallet.drongo.policy; exports com.sparrowwallet.drongo.uri; + exports com.sparrowwallet.drongo.bip47; exports org.bitcoin; } \ No newline at end of file diff --git a/src/test/java/com/sparrowwallet/drongo/OutputDescriptorTest.java b/src/test/java/com/sparrowwallet/drongo/OutputDescriptorTest.java index 4a5873a..8b603ac 100644 --- a/src/test/java/com/sparrowwallet/drongo/OutputDescriptorTest.java +++ b/src/test/java/com/sparrowwallet/drongo/OutputDescriptorTest.java @@ -47,7 +47,7 @@ public class OutputDescriptorTest { @Test public void masterP2PKH() { OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor("pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)"); - Assert.assertEquals("pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)", descriptor.toString()); + Assert.assertEquals("pkh([d34db33f/44h/0h/0h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)", descriptor.toString()); ExtendedKey extendedPublicKey = descriptor.getSingletonExtendedPublicKey(); KeyDerivation derivation = descriptor.getKeyDerivation(extendedPublicKey); Assert.assertEquals("d34db33f", derivation.getMasterFingerprint()); @@ -58,7 +58,7 @@ public class OutputDescriptorTest { @Test public void singleP2SH_P2WPKH() { OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor("sh(wpkh([f09a3b29/49h/0h/0h]xpub6CjUWYtkq9KT1zkM5NPMxoJTCMm8JSFw7JPyMG6YLBzv5AsCTkASnsVyJhqL1aaqF5XSsFinHK3FDi8RoeEWcTG3DQA2TjqrZ6HJtatYbsU/0/*))"); - Assert.assertEquals("sh(wpkh([f09a3b29/49'/0'/0']xpub6CjUWYtkq9KT1zkM5NPMxoJTCMm8JSFw7JPyMG6YLBzv5AsCTkASnsVyJhqL1aaqF5XSsFinHK3FDi8RoeEWcTG3DQA2TjqrZ6HJtatYbsU/0/*))", descriptor.toString()); + Assert.assertEquals("sh(wpkh([f09a3b29/49h/0h/0h]xpub6CjUWYtkq9KT1zkM5NPMxoJTCMm8JSFw7JPyMG6YLBzv5AsCTkASnsVyJhqL1aaqF5XSsFinHK3FDi8RoeEWcTG3DQA2TjqrZ6HJtatYbsU/0/*))", descriptor.toString()); ExtendedKey extendedPublicKey = descriptor.getSingletonExtendedPublicKey(); KeyDerivation derivation = descriptor.getKeyDerivation(extendedPublicKey); Assert.assertEquals("f09a3b29", derivation.getMasterFingerprint()); @@ -95,7 +95,7 @@ public class OutputDescriptorTest { @Test public void testChecksum() { OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t"); - Assert.assertEquals("sh(sortedmulti(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#s66h0xn6", descriptor.toString(true)); + Assert.assertEquals("sh(sortedmulti(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#vqfgjk5v", descriptor.toString(true)); } @Test(expected = IllegalArgumentException.class) diff --git a/src/test/java/com/sparrowwallet/drongo/bip47/PaymentCodeTest.java b/src/test/java/com/sparrowwallet/drongo/bip47/PaymentCodeTest.java new file mode 100644 index 0000000..e4cd501 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/bip47/PaymentCodeTest.java @@ -0,0 +1,171 @@ +package com.sparrowwallet.drongo.bip47; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.DeterministicKey; +import com.sparrowwallet.drongo.crypto.DumpedPrivateKey; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.wallet.*; +import org.junit.Assert; +import org.junit.Test; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.spec.InvalidKeySpecException; +import java.util.List; + +public class PaymentCodeTest { + @Test + public void testNotificationAddress() throws InvalidPaymentCodeException, InvalidKeySpecException, NoSuchAlgorithmException, InvalidKeyException, NoSuchProviderException, MnemonicException { + PaymentCode alicePaymentCode = new PaymentCode("PM8TJTLJbPRGxSbc8EJi42Wrr6QbNSaSSVJ5Y3E4pbCYiTHUskHg13935Ubb7q8tx9GVbh2UuRnBc3WSyJHhUrw8KhprKnn9eDznYGieTzFcwQRya4GA"); + Address aliceNotificationAddress = alicePaymentCode.getNotificationAddress(); + Assert.assertEquals("1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW", aliceNotificationAddress.toString()); + + ECKey alicePrivKey = DumpedPrivateKey.fromBase58("Kx983SRhAZpAhj7Aac1wUXMJ6XZeyJKqCxJJ49dxEbYCT4a1ozRD").getKey(); + + byte[] alicePayload = alicePaymentCode.getPayload(); + Assert.assertEquals("010002b85034fb08a8bfefd22848238257b252721454bbbfba2c3667f168837ea2cdad671af9f65904632e2dcc0c6ad314e11d53fc82fa4c4ea27a4a14eccecc478fee00000000000000000000000000", Utils.bytesToHex(alicePayload)); + + PaymentCode paymentCodeBob = new PaymentCode("PM8TJS2JxQ5ztXUpBBRnpTbcUXbUHy2T1abfrb3KkAAtMEGNbey4oumH7Hc578WgQJhPjBxteQ5GHHToTYHE3A1w6p7tU6KSoFmWBVbFGjKPisZDbP97"); + ECKey bobNotificationPubKey = paymentCodeBob.getNotificationKey(); + Assert.assertEquals("024ce8e3b04ea205ff49f529950616c3db615b1e37753858cc60c1ce64d17e2ad8", Utils.bytesToHex(bobNotificationPubKey.getPubKey())); + + TransactionOutPoint transactionOutPoint = new TransactionOutPoint(Sha256Hash.wrapReversed(Utils.hexToBytes("86f411ab1c8e70ae8a0795ab7a6757aea6e4d5ae1826fc7b8f00c597d500609c")), 1); + Assert.assertEquals("86f411ab1c8e70ae8a0795ab7a6757aea6e4d5ae1826fc7b8f00c597d500609c01000000", Utils.bytesToHex(transactionOutPoint.bitcoinSerialize())); + + SecretPoint secretPoint = new SecretPoint(alicePrivKey.getPrivKeyBytes(), bobNotificationPubKey.getPubKey()); + Assert.assertEquals("736a25d9250238ad64ed5da03450c6a3f4f8f4dcdf0b58d1ed69029d76ead48d", Utils.bytesToHex(secretPoint.ECDHSecretAsBytes())); + + byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), transactionOutPoint.bitcoinSerialize()); + Assert.assertEquals("be6e7a4256cac6f4d4ed4639b8c39c4cb8bece40010908e70d17ea9d77b4dc57f1da36f2d6641ccb37cf2b9f3146686462e0fa3161ae74f88c0afd4e307adbd5", Utils.bytesToHex(blindingMask)); + + byte[] blindedPaymentCode = PaymentCode.blind(alicePayload, blindingMask); + Assert.assertEquals("010002063e4eb95e62791b06c50e1a3a942e1ecaaa9afbbeb324d16ae6821e091611fa96c0cf048f607fe51a0327f5e2528979311c78cb2de0d682c61e1180fc3d543b00000000000000000000000000", Utils.bytesToHex(blindedPaymentCode)); + + Transaction transaction = new Transaction(); + List inputChunks = List.of(ScriptChunk.fromData(Utils.hexToBytes("3045022100ac8c6dbc482c79e86c18928a8b364923c774bfdbd852059f6b3778f2319b59a7022029d7cc5724e2f41ab1fcfc0ba5a0d4f57ca76f72f19530ba97c860c70a6bf0a801")), ScriptChunk.fromData(alicePrivKey.getPubKey())); + transaction.addInput(transactionOutPoint.getHash(), transactionOutPoint.getIndex(), new Script(inputChunks)); + transaction.addOutput(10000, paymentCodeBob.getNotificationAddress()); + List opReturnChunks = List.of(ScriptChunk.fromOpcode(ScriptOpCodes.OP_RETURN), ScriptChunk.fromData(blindedPaymentCode)); + transaction.addOutput(10000, new Script(opReturnChunks)); + Assert.assertEquals("010000000186f411ab1c8e70ae8a0795ab7a6757aea6e4d5ae1826fc7b8f00c597d500609c010000006b483045022100ac8c6dbc482c79e86c18928a8b364923c774bfdb" + + "d852059f6b3778f2319b59a7022029d7cc5724e2f41ab1fcfc0ba5a0d4f57ca76f72f19530ba97c860c70a6bf0a801210272d83d8a1fa323feab1c085157a0791b46eba34afb8bfbfaeb3a3fcc3f2" + + "c9ad8ffffffff0210270000000000001976a9148066a8e7ee82e5c5b9b7dc1765038340dc5420a988ac1027000000000000536a4c50010002063e4eb95e62791b06c50e1a3a942e1ecaaa9afbbeb3" + + "24d16ae6821e091611fa96c0cf048f607fe51a0327f5e2528979311c78cb2de0d682c61e1180fc3d543b0000000000000000000000000000000000", Utils.bytesToHex(transaction.bitcoinSerialize())); + Assert.assertEquals("9414f1681fb1255bd168a806254321a837008dd4480c02226063183deb100204", transaction.getTxId().toString()); + + ECKey alicePubKey = ECKey.fromPublicOnly(transaction.getInputs().get(0).getScriptSig().getChunks().get(1).data); + Assert.assertArrayEquals(alicePubKey.getPubKey(), alicePrivKey.getPubKey()); + + DeterministicSeed bobSeed = new DeterministicSeed("reward upper indicate eight swift arch injury crystal super wrestle already dentist", "", 0, DeterministicSeed.Type.BIP39); + Keystore bobKeystore = Keystore.fromSeed(bobSeed, List.of(new ChildNumber(47, true), ChildNumber.ZERO_HARDENED, ChildNumber.ZERO_HARDENED)); + ECKey bobNotificationPrivKey = bobKeystore.getBip47ExtendedPrivateKey().getKey(List.of(ChildNumber.ZERO_HARDENED, new ChildNumber(0))); + + SecretPoint bobSecretPoint = new SecretPoint(bobNotificationPrivKey.getPrivKeyBytes(), alicePubKey.getPubKey()); + Assert.assertEquals("736a25d9250238ad64ed5da03450c6a3f4f8f4dcdf0b58d1ed69029d76ead48d", Utils.bytesToHex(bobSecretPoint.ECDHSecretAsBytes())); + + byte[] bobBlindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), transaction.getInputs().get(0).getOutpoint().bitcoinSerialize()); + Assert.assertEquals("be6e7a4256cac6f4d4ed4639b8c39c4cb8bece40010908e70d17ea9d77b4dc57f1da36f2d6641ccb37cf2b9f3146686462e0fa3161ae74f88c0afd4e307adbd5", Utils.bytesToHex(bobBlindingMask)); + + PaymentCode unblindedPaymentCode = new PaymentCode(PaymentCode.blind(transaction.getOutputs().get(1).getScript().getChunks().get(1).data, blindingMask)); + Assert.assertEquals(alicePaymentCode, unblindedPaymentCode); + + PaymentCode unblindedPaymentCode2 = PaymentCode.getPaymentCode(transaction, bobKeystore); + Assert.assertEquals(alicePaymentCode, unblindedPaymentCode2); + } + + @Test + public void testFromSeed() throws MnemonicException { + DeterministicSeed aliceSeed = new DeterministicSeed("response seminar brave tip suit recall often sound stick owner lottery motion", "", 0, DeterministicSeed.Type.BIP39); + Keystore aliceKeystore = Keystore.fromSeed(aliceSeed, List.of(new ChildNumber(47, true), ChildNumber.ZERO_HARDENED, ChildNumber.ZERO_HARDENED)); + + DeterministicKey bip47PubKey = aliceKeystore.getExtendedPublicKey().getKey(); + PaymentCode alicePaymentCode = new PaymentCode(bip47PubKey.getPubKey(), bip47PubKey.getChainCode()); + Assert.assertEquals("PM8TJTLJbPRGxSbc8EJi42Wrr6QbNSaSSVJ5Y3E4pbCYiTHUskHg13935Ubb7q8tx9GVbh2UuRnBc3WSyJHhUrw8KhprKnn9eDznYGieTzFcwQRya4GA", alicePaymentCode.toString()); + } + + @Test + public void testPaymentAddress() throws MnemonicException, InvalidPaymentCodeException, NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException, NoSuchProviderException, NotSecp256k1Exception { + DeterministicSeed seed = new DeterministicSeed("response seminar brave tip suit recall often sound stick owner lottery motion", "", 0, DeterministicSeed.Type.BIP39); + Keystore keystore = Keystore.fromSeed(seed, List.of(new ChildNumber(47, true), ChildNumber.ZERO_HARDENED, ChildNumber.ZERO_HARDENED)); + DeterministicKey privateKey = keystore.getExtendedPrivateKey().getKey(List.of(ChildNumber.ZERO_HARDENED, ChildNumber.ZERO)); + + PaymentCode paymentCodeBob = new PaymentCode("PM8TJS2JxQ5ztXUpBBRnpTbcUXbUHy2T1abfrb3KkAAtMEGNbey4oumH7Hc578WgQJhPjBxteQ5GHHToTYHE3A1w6p7tU6KSoFmWBVbFGjKPisZDbP97"); + + PaymentAddress paymentAddress0 = new PaymentAddress(paymentCodeBob, 0, privateKey.getPrivKeyBytes()); + ECKey sendKey0 = paymentAddress0.getSendECKey(); + Assert.assertEquals("141fi7TY3h936vRUKh1qfUZr8rSBuYbVBK", ScriptType.P2PKH.getAddress(sendKey0).toString()); + + PaymentAddress paymentAddress1 = new PaymentAddress(paymentCodeBob, 1, privateKey.getPrivKeyBytes()); + ECKey sendKey1 = paymentAddress1.getSendECKey(); + Assert.assertEquals("12u3Uued2fuko2nY4SoSFGCoGLCBUGPkk6", ScriptType.P2PKH.getAddress(sendKey1).toString()); + } + + @Test + public void testChildWallet() throws MnemonicException, InvalidPaymentCodeException { + DeterministicSeed aliceSeed = new DeterministicSeed("response seminar brave tip suit recall often sound stick owner lottery motion", "", 0, DeterministicSeed.Type.BIP39); + Wallet aliceWallet = new Wallet(); + aliceWallet.setPolicyType(PolicyType.SINGLE); + aliceWallet.setScriptType(ScriptType.P2PKH); + aliceWallet.getKeystores().add(Keystore.fromSeed(aliceSeed, aliceWallet.getScriptType().getDefaultDerivation())); + aliceWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, aliceWallet.getKeystores(), 1)); + + PaymentCode paymentCodeBob = new PaymentCode("PM8TJS2JxQ5ztXUpBBRnpTbcUXbUHy2T1abfrb3KkAAtMEGNbey4oumH7Hc578WgQJhPjBxteQ5GHHToTYHE3A1w6p7tU6KSoFmWBVbFGjKPisZDbP97"); + + Wallet aliceBip47Wallet = aliceWallet.addChildWallet(paymentCodeBob, ScriptType.P2PKH); + PaymentCode paymentCodeAlice = aliceBip47Wallet.getKeystores().get(0).getPaymentCode(); + + Assert.assertEquals(aliceWallet.getPaymentCode(), aliceBip47Wallet.getPaymentCode()); + Assert.assertEquals("PM8TJTLJbPRGxSbc8EJi42Wrr6QbNSaSSVJ5Y3E4pbCYiTHUskHg13935Ubb7q8tx9GVbh2UuRnBc3WSyJHhUrw8KhprKnn9eDznYGieTzFcwQRya4GA", paymentCodeAlice.toString()); + Assert.assertEquals("1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW", paymentCodeAlice.getNotificationAddress().toString()); + + WalletNode sendNode0 = aliceBip47Wallet.getFreshNode(KeyPurpose.SEND); + Address address0 = aliceBip47Wallet.getAddress(sendNode0); + Assert.assertEquals("141fi7TY3h936vRUKh1qfUZr8rSBuYbVBK", address0.toString()); + + WalletNode sendNode1 = aliceBip47Wallet.getFreshNode(KeyPurpose.SEND, sendNode0); + Address address1 = aliceBip47Wallet.getAddress(sendNode1); + Assert.assertEquals("12u3Uued2fuko2nY4SoSFGCoGLCBUGPkk6", address1.toString()); + + WalletNode sendNode2 = aliceBip47Wallet.getFreshNode(KeyPurpose.SEND, sendNode1); + Address address2 = aliceBip47Wallet.getAddress(sendNode2); + Assert.assertEquals("1FsBVhT5dQutGwaPePTYMe5qvYqqjxyftc", address2.toString()); + + DeterministicSeed bobSeed = new DeterministicSeed("reward upper indicate eight swift arch injury crystal super wrestle already dentist", "", 0, DeterministicSeed.Type.BIP39); + Wallet bobWallet = new Wallet(); + bobWallet.setPolicyType(PolicyType.SINGLE); + bobWallet.setScriptType(ScriptType.P2PKH); + bobWallet.getKeystores().add(Keystore.fromSeed(bobSeed, bobWallet.getScriptType().getDefaultDerivation())); + bobWallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, bobWallet.getKeystores(), 1)); + + Wallet bobBip47Wallet = bobWallet.addChildWallet(paymentCodeAlice, ScriptType.P2PKH); + Assert.assertEquals(paymentCodeBob.toString(), bobBip47Wallet.getKeystores().get(0).getPaymentCode().toString()); + Assert.assertEquals("1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV", paymentCodeBob.getNotificationAddress().toString()); + + WalletNode receiveNode0 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE); + Address receiveAddress0 = bobBip47Wallet.getAddress(receiveNode0); + Assert.assertEquals("141fi7TY3h936vRUKh1qfUZr8rSBuYbVBK", receiveAddress0.toString()); + + WalletNode receiveNode1 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE, receiveNode0); + Address receiveAddress1 = bobBip47Wallet.getAddress(receiveNode1); + Assert.assertEquals("12u3Uued2fuko2nY4SoSFGCoGLCBUGPkk6", receiveAddress1.toString()); + + WalletNode receiveNode2 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE, receiveNode1); + Address receiveAddress2 = bobBip47Wallet.getAddress(receiveNode2); + Assert.assertEquals("1FsBVhT5dQutGwaPePTYMe5qvYqqjxyftc", receiveAddress2.toString()); + + ECKey privKey0 = bobWallet.getKeystores().get(0).getKey(receiveNode0); + ECKey pubKey0 = bobWallet.getKeystores().get(0).getPubKey(receiveNode0); + Assert.assertArrayEquals(privKey0.getPubKey(), pubKey0.getPubKey()); + + ECKey privKey1 = bobWallet.getKeystores().get(0).getKey(receiveNode1); + ECKey pubKey1 = bobWallet.getKeystores().get(0).getPubKey(receiveNode1); + Assert.assertArrayEquals(privKey1.getPubKey(), pubKey1.getPubKey()); + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java index 930f7b3..dd9ff2f 100644 --- a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java +++ b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java @@ -19,12 +19,6 @@ public class PSBTTest { PSBT.fromString(psbt); } - @Test(expected = PSBTParseException.class) - public void missingOutputs() throws PSBTParseException { - String psbt = "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQD9pQEBAAAAAAECiaPHHqtNIOA3G7ukzGmPopXJRjr6Ljl/hTPMti+VZ+UBAAAAFxYAFL4Y0VKpsBIDna89p95PUzSe7LmF/////4b4qkOnHf8USIk6UwpyN+9rRgi7st0tAXHmOuxqSJC0AQAAABcWABT+Pp7xp0XpdNkCxDVZQ6vLNL1TU/////8CAMLrCwAAAAAZdqkUhc/xCX/Z4Ai7NK9wnGIZeziXikiIrHL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAkcwRAIgJxK+IuAnDzlPVoMR3HyppolwuAJf3TskAinwf4pfOiQCIAGLONfc0xTnNMkna9b7QPZzMlvEuqFEyADS8vAtsnZcASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUCSDBFAiEA0SuFLYXc2WHS9fSrZgZU327tzHlMDDPOXMMJ/7X85Y0CIGczio4OFyXBl/saiK9Z9R5E5CVbIBZ8hoQDHAXR8lkqASECI7cr7vCWXRC+B3jv7NYfysb3mk6haTkzgHNEZPhPKrMAAAAAAA=="; - PSBT.fromString(psbt); - } - @Test(expected = PSBTParseException.class) public void unsignedTxWithScriptSig() throws PSBTParseException { String psbt = "cHNidP8BAP0KAQIAAAACqwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QAAAAAakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpL+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAABASAA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHhwEEFgAUhdE1N/LiZUBaNNuvqePdoB+4IwgAAAA="; diff --git a/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java b/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java index 92804b8..d9f23e1 100644 --- a/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java +++ b/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java @@ -104,8 +104,8 @@ public class WalletTest { wallet.getKeystores().add(keystore); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, wallet.getKeystores(), 1)); - Assert.assertEquals("12kTQjuWDp7Uu6PwY6CsS1KLTt3d1DBHZa", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("1HbQwQCitHQxVtP39isXmUdHx7hQCZovrK", wallet.getAddress(KeyPurpose.RECEIVE, 1).toString()); + Assert.assertEquals("12kTQjuWDp7Uu6PwY6CsS1KLTt3d1DBHZa", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("1HbQwQCitHQxVtP39isXmUdHx7hQCZovrK", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 1)).toString()); } @Test @@ -119,8 +119,8 @@ public class WalletTest { wallet.getKeystores().add(keystore); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2SH_P2WPKH, wallet.getKeystores(), 1)); - Assert.assertEquals("3NZLE4TntsjtcZ5MbrfxwtYo9meBVybVQj", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("32YBBuRsp8XTeLx4T6BmD2L4nANGaNDkSg", wallet.getAddress(KeyPurpose.RECEIVE, 1).toString()); + Assert.assertEquals("3NZLE4TntsjtcZ5MbrfxwtYo9meBVybVQj", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("32YBBuRsp8XTeLx4T6BmD2L4nANGaNDkSg", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 1)).toString()); } @Test @@ -134,8 +134,8 @@ public class WalletTest { wallet.getKeystores().add(keystore); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), 1)); - Assert.assertEquals("bc1quvxdut936uswuxwxrk6nvjmgwxh463r0fjwn55", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("bc1q95j2862dz7mqpraw6qdjc70gumyu5z7adgq9x9", wallet.getAddress(KeyPurpose.RECEIVE, 1).toString()); + Assert.assertEquals("bc1quvxdut936uswuxwxrk6nvjmgwxh463r0fjwn55", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("bc1q95j2862dz7mqpraw6qdjc70gumyu5z7adgq9x9", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 1)).toString()); } @Test @@ -159,8 +159,8 @@ public class WalletTest { wallet.getKeystores().add(keystore2); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.MULTI, ScriptType.P2SH, wallet.getKeystores(), 2)); - Assert.assertEquals("38kq6yz4VcYymTExQPY3gppbz38mtPLveK", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("3EdKaNsnjBTBggWcSMRyVju6GbHWy68mAH", wallet.getAddress(KeyPurpose.CHANGE, 1).toString()); + Assert.assertEquals("38kq6yz4VcYymTExQPY3gppbz38mtPLveK", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("3EdKaNsnjBTBggWcSMRyVju6GbHWy68mAH", wallet.getAddress(new WalletNode(KeyPurpose.CHANGE, 1)).toString()); } @Test @@ -184,8 +184,8 @@ public class WalletTest { wallet.getKeystores().add(keystore2); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.MULTI, ScriptType.P2SH_P2WSH, wallet.getKeystores(), 2)); - Assert.assertEquals("3Mw8xqAHh8g3eBvh7q1UEUmoexqdXDK9Tf", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("35dFo1ivJ8jyHpyf42MWvnYf5LBU8Siren", wallet.getAddress(KeyPurpose.CHANGE, 1).toString()); + Assert.assertEquals("3Mw8xqAHh8g3eBvh7q1UEUmoexqdXDK9Tf", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("35dFo1ivJ8jyHpyf42MWvnYf5LBU8Siren", wallet.getAddress(new WalletNode(KeyPurpose.CHANGE, 1)).toString()); } @Test @@ -209,8 +209,8 @@ public class WalletTest { wallet.getKeystores().add(keystore2); wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.MULTI, ScriptType.P2WSH, wallet.getKeystores(), 2)); - Assert.assertEquals("bc1q20e4vm656h5lvmngz9ztz6hjzftvh39yzngqhuqzk8qzj7tqnzaqgclrwc", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); - Assert.assertEquals("bc1q2epdx7dplwaas2jucfrzmxm8350rqh68hs6vqreysku80ye44mfqla85f2", wallet.getAddress(KeyPurpose.CHANGE, 1).toString()); + Assert.assertEquals("bc1q20e4vm656h5lvmngz9ztz6hjzftvh39yzngqhuqzk8qzj7tqnzaqgclrwc", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); + Assert.assertEquals("bc1q2epdx7dplwaas2jucfrzmxm8350rqh68hs6vqreysku80ye44mfqla85f2", wallet.getAddress(new WalletNode(KeyPurpose.CHANGE, 1)).toString()); } @Test @@ -227,6 +227,6 @@ public class WalletTest { List derivation = List.of(keystore.getExtendedPublicKey().getKeyChildNumber(), new ChildNumber(0)); Assert.assertEquals("027ecc656f4b91b92881b6f07cf876cd2e42b20df7acc4df54fc3315fbb2d13e1c", Utils.bytesToHex(extendedKey.getKey(derivation).getPubKey())); - Assert.assertEquals("bc1qarzeu6ncapyvjzdeayjq8vnzp6uvcn4eaeuuqq", wallet.getAddress(KeyPurpose.RECEIVE, 0).toString()); + Assert.assertEquals("bc1qarzeu6ncapyvjzdeayjq8vnzp6uvcn4eaeuuqq", wallet.getAddress(new WalletNode(KeyPurpose.RECEIVE, 0)).toString()); } }