diff --git a/build.gradle b/build.gradle index 55e30fd..aa4179e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,35 +4,30 @@ buildscript { url "https://plugins.gradle.org/m2/" } } - dependencies { - classpath "org.javamodularity:moduleplugin:1.6.0" - } } plugins { - id 'java' + id 'java-library' + id 'extra-java-module-info' id 'com.github.johnrengelman.shadow' version '5.2.0' } -def javamodularityPluginId = 'org.javamodularity.moduleplugin' -final hasPlugin = project.getPlugins().hasPlugin(javamodularityPluginId); -if(hasPlugin) { - final Plugin plugin = project.getPlugins().getPlugin(javamodularityPluginId) - println 'Plugin already applied - version ' + plugin.properties['javamodularityPluginId'] -} else { - apply plugin: "org.javamodularity.moduleplugin" -} - tasks.withType(AbstractArchiveTask) { preserveFileTimestamps = false reproducibleFileOrder = true } group 'com.sparrowwallet' -version '0.9' +version '1.0' -sourceCompatibility = 1.9 -targetCompatibility = 1.9 +def os = org.gradle.internal.os.OperatingSystem.current() +def osName = os.getFamilyName() +if(os.macOsX) { + osName = "osx" +} + +sourceCompatibility = 16 +targetCompatibility = 16 repositories { mavenCentral() @@ -49,13 +44,17 @@ dependencies { implementation ('org.bouncycastle:bcprov-jdk15on:1.64') { exclude group: 'org.hamcrest', module: 'hamcrest-core' } - implementation ('de.mkammerer:argon2-jvm:2.7') { + implementation ('de.mkammerer:argon2-jvm:2.11') { exclude group: 'org.hamcrest', module: 'hamcrest-core' exclude group: 'junit', module: 'junit' + exclude group: 'net.java.dev.jna', module: 'jna' } - implementation ('ch.qos.logback:logback-classic:1.2.3') { + implementation ('net.java.dev.jna:jna:5.8.0') + implementation ('ch.qos.logback:logback-classic:1.2.8') { exclude group: 'org.hamcrest', module: 'hamcrest-core' + exclude group: 'org.slf4j' } + implementation ('org.slf4j:slf4j-api:1.7.30') testImplementation ('junit:junit:4.12') { exclude group: 'org.hamcrest', module: 'hamcrest-core' } @@ -63,8 +62,16 @@ dependencies { implementation 'de.sfuhrm:saphir-hash-core:3.0.5' } +processResources { + doLast { + delete fileTree("$buildDir/resources/main/native").matching { + exclude "${osName}/**" + } + } +} + task(runDrongo, dependsOn: 'classes', type: JavaExec) { - main = 'com.sparrowwallet.drongo.Main' + mainClass = 'com.sparrowwallet.drongo.Main' classpath = sourceSets.main.runtimeClasspath args 'drongo.properties' } @@ -84,3 +91,30 @@ shadowJar { archiveVersion = '0.9' classifier = 'all' } + +extraJavaModuleInfo { + module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') { + exports('ch.qos.logback.core') + exports('ch.qos.logback.core.spi') + requires('java.xml') + } + module('logback-classic-1.2.8.jar', 'logback.classic', '1.2.8') { + exports('ch.qos.logback.classic') + exports('ch.qos.logback.classic.spi') + requires('org.slf4j') + requires('logback.core') + requires('java.xml') + requires('java.logging') + } + module('jeromq-0.5.0.jar', 'jeromq', '0.5.0') { + exports('org.zeromq') + } + module('json-simple-1.1.1.jar', 'json.simple', '1.1.1') { + exports('org.json.simple') + exports('org.json.simple.parser') + } + module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0') + module('junit-4.12.jar', 'junit', '4.12') { + exports('org.junit') + } +} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000..ebb5470 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'java-gradle-plugin' // so we can assign and ID to our plugin +} + +dependencies { + implementation 'org.ow2.asm:asm:8.0.1' +} + +repositories { + mavenCentral() +} + +gradlePlugin { + plugins { + // here we register our plugin with an ID + register("extra-java-module-info") { + id = "extra-java-module-info" + implementationClass = "org.gradle.sample.transform.javamodules.ExtraModuleInfoPlugin" + } + } +} diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPlugin.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPlugin.java new file mode 100644 index 0000000..48d0b0b --- /dev/null +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPlugin.java @@ -0,0 +1,54 @@ +package org.gradle.sample.transform.javamodules; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.attributes.Attribute; +import org.gradle.api.plugins.JavaPlugin; + +/** + * Entry point of our plugin that should be applied in the root project. + */ +public class ExtraModuleInfoPlugin implements Plugin { + + @Override + public void apply(Project project) { + // register the plugin extension as 'extraJavaModuleInfo {}' configuration block + ExtraModuleInfoPluginExtension extension = project.getObjects().newInstance(ExtraModuleInfoPluginExtension.class); + project.getExtensions().add(ExtraModuleInfoPluginExtension.class, "extraJavaModuleInfo", extension); + + // setup the transform for all projects in the build + project.getPlugins().withType(JavaPlugin.class).configureEach(javaPlugin -> configureTransform(project, extension)); + } + + private void configureTransform(Project project, ExtraModuleInfoPluginExtension extension) { + Attribute artifactType = Attribute.of("artifactType", String.class); + Attribute javaModule = Attribute.of("javaModule", Boolean.class); + + // compile and runtime classpath express that they only accept modules by requesting the javaModule=true attribute + project.getConfigurations().matching(this::isResolvingJavaPluginConfiguration).all( + c -> c.getAttributes().attribute(javaModule, true)); + + // all Jars have a javaModule=false attribute by default; the transform also recognizes modules and returns them without modification + project.getDependencies().getArtifactTypes().getByName("jar").getAttributes().attribute(javaModule, false); + + // register the transform for Jars and "javaModule=false -> javaModule=true"; the plugin extension object fills the input parameter + project.getDependencies().registerTransform(ExtraModuleInfoTransform.class, t -> { + t.parameters(p -> { + p.setModuleInfo(extension.getModuleInfo()); + p.setAutomaticModules(extension.getAutomaticModules()); + }); + t.getFrom().attribute(artifactType, "jar").attribute(javaModule, false); + t.getTo().attribute(artifactType, "jar").attribute(javaModule, true); + }); + } + + private boolean isResolvingJavaPluginConfiguration(Configuration configuration) { + if (!configuration.isCanBeResolved()) { + return false; + } + return configuration.getName().endsWith(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME.substring(1)) + || configuration.getName().endsWith(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME.substring(1)) + || configuration.getName().endsWith(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME.substring(1)); + } +} diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPluginExtension.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPluginExtension.java new file mode 100644 index 0000000..d0d4e0f --- /dev/null +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoPluginExtension.java @@ -0,0 +1,52 @@ +package org.gradle.sample.transform.javamodules; + + +import org.gradle.api.Action; + +import javax.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; + +/** + * A data class to collect all the module information we want to add. + * Here the class is used as extension that can be configured in the build script + * and as input to the ExtraModuleInfoTransform that add the information to Jars. + */ +public class ExtraModuleInfoPluginExtension { + + private final Map moduleInfo = new HashMap<>(); + private final Map automaticModules = new HashMap<>(); + + /** + * Add full module information for a given Jar file. + */ + public void module(String jarName, String moduleName, String moduleVersion) { + module(jarName, moduleName, moduleVersion, null); + } + + /** + * Add full module information, including exported packages and dependencies, for a given Jar file. + */ + public void module(String jarName, String moduleName, String moduleVersion, @Nullable Action conf) { + ModuleInfo moduleInfo = new ModuleInfo(moduleName, moduleVersion); + if (conf != null) { + conf.execute(moduleInfo); + } + this.moduleInfo.put(jarName, moduleInfo); + } + + /** + * Add only an automatic module name to a given jar file. + */ + public void automaticModule(String jarName, String moduleName) { + automaticModules.put(jarName, moduleName); + } + + protected Map getModuleInfo() { + return moduleInfo; + } + + protected Map getAutomaticModules() { + return automaticModules; + } +} diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java new file mode 100644 index 0000000..94e6922 --- /dev/null +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java @@ -0,0 +1,164 @@ +package org.gradle.sample.transform.javamodules; + +import org.gradle.api.artifacts.transform.InputArtifact; +import org.gradle.api.artifacts.transform.TransformAction; +import org.gradle.api.artifacts.transform.TransformOutputs; +import org.gradle.api.artifacts.transform.TransformParameters; +import org.gradle.api.file.FileSystemLocation; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Input; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.ModuleVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.*; +import java.util.Collections; +import java.util.Map; +import java.util.jar.*; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; + +/** + * An artifact transform that applies additional information to Jars without module information. + * The transformation fails the build if a Jar does not contain information and no extra information + * was defined for it. This way we make sure that all Jars are turned into modules. + */ +abstract public class ExtraModuleInfoTransform implements TransformAction { + + public static class Parameter implements TransformParameters, Serializable { + private Map moduleInfo = Collections.emptyMap(); + private Map automaticModules = Collections.emptyMap(); + + @Input + public Map getModuleInfo() { + return moduleInfo; + } + + @Input + public Map getAutomaticModules() { + return automaticModules; + } + + public void setModuleInfo(Map moduleInfo) { + this.moduleInfo = moduleInfo; + } + + public void setAutomaticModules(Map automaticModules) { + this.automaticModules = automaticModules; + } + } + + @InputArtifact + protected abstract Provider getInputArtifact(); + + @Override + public void transform(TransformOutputs outputs) { + Map moduleInfo = getParameters().moduleInfo; + Map automaticModules = getParameters().automaticModules; + File originalJar = getInputArtifact().get().getAsFile(); + String originalJarName = originalJar.getName(); + + if (isModule(originalJar)) { + outputs.file(originalJar); + } else if (moduleInfo.containsKey(originalJarName)) { + addModuleDescriptor(originalJar, getModuleJar(outputs, originalJar), moduleInfo.get(originalJarName)); + } else if (isAutoModule(originalJar)) { + outputs.file(originalJar); + } else if (automaticModules.containsKey(originalJarName)) { + addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), automaticModules.get(originalJarName)); + } else { + throw new RuntimeException("Not a module and no mapping defined: " + originalJarName); + } + } + + private boolean isModule(File jar) { + Pattern moduleInfoClassMrjarPath = Pattern.compile("META-INF/versions/\\d+/module-info.class"); + try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) { + boolean isMultiReleaseJar = containsMultiReleaseJarEntry(inputStream); + ZipEntry next = inputStream.getNextEntry(); + while (next != null) { + if ("module-info.class".equals(next.getName())) { + return true; + } + if (isMultiReleaseJar && moduleInfoClassMrjarPath.matcher(next.getName()).matches()) { + return true; + } + next = inputStream.getNextEntry(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + return false; + } + + private boolean containsMultiReleaseJarEntry(JarInputStream jarStream) { + Manifest manifest = jarStream.getManifest(); + return manifest != null && Boolean.parseBoolean(manifest.getMainAttributes().getValue("Multi-Release")); + } + + private boolean isAutoModule(File jar) { + try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) { + return inputStream.getManifest().getMainAttributes().getValue("Automatic-Module-Name") != null; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private File getModuleJar(TransformOutputs outputs, File originalJar) { + return outputs.file(originalJar.getName().substring(0, originalJar.getName().lastIndexOf('.')) + "-module.jar"); + } + + private static void addAutomaticModuleName(File originalJar, File moduleJar, String moduleName) { + try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) { + Manifest manifest = inputStream.getManifest(); + manifest.getMainAttributes().put(new Attributes.Name("Automatic-Module-Name"), moduleName); + try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) { + copyEntries(inputStream, outputStream); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void addModuleDescriptor(File originalJar, File moduleJar, ModuleInfo moduleInfo) { + try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) { + try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) { + copyEntries(inputStream, outputStream); + outputStream.putNextEntry(new JarEntry("module-info.class")); + outputStream.write(addModuleInfo(moduleInfo)); + outputStream.closeEntry(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void copyEntries(JarInputStream inputStream, JarOutputStream outputStream) throws IOException { + JarEntry jarEntry = inputStream.getNextJarEntry(); + while (jarEntry != null) { + outputStream.putNextEntry(jarEntry); + outputStream.write(inputStream.readAllBytes()); + outputStream.closeEntry(); + jarEntry = inputStream.getNextJarEntry(); + } + } + + private static byte[] addModuleInfo(ModuleInfo moduleInfo) { + ClassWriter classWriter = new ClassWriter(0); + classWriter.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor moduleVisitor = classWriter.visitModule(moduleInfo.getModuleName(), Opcodes.ACC_OPEN, moduleInfo.getModuleVersion()); + for (String packageName : moduleInfo.getExports()) { + moduleVisitor.visitExport(packageName.replace('.', '/'), 0); + } + moduleVisitor.visitRequire("java.base", 0, null); + for (String requireName : moduleInfo.getRequires()) { + moduleVisitor.visitRequire(requireName, 0, null); + } + for (String requireName : moduleInfo.getRequiresTransitive()) { + moduleVisitor.visitRequire(requireName, Opcodes.ACC_TRANSITIVE, null); + } + moduleVisitor.visitEnd(); + classWriter.visitEnd(); + return classWriter.toByteArray(); + } +} diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ModuleInfo.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ModuleInfo.java new file mode 100644 index 0000000..9884a91 --- /dev/null +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ModuleInfo.java @@ -0,0 +1,53 @@ +package org.gradle.sample.transform.javamodules; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * Data class to hold the information that should be added as module-info.class to an existing Jar file. + */ +public class ModuleInfo implements Serializable { + private String moduleName; + private String moduleVersion; + private List exports = new ArrayList<>(); + private List requires = new ArrayList<>(); + private List requiresTransitive = new ArrayList<>(); + + ModuleInfo(String moduleName, String moduleVersion) { + this.moduleName = moduleName; + this.moduleVersion = moduleVersion; + } + + public void exports(String exports) { + this.exports.add(exports); + } + + public void requires(String requires) { + this.requires.add(requires); + } + + public void requiresTransitive(String requiresTransitive) { + this.requiresTransitive.add(requiresTransitive); + } + + public String getModuleName() { + return moduleName; + } + + protected String getModuleVersion() { + return moduleVersion; + } + + protected List getExports() { + return exports; + } + + protected List getRequires() { + return requires; + } + + protected List getRequiresTransitive() { + return requiresTransitive; + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 490fda8..249e583 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4b4429..8049c68 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 2fe81a7..a69d9cb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,78 +17,113 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -97,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -105,79 +140,101 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 9109989..53a6b23 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -54,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -64,38 +64,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/src/main/java/com/sparrowwallet/drongo/ApplicationAppender.java b/src/main/java/com/sparrowwallet/drongo/ApplicationAppender.java new file mode 100644 index 0000000..18d0f74 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/ApplicationAppender.java @@ -0,0 +1,20 @@ +package com.sparrowwallet.drongo; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import org.slf4j.event.Level; + +import java.lang.reflect.InvocationTargetException; + +public class ApplicationAppender extends AppenderBase { + private LogHandler callback; + + @Override + protected void append(ILoggingEvent e) { + callback.handleLog(e.getThreadName(), Level.valueOf(e.getLevel().toString()), e.getMessage(), e.getLoggerName(), e.getTimeStamp(), e.getCallerData()); + } + + public void setCallback(String callback) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + this.callback = (LogHandler)Class.forName(callback).getConstructor().newInstance(); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/BitcoinUnit.java b/src/main/java/com/sparrowwallet/drongo/BitcoinUnit.java index aac7b4c..d7228e4 100644 --- a/src/main/java/com/sparrowwallet/drongo/BitcoinUnit.java +++ b/src/main/java/com/sparrowwallet/drongo/BitcoinUnit.java @@ -17,7 +17,7 @@ public enum BitcoinUnit { BTC("GRS") { @Override public long getSatsValue(double unitValue) { - return (long)(unitValue * Transaction.SATOSHIS_PER_BITCOIN); + return Math.round(unitValue * Transaction.SATOSHIS_PER_BITCOIN); } @Override diff --git a/src/main/java/com/sparrowwallet/drongo/Drongo.java b/src/main/java/com/sparrowwallet/drongo/Drongo.java index 5f60c70..58bc521 100644 --- a/src/main/java/com/sparrowwallet/drongo/Drongo.java +++ b/src/main/java/com/sparrowwallet/drongo/Drongo.java @@ -1,12 +1,15 @@ package com.sparrowwallet.drongo; import com.sparrowwallet.drongo.rpc.BitcoinJSONRPCClient; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; import org.zeromq.SocketType; import org.zeromq.ZContext; import org.zeromq.ZMQ; +import java.security.Provider; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -74,4 +77,18 @@ public class Drongo { public List getWallets() { return watchWallets; } + + public static void setRootLogLevel(Level level) { + ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + root.setLevel(ch.qos.logback.classic.Level.toLevel(level.toString())); + } + + public static void removeRootLogAppender(String appenderName) { + ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + root.detachAppender(appenderName); + } + + public static Provider getProvider() { + return new BouncyCastleProvider(); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java b/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java index adb7a10..37efd09 100644 --- a/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java +++ b/src/main/java/com/sparrowwallet/drongo/ExtendedKey.java @@ -206,7 +206,7 @@ public class ExtendedKey { } public static List
getHeaders(Network network) { - return Arrays.stream(Header.values()).filter(header -> header.getNetwork() == network || (header.getNetwork() == Network.TESTNET && network == Network.REGTEST)).collect(Collectors.toList()); + return Arrays.stream(Header.values()).filter(header -> header.getNetwork() == network || (header.getNetwork() == Network.TESTNET && network == Network.REGTEST) || (header.getNetwork() == Network.TESTNET && network == Network.SIGNET)).collect(Collectors.toList()); } public static Header fromExtendedKey(String xkey) { diff --git a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java index b1f4e10..123f046 100644 --- a/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java +++ b/src/main/java/com/sparrowwallet/drongo/KeyDerivation.java @@ -5,14 +5,19 @@ import com.sparrowwallet.drongo.crypto.ChildNumber; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; public class KeyDerivation { private final String masterFingerprint; 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.masterFingerprint = masterFingerprint == null ? null : masterFingerprint.toLowerCase(Locale.ROOT); this.derivationPath = derivationPath; this.derivation = parsePath(derivationPath); } @@ -68,13 +73,17 @@ public class KeyDerivation { } public static String writePath(List pathList) { - String path = "m"; - for (ChildNumber child: pathList) { - path += "/"; - path += child.toString(); + return writePath(pathList, true); + } + + public static String writePath(List pathList, boolean useApostrophes) { + StringBuilder path = new StringBuilder("m"); + for(ChildNumber child: pathList) { + path.append("/"); + path.append(child.toString(useApostrophes)); } - return path; + return path.toString(); } public static boolean isValid(String derivationPath) { @@ -87,6 +96,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(Math.max(0, 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 4563692..5c14396 100644 --- a/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java +++ b/src/main/java/com/sparrowwallet/drongo/KeyPurpose.java @@ -2,9 +2,18 @@ package com.sparrowwallet.drongo; import com.sparrowwallet.drongo.crypto.ChildNumber; +import java.util.List; + public enum KeyPurpose { RECEIVE(ChildNumber.ZERO), CHANGE(ChildNumber.ONE); + 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/LogHandler.java b/src/main/java/com/sparrowwallet/drongo/LogHandler.java new file mode 100644 index 0000000..778e882 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/LogHandler.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.drongo; + +import org.slf4j.event.Level; + +public interface LogHandler { + void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData); +} diff --git a/src/main/java/com/sparrowwallet/drongo/NativeUtils.java b/src/main/java/com/sparrowwallet/drongo/NativeUtils.java new file mode 100644 index 0000000..73cf725 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/NativeUtils.java @@ -0,0 +1,118 @@ +package com.sparrowwallet.drongo; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; + +/** + * A simple library class which helps with loading dynamic libraries stored in the + * JAR archive. These libraries usually contain implementation of some methods in + * native code (using JNI - Java Native Interface). + * + * @see http://adamheinrich.com/blog/2012/how-to-load-native-jni-library-from-jar + * @see https://github.com/adamheinrich/native-utils + * + */ +public class NativeUtils { + + /** + * The minimum length a prefix for a file has to have according to {@link File#createTempFile(String, String)}}. + */ + private static final int MIN_PREFIX_LENGTH = 3; + public static final String NATIVE_FOLDER_PATH_PREFIX = "nativeutils"; + + /** + * Temporary directory which will contain the DLLs. + */ + private static File temporaryDir; + + /** + * Private constructor - this class will never be instanced + */ + private NativeUtils() { + } + + /** + * Loads library from current JAR archive + * + * The file from JAR is copied into system temporary directory and then loaded. The temporary file is deleted after + * exiting. + * Method uses String as filename because the pathname is "abstract", not system-dependent. + * + * @param path The path of file inside JAR as absolute path (beginning with '/'), e.g. /package/File.ext + * @throws IOException If temporary file creation or read/write operation fails + * @throws IllegalArgumentException If source file (param path) does not exist + * @throws IllegalArgumentException If the path is not absolute or if the filename is shorter than three characters + * (restriction of {@link File#createTempFile(String, String)}). + * @throws FileNotFoundException If the file could not be found inside the JAR. + */ + public static void loadLibraryFromJar(String path) throws IOException { + + if (null == path || !path.startsWith("/")) { + throw new IllegalArgumentException("The path has to be absolute (start with '/')."); + } + + // Obtain filename from path + String[] parts = path.split("/"); + String filename = (parts.length > 1) ? parts[parts.length - 1] : null; + + // Check if the filename is okay + if (filename == null || filename.length() < MIN_PREFIX_LENGTH) { + throw new IllegalArgumentException("The filename has to be at least 3 characters long."); + } + + // Prepare temporary file + if (temporaryDir == null) { + temporaryDir = createTempDirectory(NATIVE_FOLDER_PATH_PREFIX); + temporaryDir.deleteOnExit(); + } + + File temp = new File(temporaryDir, filename); + + try (InputStream is = NativeUtils.class.getResourceAsStream(path)) { + Files.copy(is, temp.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + temp.delete(); + throw e; + } catch (NullPointerException e) { + temp.delete(); + throw new FileNotFoundException("File " + path + " was not found inside JAR."); + } + + try { + System.load(temp.getAbsolutePath()); + } finally { + if (isPosixCompliant()) { + // Assume POSIX compliant file system, can be deleted after loading + temp.delete(); + } else { + // Assume non-POSIX, and don't delete until last file descriptor closed + temp.deleteOnExit(); + } + } + } + + private static boolean isPosixCompliant() { + try { + return FileSystems.getDefault() + .supportedFileAttributeViews() + .contains("posix"); + } catch (FileSystemNotFoundException + | ProviderNotFoundException + | SecurityException e) { + return false; + } + } + + private static File createTempDirectory(String prefix) throws IOException { + String tempDir = System.getProperty("java.io.tmpdir"); + File generatedDir = new File(tempDir, prefix + System.nanoTime()); + + if (!generatedDir.mkdir()) + throw new IOException("Failed to create temp directory " + generatedDir.getName()); + + return generatedDir; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/Network.java b/src/main/java/com/sparrowwallet/drongo/Network.java index 29bcd78..480d925 100644 --- a/src/main/java/com/sparrowwallet/drongo/Network.java +++ b/src/main/java/com/sparrowwallet/drongo/Network.java @@ -1,11 +1,16 @@ package com.sparrowwallet.drongo; -public enum Network { - MAINNET("mainnet", 36, "F", 5, "3", "grs", ExtendedKey.Header.xprv, ExtendedKey.Header.xpub, 1331), - TESTNET("testnet", 111, "mn", 196, "2", "tgrs", ExtendedKey.Header.tprv, ExtendedKey.Header.tpub, 17777), - REGTEST("regtest", 111, "mn", 239, "2", "grsrt", ExtendedKey.Header.tprv, ExtendedKey.Header.tpub, 18888); +import java.util.Locale; - Network(String name, int p2pkhAddressHeader, String p2pkhAddressPrefix, int p2shAddressHeader, String p2shAddressPrefix, String bech32AddressHrp, ExtendedKey.Header xprvHeader, ExtendedKey.Header xpubHeader, int defaultPort) { +public enum Network { + MAINNET("mainnet", 36, "F", 5, "3", "grs", ExtendedKey.Header.xprv, ExtendedKey.Header.xpub, 128, 1331), + TESTNET("testnet", 111, "mn", 196, "2", "tgrs", ExtendedKey.Header.tprv, ExtendedKey.Header.tpub, 239, 17777), + REGTEST("regtest", 111, "mn", 239, "2", "grsrt", ExtendedKey.Header.tprv, ExtendedKey.Header.tpub, 239, 18888); + SIGNET("signet", 111, "mn", 196, "2", "tgrs", ExtendedKey.Header.tprv, ExtendedKey.Header.tpub, 239, 31331); + + public static final String BLOCK_HEIGHT_PROPERTY = "com.sparrowwallet.blockHeight"; + + Network(String name, int p2pkhAddressHeader, String p2pkhAddressPrefix, int p2shAddressHeader, String p2shAddressPrefix, String bech32AddressHrp, ExtendedKey.Header xprvHeader, ExtendedKey.Header xpubHeader, int dumpedPrivateKeyHeader, int defaultPort) { this.name = name; this.p2pkhAddressHeader = p2pkhAddressHeader; this.p2pkhAddressPrefix = p2pkhAddressPrefix; @@ -14,6 +19,7 @@ public enum Network { this.bech32AddressHrp = bech32AddressHrp; this.xprvHeader = xprvHeader; this.xpubHeader = xpubHeader; + this.dumpedPrivateKeyHeader = dumpedPrivateKeyHeader; this.defaultPort = defaultPort; } @@ -25,6 +31,7 @@ public enum Network { private final String bech32AddressHrp; private final ExtendedKey.Header xprvHeader; private final ExtendedKey.Header xpubHeader; + private final int dumpedPrivateKeyHeader; private final int defaultPort; private static Network currentNetwork; @@ -33,6 +40,10 @@ public enum Network { return name; } + public String toDisplayString() { + return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); + } + public int getP2PKHAddressHeader() { return p2pkhAddressHeader; } @@ -53,6 +64,10 @@ public enum Network { return xpubHeader; } + public int getDumpedPrivateKeyHeader() { + return dumpedPrivateKeyHeader; + } + public int getDefaultPort() { return defaultPort; } diff --git a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java index e9bbbe6..c5d17d7 100644 --- a/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java +++ b/src/main/java/com/sparrowwallet/drongo/OutputDescriptor.java @@ -9,10 +9,7 @@ import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ProtocolException; import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.ScriptType; -import com.sparrowwallet.drongo.wallet.Keystore; -import com.sparrowwallet.drongo.wallet.KeystoreSource; -import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.drongo.wallet.WalletModel; +import com.sparrowwallet.drongo.wallet.*; import java.math.BigInteger; import java.util.*; @@ -25,10 +22,11 @@ public class OutputDescriptor { private static final String INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "; private static final String CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; - private static final Pattern XPUB_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(.pub[^/\\,)]{100,112})(/[/\\d*'hH]+)?"); + private static final Pattern XPUB_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(.pub[^/\\,)]{100,112})(/[/\\d*'hH<>;]+)?"); private static final Pattern PUBKEY_PATTERN = Pattern.compile("(\\[[^\\]]+\\])?(0[23][0-9a-fA-F]{32})"); private static final Pattern MULTI_PATTERN = Pattern.compile("multi\\(([\\d+])"); private static final Pattern KEY_ORIGIN_PATTERN = Pattern.compile("\\[([A-Fa-f0-9]{8})([/\\d'hH]+)\\]"); + private static final Pattern MULTIPATH_PATTERN = Pattern.compile("<([\\d*'hH;]+)>"); private static final Pattern CHECKSUM_PATTERN = Pattern.compile("#([" + CHECKSUM_CHARSET + "]{8})$"); private final ScriptType scriptType; @@ -134,6 +132,10 @@ public class OutputDescriptor { return extendedPublicKeys.size() > 1; } + public boolean isCosigner() { + return !isMultisig() && scriptType.isAllowed(PolicyType.MULTI); + } + public ExtendedKey getSingletonExtendedPublicKey() { if(isMultisig()) { throw new IllegalStateException("Output descriptor contains multiple public keys but singleton requested"); @@ -235,7 +237,7 @@ public class OutputDescriptor { public Wallet toWallet() { Wallet wallet = new Wallet(); - wallet.setPolicyType(isMultisig() ? PolicyType.MULTI : PolicyType.SINGLE); + wallet.setPolicyType(isMultisig() || isCosigner() ? PolicyType.MULTI : PolicyType.SINGLE); wallet.setScriptType(scriptType); for(Map.Entry extKeyEntry : extendedPublicKeys.entrySet()) { @@ -252,17 +254,59 @@ public class OutputDescriptor { return wallet; } + public Wallet toKeystoreWallet(String masterFingerprint) { + Wallet wallet = new Wallet(); + if(isMultisig()) { + throw new IllegalStateException("Multisig output descriptors are unsupported."); + } + + ExtendedKey extendedKey = getSingletonExtendedPublicKey(); + if(masterFingerprint == null) { + masterFingerprint = getKeyDerivation(extendedKey).getMasterFingerprint(); + } + + wallet.setScriptType(getScriptType()); + Keystore keystore = new Keystore(); + keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(getKeyDerivation(extendedKey).getDerivation()))); + keystore.setExtendedPublicKey(extendedKey); + wallet.getKeystores().add(keystore); + wallet.setDefaultPolicy(Policy.getPolicy(isCosigner() ? PolicyType.MULTI : PolicyType.SINGLE, wallet.getScriptType(), wallet.getKeystores(), 1)); + + return wallet; + } + + public static String toDescriptorString(Address address) { + return "addr(" + address + ")"; + } + public static OutputDescriptor getOutputDescriptor(Wallet wallet) { return getOutputDescriptor(wallet, null); } public static OutputDescriptor getOutputDescriptor(Wallet wallet, KeyPurpose keyPurpose) { + return getOutputDescriptor(wallet, keyPurpose, null); + } + + public static OutputDescriptor getOutputDescriptor(Wallet wallet, KeyPurpose keyPurpose, Integer index) { + return getOutputDescriptor(wallet, keyPurpose == null ? null : List.of(keyPurpose), index); + } + + public static OutputDescriptor getOutputDescriptor(Wallet wallet, List keyPurposes, Integer index) { Map extendedKeyDerivationMap = new LinkedHashMap<>(); Map extendedKeyChildDerivationMap = new LinkedHashMap<>(); for(Keystore keystore : wallet.getKeystores()) { extendedKeyDerivationMap.put(keystore.getExtendedPublicKey(), keystore.getKeyDerivation()); - if(keyPurpose != null) { - extendedKeyChildDerivationMap.put(keystore.getExtendedPublicKey(), keyPurpose.getPathIndex().num() + "/*"); + if(keyPurposes != null) { + String chain; + if(keyPurposes.size() == 1) { + chain = Integer.toString(keyPurposes.get(0).getPathIndex().num()); + } else { + StringJoiner joiner = new StringJoiner(";"); + keyPurposes.forEach(keyPurpose -> joiner.add(Integer.toString(keyPurpose.getPathIndex().num()))); + chain = "<" + joiner + ">"; + } + + extendedKeyChildDerivationMap.put(keystore.getExtendedPublicKey(), chain + "/" + (index == null ? "*" : index)); } } @@ -352,6 +396,17 @@ public class OutputDescriptor { return new OutputDescriptor(scriptType, multisigThreshold, keyDerivationMap, keyChildDerivationMap); } + public static String normalize(String descriptor) { + String normalized = descriptor.replaceAll("'", "h"); + + int checksumHash = normalized.lastIndexOf('#'); + if(checksumHash > -1) { + normalized = normalized.substring(0, checksumHash); + } + + return normalized + "#" + getChecksum(normalized); + } + private static String getChecksum(String descriptor) { BigInteger c = BigInteger.valueOf(1); int cls = 0; @@ -361,7 +416,7 @@ public class OutputDescriptor { int pos = INPUT_CHARSET.indexOf(ch); if(pos < 0) { - return ""; + continue; } c = polyMod(c, pos & 31); // Emit a symbol for the position inside the group, for every character. @@ -394,7 +449,7 @@ public class OutputDescriptor { private static BigInteger polyMod(BigInteger c, int val) { byte c0 = c.shiftRight(35).byteValue(); - c = c.and(new BigInteger("7ffffffff", 16)).shiftLeft(5).or(BigInteger.valueOf(val)); + c = c.and(new BigInteger("7ffffffff", 16)).shiftLeft(5).xor(BigInteger.valueOf(val)); if((c0 & 1) > 0) { c = c.xor(new BigInteger("f5dee51989", 16)); @@ -431,7 +486,7 @@ public class OutputDescriptor { builder.append(ScriptType.MULTISIG.getDescriptor()); StringJoiner joiner = new StringJoiner(","); joiner.add(Integer.toString(multisigThreshold)); - for(ExtendedKey pubKey : extendedPublicKeys.keySet()) { + for(ExtendedKey pubKey : sortExtendedPubKeys(extendedPublicKeys.keySet())) { String extKeyString = toString(pubKey, addKeyOrigin); joiner.add(extKeyString); } @@ -452,6 +507,47 @@ public class OutputDescriptor { return builder.toString(); } + private List sortExtendedPubKeys(Collection keys) { + List sortedKeys = new ArrayList<>(keys); + if(mapChildrenDerivations == null || mapChildrenDerivations.isEmpty() || mapChildrenDerivations.containsKey(null)) { + return sortedKeys; + } + + Utils.LexicographicByteArrayComparator lexicographicByteArrayComparator = new Utils.LexicographicByteArrayComparator(); + sortedKeys.sort((o1, o2) -> { + 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()) { + String multipath = matcher.group(1); + String[] paths = multipath.split(";"); + List> derivations = new ArrayList<>(); + for(String path : paths) { + derivations.add(KeyDerivation.parsePath(childDerivation.replace(matcher.group(), path))); + } + return derivations; + } else { + return List.of(KeyDerivation.parsePath(childDerivation)); + } + } + private String toString(ExtendedKey pubKey, boolean addKeyOrigin) { StringBuilder keyBuilder = new StringBuilder(); KeyDerivation keyDerivation = extendedPublicKeys.get(pubKey); @@ -461,7 +557,7 @@ public class OutputDescriptor { keyBuilder.append(keyDerivation.getMasterFingerprint()); keyBuilder.append("/"); } - keyBuilder.append(keyDerivation.getDerivationPath().replaceFirst("^m?/", "")); + keyBuilder.append(keyDerivation.getDerivationPath().replaceFirst("^m?/", "").replace('\'', 'h')); keyBuilder.append("]"); } diff --git a/src/main/java/com/sparrowwallet/drongo/PropertyDefiner.java b/src/main/java/com/sparrowwallet/drongo/PropertyDefiner.java index 7f36c21..e994663 100644 --- a/src/main/java/com/sparrowwallet/drongo/PropertyDefiner.java +++ b/src/main/java/com/sparrowwallet/drongo/PropertyDefiner.java @@ -2,6 +2,8 @@ package com.sparrowwallet.drongo; import ch.qos.logback.core.PropertyDefinerBase; +import java.util.Locale; + public class PropertyDefiner extends PropertyDefinerBase { private String application; @@ -11,15 +13,15 @@ public class PropertyDefiner extends PropertyDefinerBase { @Override public String getPropertyValue() { - if(System.getProperty(application.toLowerCase() + ".home") != null) { - return System.getProperty(application.toLowerCase() + ".home"); + if(System.getProperty(application.toLowerCase(Locale.ROOT) + ".home") != null) { + return System.getProperty(application.toLowerCase(Locale.ROOT) + ".home"); } - return isWindows() ? System.getenv("APPDATA") + "/" + application.substring(0, 1).toUpperCase() + application.substring(1).toLowerCase() : System.getProperty("user.home") + "/." + application.toLowerCase(); + return isWindows() ? System.getenv("APPDATA") + "/" + application.substring(0, 1).toUpperCase(Locale.ROOT) + application.substring(1).toLowerCase(Locale.ROOT) : System.getProperty("user.home") + "/." + application.toLowerCase(Locale.ROOT); } private boolean isWindows() { String osName = System.getProperty("os.name"); - return (osName != null && osName.toLowerCase().startsWith("windows")); + return (osName != null && osName.toLowerCase(Locale.ROOT).startsWith("windows")); } } diff --git a/src/main/java/com/sparrowwallet/drongo/SecureString.java b/src/main/java/com/sparrowwallet/drongo/SecureString.java index ca2b396..2cbb608 100644 --- a/src/main/java/com/sparrowwallet/drongo/SecureString.java +++ b/src/main/java/com/sparrowwallet/drongo/SecureString.java @@ -73,18 +73,6 @@ public class SecureString implements CharSequence { return "Secure:XXXXX"; } - /** - * Called by garbage collector. - *

- * {@inheritDoc} - */ - @SuppressWarnings("deprecation") - @Override - public void finalize() throws Throwable { - clear(); - super.finalize(); - } - /** * Randomly pad the characters to not store the real character in memory. * diff --git a/src/main/java/com/sparrowwallet/drongo/Utils.java b/src/main/java/com/sparrowwallet/drongo/Utils.java index 827e777..34f8d5b 100644 --- a/src/main/java/com/sparrowwallet/drongo/Utils.java +++ b/src/main/java/com/sparrowwallet/drongo/Utils.java @@ -12,9 +12,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.math.BigInteger; -import java.nio.Buffer; import java.nio.ByteBuffer; -import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.util.*; @@ -115,6 +113,21 @@ public class Utils { return dest; } + public static void reverse(byte[] array) { + for (int i = 0; i < array.length / 2; i++) { + byte temp = array[i]; + array[i] = array[array.length - i - 1]; + array[array.length - i - 1] = temp; + } + } + + public static byte[] concat(byte[] a, byte[] b) { + byte[] c = new byte[a.length + b.length]; + System.arraycopy(a, 0, c, 0, a.length); + System.arraycopy(b, 0, c, a.length, b.length); + return c; + } + /** Parse 4 bytes from the byte array (starting at the offset) as unsigned 32-bit integer in little endian format. */ public static long readUint32(byte[] bytes, int offset) { return (bytes[offset] & 0xffl) | @@ -285,6 +298,16 @@ public class Utils { return out; } + public static byte[] taggedHash(String tag, byte[] msg) { + byte[] hash = Sha256Hash.hash(tag.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buffer = ByteBuffer.allocate(hash.length + hash.length + msg.length); + buffer.put(hash); + buffer.put(hash); + buffer.put(msg); + + return Sha256Hash.hash(buffer.array()); + } + public static class LexicographicByteArrayComparator implements Comparator { @Override public int compare(byte[] left, byte[] right) { diff --git a/src/main/java/com/sparrowwallet/drongo/address/Address.java b/src/main/java/com/sparrowwallet/drongo/address/Address.java index 90bf9ca..fc9bb0d 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/Address.java +++ b/src/main/java/com/sparrowwallet/drongo/address/Address.java @@ -7,16 +7,17 @@ import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.ScriptType; import java.util.Arrays; +import java.util.Locale; public abstract class Address { - protected final byte[] hash; + protected final byte[] data; - public Address(byte[] hash) { - this.hash = hash; + public Address(byte[] data) { + this.data = data; } - public byte[] getHash() { - return hash; + public byte[] getData() { + return data; } public String getAddress() { @@ -24,7 +25,7 @@ public abstract class Address { } public String getAddress(Network network) { - return Base58.encodeChecked(getVersion(network), hash); + return Base58.encodeChecked(getVersion(network), data); } public String toString() { @@ -43,23 +44,26 @@ public abstract class Address { public abstract ScriptType getScriptType(); - public abstract Script getOutputScript(); + public Script getOutputScript() { + return getScriptType().getOutputScript(data); + } - public abstract byte[] getOutputScriptData(); + public byte[] getOutputScriptData() { + return data; + } public abstract String getOutputScriptDataType(); public boolean equals(Object obj) { - if(!(obj instanceof Address)) { + if(!(obj instanceof Address address)) { return false; } - Address address = (Address)obj; - return address.getAddress().equals(this.getAddress()); + return Arrays.equals(data, address.data) && getVersion(Network.get()) == address.getVersion(Network.get()); } public int hashCode() { - return getAddress().hashCode(); + return Arrays.hashCode(data) + getVersion(Network.get()); } public static Address fromString(String address) throws InvalidAddressException { @@ -103,20 +107,34 @@ public abstract class Address { } } - if(address.toLowerCase().startsWith(network.getBech32AddressHRP())) { + if(address.toLowerCase(Locale.ROOT).startsWith(network.getBech32AddressHRP())) { try { 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..dd2e1da 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2PKAddress.java @@ -6,11 +6,8 @@ import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.ScriptType; public class P2PKAddress extends Address { - private byte[] pubKey; - public P2PKAddress(byte[] pubKey) { - super(Utils.sha256hash160(pubKey)); - this.pubKey = pubKey; + super(pubKey); } @Override @@ -18,19 +15,15 @@ public class P2PKAddress extends Address { return network.getP2PKHAddressHeader(); } + @Override + public String getAddress(Network network) { + return Utils.bytesToHex(data); + } + public ScriptType getScriptType() { return ScriptType.P2PK; } - public Script getOutputScript() { - return getScriptType().getOutputScript(pubKey); - } - - @Override - public byte[] getOutputScriptData() { - return pubKey; - } - @Override public String getOutputScriptDataType() { return "Public Key"; diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2PKHAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2PKHAddress.java index dcf9488..6de9b9a 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2PKHAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2PKHAddress.java @@ -19,16 +19,6 @@ public class P2PKHAddress extends Address { return ScriptType.P2PKH; } - @Override - public Script getOutputScript() { - return getScriptType().getOutputScript(hash); - } - - @Override - public byte[] getOutputScriptData() { - return hash; - } - @Override public String getOutputScriptDataType() { return "Public Key Hash"; diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2SHAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2SHAddress.java index 8692a07..de02e8f 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2SHAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2SHAddress.java @@ -20,16 +20,6 @@ public class P2SHAddress extends Address { return ScriptType.P2SH; } - @Override - public Script getOutputScript() { - return getScriptType().getOutputScript(hash); - } - - @Override - public byte[] getOutputScriptData() { - return hash; - } - @Override public String getOutputScriptDataType() { return "Script Hash"; 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..255982a --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/address/P2TRAddress.java @@ -0,0 +1,32 @@ +package com.sparrowwallet.drongo.address; + +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.protocol.Bech32; +import com.sparrowwallet.drongo.protocol.Script; +import com.sparrowwallet.drongo.protocol.ScriptType; + +public class P2TRAddress extends Address { + public P2TRAddress(byte[] pubKey) { + super(pubKey); + } + + @Override + public int getVersion(Network network) { + return 1; + } + + @Override + public String getAddress(Network network) { + return Bech32.encode(network.getBech32AddressHRP(), getVersion(), data); + } + + @Override + public ScriptType getScriptType() { + return ScriptType.P2TR; + } + + @Override + public String getOutputScriptDataType() { + return "Taproot"; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2WPKHAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2WPKHAddress.java index b4ed379..f6358bb 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2WPKHAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2WPKHAddress.java @@ -17,7 +17,7 @@ public class P2WPKHAddress extends Address { @Override public String getAddress(Network network) { - return Bech32.encode(network.getBech32AddressHRP(), getVersion(), hash); + return Bech32.encode(network.getBech32AddressHRP(), getVersion(), data); } @Override @@ -25,16 +25,6 @@ public class P2WPKHAddress extends Address { return ScriptType.P2WPKH; } - @Override - public Script getOutputScript() { - return getScriptType().getOutputScript(hash); - } - - @Override - public byte[] getOutputScriptData() { - return hash; - } - @Override public String getOutputScriptDataType() { return "Witness Public Key Hash"; diff --git a/src/main/java/com/sparrowwallet/drongo/address/P2WSHAddress.java b/src/main/java/com/sparrowwallet/drongo/address/P2WSHAddress.java index 0886dc4..3b70585 100644 --- a/src/main/java/com/sparrowwallet/drongo/address/P2WSHAddress.java +++ b/src/main/java/com/sparrowwallet/drongo/address/P2WSHAddress.java @@ -15,7 +15,7 @@ public class P2WSHAddress extends Address { @Override public String getAddress(Network network) { - return Bech32.encode(network.getBech32AddressHRP(), getVersion(), hash); + return Bech32.encode(network.getBech32AddressHRP(), getVersion(), data); } @Override @@ -23,16 +23,6 @@ public class P2WSHAddress extends Address { return ScriptType.P2WSH; } - @Override - public Script getOutputScript() { - return getScriptType().getOutputScript(hash); - } - - @Override - public byte[] getOutputScriptData() { - return hash; - } - @Override public String getOutputScriptDataType() { return "Witness Script Hash"; 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..d997809 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/bip47/PaymentCode.java @@ -0,0 +1,393 @@ +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; + + public static final List SEGWIT_SCRIPT_TYPES = List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH); + public static final List V1_SCRIPT_TYPES = List.of(ScriptType.P2PKH); + + 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); + + List derivation = keystore.getKeyDerivation().getDerivation(); + ChildNumber derivationStart = derivation.isEmpty() ? ChildNumber.ZERO_HARDENED : derivation.get(derivation.size() - 1); + ECKey notificationPrivKey = keystore.getBip47ExtendedPrivateKey().getKey(List.of(derivationStart, 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; + } + byte[] data = scriptChunks.get(1).getData(); + if(data[0] != 0x01 || (data[2] != 0x02 && data[2] != 0x03)) { + 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); + } + + public String toAbbreviatedString() { + return strPaymentCode.substring(0, 8) + "..." + strPaymentCode.substring(strPaymentCode.length() - 3); + } + + @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/BIP38.java b/src/main/java/com/sparrowwallet/drongo/crypto/BIP38.java new file mode 100644 index 0000000..c51e751 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/BIP38.java @@ -0,0 +1,165 @@ +/** + * Implementation of BIP38 encryption / decryption / key-address generation + * Based on https://github.com/bitcoin/bips/blob/master/bip-0038.mediawiki + * + * Tips much appreciated: 1EmwBbfgH7BPMoCpcFzyzgAN9Ya7jm8L1Z :) + * + * Copyright 2014 Diego Basch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.Drongo; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Base58; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import org.bouncycastle.crypto.generators.SCrypt; +import org.bouncycastle.math.ec.ECPoint; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +import static com.sparrowwallet.drongo.crypto.ECKey.CURVE; + +public class BIP38 { + /** + * Decrypts an encrypted key. + * @param passphrase + * @param encryptedKey + * @throws UnsupportedEncodingException + * @throws GeneralSecurityException + */ + public static DumpedPrivateKey decrypt(String passphrase, String encryptedKey) throws UnsupportedEncodingException, GeneralSecurityException { + byte[] encryptedKeyBytes = Base58.decodeChecked(encryptedKey); + DumpedPrivateKey result; + byte ec = encryptedKeyBytes[1]; + switch (ec) { + case 0x43: result = decryptEC(passphrase, encryptedKeyBytes); + break; + case 0x42: result = decryptNoEC(passphrase, encryptedKeyBytes); + break; + default: throw new RuntimeException("Invalid key - second byte is: " + ec); + } + return result; + } + + /** + * Decrypts a key encrypted with EC multiplication + * @param passphrase + * @param encryptedKey + * @throws UnsupportedEncodingException + * @throws GeneralSecurityException + */ + public static DumpedPrivateKey decryptEC(String passphrase, byte[] encryptedKey) throws UnsupportedEncodingException, GeneralSecurityException { + + byte flagByte = encryptedKey[2]; + byte[] passFactor; + boolean hasLot = (flagByte & 4) == 4; + byte[] ownerSalt = Arrays.copyOfRange(encryptedKey, 7, 15 - (flagByte & 4)); + if (!hasLot) { + passFactor = SCrypt.generate(passphrase.getBytes("UTF8"), ownerSalt, 16384, 8, 8, 32); + } + else { + byte[] preFactor = SCrypt.generate(passphrase.getBytes("UTF8"), ownerSalt, 16384, 8, 8, 32); + byte[] ownerEntropy = Arrays.copyOfRange(encryptedKey, 7, 15); + byte[] tmp = Utils.concat(preFactor, ownerEntropy); + passFactor = Sha256Hash.hashTwice(tmp, 0, 40); + } + + byte[] addressHash = Arrays.copyOfRange(encryptedKey, 3, 7); + ECPoint g = CURVE.getG(); + ECPoint p = g.multiply(new BigInteger(1, passFactor)); + byte[] passPoint = p.getEncoded(true); + byte[] salt = new byte[12]; + byte[] encryptedPart2 = Arrays.copyOfRange(encryptedKey, 23, 39); + System.arraycopy(addressHash, 0, salt, 0, 4); + System.arraycopy(encryptedKey, 7, salt, 4, 8); + + byte[] secondKey = SCrypt.generate(passPoint, salt, 1024, 1, 1, 64); + byte[] derivedHalf1 = Arrays.copyOfRange(secondKey, 0, 32); + byte[] derivedHalf2 = Arrays.copyOfRange(secondKey, 32, 64); + byte[] m2 = decryptAES(encryptedPart2, derivedHalf2); + + byte[] encryptedPart1 = new byte[16]; + System.arraycopy(encryptedKey, 15, encryptedPart1, 0, 8); + + byte[] seedB = new byte[24]; + + for (int i = 0; i < 16; i++) { + m2[i] = (byte) (m2[i] ^ derivedHalf1[16 + i]); + } + System.arraycopy(m2, 0, encryptedPart1, 8, 8); + + byte[] m1 = decryptAES(encryptedPart1, derivedHalf2); + + for (int i = 0; i < 16; i++) { + seedB[i] = (byte) (m1[i] ^ derivedHalf1[i]); + } + + System.arraycopy(m2, 8, seedB, 16, 8); + byte[] factorB = Sha256Hash.hashTwice(seedB, 0, 24); + BigInteger n = CURVE.getN(); + BigInteger pk = new BigInteger(1, passFactor).multiply(new BigInteger(1, factorB)).remainder(n); + + ECKey privKey = ECKey.fromPrivate(pk, false); + return privKey.getPrivateKeyEncoded(); + } + + /** + * Decrypts a key that was encrypted without EC multiplication. + * @param passphrase + * @param encryptedKey + * @throws UnsupportedEncodingException + * @throws GeneralSecurityException + */ + public static DumpedPrivateKey decryptNoEC(String passphrase, byte[] encryptedKey) throws UnsupportedEncodingException, GeneralSecurityException { + + byte[] addressHash = Arrays.copyOfRange(encryptedKey, 3, 7); + byte[] scryptKey = SCrypt.generate(passphrase.getBytes("UTF8"), addressHash, 16384, 8, 8, 64); + byte[] derivedHalf1 = Arrays.copyOfRange(scryptKey, 0, 32); + byte[] derivedHalf2 = Arrays.copyOfRange(scryptKey, 32, 64); + + byte[] encryptedHalf1 = Arrays.copyOfRange(encryptedKey, 7, 23); + byte[] encryptedHalf2 = Arrays.copyOfRange(encryptedKey, 23, 39); + byte[] k1 = decryptAES(encryptedHalf1, derivedHalf2); + byte[] k2 = decryptAES(encryptedHalf2, derivedHalf2); + byte[] keyBytes = new byte[32]; + for (int i = 0; i < 16; i++) { + keyBytes[i] = (byte) (k1[i] ^ derivedHalf1[i]); + keyBytes[i + 16] = (byte) (k2[i] ^ derivedHalf1[i + 16]); + } + + boolean compressed = (encryptedKey[2] & (byte) 0x20) == 0x20; + ECKey k = new ECKey(new BigInteger(1, keyBytes), null, compressed); + return k.getPrivateKeyEncoded(); + } + + /** + * Decrypts ciphertext with AES + * @param ciphertext + * @param key + * @throws GeneralSecurityException + */ + public static byte[] decryptAES(byte[] ciphertext, byte[] key) throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding", Drongo.getProvider()); + SecretKeySpec aesKey = new SecretKeySpec(key, "AES"); + cipher.init(Cipher.DECRYPT_MODE, aesKey); + return cipher.doFinal(ciphertext); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ChildNumber.java b/src/main/java/com/sparrowwallet/drongo/crypto/ChildNumber.java index 67ec957..90d591c 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ChildNumber.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ChildNumber.java @@ -46,7 +46,11 @@ public class ChildNumber { public int i() { return i; } public String toString() { - return String.format(Locale.US, "%d%s", num(), isHardened() ? "'" : ""); + return toString(true); + } + + public String toString(boolean useApostrophes) { + return num() + (isHardened() ? (useApostrophes ? "'" : "h") : ""); } public boolean equals(Object o) { diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/DumpedPrivateKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/DumpedPrivateKey.java new file mode 100644 index 0000000..8fac3ce --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/DumpedPrivateKey.java @@ -0,0 +1,88 @@ +package com.sparrowwallet.drongo.crypto; + + +import com.sparrowwallet.drongo.Network; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Parses and generates private keys in the form used by the Bitcoin "dumpprivkey" command. This is the private key + * bytes with a header byte and 4 checksum bytes at the end. If there are 33 private key bytes instead of 32, then + * the last byte is a discriminator value for the compressed pubkey. + */ +public class DumpedPrivateKey extends VersionedChecksummedBytes { + private final boolean compressed; + + /** + * Construct a private key from its Base58 representation. + * @param base58 + * The textual form of the private key. + */ + public static DumpedPrivateKey fromBase58(String base58) { + return new DumpedPrivateKey(base58); + } + + // Used by ECKey.getPrivateKeyEncoded() + DumpedPrivateKey(byte[] keyBytes, boolean compressed) { + super(Network.get().getDumpedPrivateKeyHeader(), encode(keyBytes, compressed)); + this.compressed = compressed; + } + + private static byte[] encode(byte[] keyBytes, boolean compressed) { + if(keyBytes.length != 32) { + throw new IllegalArgumentException("Private keys must be 32 bytes"); + } + + if (!compressed) { + return keyBytes; + } else { + // Keys that have compressed public components have an extra 1 byte on the end in dumped form. + byte[] bytes = new byte[33]; + System.arraycopy(keyBytes, 0, bytes, 0, 32); + bytes[32] = 1; + return bytes; + } + } + + private DumpedPrivateKey(String encoded) { + super(encoded); + if(version != Network.get().getDumpedPrivateKeyHeader()) + throw new IllegalArgumentException("Invalid version " + version + " for network " + Network.get()); + if(bytes.length == 33 && bytes[32] == 1) { + compressed = true; + bytes = Arrays.copyOf(bytes, 32); // Chop off the additional marker byte. + } else if(bytes.length == 32) { + compressed = false; + } else { + throw new IllegalArgumentException("Wrong number of bytes for a private key, not 32 or 33"); + } + } + + /** + * Returns an ECKey created from this encoded private key. + */ + public ECKey getKey() { + return ECKey.fromPrivate(bytes, compressed); + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + if(!super.equals(o)) { + return false; + } + DumpedPrivateKey that = (DumpedPrivateKey) o; + return compressed == that.compressed; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), compressed); + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECDSASignature.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECDSASignature.java new file mode 100644 index 0000000..0a7c6eb --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECDSASignature.java @@ -0,0 +1,254 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.protocol.SigHash; +import com.sparrowwallet.drongo.protocol.SignatureDecodeException; +import com.sparrowwallet.drongo.protocol.TransactionSignature; +import com.sparrowwallet.drongo.protocol.VerificationException; +import org.bouncycastle.asn1.*; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.util.Objects; + +import static com.sparrowwallet.drongo.crypto.ECKey.CURVE; + +/** + * Groups the two components that make up a signature, and provides a way to encode to DER form, which is + * how ECDSA signatures are represented when embedded in other data structures in the Bitcoin protocol. The raw + * components can be useful for doing further EC maths on them. + */ +public class ECDSASignature { + private static final Logger log = LoggerFactory.getLogger(ECDSASignature.class); + + /** + * The two components of the signature. + */ + public final BigInteger r, s; + + /** + * Constructs a signature with the given components. Does NOT automatically canonicalise the signature. + */ + public ECDSASignature(BigInteger r, BigInteger s) { + this.r = r; + this.s = s; + } + + /** + * Returns true if the S component is "low", that means it is below {@link ECKey#HALF_CURVE_ORDER}. See BIP62. + */ + public boolean isCanonical() { + return s.compareTo(ECKey.HALF_CURVE_ORDER) <= 0; + } + + /** + * Will automatically adjust the S component to be less than or equal to half the curve order, if necessary. + * This is required because for every signature (r,s) the signature (r, -s (mod N)) is a valid signature of + * the same message. However, we dislike the ability to modify the bits of a Bitcoin transaction after it's + * been signed, as that violates various assumed invariants. Thus in future only one of those forms will be + * considered legal and the other will be banned. + */ + public ECDSASignature toCanonicalised() { + if(!isCanonical()) { + // The order of the curve is the number of valid points that exist on that curve. If S is in the upper + // half of the number of valid points, then bring it back to the lower half. Otherwise, imagine that + // N = 10 + // s = 8, so (-8 % 10 == 2) thus both (r, 8) and (r, 2) are valid solutions. + // 10 - 8 == 2, giving us always the latter solution, which is canonical. + return new ECDSASignature(r, CURVE.getN().subtract(s)); + } else { + return this; + } + } + + /** + * DER is an international standard for serializing data structures which is widely used in cryptography. + * It's somewhat like protocol buffers but less convenient. This method returns a standard DER encoding + * of the signature, as recognized by OpenSSL and other libraries. + */ + public byte[] encodeToDER() { + try { + return derByteStream().toByteArray(); + } catch(IOException e) { + throw new RuntimeException(e); // Cannot happen. + } + } + + /** + * @throws SignatureDecodeException if the signature is unparseable in some way. + */ + public static ECDSASignature decodeFromDER(byte[] bytes) throws SignatureDecodeException { + ASN1InputStream decoder = null; + try { + // BouncyCastle by default is strict about parsing ASN.1 integers. We relax this check, because some + // Bitcoin signatures would not parse. + Properties.setThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer", true); + decoder = new ASN1InputStream(bytes); + final ASN1Primitive seqObj = decoder.readObject(); + if(seqObj == null) { + throw new SignatureDecodeException("Reached past end of ASN.1 stream."); + } + if(!(seqObj instanceof DLSequence)) { + throw new SignatureDecodeException("Read unexpected class: " + seqObj.getClass().getName()); + } + final DLSequence seq = (DLSequence) seqObj; + ASN1Integer r, s; + try { + r = (ASN1Integer) seq.getObjectAt(0); + s = (ASN1Integer) seq.getObjectAt(1); + } catch(ClassCastException e) { + throw new SignatureDecodeException(e); + } + // OpenSSL deviates from the DER spec by interpreting these values as unsigned, though they should not be + // Thus, we always use the positive versions. See: http://r6.ca/blog/20111119T211504Z.html + return new ECDSASignature(r.getPositiveValue(), s.getPositiveValue()); + } catch(IOException e) { + throw new SignatureDecodeException(e); + } finally { + if(decoder != null) { + try { + decoder.close(); + } catch(IOException x) { + } + } + Properties.removeThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer"); + } + } + + /** + *

Verifies the given ECDSA signature against the message bytes using the public key bytes.

+ * + *

When using native ECDSA verification, data must be 32 bytes, and no element may be + * larger than 520 bytes.

+ * + * @param data Hash of the data to verify. + * @param pub The public key bytes to use. + */ + public boolean verify(byte[] data, byte[] pub) { + ECDSASigner signer = new ECDSASigner(); + ECPublicKeyParameters params = new ECPublicKeyParameters(CURVE.getCurve().decodePoint(pub), CURVE); + signer.init(false, params); + try { + return signer.verifySignature(data, r, s); + } catch (NullPointerException e) { + // Bouncy Castle contains a bug that can cause NPEs given specially crafted signatures. Those signatures + // are inherently invalid/attack sigs so we just fail them here rather than crash the thread. + log.error("Caught NPE inside bouncy castle", e); + return false; + } + } + + public ByteArrayOutputStream derByteStream() throws IOException { + // Usually 70-72 bytes. + ByteArrayOutputStream bos = new ByteArrayOutputStream(72); + DERSequenceGenerator seq = new DERSequenceGenerator(bos); + seq.addObject(new ASN1Integer(r)); + seq.addObject(new ASN1Integer(s)); + seq.close(); + return bos; + } + + protected boolean hasLowR() { + //A low R signature will have less than 71 bytes when encoded to DER + return toCanonicalised().encodeToDER().length < 71; + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + ECDSASignature other = (ECDSASignature) o; + return r.equals(other.r) && s.equals(other.s); + } + + @Override + public int hashCode() { + return Objects.hash(r, s); + } + + /** + * Returns a decoded signature. + * + * @param requireCanonicalEncoding if the encoding of the signature must + * be canonical. + * @param requireCanonicalSValue if the S-value must be canonical (below half + * the order of the curve). + * @throws SignatureDecodeException if the signature is unparseable in some way. + * @throws VerificationException if the signature is invalid. + */ + public static TransactionSignature decodeFromBitcoin(byte[] bytes, boolean requireCanonicalEncoding, + boolean requireCanonicalSValue) throws SignatureDecodeException, VerificationException { + // Bitcoin encoding is DER signature + sighash byte. + if (requireCanonicalEncoding && !isEncodingCanonical(bytes)) + throw new VerificationException.NoncanonicalSignature(); + ECDSASignature sig = ECDSASignature.decodeFromDER(bytes); + if (requireCanonicalSValue && !sig.isCanonical()) + throw new VerificationException("S-value is not canonical."); + + // In Bitcoin, any value of the final byte is valid, but not necessarily canonical. See javadocs for + // isEncodingCanonical to learn more about this. So we must store the exact byte found. + return new TransactionSignature(sig.r, sig.s, TransactionSignature.Type.ECDSA, bytes[bytes.length - 1]); + } + + /** + * Returns true if the given signature is has canonical encoding, and will thus be accepted as standard by + * Bitcoin Core. DER and the SIGHASH encoding allow for quite some flexibility in how the same structures + * are encoded, and this can open up novel attacks in which a man in the middle takes a transaction and then + * changes its signature such that the transaction hash is different but it's still valid. This can confuse wallets + * and generally violates people's mental model of how Bitcoin should work, thus, non-canonical signatures are now + * not relayed by default. + */ + public static boolean isEncodingCanonical(byte[] signature) { + // See Bitcoin Core's IsCanonicalSignature, https://bitcointalk.org/index.php?topic=8392.msg127623#msg127623 + // A canonical signature exists of: <30> <02> <02> + // Where R and S are not negative (their first byte has its highest bit not set), and not + // excessively padded (do not start with a 0 byte, unless an otherwise negative number follows, + // in which case a single 0 byte is necessary and even required). + + // Empty signatures, while not strictly DER encoded, are allowed. + if (signature.length == 0) + return true; + + if (signature.length < 9 || signature.length > 73) + return false; + + int hashType = (signature[signature.length-1] & 0xff) & ~SigHash.ANYONECANPAY.value; // mask the byte to prevent sign-extension hurting us + if (hashType < SigHash.ALL.value || hashType > SigHash.SINGLE.value) + return false; + + // "wrong type" "wrong length marker" + if ((signature[0] & 0xff) != 0x30 || (signature[1] & 0xff) != signature.length-3) + return false; + + int lenR = signature[3] & 0xff; + if (5 + lenR >= signature.length || lenR == 0) + return false; + int lenS = signature[5+lenR] & 0xff; + if (lenR + lenS + 7 != signature.length || lenS == 0) + return false; + + // R value type mismatch R value negative + if (signature[4-2] != 0x02 || (signature[4] & 0x80) == 0x80) + return false; + if (lenR > 1 && signature[4] == 0x00 && (signature[4+1] & 0x80) != 0x80) + return false; // R value excessively padded + + // S value type mismatch S value negative + if (signature[6 + lenR - 2] != 0x02 || (signature[6 + lenR] & 0x80) == 0x80) + return false; + if (lenS > 1 && signature[6 + lenR] == 0x00 && (signature[6 + lenR + 1] & 0x80) != 0x80) + return false; // S value excessively padded + + return true; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java index 7a3a755..7954be3 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/ECKey.java @@ -1,10 +1,10 @@ package com.sparrowwallet.drongo.crypto; import com.sparrowwallet.drongo.Utils; -import com.sparrowwallet.drongo.protocol.ScriptType; -import com.sparrowwallet.drongo.protocol.Sha256Hash; -import com.sparrowwallet.drongo.protocol.SignatureDecodeException; -import com.sparrowwallet.drongo.protocol.VarInt; +import com.sparrowwallet.drongo.protocol.*; +import org.bitcoin.NativeSecp256k1; +import org.bitcoin.NativeSecp256k1Util; +import org.bitcoin.Secp256k1Context; import org.bouncycastle.asn1.*; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.asn1.x9.X9IntegerConverter; @@ -17,12 +17,8 @@ import org.bouncycastle.crypto.params.ECKeyGenerationParameters; import org.bouncycastle.crypto.params.ECPrivateKeyParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.signers.ECDSASigner; -import org.bouncycastle.math.ec.ECAlgorithms; -import org.bouncycastle.math.ec.ECPoint; -import org.bouncycastle.math.ec.FixedPointCombMultiplier; -import org.bouncycastle.math.ec.FixedPointUtil; +import org.bouncycastle.math.ec.*; import org.bouncycastle.math.ec.custom.sec.SecP256K1Curve; -import org.bouncycastle.util.Properties; import org.bouncycastle.util.encoders.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,21 +62,9 @@ import java.util.Objects; * this class so round-tripping preserves state. Unless you're working with old software or doing unusual things, you * can usually ignore the compressed/uncompressed distinction.

*/ -public class ECKey implements EncryptableItem { +public class ECKey { private static final Logger log = LoggerFactory.getLogger(ECKey.class); - /** Sorts oldest keys first, newest last. */ - public static final Comparator AGE_COMPARATOR = new Comparator() { - - @Override - public int compare(ECKey k1, ECKey k2) { - if (k1.creationTimeSeconds == k2.creationTimeSeconds) - return 0; - else - return k1.creationTimeSeconds > k2.creationTimeSeconds ? 1 : -1; - } - }; - // The parameters of the secp256k1 curve that Bitcoin uses. private static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1"); @@ -108,13 +92,6 @@ public class ECKey implements EncryptableItem { protected final BigInteger priv; protected final LazyECPoint pub; - // Creation time of the key in seconds since the epoch, or zero if the key was deserialized from a version that did - // not have this field. - protected long creationTimeSeconds; - - protected KeyCrypter keyCrypter; - protected EncryptedData encryptedPrivateKey; - private byte[] pubKeyHash; /** @@ -159,18 +136,6 @@ public class ECKey implements EncryptableItem { this.pub = pub; } - /** - * Constructs a key that has an encrypted private component. The given object wraps encrypted bytes and an - * initialization vector. Note that the key will not be decrypted during this call: the returned ECKey is - * unusable for signing unless a decryption key is supplied. - */ - public static ECKey fromEncrypted(EncryptedData encryptedPrivateKey, KeyCrypter crypter, byte[] pubKey) { - ECKey key = fromPublicOnly(pubKey); - key.encryptedPrivateKey = encryptedPrivateKey; - key.keyCrypter = crypter; - return key; - } - /** * Utility for compressing an elliptic curve point. Returns the same point if it's already compressed. * See the ECKey class docs for a discussion of point compression. @@ -268,11 +233,6 @@ public class ECKey implements EncryptableItem { return priv != null; } - /** Returns true if this key is watch only, meaning it has a public key but no private key. */ - public boolean isWatching() { - return isPubKeyOnly() && !isEncrypted(); - } - /** * Output this ECKey as an ASN.1 encoded private key, as understood by OpenSSL or used by Bitcoin Core * in its wallet storage format. @@ -315,13 +275,20 @@ public class ECKey implements EncryptableItem { * use {@code new BigInteger(1, bytes);} */ public static ECPoint publicPointFromPrivate(BigInteger privKey) { - /* - * TODO: FixedPointCombMultiplier currently doesn't support scalars longer than the group order, - * but that could change in future versions. - */ if (privKey.bitLength() > CURVE.getN().bitLength()) { privKey = privKey.mod(CURVE.getN()); } + + if(Secp256k1Context.isEnabled()) { + try { + byte[] pubKeyBytes = NativeSecp256k1.computePubkey(Utils.bigIntegerToBytes(privKey, 32), false); + LazyECPoint lazyECPoint = new LazyECPoint(CURVE.getCurve(), pubKeyBytes); + return lazyECPoint.get(); + } catch(NativeSecp256k1Util.AssertFailException e) { + log.error("Error computing public key from private", e); + } + } + return new FixedPointCombMultiplier().multiply(CURVE.getG(), privKey); } @@ -340,6 +307,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(); @@ -359,6 +333,17 @@ public class ECKey implements EncryptableItem { return priv; } + /** + * Exports the private key in the form used by Bitcoin Core's "dumpprivkey" and "importprivkey" commands. Use + * the {@link DumpedPrivateKey#toString()} method to get the string. + * + * @return Private key bytes as a {@link DumpedPrivateKey}. + * @throws IllegalStateException if the private key is not available. + */ + public DumpedPrivateKey getPrivateKeyEncoded() { + return new DumpedPrivateKey(getPrivKeyBytes(), isCompressed()); + } + /** * Returns whether this key is using the compressed form or not. Compressed pubkeys are only 33 bytes, not 64. */ @@ -366,257 +351,129 @@ public class ECKey implements EncryptableItem { return pub.isCompressed(); } - /** - * Groups the two components that make up a signature, and provides a way to encode to DER form, which is - * how ECDSA signatures are represented when embedded in other data structures in the Bitcoin protocol. The raw - * components can be useful for doing further EC maths on them. - */ - public static class ECDSASignature { - /** The two components of the signature. */ - public final BigInteger r, s; + public TransactionSignature sign(Sha256Hash input, SigHash sigHash, TransactionSignature.Type type) { + TransactionSignature transactionSignature; - /** - * Constructs a signature with the given components. Does NOT automatically canonicalise the signature. - */ - public ECDSASignature(BigInteger r, BigInteger s) { - this.r = r; - this.s = s; - } - - /** - * Returns true if the S component is "low", that means it is below {@link ECKey#HALF_CURVE_ORDER}. See BIP62. - */ - public boolean isCanonical() { - return s.compareTo(HALF_CURVE_ORDER) <= 0; - } - - /** - * Will automatically adjust the S component to be less than or equal to half the curve order, if necessary. - * This is required because for every signature (r,s) the signature (r, -s (mod N)) is a valid signature of - * the same message. However, we dislike the ability to modify the bits of a Bitcoin transaction after it's - * been signed, as that violates various assumed invariants. Thus in future only one of those forms will be - * considered legal and the other will be banned. - */ - public ECDSASignature toCanonicalised() { - if (!isCanonical()) { - // The order of the curve is the number of valid points that exist on that curve. If S is in the upper - // half of the number of valid points, then bring it back to the lower half. Otherwise, imagine that - // N = 10 - // s = 8, so (-8 % 10 == 2) thus both (r, 8) and (r, 2) are valid solutions. - // 10 - 8 == 2, giving us always the latter solution, which is canonical. - return new ECDSASignature(r, CURVE.getN().subtract(s)); - } else { - return this; - } - } - - /** - * DER is an international standard for serializing data structures which is widely used in cryptography. - * It's somewhat like protocol buffers but less convenient. This method returns a standard DER encoding - * of the signature, as recognized by OpenSSL and other libraries. - */ - public byte[] encodeToDER() { - try { - return derByteStream().toByteArray(); - } catch (IOException e) { - throw new RuntimeException(e); // Cannot happen. - } - } - - /** - * @throws SignatureDecodeException if the signature is unparseable in some way. - */ - public static ECDSASignature decodeFromDER(byte[] bytes) throws SignatureDecodeException { - ASN1InputStream decoder = null; - try { - // BouncyCastle by default is strict about parsing ASN.1 integers. We relax this check, because some - // Bitcoin signatures would not parse. - Properties.setThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer", true); - decoder = new ASN1InputStream(bytes); - final ASN1Primitive seqObj = decoder.readObject(); - if (seqObj == null) - throw new SignatureDecodeException("Reached past end of ASN.1 stream."); - if (!(seqObj instanceof DLSequence)) - throw new SignatureDecodeException("Read unexpected class: " + seqObj.getClass().getName()); - final DLSequence seq = (DLSequence) seqObj; - ASN1Integer r, s; - try { - r = (ASN1Integer) seq.getObjectAt(0); - s = (ASN1Integer) seq.getObjectAt(1); - } catch (ClassCastException e) { - throw new SignatureDecodeException(e); - } - // OpenSSL deviates from the DER spec by interpreting these values as unsigned, though they should not be - // Thus, we always use the positive versions. See: http://r6.ca/blog/20111119T211504Z.html - return new ECDSASignature(r.getPositiveValue(), s.getPositiveValue()); - } catch (IOException e) { - throw new SignatureDecodeException(e); - } finally { - if (decoder != null) - try { decoder.close(); } catch (IOException x) {} - Properties.removeThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer"); - } - } - - protected ByteArrayOutputStream derByteStream() throws IOException { - // Usually 70-72 bytes. - ByteArrayOutputStream bos = new ByteArrayOutputStream(72); - DERSequenceGenerator seq = new DERSequenceGenerator(bos); - seq.addObject(new ASN1Integer(r)); - seq.addObject(new ASN1Integer(s)); - seq.close(); - return bos; - } - - protected boolean hasLowR() { - return r.toByteArray().length <= 32; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ECDSASignature other = (ECDSASignature) o; - return r.equals(other.r) && s.equals(other.s); - } - - @Override - public int hashCode() { - return Objects.hash(r, s); - } - } - - /** - * Signs the given hash and returns the R and S components as BigIntegers. In the Bitcoin protocol, they are - * usually encoded using ASN.1 format, so you want {@link ECKey.ECDSASignature#toASN1()} - * instead. However sometimes the independent components can be useful, for instance, if you're going to do - * further EC maths on them. - * @throws KeyCrypterException if this ECKey doesn't have a private part. - */ - public ECDSASignature sign(Sha256Hash input) throws KeyCrypterException { - return sign(input, null); - } - - /** - * Signs the given hash and returns the R and S components as BigIntegers. In the Bitcoin protocol, they are - * usually encoded using DER format, so you want {@link ECKey.ECDSASignature#encodeToDER()} - * instead. However sometimes the independent components can be useful, for instance, if you're doing to do further - * EC maths on them. - * - * @param aesKey The AES key to use for decryption of the private key. If null then no decryption is required. - * @throws KeyCrypterException if there's something wrong with aesKey. - * @throws ECKey.MissingPrivateKeyException if this key cannot sign because it's pubkey only. - */ - public ECDSASignature sign(Sha256Hash input, Key aesKey) throws KeyCrypterException { - KeyCrypter crypter = getKeyCrypter(); - if (crypter != null) { - if (aesKey == null) { - throw new KeyIsEncryptedException(); - } - return decrypt(aesKey).sign(input); + if(type == TransactionSignature.Type.SCHNORR) { + SchnorrSignature schnorrSignature = signSchnorr(input); + transactionSignature = new TransactionSignature(schnorrSignature, sigHash); } else { - // No decryption of private key required. - if (priv == null) { - throw new MissingPrivateKeyException(); - } + ECDSASignature ecdsaSignature = signEcdsa(input); + transactionSignature = new TransactionSignature(ecdsaSignature, sigHash); } - return doSign(input, priv); + + //Verify transaction signature immediately after signing as recommended in BIP340 + if(!transactionSignature.verify(input.getBytes(), this)) { + throw new IllegalStateException("Generated signature failed verification"); + } + + return transactionSignature; } - protected ECDSASignature doSign(Sha256Hash input, BigInteger privateKeyForSigning) { - if(privateKeyForSigning == null) { + /** + * Signs the given hash and returns the R and S components as an ECDSASignature. + */ + public ECDSASignature signEcdsa(Sha256Hash input) { + if(priv == null) { throw new IllegalArgumentException("Private key cannot be null"); } ECDSASignature signature; - int counter = 0; + Integer counter = null; do { ECDSASigner signer = new ECDSASigner(new HMacDSANonceKCalculator(new SHA256Digest(), counter)); - ECPrivateKeyParameters privKey = new ECPrivateKeyParameters(privateKeyForSigning, CURVE); + ECPrivateKeyParameters privKey = new ECPrivateKeyParameters(priv, CURVE); signer.init(true, privKey); BigInteger[] components = signer.generateSignature(input.getBytes()); signature = new ECDSASignature(components[0], components[1]).toCanonicalised(); - counter++; + counter = (counter == null ? 1 : counter+1); } while(!signature.hasLowR()); return signature; } /** - *

Verifies the given ECDSA signature against the message bytes using the public key bytes.

- * - *

When using native ECDSA verification, data must be 32 bytes, and no element may be - * larger than 520 bytes.

- * - * @param data Hash of the data to verify. - * @param signature ASN.1 encoded signature. - * @param pub The public key bytes to use. + * Signs the given hash and returns the R and S components as a SchnorrSignature. */ - public static boolean verify(byte[] data, ECDSASignature signature, byte[] pub) { - ECDSASigner signer = new ECDSASigner(); - ECPublicKeyParameters params = new ECPublicKeyParameters(CURVE.getCurve().decodePoint(pub), CURVE); - signer.init(false, params); - try { - return signer.verifySignature(data, signature.r, signature.s); - } catch (NullPointerException e) { - // Bouncy Castle contains a bug that can cause NPEs given specially crafted signatures. Those signatures - // are inherently invalid/attack sigs so we just fail them here rather than crash the thread. - log.error("Caught NPE inside bouncy castle", e); - return false; + public SchnorrSignature signSchnorr(Sha256Hash input) { + if(priv == null) { + throw new IllegalArgumentException("Private key cannot be null"); } + + if(!Secp256k1Context.isEnabled()) { + throw new IllegalStateException("libsecp256k1 is not enabled"); + } + + try { + byte[] sigBytes = NativeSecp256k1.schnorrSign(input.getBytes(), Utils.bigIntegerToBytes(priv, 32), new byte[32]); + return SchnorrSignature.decode(sigBytes); + } catch(NativeSecp256k1Util.AssertFailException e) { + log.error("Error signing schnorr", e); + } + + return null; } /** - * Verifies the given ASN.1 encoded ECDSA signature against a hash using the public key. - * - * @param data Hash of the data to verify. - * @param signature ASN.1 encoded signature. - * @param pub The public key bytes to use. - * @throws SignatureDecodeException if the signature is unparseable in some way. + * Verifies the given TransactionSignature against the provided byte array using the public key. */ - public static boolean verify(byte[] data, byte[] signature, byte[] pub) throws SignatureDecodeException { - return verify(data, ECDSASignature.decodeFromDER(signature), pub); - } - - /** - * Verifies the given ASN.1 encoded ECDSA signature against a hash using the public key. - * - * @param hash Hash of the data to verify. - * @param signature ASN.1 encoded signature. - * @throws SignatureDecodeException if the signature is unparseable in some way. - */ - public boolean verify(byte[] hash, byte[] signature) throws SignatureDecodeException { - return ECKey.verify(hash, signature, getPubKey()); + public boolean verify(byte[] data, TransactionSignature signature) { + return signature.verify(data, this); } /** * Verifies the given R/S pair (signature) against a hash using the public key. */ - public boolean verify(Sha256Hash sigHash, ECDSASignature signature) { - return ECKey.verify(sigHash.getBytes(), signature, getPubKey()); + public boolean verify(Sha256Hash sigHash, TransactionSignature signature) { + return verify(sigHash.getBytes(), signature); } - /** - * Verifies the given ASN.1 encoded ECDSA signature against a hash using the public key, and throws an exception - * if the signature doesn't match - * @throws SignatureDecodeException if the signature is unparseable in some way. - * @throws java.security.SignatureException if the signature does not match. - */ - public void verifyOrThrow(byte[] hash, byte[] signature) throws SignatureDecodeException, SignatureException { - if (!verify(hash, signature)) { - throw new SignatureException(); + public ECKey getTweakedOutputKey() { + TaprootPubKey taprootPubKey = liftX(getPubKeyXCoord()); + ECPoint internalKey = taprootPubKey.ecPoint; + byte[] taggedHash = Utils.taggedHash("TapTweak", internalKey.getXCoord().getEncoded()); + ECKey tweakValue = ECKey.fromPrivate(taggedHash); + ECPoint outputKey = internalKey.add(tweakValue.getPubKeyPoint()); + + if(hasPrivKey()) { + BigInteger taprootPriv = priv; + BigInteger tweakedPrivKey = taprootPriv.add(tweakValue.getPrivKey()).mod(CURVE_PARAMS.getCurve().getOrder()); + //TODO: Improve on this hack. How do we know whether to negate the private key before tweaking it? + if(!ECKey.fromPrivate(tweakedPrivKey).getPubKeyPoint().equals(outputKey)) { + taprootPriv = CURVE_PARAMS.getCurve().getOrder().subtract(priv); + tweakedPrivKey = taprootPriv.add(tweakValue.getPrivKey()).mod(CURVE_PARAMS.getCurve().getOrder()); + } + + return new ECKey(tweakedPrivKey, outputKey, true); } + + return ECKey.fromPublicOnly(outputKey, true); } - /** - * Verifies the given R/S pair (signature) against a hash using the public key, and throws an exception - * if the signature doesn't match - * @throws java.security.SignatureException if the signature does not match. - */ - public void verifyOrThrow(Sha256Hash sigHash, ECDSASignature signature) throws SignatureException { - if (!ECKey.verify(sigHash.getBytes(), signature, getPubKey())) { - throw new SignatureException(); + private static TaprootPubKey liftX(byte[] bytes) { + SecP256K1Curve secP256K1Curve = (SecP256K1Curve)CURVE_PARAMS.getCurve(); + BigInteger x = new BigInteger(1, bytes); + BigInteger p = secP256K1Curve.getQ(); + if(x.compareTo(p) > -1) { + throw new IllegalArgumentException("Provided bytes must be less than secp256k1 field size"); + } + + BigInteger y_sq = x.modPow(BigInteger.valueOf(3), p).add(BigInteger.valueOf(7)).mod(p); + BigInteger y = y_sq.modPow(p.add(BigInteger.valueOf(1)).divide(BigInteger.valueOf(4)), p); + if(!y.modPow(BigInteger.valueOf(2), p).equals(y_sq)) { + throw new IllegalStateException("Calculated invalid y_sq when solving for y co-ordinate"); + } + + return y.and(BigInteger.ONE).equals(BigInteger.ZERO) ? new TaprootPubKey(secP256K1Curve.createPoint(x, y), false) : new TaprootPubKey(secP256K1Curve.createPoint(x, p.subtract(y)), true); + } + + private static class TaprootPubKey { + public final ECPoint ecPoint; + public final boolean negated; + + public TaprootPubKey(ECPoint ecPoint, boolean negated) { + this.ecPoint = ecPoint; + this.negated = negated; } } @@ -624,8 +481,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) @@ -643,7 +502,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; @@ -715,12 +574,11 @@ public class ECKey implements EncryptableItem { * encoded string. * * @throws IllegalStateException if this ECKey does not have the private part. - * @throws KeyCrypterException if this ECKey is encrypted and no AESKey is provided or it does not decrypt the ECKey. */ - public String signMessage(String message, ScriptType scriptType, Key aesKey) throws KeyCrypterException { + public String signMessage(String message, ScriptType scriptType) { byte[] data = formatMessageForSigning(message); Sha256Hash hash = Sha256Hash.of(data); - ECDSASignature sig = sign(hash, aesKey); + ECDSASignature sig = signEcdsa(hash); byte recId = findRecoveryId(hash, sig); int headerByte = recId + getSigningTypeConstant(scriptType); byte[] sigData = new byte[65]; // 1 header + 32 bytes for R + 32 bytes for S @@ -939,181 +797,9 @@ public class ECKey implements EncryptableItem { } } - /** - * Returns the creation time of this key or zero if the key was deserialized from a version that did not store - * that data. - */ - @Override - public long getCreationTimeSeconds() { - return creationTimeSeconds; - } - - /** - * Sets the creation time of this key. Zero is a convention to mean "unavailable". This method can be useful when - * you have a raw key you are importing from somewhere else. - */ - public void setCreationTimeSeconds(long newCreationTimeSeconds) { - if (newCreationTimeSeconds < 0) { - throw new IllegalArgumentException("Cannot set creation time to negative value: " + newCreationTimeSeconds); - } - creationTimeSeconds = newCreationTimeSeconds; - } - - /** - * Create an encrypted private key with the keyCrypter and the AES key supplied. - * This method returns a new encrypted key and leaves the original unchanged. - * - * @param keyCrypter The keyCrypter that specifies exactly how the encrypted bytes are created. - * @param aesKey The Key with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached as it is slow to create). - * @return encryptedKey - */ - public ECKey encrypt(KeyCrypter keyCrypter, Key aesKey) throws KeyCrypterException { - if(keyCrypter == null) { - throw new KeyCrypterException("Keycrypter cannot be null"); - } - - final byte[] privKeyBytes = getPrivKeyBytes(); - EncryptedData encryptedPrivateKey = keyCrypter.encrypt(privKeyBytes, null, aesKey); - ECKey result = ECKey.fromEncrypted(encryptedPrivateKey, keyCrypter, getPubKey()); - result.setCreationTimeSeconds(creationTimeSeconds); - return result; - } - - /** - * Create a decrypted private key with the keyCrypter and AES key supplied. Note that if the aesKey is wrong, this - * has some chance of throwing KeyCrypterException due to the corrupted padding that will result, but it can also - * just yield a garbage key. - * - * @param keyCrypter The keyCrypter that specifies exactly how the decrypted bytes are created. - * @param aesKey The Key with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached). - */ - public ECKey decrypt(KeyCrypter keyCrypter, Key aesKey) throws KeyCrypterException { - if(keyCrypter == null) { - throw new KeyCrypterException("Keycrypter cannot be null"); - } - - // Check that the keyCrypter matches the one used to encrypt the keys, if set. - if (this.keyCrypter != null && !this.keyCrypter.equals(keyCrypter)) { - throw new KeyCrypterException("The keyCrypter being used to decrypt the key is different to the one that was used to encrypt it"); - } - - if(encryptedPrivateKey == null) { - throw new IllegalArgumentException("This key is not encrypted"); - } - - byte[] unencryptedPrivateKey = keyCrypter.decrypt(encryptedPrivateKey, aesKey); - ECKey key = ECKey.fromPrivate(unencryptedPrivateKey); - - if (!Arrays.equals(key.getPubKey(), getPubKey())) { - throw new KeyCrypterException("Provided AES key is wrong"); - } - - key.setCreationTimeSeconds(creationTimeSeconds); - return key; - } - - /** - * Create a decrypted private key with AES key. Note that if the AES key is wrong, this - * has some chance of throwing KeyCrypterException due to the corrupted padding that will result, but it can also - * just yield a garbage key. - * - * @param aesKey The Key with the AES encryption key (usually constructed with keyCrypter#deriveKey and cached). - */ - public ECKey decrypt(Key aesKey) throws KeyCrypterException { - final KeyCrypter crypter = getKeyCrypter(); - if (crypter == null) { - throw new KeyCrypterException("No key crypter available"); - } - - return decrypt(crypter, aesKey); - } - - /** - * Creates decrypted private key if needed. - */ - public ECKey maybeDecrypt(Key aesKey) throws KeyCrypterException { - return isEncrypted() && aesKey != null ? decrypt(aesKey) : this; - } - - /** - *

Check that it is possible to decrypt the key with the keyCrypter and that the original key is returned.

- * - *

Because it is a critical failure if the private keys cannot be decrypted successfully (resulting of loss of all - * bitcoins controlled by the private key) you can use this method to check when you *encrypt* a wallet that - * it can definitely be decrypted successfully.

- * - * @return true if the encrypted key can be decrypted back to the original key successfully. - */ - public static boolean encryptionIsReversible(ECKey originalKey, ECKey encryptedKey, KeyCrypter keyCrypter, Key aesKey) { - try { - ECKey rebornUnencryptedKey = encryptedKey.decrypt(keyCrypter, aesKey); - byte[] originalPrivateKeyBytes = originalKey.getPrivKeyBytes(); - byte[] rebornKeyBytes = rebornUnencryptedKey.getPrivKeyBytes(); - if (!Arrays.equals(originalPrivateKeyBytes, rebornKeyBytes)) { - log.error("The check that encryption could be reversed failed for {}", originalKey); - return false; - } - return true; - } catch (KeyCrypterException kce) { - log.error(kce.getMessage()); - return false; - } - } - - /** - * Indicates whether the private key is encrypted (true) or not (false). - * A private key is deemed to be encrypted when there is both a KeyCrypter and the encryptedPrivateKey is non-zero. - */ - @Override - public boolean isEncrypted() { - return keyCrypter != null && encryptedPrivateKey != null && encryptedPrivateKey.getEncryptedBytes().length > 0; - } - - @Override - public EncryptionType getEncryptionType() { - return new EncryptionType(EncryptionType.Deriver.SCRYPT, keyCrypter != null ? keyCrypter.getCrypterType() : EncryptionType.Crypter.NONE); - } - - /** - * A wrapper for {@link #getPrivKeyBytes()} that returns null if the private key bytes are missing or would have - * to be derived (for the HD key case). - */ - @Override - public byte[] getSecretBytes() { - if (hasPrivKey()) { - return getPrivKeyBytes(); - } else { - return null; - } - } - - /** An alias for {@link #getEncryptedPrivateKey()} */ - @Override - public EncryptedData getEncryptedData() { - return getEncryptedPrivateKey(); - } - - /** - * Returns the the encrypted private key bytes and initialisation vector for this ECKey, or null if the ECKey - * is not encrypted. - */ - public EncryptedData getEncryptedPrivateKey() { - return encryptedPrivateKey; - } - - /** - * Returns the KeyCrypter that was used to encrypt to encrypt this ECKey. You need this to decrypt the ECKey. - */ - public KeyCrypter getKeyCrypter() { - return keyCrypter; - } - public static class MissingPrivateKeyException extends RuntimeException { } - public static class KeyIsEncryptedException extends MissingPrivateKeyException { - } - @Override public boolean equals(Object o) { if (this == o) return true; 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/crypto/HMacDSANonceKCalculator.java b/src/main/java/com/sparrowwallet/drongo/crypto/HMacDSANonceKCalculator.java index 90aa721..ee1e499 100644 --- a/src/main/java/com/sparrowwallet/drongo/crypto/HMacDSANonceKCalculator.java +++ b/src/main/java/com/sparrowwallet/drongo/crypto/HMacDSANonceKCalculator.java @@ -21,7 +21,7 @@ public class HMacDSANonceKCalculator implements DSAKCalculator { private final HMac hMac; private final byte[] K; private final byte[] V; - private final long counter; + private final Long counter; private BigInteger n; @@ -31,11 +31,11 @@ public class HMacDSANonceKCalculator implements DSAKCalculator { * @param digest digest to build the HMAC on. * @param counter additional data as per RFC 6979 3.6 */ - public HMacDSANonceKCalculator(Digest digest, int counter) { + public HMacDSANonceKCalculator(Digest digest, Integer counter) { this.hMac = new HMac(digest); this.V = new byte[hMac.getMacSize()]; this.K = new byte[hMac.getMacSize()]; - this.counter = Integer.toUnsignedLong(counter); + this.counter = (counter == null ? null : Integer.toUnsignedLong(counter)); } public boolean isDeterministic() @@ -74,8 +74,12 @@ public class HMacDSANonceKCalculator implements DSAKCalculator { System.arraycopy(mVal, 0, m, m.length - mVal.length, mVal.length); - BigInteger additional = BigInteger.valueOf(counter); - byte[] aData = Utils.bigIntegerToBytes(additional, size); + byte[] c = null; + if(counter != null) { + BigInteger additional = BigInteger.valueOf(counter); + c = Utils.bigIntegerToBytes(additional, size); + Utils.reverse(c); + } hMac.init(new KeyParameter(K)); @@ -83,7 +87,9 @@ public class HMacDSANonceKCalculator implements DSAKCalculator { hMac.update((byte)0x00); hMac.update(x, 0, x.length); hMac.update(m, 0, m.length); - hMac.update(aData, 0, aData.length); + if(c != null) { + hMac.update(c, 0, c.length); + } hMac.doFinal(K, 0); @@ -97,6 +103,9 @@ public class HMacDSANonceKCalculator implements DSAKCalculator { hMac.update((byte)0x01); hMac.update(x, 0, x.length); hMac.update(m, 0, m.length); + if(counter != null) { + hMac.update(c, 0, c.length); + } hMac.doFinal(K, 0); 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/crypto/SchnorrSignature.java b/src/main/java/com/sparrowwallet/drongo/crypto/SchnorrSignature.java new file mode 100644 index 0000000..92ddb7b --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/SchnorrSignature.java @@ -0,0 +1,98 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.TransactionSignature; +import org.bitcoin.NativeSecp256k1; +import org.bitcoin.NativeSecp256k1Util; +import org.bitcoin.Secp256k1Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; + +/** + * Groups the two components that make up a Schnorr signature + */ +public class SchnorrSignature { + private static final Logger log = LoggerFactory.getLogger(SchnorrSignature.class); + + /** + * The two components of the signature. + */ + public final BigInteger r, s; + + /** + * Constructs a signature with the given components. Does NOT automatically canonicalise the signature. + */ + public SchnorrSignature(BigInteger r, BigInteger s) { + this.r = r; + this.s = s; + } + + public byte[] encode() { + ByteBuffer buffer = ByteBuffer.allocate(64); + buffer.put(Utils.bigIntegerToBytes(r, 32)); + buffer.put(Utils.bigIntegerToBytes(s, 32)); + return buffer.array(); + } + + public static SchnorrSignature decode(byte[] bytes) { + if(bytes.length != 64) { + throw new IllegalArgumentException("Invalid Schnorr signature length of " + bytes.length + " bytes"); + } + + BigInteger r = new BigInteger(1, Arrays.copyOfRange(bytes, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(bytes, 32, 64)); + + return new SchnorrSignature(r, s); + } + + public static TransactionSignature decodeFromBitcoin(byte[] bytes) { + if(bytes.length < 64 || bytes.length > 65) { + throw new IllegalArgumentException("Invalid Schnorr signature length of " + bytes.length + " bytes"); + } + + BigInteger r = new BigInteger(1, Arrays.copyOfRange(bytes, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(bytes, 32, 64)); + + if(bytes.length == 65) { + return new TransactionSignature(r, s, TransactionSignature.Type.SCHNORR, bytes[64]); + } + + return new TransactionSignature(r, s, TransactionSignature.Type.SCHNORR, (byte)0); + } + + public boolean verify(byte[] data, byte[] pub) { + if(!Secp256k1Context.isEnabled()) { + throw new IllegalStateException("libsecp256k1 is not enabled"); + } + + try { + return NativeSecp256k1.schnorrVerify(encode(), data, pub); + } catch(NativeSecp256k1Util.AssertFailException e) { + log.error("Error verifying schnorr signature", e); + } + + return false; + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + SchnorrSignature that = (SchnorrSignature) o; + return r.equals(that.r) && s.equals(that.s); + } + + @Override + public int hashCode() { + return Objects.hash(r, s); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/crypto/VersionedChecksummedBytes.java b/src/main/java/com/sparrowwallet/drongo/crypto/VersionedChecksummedBytes.java new file mode 100644 index 0000000..56f280f --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/crypto/VersionedChecksummedBytes.java @@ -0,0 +1,109 @@ +package com.sparrowwallet.drongo.crypto; + + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Base58; +import com.sparrowwallet.drongo.protocol.Sha256Hash; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Objects; + +/** + *

In Bitcoin the following format is often used to represent some type of key:

+ *

+ *

[one version byte] [data bytes] [4 checksum bytes]
+ *

+ *

and the result is then Base58 encoded. This format is used for addresses, and private keys exported using the + * dumpprivkey command.

+ */ +public class VersionedChecksummedBytes implements Serializable, Cloneable, Comparable { + protected final int version; + protected byte[] bytes; + + protected VersionedChecksummedBytes(String encoded) { + byte[] versionAndDataBytes = Base58.decodeChecked(encoded); + byte versionByte = versionAndDataBytes[0]; + version = versionByte & 0xFF; + bytes = new byte[versionAndDataBytes.length - 1]; + System.arraycopy(versionAndDataBytes, 1, bytes, 0, versionAndDataBytes.length - 1); + } + + protected VersionedChecksummedBytes(int version, byte[] bytes) { + this.version = version; + this.bytes = bytes; + } + + /** + * Returns the base-58 encoded String representation of this + * object, including version and checksum bytes. + */ + public final String toBase58() { + // A stringified buffer is: + // 1 byte version + data bytes + 4 bytes check code (a truncated hash) + byte[] addressBytes = new byte[1 + bytes.length + 4]; + addressBytes[0] = (byte) version; + System.arraycopy(bytes, 0, addressBytes, 1, bytes.length); + byte[] checksum = Sha256Hash.hashTwice(addressBytes, 0, bytes.length + 1); + System.arraycopy(checksum, 0, addressBytes, bytes.length + 1, 4); + return Base58.encode(addressBytes); + } + + @Override + public String toString() { + return toBase58(); + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + VersionedChecksummedBytes that = (VersionedChecksummedBytes) o; + return version == that.version && Arrays.equals(bytes, that.bytes); + } + + @Override + public int hashCode() { + int result = Objects.hash(version); + result = 31 * result + Arrays.hashCode(bytes); + return result; + } + + /** + * {@inheritDoc} + * + * This implementation narrows the return type to VersionedChecksummedBytes + * and allows subclasses to throw CloneNotSupportedException even though it + * is never thrown by this implementation. + */ + @Override + public VersionedChecksummedBytes clone() throws CloneNotSupportedException { + return (VersionedChecksummedBytes) super.clone(); + } + + /** + * {@inheritDoc} + * + * This implementation uses an optimized Google Guava method to compare bytes. + */ + @Override + public int compareTo(VersionedChecksummedBytes o) { + int result = Integer.compare(this.version, o.version); + Utils.LexicographicByteArrayComparator lexicographicByteArrayComparator = new Utils.LexicographicByteArrayComparator(); + return result != 0 ? result : lexicographicByteArrayComparator.compare(this.bytes, o.bytes); + } + + /** + * Returns the "version" or "header" byte: the first byte of the data. This is used to disambiguate what the + * contents apply to, for example, which network the key or address is valid on. + * + * @return A positive number between 0 and 255. + */ + public int getVersion() { + return version; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java b/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java index c372540..98294b5 100644 --- a/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java +++ b/src/main/java/com/sparrowwallet/drongo/policy/Miniscript.java @@ -5,6 +5,7 @@ import java.util.regex.Pattern; public class Miniscript { private static final Pattern SINGLE_PATTERN = Pattern.compile("pkh?\\("); + private static final Pattern TAPROOT_PATTERN = Pattern.compile("tr\\("); private static final Pattern MULTI_PATTERN = Pattern.compile("multi\\(([\\d+])"); private String script; @@ -27,6 +28,11 @@ public class Miniscript { return 1; } + Matcher taprootMatcher = TAPROOT_PATTERN.matcher(script); + if(taprootMatcher.find()) { + return 1; + } + Matcher multiMatcher = MULTI_PATTERN.matcher(script); if(multiMatcher.find()) { String threshold = multiMatcher.group(1); diff --git a/src/main/java/com/sparrowwallet/drongo/policy/Policy.java b/src/main/java/com/sparrowwallet/drongo/policy/Policy.java index b6991a2..370a111 100644 --- a/src/main/java/com/sparrowwallet/drongo/policy/Policy.java +++ b/src/main/java/com/sparrowwallet/drongo/policy/Policy.java @@ -2,13 +2,14 @@ package com.sparrowwallet.drongo.policy; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Persistable; import java.util.List; import static com.sparrowwallet.drongo.protocol.ScriptType.*; import static com.sparrowwallet.drongo.policy.PolicyType.*; -public class Policy { +public class Policy extends Persistable { private static final String DEFAULT_NAME = "Default"; private String name; @@ -23,6 +24,10 @@ public class Policy { this.miniscript = miniscript; } + public String getName() { + return name; + } + public Miniscript getMiniscript() { return miniscript; } @@ -57,6 +62,8 @@ public class Policy { } public Policy copy() { - return new Policy(name, miniscript.copy()); + Policy policy = new Policy(name, miniscript.copy()); + policy.setId(getId()); + return policy; } } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java b/src/main/java/com/sparrowwallet/drongo/protocol/Bech32.java index da5ad0a..b625fec 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); @@ -133,10 +150,14 @@ public class Bech32 { /** Decode a Bech32 string. */ public static Bech32Data decode(final String str) { + return decode(str, 90); + } + + public static Bech32Data decode(final String str, int limit) { boolean lower = false, upper = false; if (str.length() < 8) throw new ProtocolException("Input too short: " + str.length()); - if (str.length() > 90) + if (str.length() > limit) throw new ProtocolException("Input too long: " + str.length()); for (int i = 0; i < str.length(); ++i) { char c = str.charAt(i); @@ -163,14 +184,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 +231,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/BlockHeader.java b/src/main/java/com/sparrowwallet/drongo/protocol/BlockHeader.java index f152876..863d3cb 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/BlockHeader.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/BlockHeader.java @@ -1,7 +1,12 @@ package com.sparrowwallet.drongo.protocol; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.Date; +import static com.sparrowwallet.drongo.Utils.uint32ToByteStreamLE; + public class BlockHeader extends Message { private long version; private Sha256Hash prevBlockHash; @@ -14,6 +19,16 @@ public class BlockHeader extends Message { super(rawheader, 0); } + public BlockHeader(long version, Sha256Hash prevBlockHash, Sha256Hash merkleRoot, Sha256Hash witnessRoot, long time, long difficultyTarget, long nonce) { + this.version = version; + this.prevBlockHash = prevBlockHash; + this.merkleRoot = merkleRoot; + this.witnessRoot = witnessRoot; + this.time = time; + this.difficultyTarget = difficultyTarget; + this.nonce = nonce; + } + @Override protected void parse() throws ProtocolException { version = readUint32(); @@ -57,4 +72,25 @@ public class BlockHeader extends Message { public long getNonce() { return nonce; } + + public byte[] bitcoinSerialize() { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + bitcoinSerializeToStream(outputStream); + return outputStream.toByteArray(); + } catch (IOException e) { + //can't happen + } + + return null; + } + + protected void bitcoinSerializeToStream(OutputStream stream) throws IOException { + uint32ToByteStreamLE(version, stream); + stream.write(prevBlockHash.getReversedBytes()); + stream.write(merkleRoot.getReversedBytes()); + uint32ToByteStreamLE(time, stream); + uint32ToByteStreamLE(difficultyTarget, stream); + uint32ToByteStreamLE(nonce, stream); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/HashIndex.java b/src/main/java/com/sparrowwallet/drongo/protocol/HashIndex.java new file mode 100644 index 0000000..2559099 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/protocol/HashIndex.java @@ -0,0 +1,48 @@ +package com.sparrowwallet.drongo.protocol; + +public class HashIndex { + private final Sha256Hash hash; + private final long index; + + public HashIndex(Sha256Hash hash, long index) { + this.hash = hash; + this.index = index; + } + + public Sha256Hash getHash() { + return hash; + } + + public long getIndex() { + return index; + } + + @Override + public String toString() { + return hash.toString() + ":" + index; + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + + HashIndex hashIndex = (HashIndex) o; + + if(index != hashIndex.index) { + return false; + } + return hash.equals(hashIndex.hash); + } + + @Override + public int hashCode() { + int result = hash.hashCode(); + result = 31 * result + (int) (index ^ (index >>> 32)); + return result; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Script.java b/src/main/java/com/sparrowwallet/drongo/protocol/Script.java index 05435f4..b79025e 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.

* @@ -150,6 +165,14 @@ public class Script { throw new ProtocolException("Script not a standard form that contains a single hash"); } + public Address getToAddress() { + try { + return getToAddresses()[0]; + } catch(Exception e) { + return null; + } + } + /** * Gets the destination address from this script, if it's in the required form. */ @@ -160,8 +183,15 @@ public class Script { } } - if(P2PK.isScriptType(this)) { - return new Address[] { P2PK.getAddress(P2PK.getPublicKeyFromScript(this).getPubKey()) }; + //Special handling for taproot tweaked keys - we don't want to tweak them again + if(P2TR.isScriptType(this)) { + return new Address[] { new P2TRAddress(P2TR.getPublicKeyFromScript(this).getPubKeyXCoord()) }; + } + + for(ScriptType scriptType : SINGLE_KEY_TYPES) { + if(scriptType.isScriptType(this)) { + return new Address[] { scriptType.getAddress(scriptType.getPublicKeyFromScript(this)) }; + } } if(MULTISIG.isScriptType(this)) { @@ -178,7 +208,8 @@ public class Script { } public int getNumRequiredSignatures() throws NonStandardScriptException { - if(P2PK.isScriptType(this) || P2PKH.isScriptType(this) || P2WPKH.isScriptType(this)) { + //TODO: Handle P2TR script path spends + if(P2PK.isScriptType(this) || P2PKH.isScriptType(this) || P2WPKH.isScriptType(this) || P2TR.isScriptType(this)) { return 1; } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java index 5a2e015..f74b4d4 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptChunk.java @@ -110,8 +110,8 @@ public class ScriptChunk { } try { - ECKey.ECDSASignature.decodeFromDER(data); - } catch(SignatureDecodeException e) { + TransactionSignature.decodeFromBitcoin(data, false); + } catch(Exception e) { return false; } @@ -120,7 +120,7 @@ public class ScriptChunk { public TransactionSignature getSignature() { try { - return TransactionSignature.decodeFromBitcoin(data, false, false); + return TransactionSignature.decodeFromBitcoin(data, false); } catch(SignatureDecodeException e) { throw new ProtocolException("Could not decode signature", e); } diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java index 23a8345..0b8cfa9 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/ScriptType.java @@ -8,6 +8,8 @@ import com.sparrowwallet.drongo.crypto.ChildNumber; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.policy.PolicyType; +import java.time.LocalDate; +import java.time.Month; import java.util.*; import java.util.stream.Collectors; @@ -17,7 +19,7 @@ import static com.sparrowwallet.drongo.protocol.ScriptOpCodes.*; import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR; public enum ScriptType { - P2PK("P2PK", "m/44'/17'/0'") { + P2PK("P2PK", "Legacy (P2PK)", "m/44'/17'/0'") { @Override public Address getAddress(byte[] pubKey) { return new P2PKAddress(pubKey); @@ -126,12 +128,17 @@ public enum ScriptType { throw new ProtocolException(getName() + " is not a multisig script type"); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(SINGLE); } }, - P2PKH("P2PKH", "m/44'/17'/0'") { + P2PKH("P2PKH", "Legacy (P2PKH)", "m/44'/17'/0'") { @Override public Address getAddress(byte[] pubKeyHash) { return new P2PKHAddress(pubKeyHash); @@ -239,12 +246,17 @@ public enum ScriptType { throw new ProtocolException(getName() + " is not a multisig script type"); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(SINGLE); } }, - MULTISIG("Bare Multisig", "m/44'/17'/0'") { + MULTISIG("Bare Multisig", "Bare Multisig", "m/44'/17'/0'") { @Override public Address getAddress(byte[] bytes) { throw new ProtocolException("No single address for multisig script type"); @@ -425,12 +437,17 @@ public enum ScriptType { return transaction.addInput(prevOutput.getHash(), prevOutput.getIndex(), scriptSig); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(MULTI); } }, - P2SH("P2SH", "m/45'") { + P2SH("P2SH", "Legacy (P2SH)", "m/45'") { @Override public Address getAddress(byte[] scriptHash) { return new P2SHAddress(scriptHash); @@ -550,12 +567,17 @@ public enum ScriptType { return transaction.addInput(prevOutput.getHash(), prevOutput.getIndex(), scriptSig); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(MULTI); } }, - P2SH_P2WPKH("P2SH-P2WPKH", "m/49'/17'/0'") { + P2SH_P2WPKH("P2SH-P2WPKH", "Nested Segwit (P2SH-P2WPKH)", "m/49'/17'/0'") { @Override public Address getAddress(byte[] scriptHash) { return P2SH.getAddress(scriptHash); @@ -653,12 +675,17 @@ public enum ScriptType { throw new ProtocolException(getName() + " is not a multisig script type"); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(SINGLE); } }, - P2SH_P2WSH("P2SH-P2WSH", "m/48'/17'/0'/1'") { + P2SH_P2WSH("P2SH-P2WSH", "Nested Segwit (P2SH-P2WSH)", "m/48'/17'/0'/1'") { @Override public Address getAddress(byte[] scriptHash) { return P2SH.getAddress(scriptHash); @@ -754,12 +781,17 @@ public enum ScriptType { return transaction.addInput(prevOutput.getHash(), prevOutput.getIndex(), scriptSig, witness); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(MULTI, CUSTOM); } }, - P2WPKH("P2WPKH", "m/84'/17'/0'") { + P2WPKH("P2WPKH", "Native Segwit (P2WPKH)", "m/84'/17'/0'") { @Override public Address getAddress(byte[] pubKeyHash) { return new P2WPKHAddress(pubKeyHash); @@ -859,12 +891,17 @@ public enum ScriptType { throw new ProtocolException(getName() + " is not a multisig script type"); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(SINGLE); } }, - P2WSH("P2WSH", "m/48'/17'/0'/2'") { + P2WSH("P2WSH", "Native Segwit (P2WSH)", "m/48'/17'/0'/2'") { @Override public Address getAddress(byte[] scriptHash) { return new P2WSHAddress(scriptHash); @@ -970,17 +1007,144 @@ public enum ScriptType { return transaction.addInput(prevOutput.getHash(), prevOutput.getIndex(), scriptSig, witness); } + @Override + public TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.ECDSA; + }; + @Override public List getAllowedPolicyTypes() { return List.of(MULTI, CUSTOM); } + }, + P2TR("P2TR", "Taproot (P2TR)", "m/86'/0'/0'") { + @Override + public ECKey getOutputKey(ECKey derivedKey) { + return derivedKey.getTweakedOutputKey(); + } + + @Override + public Address getAddress(byte[] pubKey) { + return new P2TRAddress(pubKey); + } + + @Override + public Address getAddress(ECKey derivedKey) { + return getAddress(getOutputKey(derivedKey).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 derivedKey) { + return getOutputScript(getOutputKey(derivedKey).getPubKeyXCoord()); + } + + @Override + public Script getOutputScript(Script script) { + throw new ProtocolException("Cannot create a taproot output script without a keypath"); + } + + @Override + public String getOutputDescriptor(ECKey derivedKey) { + return getDescriptor() + Utils.bytesToHex(derivedKey.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) { + if(!isScriptType(scriptPubKey)) { + throw new ProtocolException("Provided scriptPubKey is not a " + getName() + " script"); + } + + if(!scriptPubKey.equals(getOutputScript(pubKey))) { + throw new ProtocolException("Provided P2TR scriptPubKey does not match constructed pubkey script"); + } + + return new Script(new byte[0]); + } + + @Override + public TransactionInput addSpendingInput(Transaction transaction, TransactionOutput prevOutput, ECKey pubKey, TransactionSignature signature) { + Script scriptSig = getScriptSig(prevOutput.getScript(), pubKey, signature); + TransactionWitness witness = new TransactionWitness(transaction, signature); + return transaction.addInput(prevOutput.getHash(), prevOutput.getIndex(), scriptSig, witness); + } + + @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 TransactionSignature.Type getSignatureType() { + return TransactionSignature.Type.SCHNORR; + }; + + @Override + public List getAllowedPolicyTypes() { + return List.of(SINGLE); + } }; private final String name; + private final String description; private final String defaultDerivationPath; - ScriptType(String name, String defaultDerivationPath) { + ScriptType(String name, String description, String defaultDerivationPath) { this.name = name; + this.description = description; this.defaultDerivationPath = defaultDerivationPath; } @@ -988,6 +1152,10 @@ public enum ScriptType { return name; } + public String getDescription() { + return description; + } + public String getDefaultDerivationPath() { return Network.get() != Network.MAINNET ? defaultDerivationPath.replace("/17'/0'", "/1'/0'") : defaultDerivationPath; } @@ -1027,6 +1195,10 @@ public enum ScriptType { return getAllowedPolicyTypes().contains(policyType); } + public ECKey getOutputKey(ECKey derivedKey) { + return derivedKey; + } + public abstract Address getAddress(byte[] bytes); public abstract Address getAddress(ECKey key); @@ -1081,18 +1253,24 @@ public enum ScriptType { public abstract TransactionInput addMultisigSpendingInput(Transaction transaction, TransactionOutput prevOutput, int threshold, Map pubKeySignatures); + public abstract TransactionSignature.Type getSignatureType(); + + 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) { @@ -1110,7 +1288,7 @@ public enum ScriptType { scriptTypes.sort((o1, o2) -> o2.getDescriptor().length() - o1.getDescriptor().length()); for(ScriptType scriptType : scriptTypes) { - if(descriptor.toLowerCase().startsWith(scriptType.getDescriptor())) { + if(descriptor.toLowerCase(Locale.ROOT).startsWith(scriptType.getDescriptor())) { return scriptType; } } @@ -1160,6 +1338,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/protocol/SigHash.java b/src/main/java/com/sparrowwallet/drongo/protocol/SigHash.java index 97ea175..de060df 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/SigHash.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/SigHash.java @@ -1,22 +1,27 @@ package com.sparrowwallet.drongo.protocol; +import java.util.List; + /** * These constants are a part of a scriptSig signature on the inputs. They define the details of how a * transaction can be redeemed, specifically, they control how the hash of the transaction is calculated. */ public enum SigHash { - ALL("All (Recommended)", (byte)1), + ALL("All", (byte)1), NONE("None", (byte)2), SINGLE("Single", (byte)3), ANYONECANPAY("Anyone Can Pay", (byte)0x80), // Caution: Using this type in isolation is non-standard. Treated similar to ANYONECANPAY_ALL. ANYONECANPAY_ALL("All + Anyone Can Pay", (byte)0x81), ANYONECANPAY_NONE("None + Anyone Can Pay", (byte)0x82), ANYONECANPAY_SINGLE("Single + Anyone Can Pay", (byte)0x83), - UNSET("Unset", (byte)0); // Caution: Using this type in isolation is non-standard. Treated similar to ALL. + DEFAULT("Default", (byte)0); private final String name; public final byte value; + public static final List LEGACY_SIGNING_TYPES = List.of(ALL, NONE, SINGLE, ANYONECANPAY_ALL, ANYONECANPAY_NONE, ANYONECANPAY_SINGLE); + public static final List TAPROOT_SIGNING_TYPES = List.of(DEFAULT, ALL, NONE, SINGLE, ANYONECANPAY_ALL, ANYONECANPAY_NONE, ANYONECANPAY_SINGLE); + private SigHash(final String name, final byte value) { this.name = name; this.value = value; diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java index 7bc9826..33d7697 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/Transaction.java @@ -22,7 +22,8 @@ public class Transaction extends ChildMessage { public static final long SATOSHIS_PER_BITCOIN = 100 * 1000 * 1000L; public static final long MAX_BLOCK_LOCKTIME = 500000000L; public static final int WITNESS_SCALE_FACTOR = 4; - public static final int DEFAULT_SEGWIT_VERSION = 1; + public static final int DEFAULT_SEGWIT_FLAG = 1; + public static final int COINBASE_MATURITY_THRESHOLD = 100; //Min feerate for defining dust, defined in sats/vByte //From: https://github.com/bitcoin/bitcoin/blob/0.19/src/policy/policy.h#L50 @@ -31,10 +32,12 @@ public class Transaction extends ChildMessage { //Default min feerate, defined in sats/vByte public static final double DEFAULT_MIN_RELAY_FEE = 1d; + public static final byte LEAF_VERSION_TAPSCRIPT = (byte)0xc0; + private long version; private long locktime; private boolean segwit; - private int segwitVersion; + private int segwitFlag; private Sha256Hash cachedTxId; private Sha256Hash cachedWTxId; @@ -89,8 +92,6 @@ public class Transaction extends ChildMessage { } public boolean isReplaceByFee() { - if(locktime == 0) return false; - for(TransactionInput input : inputs) { if(input.isReplaceByFeeEnabled()) { return true; @@ -136,17 +137,17 @@ public class Transaction extends ChildMessage { return segwit; } - public int getSegwitVersion() { - return segwitVersion; + public int getSegwitFlag() { + return segwitFlag; } - public void setSegwitVersion(int segwitVersion) { + public void setSegwitFlag(int segwitFlag) { if(!segwit) { adjustLength(2); this.segwit = true; } - this.segwitVersion = segwitVersion; + this.segwitFlag = segwitFlag; } public void clearSegwit() { @@ -210,7 +211,7 @@ public class Transaction extends ChildMessage { // marker, flag if(useWitnessFormat) { stream.write(0); - stream.write(segwitVersion); + stream.write(segwitFlag); } // txin_count, txins @@ -255,7 +256,7 @@ public class Transaction extends ChildMessage { // marker, flag if (segwit) { byte[] segwitHeader = readBytes(2); - segwitVersion = segwitHeader[1]; + segwitFlag = segwitHeader[1]; } // txin_count, txins parseInputs(); @@ -305,8 +306,8 @@ public class Transaction extends ChildMessage { return length; } - public int getVirtualSize() { - return (int)Math.ceil((double)getWeightUnits() / (double)WITNESS_SCALE_FACTOR); + public double getVirtualSize() { + return (double)getWeightUnits() / (double)WITNESS_SCALE_FACTOR; } public int getWeightUnits() { @@ -354,7 +355,7 @@ public class Transaction extends ChildMessage { public TransactionInput addInput(Sha256Hash spendTxHash, long outputIndex, Script script, TransactionWitness witness) { if(!isSegwit()) { - setSegwitVersion(DEFAULT_SEGWIT_VERSION); + setSegwitFlag(DEFAULT_SEGWIT_FLAG); } return addInput(new TransactionInput(this, new TransactionOutPoint(spendTxHash, outputIndex), script.getProgram(), witness)); @@ -439,6 +440,9 @@ public class Transaction extends ChildMessage { public static boolean isTransaction(byte[] bytes) { //Incomplete quick test + if(bytes.length == 0) { + return false; + } long version = Utils.readUint32(bytes, 0); return version > 0 && version < 5; } @@ -608,4 +612,123 @@ public class Transaction extends ChildMessage { return Sha256Hash.of(bos.toByteArray()); } + + /** + *

Calculates a signature hash, that is, a hash of a simplified form of the transaction. How exactly the transaction + * is simplified is specified by the type and anyoneCanPay parameters.

+ * + * (See BIP341: https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)

+ * + * @param spentUtxos the ordered list of spent UTXOs corresponding to the inputs of this transaction + * @param inputIndex input the signature is being calculated for. Tx signatures are always relative to an input. + * @param scriptPath whether we are signing for the keypath or the scriptpath + * @param script if signing for the scriptpath, the script to sign + * @param sigHash should usually be SigHash.ALL + * @param annex annex data + */ + public synchronized Sha256Hash hashForTaprootSignature(List spentUtxos, int inputIndex, boolean scriptPath, Script script, SigHash sigHash, byte[] annex) { + return hashForTaprootSignature(spentUtxos, inputIndex, scriptPath, script, sigHash.value, annex); + } + + public synchronized Sha256Hash hashForTaprootSignature(List spentUtxos, int inputIndex, boolean scriptPath, Script script, byte sigHashType, byte[] annex) { + if(spentUtxos.size() != getInputs().size()) { + throw new IllegalArgumentException("Provided spent UTXOs length does not equal the number of transaction inputs"); + } + if(inputIndex >= getInputs().size()) { + throw new IllegalArgumentException("Input index is greater than the number of transaction inputs"); + } + + ByteArrayOutputStream bos = new UnsafeByteArrayOutputStream(length == UNKNOWN_LENGTH ? 256 : length + 4); + try { + byte outType = sigHashType == 0x00 ? SigHash.ALL.value : (byte)(sigHashType & 0x03); + boolean anyoneCanPay = (sigHashType & SigHash.ANYONECANPAY.value) == SigHash.ANYONECANPAY.value; + + bos.write(0x00); + bos.write(sigHashType); + uint32ToByteStreamLE(this.version, bos); + uint32ToByteStreamLE(this.locktime, bos); + + if(!anyoneCanPay) { + ByteArrayOutputStream outpoints = new ByteArrayOutputStream(); + ByteArrayOutputStream outputValues = new ByteArrayOutputStream(); + ByteArrayOutputStream outputScriptPubKeys = new ByteArrayOutputStream(); + ByteArrayOutputStream inputSequences = new ByteArrayOutputStream(); + for(int i = 0; i < getInputs().size(); i++) { + TransactionInput input = getInputs().get(i); + input.getOutpoint().bitcoinSerializeToStream(outpoints); + Utils.uint64ToByteStreamLE(BigInteger.valueOf(spentUtxos.get(i).getValue()), outputValues); + byteArraySerialize(spentUtxos.get(i).getScriptBytes(), outputScriptPubKeys); + Utils.uint32ToByteStreamLE(input.getSequenceNumber(), inputSequences); + } + bos.write(Sha256Hash.hash(outpoints.toByteArray())); + bos.write(Sha256Hash.hash(outputValues.toByteArray())); + bos.write(Sha256Hash.hash(outputScriptPubKeys.toByteArray())); + bos.write(Sha256Hash.hash(inputSequences.toByteArray())); + } + + if(outType == SigHash.ALL.value) { + ByteArrayOutputStream outputs = new ByteArrayOutputStream(); + for(TransactionOutput output : getOutputs()) { + output.bitcoinSerializeToStream(outputs); + } + bos.write(Sha256Hash.hash(outputs.toByteArray())); + } + + byte spendType = 0x00; + if(annex != null) { + spendType |= 0x01; + } + if(scriptPath) { + spendType |= 0x02; + } + bos.write(spendType); + + if(anyoneCanPay) { + getInputs().get(inputIndex).getOutpoint().bitcoinSerializeToStream(bos); + Utils.uint32ToByteStreamLE(spentUtxos.get(inputIndex).getValue(), bos); + byteArraySerialize(spentUtxos.get(inputIndex).getScriptBytes(), bos); + Utils.uint32ToByteStreamLE(getInputs().get(inputIndex).getSequenceNumber(), bos); + } else { + Utils.uint32ToByteStreamLE(inputIndex, bos); + } + + if((spendType & 0x01) != 0) { + ByteArrayOutputStream annexStream = new ByteArrayOutputStream(); + byteArraySerialize(annex, annexStream); + bos.write(Sha256Hash.hash(annexStream.toByteArray())); + } + + if(outType == SigHash.SINGLE.value) { + if(inputIndex < getOutputs().size()) { + bos.write(Sha256Hash.hash(getOutputs().get(inputIndex).bitcoinSerialize())); + } else { + bos.write(Sha256Hash.ZERO_HASH.getBytes()); + } + } + + if(scriptPath) { + ByteArrayOutputStream leafStream = new ByteArrayOutputStream(); + leafStream.write(LEAF_VERSION_TAPSCRIPT); + byteArraySerialize(script.getProgram(), leafStream); + bos.write(Utils.taggedHash("TapLeaf", leafStream.toByteArray())); + bos.write(0x00); + Utils.uint32ToByteStreamLE(-1, bos); + } + + byte[] msgBytes = bos.toByteArray(); + long requiredLength = 175 - (anyoneCanPay ? 49 : 0) - (outType != SigHash.ALL.value && outType != SigHash.SINGLE.value ? 32 : 0) + (annex != null ? 32 : 0) + (scriptPath ? 37 : 0); + if(msgBytes.length != requiredLength) { + throw new IllegalStateException("Invalid message length, was " + msgBytes.length + " not " + requiredLength); + } + + return Sha256Hash.wrap(Utils.taggedHash("TapSighash", msgBytes)); + } catch (IOException e) { + throw new RuntimeException(e); // Cannot happen. + } + } + + private void byteArraySerialize(byte[] bytes, OutputStream outputStream) throws IOException { + outputStream.write(new VarInt(bytes.length).encode()); + outputStream.write(bytes); + } } 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/protocol/TransactionSignature.java b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java index 86714bd..e9b173e 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionSignature.java @@ -1,13 +1,19 @@ package com.sparrowwallet.drongo.protocol; +import com.sparrowwallet.drongo.crypto.ECDSASignature; import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.SchnorrSignature; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigInteger; +import java.nio.ByteBuffer; import java.util.Objects; -public class TransactionSignature extends ECKey.ECDSASignature { +public class TransactionSignature { + private final ECDSASignature ecdsaSignature; + private final SchnorrSignature schnorrSignature; + /** * A byte that controls which parts of a transaction are signed. This is exposed because signatures * parsed off the wire may have sighash flags that aren't "normal" serializations of the enum values. @@ -16,21 +22,26 @@ public class TransactionSignature extends ECKey.ECDSASignature { */ public final byte sighashFlags; - /** Constructs a signature with the given components and SIGHASH_ALL. */ - public TransactionSignature(BigInteger r, BigInteger s) { - this(r, s, SigHash.ALL.value); - } - - /** Constructs a signature with the given components and raw sighash flag bytes (needed for rule compatibility). */ - public TransactionSignature(BigInteger r, BigInteger s, byte sighashFlags) { - super(r, s); - this.sighashFlags = sighashFlags; + /** Constructs a signature with the given components of the given type and SIGHASH_ALL. */ + public TransactionSignature(BigInteger r, BigInteger s, Type type) { + this(r, s, type, type == Type.ECDSA ? SigHash.ALL.value : SigHash.DEFAULT.value); } /** Constructs a transaction signature based on the ECDSA signature. */ - public TransactionSignature(ECKey.ECDSASignature signature, SigHash sigHash) { - super(signature.r, signature.s); - sighashFlags = sigHash.value; + public TransactionSignature(ECDSASignature signature, SigHash sigHash) { + this(signature.r, signature.s, Type.ECDSA, sigHash.value); + } + + /** Constructs a transaction signature based on the Schnorr signature. */ + public TransactionSignature(SchnorrSignature signature, SigHash sigHash) { + this(signature.r, signature.s, Type.SCHNORR, sigHash.value); + } + + /** Constructs a signature with the given components, type and raw sighash flag bytes (needed for rule compatibility). */ + public TransactionSignature(BigInteger r, BigInteger s, Type type, byte sighashFlags) { + ecdsaSignature = type == Type.ECDSA ? new ECDSASignature(r, s) : null; + schnorrSignature = type == Type.SCHNORR ? new SchnorrSignature(r, s) : null; + this.sighashFlags = sighashFlags; } /** @@ -39,68 +50,20 @@ public class TransactionSignature extends ECKey.ECDSASignature { * right size (e.g. for fee calculations) but don't have the requisite signing key yet and will fill out the * real signature later. */ - public static TransactionSignature dummy() { + public static TransactionSignature dummy(Type type) { BigInteger val = ECKey.HALF_CURVE_ORDER; - return new TransactionSignature(val, val); - } - - /** - * Returns true if the given signature is has canonical encoding, and will thus be accepted as standard by - * Bitcoin Core. DER and the SIGHASH encoding allow for quite some flexibility in how the same structures - * are encoded, and this can open up novel attacks in which a man in the middle takes a transaction and then - * changes its signature such that the transaction hash is different but it's still valid. This can confuse wallets - * and generally violates people's mental model of how Bitcoin should work, thus, non-canonical signatures are now - * not relayed by default. - */ - public static boolean isEncodingCanonical(byte[] signature) { - // See Bitcoin Core's IsCanonicalSignature, https://bitcointalk.org/index.php?topic=8392.msg127623#msg127623 - // A canonical signature exists of: <30> <02> <02> - // Where R and S are not negative (their first byte has its highest bit not set), and not - // excessively padded (do not start with a 0 byte, unless an otherwise negative number follows, - // in which case a single 0 byte is necessary and even required). - - // Empty signatures, while not strictly DER encoded, are allowed. - if (signature.length == 0) - return true; - - if (signature.length < 9 || signature.length > 73) - return false; - - int hashType = (signature[signature.length-1] & 0xff) & ~SigHash.ANYONECANPAY.value; // mask the byte to prevent sign-extension hurting us - if (hashType < SigHash.ALL.value || hashType > SigHash.SINGLE.value) - return false; - - // "wrong type" "wrong length marker" - if ((signature[0] & 0xff) != 0x30 || (signature[1] & 0xff) != signature.length-3) - return false; - - int lenR = signature[3] & 0xff; - if (5 + lenR >= signature.length || lenR == 0) - return false; - int lenS = signature[5+lenR] & 0xff; - if (lenR + lenS + 7 != signature.length || lenS == 0) - return false; - - // R value type mismatch R value negative - if (signature[4-2] != 0x02 || (signature[4] & 0x80) == 0x80) - return false; - if (lenR > 1 && signature[4] == 0x00 && (signature[4+1] & 0x80) != 0x80) - return false; // R value excessively padded - - // S value type mismatch S value negative - if (signature[6 + lenR - 2] != 0x02 || (signature[6 + lenR] & 0x80) == 0x80) - return false; - if (lenS > 1 && signature[6 + lenR] == 0x00 && (signature[6 + lenR + 1] & 0x80) != 0x80) - return false; // S value excessively padded - - return true; + return new TransactionSignature(val, val, type); } public boolean anyoneCanPay() { return (sighashFlags & SigHash.ANYONECANPAY.value) != 0; } - public SigHash getSigHash() { + private SigHash getSigHash() { + if(sighashFlags == SigHash.DEFAULT.byteValue()) { + return SigHash.DEFAULT; + } + boolean anyoneCanPay = anyoneCanPay(); final int mode = sighashFlags & 0x1f; if (mode == SigHash.NONE.value) { @@ -118,18 +81,51 @@ public class TransactionSignature extends ECKey.ECDSASignature { * components into a structure, and then we append a byte to the end for the sighash flags. */ public byte[] encodeToBitcoin() { - try { - ByteArrayOutputStream bos = derByteStream(); - bos.write(sighashFlags); - return bos.toByteArray(); - } catch (IOException e) { - throw new RuntimeException(e); // Cannot happen. + if(ecdsaSignature != null) { + try { + ByteArrayOutputStream bos = ecdsaSignature.derByteStream(); + bos.write(sighashFlags); + return bos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException(e); // Cannot happen. + } + } else if(schnorrSignature != null) { + SigHash sigHash = getSigHash(); + ByteBuffer buffer = ByteBuffer.allocate(sigHash == SigHash.DEFAULT ? 64 : 65); + buffer.put(schnorrSignature.encode()); + if(sigHash != SigHash.DEFAULT) { + buffer.put(sighashFlags); + } + return buffer.array(); } + + throw new IllegalStateException("TransactionSignature has no values"); } - @Override - public ECKey.ECDSASignature toCanonicalised() { - return new TransactionSignature(super.toCanonicalised(), getSigHash()); + public static TransactionSignature decodeFromBitcoin(byte[] bytes, boolean requireCanonicalEncoding) throws SignatureDecodeException { + if(bytes.length == 64) { + return decodeFromBitcoin(Type.SCHNORR, bytes, requireCanonicalEncoding); + } + + return decodeFromBitcoin(Type.ECDSA, bytes, requireCanonicalEncoding); + } + + public static TransactionSignature decodeFromBitcoin(Type type, byte[] bytes, boolean requireCanonicalEncoding) throws SignatureDecodeException { + if(type == Type.ECDSA) { + return ECDSASignature.decodeFromBitcoin(bytes, requireCanonicalEncoding, false); + } else if(type == Type.SCHNORR) { + return SchnorrSignature.decodeFromBitcoin(bytes); + } + + throw new IllegalStateException("Unknown TransactionSignature type " + type); + } + + public boolean verify(byte[] data, ECKey pubKey) { + if(ecdsaSignature != null) { + return ecdsaSignature.verify(data, pubKey.getPubKey()); + } else { + return schnorrSignature.verify(data, pubKey.getPubKeyXCoord()); + } } @Override @@ -140,39 +136,16 @@ public class TransactionSignature extends ECKey.ECDSASignature { if(o == null || getClass() != o.getClass()) { return false; } - if(!super.equals(o)) { - return false; - } - TransactionSignature signature = (TransactionSignature) o; - return sighashFlags == signature.sighashFlags; + TransactionSignature that = (TransactionSignature) o; + return sighashFlags == that.sighashFlags && Objects.equals(ecdsaSignature, that.ecdsaSignature) && Objects.equals(schnorrSignature, that.schnorrSignature); } @Override public int hashCode() { - return Objects.hash(super.hashCode(), sighashFlags); + return Objects.hash(ecdsaSignature, schnorrSignature, sighashFlags); } - /** - * Returns a decoded signature. - * - * @param requireCanonicalEncoding if the encoding of the signature must - * be canonical. - * @param requireCanonicalSValue if the S-value must be canonical (below half - * the order of the curve). - * @throws SignatureDecodeException if the signature is unparseable in some way. - * @throws VerificationException if the signature is invalid. - */ - public static TransactionSignature decodeFromBitcoin(byte[] bytes, boolean requireCanonicalEncoding, - boolean requireCanonicalSValue) throws SignatureDecodeException, VerificationException { - // Bitcoin encoding is DER signature + sighash byte. - if (requireCanonicalEncoding && !isEncodingCanonical(bytes)) - throw new VerificationException.NoncanonicalSignature(); - ECKey.ECDSASignature sig = ECKey.ECDSASignature.decodeFromDER(bytes); - if (requireCanonicalSValue && !sig.isCanonical()) - throw new VerificationException("S-value is not canonical."); - - // In Bitcoin, any value of the final byte is valid, but not necessarily canonical. See javadocs for - // isEncodingCanonical to learn more about this. So we must store the exact byte found. - return new TransactionSignature(sig.r, sig.s, bytes[bytes.length - 1]); + public enum Type { + ECDSA, SCHNORR } } \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionWitness.java b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionWitness.java index 34be7d6..f5cad4d 100644 --- a/src/main/java/com/sparrowwallet/drongo/protocol/TransactionWitness.java +++ b/src/main/java/com/sparrowwallet/drongo/protocol/TransactionWitness.java @@ -14,6 +14,12 @@ import java.util.List; public class TransactionWitness extends ChildMessage { private List pushes; + public TransactionWitness(Transaction transaction, TransactionSignature signature) { + setParent(transaction); + this.pushes = new ArrayList<>(); + pushes.add(signature.encodeToBitcoin()); + } + public TransactionWitness(Transaction transaction, ECKey pubKey, TransactionSignature signature) { setParent(transaction); this.pushes = new ArrayList<>(); diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index 32d6c47..cf79731 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -16,6 +16,9 @@ import java.nio.ByteBuffer; import java.util.*; import static com.sparrowwallet.drongo.psbt.PSBTEntry.*; +import static com.sparrowwallet.drongo.psbt.PSBTInput.*; +import static com.sparrowwallet.drongo.psbt.PSBTOutput.*; +import static com.sparrowwallet.drongo.wallet.Wallet.addDummySpendingInput; public class PSBT { public static final byte PSBT_GLOBAL_UNSIGNED_TX = 0x00; @@ -50,7 +53,7 @@ public class PSBT { this.transaction = transaction; for(int i = 0; i < transaction.getInputs().size(); i++) { - psbtInputs.add(new PSBTInput(transaction, i)); + psbtInputs.add(new PSBTInput(this, transaction, i)); } for(int i = 0; i < transaction.getOutputs().size(); i++) { @@ -87,12 +90,16 @@ public class PSBT { this.version = version; } - boolean alwaysIncludeWitnessUtxo = wallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().alwaysIncludeNonWitnessUtxo()); - int inputIndex = 0; for(Iterator> iter = walletTransaction.getSelectedUtxos().entrySet().iterator(); iter.hasNext(); inputIndex++) { Map.Entry utxoEntry = iter.next(); - Transaction utxo = wallet.getTransactions().get(utxoEntry.getKey().getHash()).getTransaction(); + + WalletNode walletNode = utxoEntry.getValue(); + Wallet signingWallet = walletNode.getWallet(); + + boolean alwaysIncludeWitnessUtxo = signingWallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().alwaysIncludeNonWitnessUtxo()); + + Transaction utxo = signingWallet.getTransactions().get(utxoEntry.getKey().getHash()).getTransaction(); int utxoIndex = (int)utxoEntry.getKey().getIndex(); TransactionOutput utxoOutput = utxo.getOutputs().get(utxoIndex); @@ -109,12 +116,17 @@ public class PSBT { } Map derivedPublicKeys = new LinkedHashMap<>(); - for(Keystore keystore : wallet.getKeystores()) { - WalletNode walletNode = utxoEntry.getValue(); - derivedPublicKeys.put(keystore.getPubKey(walletNode), keystore.getKeyDerivation().extend(walletNode.getDerivation())); + ECKey tapInternalKey = null; + for(Keystore keystore : signingWallet.getKeystores()) { + derivedPublicKeys.put(signingWallet.getScriptType().getOutputKey(keystore.getPubKey(walletNode)), keystore.getKeyDerivation().extend(walletNode.getDerivation())); + + //TODO: Implement Musig for multisig wallets + if(signingWallet.getScriptType() == ScriptType.P2TR) { + tapInternalKey = keystore.getPubKey(walletNode); + } } - PSBTInput psbtInput = new PSBTInput(wallet.getScriptType(), transaction, inputIndex, utxo, utxoIndex, redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), alwaysIncludeWitnessUtxo); + PSBTInput psbtInput = new PSBTInput(this, signingWallet.getScriptType(), transaction, inputIndex, utxo, utxoIndex, redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey, alwaysIncludeWitnessUtxo); psbtInputs.add(psbtInput); } @@ -122,28 +134,29 @@ public class PSBT { for(TransactionOutput txOutput : transaction.getOutputs()) { try { Address address = txOutput.getScript().getToAddresses()[0]; - if(walletTransaction.getPayments().stream().anyMatch(payment -> payment.getAddress().equals(address))) { - outputNodes.add(wallet.getWalletAddresses().getOrDefault(address, null)); - } else if(address.equals(wallet.getAddress(walletTransaction.getChangeNode()))) { - outputNodes.add(walletTransaction.getChangeNode()); + if(walletTransaction.getAddressNodeMap().containsKey(address)) { + outputNodes.add(walletTransaction.getAddressNodeMap().get(address)); + } else if(walletTransaction.getChangeMap().keySet().stream().anyMatch(changeNode -> changeNode.getAddress().equals(address))) { + outputNodes.add(walletTransaction.getChangeMap().keySet().stream().filter(changeNode -> changeNode.getAddress().equals(address)).findFirst().orElse(null)); } } catch(NonStandardScriptException e) { - //Should never happen - throw new IllegalArgumentException(e); + //Ignore, likely OP_RETURN output + outputNodes.add(null); } } for(int outputIndex = 0; outputIndex < outputNodes.size(); outputIndex++) { WalletNode outputNode = outputNodes.get(outputIndex); if(outputNode == null) { - PSBTOutput externalRecipientOutput = new PSBTOutput(null, null, Collections.emptyMap(), Collections.emptyMap()); + PSBTOutput externalRecipientOutput = new PSBTOutput(null, null, null, Collections.emptyMap(), Collections.emptyMap(), null); psbtOutputs.add(externalRecipientOutput); } else { TransactionOutput txOutput = transaction.getOutputs().get(outputIndex); + Wallet recipientWallet = outputNode.getWallet(); //Construct dummy transaction to spend the UTXO created by this wallet's txOutput Transaction transaction = new Transaction(); - TransactionInput spendingInput = wallet.addDummySpendingInput(transaction, outputNode, txOutput); + TransactionInput spendingInput = addDummySpendingInput(transaction, outputNode, txOutput); Script redeemScript = null; if(ScriptType.P2SH.isScriptType(txOutput.getScript())) { @@ -156,22 +169,32 @@ public class PSBT { } Map derivedPublicKeys = new LinkedHashMap<>(); - for(Keystore keystore : wallet.getKeystores()) { - derivedPublicKeys.put(keystore.getPubKey(outputNode), keystore.getKeyDerivation().extend(outputNode.getDerivation())); + ECKey tapInternalKey = null; + for(Keystore keystore : recipientWallet.getKeystores()) { + derivedPublicKeys.put(recipientWallet.getScriptType().getOutputKey(keystore.getPubKey(outputNode)), keystore.getKeyDerivation().extend(outputNode.getDerivation())); + + //TODO: Implement Musig for multisig wallets + if(recipientWallet.getScriptType() == ScriptType.P2TR) { + tapInternalKey = keystore.getPubKey(outputNode); + } } - PSBTOutput walletOutput = new PSBTOutput(redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap()); + PSBTOutput walletOutput = new PSBTOutput(recipientWallet.getScriptType(), redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey); psbtOutputs.add(walletOutput); } } } public PSBT(byte[] psbt) throws PSBTParseException { - this.psbtBytes = psbt; - parse(); + this(psbt, true); } - private void parse() throws PSBTParseException { + public PSBT(byte[] psbt, boolean verifySignatures) throws PSBTParseException { + this.psbtBytes = psbt; + parse(verifySignatures); + } + + private void parse(boolean verifySignatures) throws PSBTParseException { int seenInputs = 0; int seenOutputs = 0; @@ -212,7 +235,7 @@ public class PSBT { seenInputs++; if (seenInputs == inputs) { currentState = STATE_OUTPUTS; - parseInputEntries(inputEntryLists); + parseInputEntries(inputEntryLists, verifySignatures); } break; case STATE_OUTPUTS: @@ -245,14 +268,6 @@ public class PSBT { if(transaction == null) { throw new PSBTParseException("Missing transaction"); } - - if(currentState == STATE_INPUTS) { - throw new PSBTParseException("Missing inputs"); - } - - if(currentState == STATE_OUTPUTS) { - throw new PSBTParseException("Missing outputs"); - } } if(log.isDebugEnabled()) { @@ -313,7 +328,7 @@ public class PSBT { } } - private void parseInputEntries(List> inputEntryLists) throws PSBTParseException { + private void parseInputEntries(List> inputEntryLists, boolean verifySignatures) throws PSBTParseException { for(List inputEntries : inputEntryLists) { PSBTEntry duplicate = findDuplicateKey(inputEntries); if(duplicate != null) { @@ -321,15 +336,13 @@ public class PSBT { } int inputIndex = this.psbtInputs.size(); - PSBTInput input = new PSBTInput(inputEntries, transaction, inputIndex); - - boolean verified = input.verifySignatures(); - if(!verified && input.getPartialSignatures().size() > 0) { - throw new PSBTParseException("Unverifiable partial signatures provided"); - } - + PSBTInput input = new PSBTInput(this, inputEntries, transaction, inputIndex); this.psbtInputs.add(input); } + + if(verifySignatures) { + verifySignatures(psbtInputs); + } } private void parseOutputEntries(List> outputEntryLists) throws PSBTParseException { @@ -364,7 +377,7 @@ public class PSBT { if(utxo != null) { fee += utxo.getValue(); } else { - log.error("Cannot determine fee - not enough information provided on inputs"); + log.warn("Cannot determine fee - inputs are missing UTXO data"); return null; } } @@ -377,9 +390,25 @@ public class PSBT { return fee; } + public void verifySignatures() throws PSBTSignatureException { + verifySignatures(getPsbtInputs()); + } + + private void verifySignatures(List psbtInputs) throws PSBTSignatureException { + for(PSBTInput input : psbtInputs) { + boolean verified = input.verifySignatures(); + if(!verified && input.getPartialSignatures().size() > 0) { + throw new PSBTSignatureException("Unverifiable partial signatures provided"); + } + if(!verified && input.isTaproot() && input.getTapKeyPathSignature() != null) { + throw new PSBTSignatureException("Unverifiable taproot keypath signature provided"); + } + } + } + public boolean hasSignatures() { for(PSBTInput psbtInput : getPsbtInputs()) { - if(!psbtInput.getPartialSignatures().isEmpty() || psbtInput.getFinalScriptSig() != null || psbtInput.getFinalScriptWitness() != null) { + if(!psbtInput.getPartialSignatures().isEmpty() || psbtInput.getTapKeyPathSignature() != null || psbtInput.getFinalScriptSig() != null || psbtInput.getFinalScriptWitness() != null) { return true; } } @@ -432,6 +461,10 @@ public class PSBT { } public byte[] serialize() { + return serialize(true); + } + + public byte[] serialize(boolean includeXpubs) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); baos.writeBytes(Utils.hexToBytes(PSBT_MAGIC_HEX)); @@ -439,14 +472,19 @@ public class PSBT { List globalEntries = getGlobalEntries(); for(PSBTEntry entry : globalEntries) { - entry.serializeToStream(baos); + if(includeXpubs || (entry.getKeyType() != PSBT_GLOBAL_BIP32_PUBKEY && entry.getKeyType() != PSBT_GLOBAL_PROPRIETARY)) { + entry.serializeToStream(baos); + } } baos.writeBytes(new byte[] {(byte)0x00}); for(PSBTInput psbtInput : getPsbtInputs()) { List inputEntries = psbtInput.getInputEntries(); for(PSBTEntry entry : inputEntries) { - entry.serializeToStream(baos); + if(includeXpubs || (entry.getKeyType() != PSBT_IN_BIP32_DERIVATION && entry.getKeyType() != PSBT_IN_PROPRIETARY + && entry.getKeyType() != PSBT_IN_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_IN_TAP_BIP32_DERIVATION)) { + entry.serializeToStream(baos); + } } baos.writeBytes(new byte[] {(byte)0x00}); } @@ -454,7 +492,11 @@ public class PSBT { for(PSBTOutput psbtOutput : getPsbtOutputs()) { List outputEntries = psbtOutput.getOutputEntries(); for(PSBTEntry entry : outputEntries) { - entry.serializeToStream(baos); + if(includeXpubs || (entry.getKeyType() != PSBT_OUT_REDEEM_SCRIPT && entry.getKeyType() != PSBT_OUT_WITNESS_SCRIPT + && entry.getKeyType() != PSBT_OUT_BIP32_DERIVATION && entry.getKeyType() != PSBT_OUT_PROPRIETARY + && entry.getKeyType() != PSBT_OUT_TAP_INTERNAL_KEY && entry.getKeyType() != PSBT_OUT_TAP_BIP32_DERIVATION)) { + entry.serializeToStream(baos); + } } baos.writeBytes(new byte[] {(byte)0x00}); } @@ -511,7 +553,7 @@ public class PSBT { Transaction finalTransaction = new Transaction(transaction.bitcoinSerialize()); if(hasWitness && !finalTransaction.isSegwit()) { - finalTransaction.setSegwitVersion(1); + finalTransaction.setSegwitFlag(Transaction.DEFAULT_SEGWIT_FLAG); } for(int i = 0; i < finalTransaction.getInputs().size(); i++) { @@ -584,7 +626,11 @@ public class PSBT { } public String toBase64String() { - return Base64.toBase64String(serialize()); + return toBase64String(true); + } + + public String toBase64String(boolean includeXpubs) { + return Base64.toBase64String(serialize(includeXpubs)); } public static boolean isPSBT(byte[] b) { @@ -600,14 +646,24 @@ public class PSBT { } public static boolean isPSBT(String s) { - if (Utils.isHex(s) && s.startsWith(PSBT_MAGIC_HEX)) { - return true; - } else { - return Utils.isBase64(s) && Utils.bytesToHex(Base64.decode(s)).startsWith(PSBT_MAGIC_HEX); + try { + if(Utils.isHex(s) && s.startsWith(PSBT_MAGIC_HEX)) { + return true; + } else { + return Utils.isBase64(s) && Utils.bytesToHex(Base64.decode(s)).startsWith(PSBT_MAGIC_HEX); + } + } catch(Exception e) { + //ignore } + + return false; } public static PSBT fromString(String strPSBT) throws PSBTParseException { + return fromString(strPSBT, true); + } + + public static PSBT fromString(String strPSBT, boolean verifySignatures) throws PSBTParseException { if (!isPSBT(strPSBT)) { throw new PSBTParseException("Provided string is not a PSBT"); } @@ -617,6 +673,6 @@ public class PSBT { } byte[] psbtBytes = Utils.hexToBytes(strPSBT); - return new PSBT(psbtBytes); + return new PSBT(psbtBytes, verifySignatures); } } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java index 2f80dbe..fa4326d 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java @@ -3,6 +3,8 @@ package com.sparrowwallet.drongo.psbt; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.VarInt; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; @@ -10,6 +12,7 @@ import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; public class PSBTEntry { private final byte[] key; @@ -55,6 +58,25 @@ public class PSBTEntry { } } + public static Map> parseTaprootKeyDerivation(byte[] data) throws PSBTParseException { + if(data.length < 1) { + throw new PSBTParseException("Invalid taproot key derivation: no bytes"); + } + VarInt varInt = new VarInt(data, 0); + int offset = varInt.getOriginalSizeInBytes(); + + if(data.length < offset + (varInt.value * 32)) { + throw new PSBTParseException("Invalid taproot key derivation: not enough bytes for leaf hashes"); + } + List leafHashes = new ArrayList<>(); + for(int i = 0; i < varInt.value; i++) { + leafHashes.add(Sha256Hash.wrap(Arrays.copyOfRange(data, offset + (i * 32), offset + (i * 32) + 32))); + } + + KeyDerivation keyDerivation = parseKeyDerivation(Arrays.copyOfRange(data, offset + (leafHashes.size() * 32), data.length)); + return Map.of(keyDerivation, leafHashes); + } + public static KeyDerivation parseKeyDerivation(byte[] data) throws PSBTParseException { if(data.length < 4) { throw new PSBTParseException("Invalid master fingerprint specified: not enough bytes"); @@ -64,7 +86,7 @@ public class PSBTEntry { throw new PSBTParseException("Invalid master fingerprint specified: " + masterFingerprint); } if(data.length < 8) { - throw new PSBTParseException("Invalid key derivation specified: not enough bytes"); + return new KeyDerivation(masterFingerprint, "m"); } List bip32pathList = readBIP32Derivation(Arrays.copyOfRange(data, 4, data.length)); String bip32path = KeyDerivation.writePath(bip32pathList); @@ -83,7 +105,7 @@ public class PSBTEntry { do { bb.get(buf); - reverse(buf); + Utils.reverse(buf); ByteBuffer pbuf = ByteBuffer.wrap(buf); path.add(new ChildNumber(pbuf.getInt())); } while(bb.hasRemaining()); @@ -91,6 +113,19 @@ public class PSBTEntry { return path; } + public static byte[] serializeTaprootKeyDerivation(List leafHashes, KeyDerivation keyDerivation) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + VarInt hashesLen = new VarInt(leafHashes.size()); + baos.writeBytes(hashesLen.encode()); + for(Sha256Hash leafHash : leafHashes) { + baos.writeBytes(leafHash.getBytes()); + } + + baos.writeBytes(serializeKeyDerivation(keyDerivation)); + return baos.toByteArray(); + } + public static byte[] serializeKeyDerivation(KeyDerivation keyDerivation) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] fingerprintBytes = Utils.hexToBytes(keyDerivation.getMasterFingerprint()); @@ -202,14 +237,6 @@ public class PSBTEntry { return bb.array(); } - private static void reverse(byte[] array) { - for (int i = 0; i < array.length / 2; i++) { - byte temp = array[i]; - array[i] = array[array.length - i - 1]; - array[array.length - i - 1] = temp; - } - } - public void checkOneByteKey() throws PSBTParseException { if(this.getKey().length != 1) { throw new PSBTParseException("PSBT key type must be one byte"); @@ -218,13 +245,19 @@ public class PSBTEntry { public void checkOneBytePlusXpubKey() throws PSBTParseException { if(this.getKey().length != 79) { - throw new PSBTParseException("PSBT key type must be one byte"); + throw new PSBTParseException("PSBT key type must be one byte plus xpub"); } } public void checkOneBytePlusPubKey() throws PSBTParseException { if(this.getKey().length != 34) { - throw new PSBTParseException("PSBT key type must be one byte"); + throw new PSBTParseException("PSBT key type must be one byte plus pub key"); + } + } + + public void checkOneBytePlusXOnlyPubKey() throws PSBTParseException { + if(this.getKey().length != 33) { + throw new PSBTParseException("PSBT key type must be one byte plus x only pub key"); } } } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java index 907efc3..8bbae7e 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTInput.java @@ -9,8 +9,10 @@ import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.stream.Collectors; import static com.sparrowwallet.drongo.protocol.ScriptType.*; +import static com.sparrowwallet.drongo.protocol.TransactionSignature.Type.*; import static com.sparrowwallet.drongo.psbt.PSBTEntry.*; public class PSBTInput { @@ -25,7 +27,11 @@ public class PSBTInput { public static final byte PSBT_IN_FINAL_SCRIPTWITNESS = 0x08; public static final byte PSBT_IN_POR_COMMITMENT = 0x09; public static final byte PSBT_IN_PROPRIETARY = (byte)0xfc; + public static final byte PSBT_IN_TAP_KEY_SIG = 0x13; + public static final byte PSBT_IN_TAP_BIP32_DERIVATION = 0x16; + public static final byte PSBT_IN_TAP_INTERNAL_KEY = 0x17; + private final PSBT psbt; private Transaction nonWitnessUtxo; private TransactionOutput witnessUtxo; private final Map partialSignatures = new LinkedHashMap<>(); @@ -37,20 +43,23 @@ public class PSBTInput { private TransactionWitness finalScriptWitness; private String porCommitment; private final Map proprietary = new LinkedHashMap<>(); + private TransactionSignature tapKeyPathSignature; + private Map>> tapDerivedPublicKeys = new LinkedHashMap<>(); + private ECKey tapInternalKey; private final Transaction transaction; private final int index; private static final Logger log = LoggerFactory.getLogger(PSBTInput.class); - PSBTInput(Transaction transaction, int index) { + PSBTInput(PSBT psbt, Transaction transaction, int index) { + this.psbt = psbt; this.transaction = transaction; this.index = index; } - PSBTInput(ScriptType scriptType, Transaction transaction, int index, Transaction utxo, int utxoIndex, Script redeemScript, Script witnessScript, Map derivedPublicKeys, Map proprietary, boolean alwaysAddNonWitnessTx) { - this(transaction, index); - sigHash = SigHash.ALL; + PSBTInput(PSBT psbt, ScriptType scriptType, Transaction transaction, int index, Transaction utxo, int utxoIndex, Script redeemScript, Script witnessScript, Map derivedPublicKeys, Map proprietary, ECKey tapInternalKey, boolean alwaysAddNonWitnessTx) { + this(psbt, transaction, index); if(Arrays.asList(ScriptType.WITNESS_TYPES).contains(scriptType)) { this.witnessUtxo = utxo.getOutputs().get(utxoIndex); @@ -66,11 +75,24 @@ public class PSBTInput { this.redeemScript = redeemScript; this.witnessScript = witnessScript; - this.derivedPublicKeys.putAll(derivedPublicKeys); + if(scriptType != P2TR) { + this.derivedPublicKeys.putAll(derivedPublicKeys); + } + this.proprietary.putAll(proprietary); + + this.tapInternalKey = tapInternalKey == null ? null : ECKey.fromPublicOnly(tapInternalKey.getPubKeyXCoord()); + + if(tapInternalKey != null && !derivedPublicKeys.values().isEmpty()) { + KeyDerivation tapKeyDerivation = derivedPublicKeys.values().iterator().next(); + tapDerivedPublicKeys.put(this.tapInternalKey, Map.of(tapKeyDerivation, Collections.emptyList())); + } + + this.sigHash = getDefaultSigHash(); } - PSBTInput(List inputEntries, Transaction transaction, int index) throws PSBTParseException { + PSBTInput(PSBT psbt, List inputEntries, Transaction transaction, int index) throws PSBTParseException { + this.psbt = psbt; for(PSBTEntry entry : inputEntries) { switch(entry.getKeyType()) { case PSBT_IN_NON_WITNESS_UTXO: @@ -89,17 +111,13 @@ public class PSBTInput { log.debug(" Transaction input references txid: " + input.getOutpoint().getHash() + " vout " + input.getOutpoint().getIndex() + " with script " + input.getScriptSig()); } for(TransactionOutput output: nonWitnessTx.getOutputs()) { - try { - log.debug(" Transaction output value: " + output.getValue() + " to addresses " + Arrays.asList(output.getScript().getToAddresses()) + " with script hex " + Utils.bytesToHex(output.getScript().getProgram()) + " to script " + output.getScript()); - } catch(NonStandardScriptException e) { - log.error("Unknown script type", e); - } + log.debug(" Transaction output value: " + output.getValue() + (output.getScript().getToAddress() != null ? " to address " + output.getScript().getToAddress() : "") + " with script hex " + Utils.bytesToHex(output.getScript().getProgram()) + " to script " + output.getScript()); } break; case PSBT_IN_WITNESS_UTXO: entry.checkOneByteKey(); TransactionOutput witnessTxOutput = new TransactionOutput(null, entry.getData(), 0); - if(!P2SH.isScriptType(witnessTxOutput.getScript()) && !P2WPKH.isScriptType(witnessTxOutput.getScript()) && !P2WSH.isScriptType(witnessTxOutput.getScript())) { + if(!P2SH.isScriptType(witnessTxOutput.getScript()) && !P2WPKH.isScriptType(witnessTxOutput.getScript()) && !P2WSH.isScriptType(witnessTxOutput.getScript()) && !P2TR.isScriptType(witnessTxOutput.getScript())) { throw new PSBTParseException("Witness UTXO provided for non-witness or unknown input"); } this.witnessUtxo = witnessTxOutput; @@ -112,8 +130,12 @@ public class PSBTInput { case PSBT_IN_PARTIAL_SIG: entry.checkOneBytePlusPubKey(); ECKey sigPublicKey = ECKey.fromPublicOnly(entry.getKeyData()); + if(entry.getData().length == 64 || entry.getData().length == 65) { + log.error("Schnorr signature provided as ECDSA partial signature, ignoring"); + break; + } //TODO: Verify signature - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(entry.getData(), true, false); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(ECDSA, entry.getData(), true); this.partialSignatures.put(sigPublicKey, signature); log.debug("Found input partial signature with public key " + sigPublicKey + " signature " + Utils.bytesToHex(entry.getData())); break; @@ -136,11 +158,15 @@ public class PSBTInput { throw new PSBTParseException("Witness UTXO provided but redeem script is not P2WPKH or P2WSH"); } } - if(scriptPubKey == null || !P2SH.isScriptType(scriptPubKey)) { - throw new PSBTParseException("PSBT provided a redeem script for a transaction output that does not need one"); - } - if(!Arrays.equals(Utils.sha256hash160(redeemScript.getProgram()), scriptPubKey.getPubKeyHash())) { - throw new PSBTParseException("Redeem script hash does not match transaction output script pubkey hash " + Utils.bytesToHex(scriptPubKey.getPubKeyHash())); + if(scriptPubKey == null) { + log.warn("PSBT provided a redeem script for a transaction output that was not provided"); + } else { + if(!P2SH.isScriptType(scriptPubKey)) { + throw new PSBTParseException("PSBT provided a redeem script for a transaction output that does not need one"); + } + if(!Arrays.equals(Utils.sha256hash160(redeemScript.getProgram()), scriptPubKey.getPubKeyHash())) { + throw new PSBTParseException("Redeem script hash does not match transaction output script pubkey hash " + Utils.bytesToHex(scriptPubKey.getPubKeyHash())); + } } this.redeemScript = redeemScript; @@ -156,7 +182,7 @@ public class PSBTInput { pubKeyHash = this.witnessUtxo.getScript().getPubKeyHash(); } if(pubKeyHash == null) { - throw new PSBTParseException("Witness script provided without P2WSH witness utxo or P2SH redeem script"); + log.warn("Witness script provided without P2WSH witness utxo or P2SH redeem script"); } else if(!Arrays.equals(Sha256Hash.hash(witnessScript.getProgram()), pubKeyHash)) { throw new PSBTParseException("Witness script hash does not match provided pay to script hash " + Utils.bytesToHex(pubKeyHash)); } @@ -192,6 +218,29 @@ public class PSBTInput { this.proprietary.put(Utils.bytesToHex(entry.getKeyData()), Utils.bytesToHex(entry.getData())); log.debug("Found proprietary input " + Utils.bytesToHex(entry.getKeyData()) + ": " + Utils.bytesToHex(entry.getData())); break; + case PSBT_IN_TAP_KEY_SIG: + entry.checkOneByteKey(); + this.tapKeyPathSignature = TransactionSignature.decodeFromBitcoin(SCHNORR, entry.getData(), true); + log.debug("Found input taproot key path signature " + Utils.bytesToHex(entry.getData())); + break; + case PSBT_IN_TAP_BIP32_DERIVATION: + entry.checkOneBytePlusXOnlyPubKey(); + ECKey tapPublicKey = ECKey.fromPublicOnly(entry.getKeyData()); + Map> tapKeyDerivations = parseTaprootKeyDerivation(entry.getData()); + if(tapKeyDerivations.isEmpty()) { + log.warn("PSBT provided an invalid input taproot key derivation"); + } else { + this.tapDerivedPublicKeys.put(tapPublicKey, tapKeyDerivations); + for(KeyDerivation tapKeyDerivation : tapKeyDerivations.keySet()) { + log.debug("Found input taproot key derivation for key " + Utils.bytesToHex(entry.getKeyData()) + " with master fingerprint " + tapKeyDerivation.getMasterFingerprint() + " at path " + tapKeyDerivation.getDerivationPath()); + } + } + break; + case PSBT_IN_TAP_INTERNAL_KEY: + entry.checkOneByteKey(); + this.tapInternalKey = ECKey.fromPublicOnly(entry.getData()); + log.debug("Found input taproot internal key " + Utils.bytesToHex(entry.getData())); + break; default: log.warn("PSBT input not recognized key type: " + entry.getKeyType()); } @@ -205,7 +254,8 @@ public class PSBTInput { List entries = new ArrayList<>(); if(nonWitnessUtxo != null) { - entries.add(populateEntry(PSBT_IN_NON_WITNESS_UTXO, null, nonWitnessUtxo.bitcoinSerialize())); + //Serialize all nonWitnessUtxo fields without witness data (pre-Segwit serialization) to reduce PSBT size + entries.add(populateEntry(PSBT_IN_NON_WITNESS_UTXO, null, nonWitnessUtxo.bitcoinSerialize(false))); } if(witnessUtxo != null) { @@ -250,6 +300,20 @@ public class PSBTInput { entries.add(populateEntry(PSBT_IN_PROPRIETARY, Utils.hexToBytes(entry.getKey()), Utils.hexToBytes(entry.getValue()))); } + if(tapKeyPathSignature != null) { + entries.add(populateEntry(PSBT_IN_TAP_KEY_SIG, null, tapKeyPathSignature.encodeToBitcoin())); + } + + for(Map.Entry>> entry : tapDerivedPublicKeys.entrySet()) { + if(!entry.getValue().isEmpty()) { + entries.add(populateEntry(PSBT_IN_TAP_BIP32_DERIVATION, entry.getKey().getPubKeyXCoord(), serializeTaprootKeyDerivation(Collections.emptyList(), entry.getValue().keySet().iterator().next()))); + } + } + + if(tapInternalKey != null) { + entries.add(populateEntry(PSBT_IN_TAP_INTERNAL_KEY, null, tapInternalKey.getPubKeyXCoord())); + } + return entries; } @@ -283,6 +347,16 @@ public class PSBTInput { } proprietary.putAll(psbtInput.proprietary); + + if(psbtInput.tapKeyPathSignature != null) { + tapKeyPathSignature = psbtInput.tapKeyPathSignature; + } + + tapDerivedPublicKeys.putAll(psbtInput.tapDerivedPublicKeys); + + if(psbtInput.tapInternalKey != null) { + tapInternalKey = psbtInput.tapInternalKey; + } } public Transaction getNonWitnessUtxo() { @@ -379,8 +453,38 @@ public class PSBTInput { return proprietary; } + public TransactionSignature getTapKeyPathSignature() { + return tapKeyPathSignature; + } + + public void setTapKeyPathSignature(TransactionSignature tapKeyPathSignature) { + this.tapKeyPathSignature = tapKeyPathSignature; + } + + public Map>> getTapDerivedPublicKeys() { + return tapDerivedPublicKeys; + } + + public void setTapDerivedPublicKeys(Map>> tapDerivedPublicKeys) { + this.tapDerivedPublicKeys = tapDerivedPublicKeys; + } + + public ECKey getTapInternalKey() { + return tapInternalKey; + } + + public void setTapInternalKey(ECKey tapInternalKey) { + this.tapInternalKey = tapInternalKey; + } + + public boolean isTaproot() { + return getUtxo() != null && getScriptType() == P2TR; + } + public boolean isSigned() { - if(!getPartialSignatures().isEmpty()) { + if(getTapKeyPathSignature() != null) { + return true; + } else if(!getPartialSignatures().isEmpty()) { try { //All partial sigs are already verified int reqSigs = getSigningScript().getNumRequiredSignatures(); @@ -399,27 +503,40 @@ public class PSBTInput { return getFinalScriptWitness().getSignatures(); } else if(getFinalScriptSig() != null) { return getFinalScriptSig().getSignatures(); + } else if(getTapKeyPathSignature() != null) { + return List.of(getTapKeyPathSignature()); } else { return getPartialSignatures().values(); } } + private SigHash getDefaultSigHash() { + if(isTaproot()) { + return SigHash.DEFAULT; + } + + return SigHash.ALL; + } + public boolean sign(ECKey privKey) { SigHash localSigHash = getSigHash(); if(localSigHash == null) { - //Assume SigHash.ALL - localSigHash = SigHash.ALL; + localSigHash = getDefaultSigHash(); } if(getNonWitnessUtxo() != null || getWitnessUtxo() != null) { Script signingScript = getSigningScript(); if(signingScript != null) { Sha256Hash hash = getHashForSignature(signingScript, localSigHash); - ECKey.ECDSASignature ecdsaSignature = privKey.sign(hash); - TransactionSignature transactionSignature = new TransactionSignature(ecdsaSignature, localSigHash); + TransactionSignature.Type type = isTaproot() ? SCHNORR : ECDSA; + TransactionSignature transactionSignature = privKey.sign(hash, localSigHash, type); - ECKey pubKey = ECKey.fromPublicOnly(privKey); - getPartialSignatures().put(pubKey, transactionSignature); + if(type == SCHNORR) { + tapKeyPathSignature = transactionSignature; + } else { + ECKey pubKey = ECKey.fromPublicOnly(privKey); + getPartialSignatures().put(pubKey, transactionSignature); + } return true; } @@ -428,11 +545,10 @@ public class PSBTInput { return false; } - boolean verifySignatures() throws PSBTParseException { + boolean verifySignatures() throws PSBTSignatureException { SigHash localSigHash = getSigHash(); if(localSigHash == null) { - //Assume SigHash.ALL - localSigHash = SigHash.ALL; + localSigHash = getDefaultSigHash(); } if(getNonWitnessUtxo() != null || getWitnessUtxo() != null) { @@ -440,10 +556,17 @@ public class PSBTInput { if(signingScript != null) { Sha256Hash hash = getHashForSignature(signingScript, localSigHash); - for(ECKey sigPublicKey : getPartialSignatures().keySet()) { - TransactionSignature signature = getPartialSignature(sigPublicKey); - if(!sigPublicKey.verify(hash, signature)) { - throw new PSBTParseException("Partial signature does not verify against provided public key"); + if(isTaproot() && tapKeyPathSignature != null) { + ECKey outputKey = P2TR.getPublicKeyFromScript(getUtxo().getScript()); + if(!outputKey.verify(hash, tapKeyPathSignature)) { + throw new PSBTSignatureException("Tweaked internal key does not verify against provided taproot keypath signature"); + } + } else { + for(ECKey sigPublicKey : getPartialSignatures().keySet()) { + TransactionSignature signature = getPartialSignature(sigPublicKey); + if(!sigPublicKey.verify(hash, signature)) { + throw new PSBTSignatureException("Partial signature does not verify against provided public key"); + } } } @@ -462,7 +585,7 @@ public class PSBTInput { Map signingKeys = new LinkedHashMap<>(); if(signingScript != null) { - Sha256Hash hash = getHashForSignature(signingScript, getSigHash() == null ? SigHash.ALL : getSigHash()); + Sha256Hash hash = getHashForSignature(signingScript, getSigHash() == null ? getDefaultSigHash() : getSigHash()); for(ECKey sigPublicKey : availableKeys) { for(TransactionSignature signature : signatures) { @@ -526,6 +649,11 @@ public class PSBTInput { } } + if(P2TR.isScriptType(signingScript)) { + //For now, only support keypath spends and just return the ScriptPubKey + //In future return the script from PSBT_IN_TAP_LEAF_SCRIPT + } + return signingScript; } @@ -549,18 +677,19 @@ public class PSBTInput { witnessScript = null; porCommitment = null; proprietary.clear(); + tapDerivedPublicKeys.clear(); + tapKeyPathSignature = null; } private Sha256Hash getHashForSignature(Script connectedScript, SigHash localSigHash) { Sha256Hash hash; ScriptType scriptType = getScriptType(); - if(getWitnessUtxo() == null && Arrays.asList(WITNESS_TYPES).contains(scriptType)) { - throw new IllegalStateException("Trying to get signature hash for " + scriptType + " script without a PSBT witness UTXO"); - } - - if(getWitnessUtxo() != null) { - long prevValue = getWitnessUtxo().getValue(); + if(scriptType == ScriptType.P2TR) { + List spentUtxos = psbt.getPsbtInputs().stream().map(PSBTInput::getUtxo).collect(Collectors.toList()); + hash = transaction.hashForTaprootSignature(spentUtxos, index, !P2TR.isScriptType(connectedScript), connectedScript, localSigHash, null); + } else if(Arrays.asList(WITNESS_TYPES).contains(scriptType)) { + long prevValue = getUtxo().getValue(); hash = transaction.hashForWitnessSignature(index, connectedScript, prevValue, localSigHash); } else { hash = transaction.hashForLegacySignature(index, connectedScript, localSigHash); diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java index af39774..fb10a71 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java @@ -4,26 +4,30 @@ import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.Script; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.protocol.Sha256Hash; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import static com.sparrowwallet.drongo.protocol.ScriptType.*; import static com.sparrowwallet.drongo.psbt.PSBTEntry.*; public class PSBTOutput { public static final byte PSBT_OUT_REDEEM_SCRIPT = 0x00; public static final byte PSBT_OUT_WITNESS_SCRIPT = 0x01; public static final byte PSBT_OUT_BIP32_DERIVATION = 0x02; + public static final byte PSBT_OUT_TAP_INTERNAL_KEY = 0x05; + public static final byte PSBT_OUT_TAP_BIP32_DERIVATION = 0x07; public static final byte PSBT_OUT_PROPRIETARY = (byte)0xfc; private Script redeemScript; private Script witnessScript; private final Map derivedPublicKeys = new LinkedHashMap<>(); private final Map proprietary = new LinkedHashMap<>(); + private Map>> tapDerivedPublicKeys = new LinkedHashMap<>(); + private ECKey tapInternalKey; private static final Logger log = LoggerFactory.getLogger(PSBTOutput.class); @@ -31,11 +35,22 @@ public class PSBTOutput { //empty constructor } - PSBTOutput(Script redeemScript, Script witnessScript, Map derivedPublicKeys, Map proprietary) { + PSBTOutput(ScriptType scriptType, Script redeemScript, Script witnessScript, Map derivedPublicKeys, Map proprietary, ECKey tapInternalKey) { this.redeemScript = redeemScript; this.witnessScript = witnessScript; - this.derivedPublicKeys.putAll(derivedPublicKeys); + + if(scriptType != P2TR) { + this.derivedPublicKeys.putAll(derivedPublicKeys); + } + this.proprietary.putAll(proprietary); + + this.tapInternalKey = tapInternalKey == null ? null : ECKey.fromPublicOnly(tapInternalKey.getPubKeyXCoord()); + + if(tapInternalKey != null && !derivedPublicKeys.values().isEmpty()) { + KeyDerivation tapKeyDerivation = derivedPublicKeys.values().iterator().next(); + tapDerivedPublicKeys.put(this.tapInternalKey, Map.of(tapKeyDerivation, Collections.emptyList())); + } } PSBTOutput(List outputEntries) throws PSBTParseException { @@ -64,6 +79,24 @@ public class PSBTOutput { proprietary.put(Utils.bytesToHex(entry.getKeyData()), Utils.bytesToHex(entry.getData())); log.debug("Found proprietary output " + Utils.bytesToHex(entry.getKeyData()) + ": " + Utils.bytesToHex(entry.getData())); break; + case PSBT_OUT_TAP_INTERNAL_KEY: + entry.checkOneByteKey(); + this.tapInternalKey = ECKey.fromPublicOnly(entry.getData()); + log.debug("Found output taproot internal key " + Utils.bytesToHex(entry.getData())); + break; + case PSBT_OUT_TAP_BIP32_DERIVATION: + entry.checkOneBytePlusXOnlyPubKey(); + ECKey tapPublicKey = ECKey.fromPublicOnly(entry.getKeyData()); + Map> tapKeyDerivations = parseTaprootKeyDerivation(entry.getData()); + if(tapKeyDerivations.isEmpty()) { + log.warn("PSBT provided an invalid output taproot key derivation"); + } else { + this.tapDerivedPublicKeys.put(tapPublicKey, tapKeyDerivations); + for(KeyDerivation tapKeyDerivation : tapKeyDerivations.keySet()) { + log.debug("Found output taproot key derivation for key " + Utils.bytesToHex(entry.getKeyData()) + " with master fingerprint " + tapKeyDerivation.getMasterFingerprint() + " at path " + tapKeyDerivation.getDerivationPath()); + } + } + break; default: log.warn("PSBT output not recognized key type: " + entry.getKeyType()); } @@ -89,6 +122,16 @@ public class PSBTOutput { entries.add(populateEntry(PSBT_OUT_PROPRIETARY, Utils.hexToBytes(entry.getKey()), Utils.hexToBytes(entry.getValue()))); } + for(Map.Entry>> entry : tapDerivedPublicKeys.entrySet()) { + if(!entry.getValue().isEmpty()) { + entries.add(populateEntry(PSBT_OUT_TAP_BIP32_DERIVATION, entry.getKey().getPubKeyXCoord(), serializeTaprootKeyDerivation(Collections.emptyList(), entry.getValue().keySet().iterator().next()))); + } + } + + if(tapInternalKey != null) { + entries.add(populateEntry(PSBT_OUT_TAP_INTERNAL_KEY, null, tapInternalKey.getPubKeyXCoord())); + } + return entries; } @@ -103,6 +146,12 @@ public class PSBTOutput { derivedPublicKeys.putAll(psbtOutput.derivedPublicKeys); proprietary.putAll(psbtOutput.proprietary); + + tapDerivedPublicKeys.putAll(psbtOutput.tapDerivedPublicKeys); + + if(psbtOutput.tapInternalKey != null) { + tapInternalKey = psbtOutput.tapInternalKey; + } } public Script getRedeemScript() { @@ -132,4 +181,24 @@ public class PSBTOutput { public Map getProprietary() { return proprietary; } + + public Map>> getTapDerivedPublicKeys() { + return tapDerivedPublicKeys; + } + + public void setTapDerivedPublicKeys(Map>> tapDerivedPublicKeys) { + this.tapDerivedPublicKeys = tapDerivedPublicKeys; + } + + public ECKey getTapInternalKey() { + return tapInternalKey; + } + + public void setTapInternalKey(ECKey tapInternalKey) { + this.tapInternalKey = tapInternalKey; + } + + public void clearNonFinalFields() { + tapDerivedPublicKeys.clear(); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTSignatureException.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTSignatureException.java new file mode 100644 index 0000000..a29f686 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTSignatureException.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.drongo.psbt; + +public class PSBTSignatureException extends PSBTParseException { + public PSBTSignatureException() { + super(); + } + + public PSBTSignatureException(String message) { + super(message); + } + + public PSBTSignatureException(Throwable cause) { + super(cause); + } + + public PSBTSignatureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java index 32b7bcd..0ebd560 100644 --- a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java +++ b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java @@ -2,6 +2,7 @@ package com.sparrowwallet.drongo.uri; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.wallet.Payment; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,9 +87,7 @@ public class BitcoinURI { * @throws BitcoinURIParseException if the URI is not syntactically or semantically valid. */ public BitcoinURI(String input) throws BitcoinURIParseException { - String scheme = BITCOIN_SCHEME; - - // Attempt to form the URI (fail fast syntax checking to official standards). + // Attempt to parse the URI URI uri; try { uri = new URI(input); @@ -99,23 +98,14 @@ public class BitcoinURI { // URI is formed as bitcoin:
? // blockchain.info generates URIs of non-BIP compliant form bitcoin://address?.... - // Remove the bitcoin scheme. - // (Note: getSchemeSpecificPart() is not used as it unescapes the label and parse then fails. - // For instance with : bitcoin:129mVqKUmJ9uwPxKJBnNdABbuaaNfho4Ha?amount=0.06&label=Tom%20%26%20Jerry - // the & (%26) in Tom and Jerry gets interpreted as a separator and the label then gets parsed - // as 'Tom ' instead of 'Tom & Jerry') - String blockchainInfoScheme = scheme + "://"; - String correctScheme = scheme + ":"; - String schemeSpecificPart; - final String inputLc = input.toLowerCase(Locale.US); - if(inputLc.startsWith(blockchainInfoScheme)) { - schemeSpecificPart = input.substring(blockchainInfoScheme.length()); - } else if(inputLc.startsWith(correctScheme)) { - schemeSpecificPart = input.substring(correctScheme.length()); - } else { + if (!BITCOIN_SCHEME.equalsIgnoreCase(uri.getScheme())) { throw new BitcoinURIParseException("Unsupported URI scheme: " + uri.getScheme()); } + String schemeSpecificPart = uri.getRawSchemeSpecificPart().startsWith("//") + ? uri.getRawSchemeSpecificPart().substring(2) + : uri.getRawSchemeSpecificPart(); + // Split off the address from the rest of the query parameters. String[] addressSplitTokens = schemeSpecificPart.split("\\?", 2); if(addressSplitTokens.length == 0) { @@ -164,7 +154,7 @@ public class BitcoinURI { if(sepIndex == 0) { throw new BitcoinURIParseException("Malformed Groestlcoin URI - empty name '" + nameValuePairToken + "'"); } - final String nameToken = nameValuePairToken.substring(0, sepIndex).toLowerCase(Locale.ENGLISH); + final String nameToken = nameValuePairToken.substring(0, sepIndex).toLowerCase(Locale.ROOT); final String valueToken = nameValuePairToken.substring(sepIndex + 1); // Parse the amount. @@ -325,6 +315,11 @@ public class BitcoinURI { return builder.toString(); } + public Payment toPayment() { + long amount = getAmount() == null ? -1 : getAmount(); + return new Payment(getAddress(), getLabel(), amount, false); + } + /** * Constructs a new BitcoinURI from the given address. * diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java index 53036c6..9e581d9 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransaction.java @@ -1,26 +1,40 @@ package com.sparrowwallet.drongo.wallet; -import com.sparrowwallet.drongo.protocol.Sha256Hash; -import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.*; import java.util.Collections; import java.util.Date; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; +import java.util.HashSet; +import java.util.Set; public class BlockTransaction extends BlockTransactionHash implements Comparable { private final Transaction transaction; private final Sha256Hash blockHash; + private final Set spending = new HashSet<>(); + private final Set funding = new HashSet<>(); + public BlockTransaction(Sha256Hash hash, int height, Date date, Long fee, Transaction transaction) { this(hash, height, date, fee, transaction, null); } public BlockTransaction(Sha256Hash hash, int height, Date date, Long fee, Transaction transaction, Sha256Hash blockHash) { - super(hash, height, date, fee); + this(hash, height, date, fee, transaction, blockHash, null); + } + + public BlockTransaction(Sha256Hash hash, int height, Date date, Long fee, Transaction transaction, Sha256Hash blockHash, String label) { + super(hash, height, date, fee, label); this.transaction = transaction; this.blockHash = blockHash; + + if(transaction != null) { + for(TransactionInput txInput : transaction.getInputs()) { + spending.add(new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex())); + } + for(TransactionOutput txOutput : transaction.getOutputs()) { + funding.add(new HashIndex(hash, txOutput.getIndex())); + } + } } public Transaction getTransaction() { @@ -31,64 +45,46 @@ public class BlockTransaction extends BlockTransactionHash implements Comparable return blockHash; } + public Set getSpending() { + return Collections.unmodifiableSet(spending); + } + + public Set getFunding() { + return Collections.unmodifiableSet(funding); + } + + public Double getFeeRate() { + if(getFee() != null && transaction != null) { + double vSize = transaction.getVirtualSize(); + return getFee() / vSize; + } + + return null; + } + @Override public int compareTo(BlockTransaction blkTx) { - if(getHeight() != blkTx.getHeight()) { - return getComparisonHeight() - blkTx.getComparisonHeight(); - } - - if(getReferencedOutpoints(this).removeAll(getOutputs(blkTx))) { - return 1; - } - - if(getReferencedOutpoints(blkTx).removeAll(getOutputs(this))) { - return -1; + int blockOrder = compareBlockOrder(blkTx); + if(blockOrder != 0) { + return blockOrder; } return super.compareTo(blkTx); } - private static List getReferencedOutpoints(BlockTransaction blockchainTransaction) { - if(blockchainTransaction.getTransaction() == null) { - return Collections.emptyList(); + public int compareBlockOrder(BlockTransaction blkTx) { + if(getHeight() != blkTx.getHeight()) { + return getComparisonHeight() - blkTx.getComparisonHeight(); } - return blockchainTransaction.getTransaction().getInputs().stream() - .map(txInput -> new HashIndex(txInput.getOutpoint().getHash(), (int)txInput.getOutpoint().getIndex())) - .collect(Collectors.toList()); - } - - private static List getOutputs(BlockTransaction blockchainTransaction) { - if(blockchainTransaction.getTransaction() == null) { - return Collections.emptyList(); + if(!Collections.disjoint(spending, blkTx.funding)) { + return 1; } - return blockchainTransaction.getTransaction().getOutputs().stream() - .map(txOutput -> new HashIndex(blockchainTransaction.getHash(), txOutput.getIndex())) - .collect(Collectors.toList()); - } - - private static class HashIndex { - public Sha256Hash hash; - public int index; - - public HashIndex(Sha256Hash hash, int index) { - this.hash = hash; - this.index = index; + if(!Collections.disjoint(blkTx.spending, funding)) { + return -1; } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - HashIndex hashIndex = (HashIndex) o; - return index == hashIndex.index && - hash.equals(hashIndex.hash); - } - - @Override - public int hashCode() { - return Objects.hash(hash, index); - } + return 0; } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java index 4717f34..daa2231 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHash.java @@ -5,7 +5,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash; import java.util.Date; import java.util.Objects; -public abstract class BlockTransactionHash { +public abstract class BlockTransactionHash extends Persistable { public static final int BLOCKS_TO_CONFIRM = 6; public static final int BLOCKS_TO_FULLY_CONFIRM = 100; @@ -16,11 +16,12 @@ public abstract class BlockTransactionHash { private String label; - public BlockTransactionHash(Sha256Hash hash, int height, Date date, Long fee) { + public BlockTransactionHash(Sha256Hash hash, int height, Date date, Long fee, String label) { this.hash = hash; this.height = height; this.date = date; this.fee = fee; + this.label = label; } public Sha256Hash getHash() { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHashIndex.java b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHashIndex.java index 0368755..30010a6 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHashIndex.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BlockTransactionHashIndex.java @@ -16,7 +16,11 @@ public class BlockTransactionHashIndex extends BlockTransactionHash implements C } public BlockTransactionHashIndex(Sha256Hash hash, int height, Date date, Long fee, long index, long value, BlockTransactionHashIndex spentBy) { - super(hash, height, date, fee); + this(hash, height, date, fee, index, value, spentBy, null); + } + + public BlockTransactionHashIndex(Sha256Hash hash, int height, Date date, Long fee, long index, long value, BlockTransactionHashIndex spentBy, String label) { + super(hash, height, date, fee, label); this.index = index; this.value = value; this.spentBy = spentBy; @@ -92,6 +96,8 @@ public class BlockTransactionHashIndex extends BlockTransactionHash implements C } public BlockTransactionHashIndex copy() { - return new BlockTransactionHashIndex(super.getHash(), super.getHeight(), super.getDate(), super.getFee(), index, value, spentBy == null ? null : spentBy.copy()); + BlockTransactionHashIndex copy = new BlockTransactionHashIndex(super.getHash(), super.getHeight(), super.getDate(), super.getFee(), index, value, spentBy == null ? null : spentBy.copy(), super.getLabel()); + copy.setId(getId()); + return copy; } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/BnBUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/BnBUtxoSelector.java index c3cacca..b3bcb47 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/BnBUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/BnBUtxoSelector.java @@ -6,7 +6,7 @@ import org.slf4j.LoggerFactory; import java.util.*; -public class BnBUtxoSelector implements UtxoSelector { +public class BnBUtxoSelector extends SingleSetUtxoSelector { private static final Logger log = LoggerFactory.getLogger(BnBUtxoSelector.class); private static final int TOTAL_TRIES = 100000; diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java b/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java new file mode 100644 index 0000000..8f079f6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/CoinbaseUtxoFilter.java @@ -0,0 +1,23 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.protocol.Transaction; + +public class CoinbaseUtxoFilter implements UtxoFilter { + private final Wallet wallet; + + public CoinbaseUtxoFilter(Wallet wallet) { + this.wallet = wallet; + } + + @Override + public boolean isEligible(BlockTransactionHashIndex candidate) { + //Disallow immature coinbase outputs + BlockTransaction blockTransaction = wallet.getWalletTransaction(candidate.getHash()); + if(blockTransaction != null && blockTransaction.getTransaction() != null && blockTransaction.getTransaction().isCoinBase() + && wallet.getStoredBlockHeight() != null && candidate.getConfirmations(wallet.getStoredBlockHeight()) < Transaction.COINBASE_MATURITY_THRESHOLD) { + return false; + } + + return true; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java b/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java index efd134e..6839d57 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/DeterministicSeed.java @@ -7,7 +7,7 @@ import com.sparrowwallet.drongo.crypto.*; import java.security.SecureRandom; import java.util.*; -public class DeterministicSeed implements EncryptableItem { +public class DeterministicSeed extends Persistable implements EncryptableItem { public static final int DEFAULT_SEED_ENTROPY_BITS = 128; public static final int MAX_SEED_ENTROPY_BITS = 512; @@ -184,6 +184,7 @@ public class DeterministicSeed implements EncryptableItem { Arrays.fill(mnemonicBytes != null ? mnemonicBytes : new byte[0], (byte)0); DeterministicSeed seed = new DeterministicSeed(encryptedMnemonic, needsPassphrase, creationTimeSeconds, type); + seed.setId(getId()); seed.setPassphrase(passphrase); return seed; @@ -209,6 +210,7 @@ public class DeterministicSeed implements EncryptableItem { KeyDeriver keyDeriver = getEncryptionType().getDeriver().getKeyDeriver(encryptedMnemonicCode.getKeySalt()); Key key = keyDeriver.deriveKey(password); DeterministicSeed seed = decrypt(key); + seed.setId(getId()); key.clear(); return seed; @@ -225,6 +227,7 @@ public class DeterministicSeed implements EncryptableItem { Arrays.fill(decrypted, (byte)0); DeterministicSeed seed = new DeterministicSeed(mnemonic, needsPassphrase, creationTimeSeconds, type); + seed.setId(getId()); seed.setPassphrase(passphrase); return seed; @@ -341,6 +344,7 @@ public class DeterministicSeed implements EncryptableItem { seed = new DeterministicSeed(new ArrayList<>(mnemonicCode), needsPassphrase, creationTimeSeconds, type); } + seed.setId(getId()); seed.setPassphrase(passphrase); return seed; } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/FinalizingPSBTWallet.java b/src/main/java/com/sparrowwallet/drongo/wallet/FinalizingPSBTWallet.java index 360836e..858709d 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/FinalizingPSBTWallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/FinalizingPSBTWallet.java @@ -5,10 +5,7 @@ import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.policy.Miniscript; import com.sparrowwallet.drongo.policy.Policy; import com.sparrowwallet.drongo.policy.PolicyType; -import com.sparrowwallet.drongo.protocol.NonStandardScriptException; -import com.sparrowwallet.drongo.protocol.Script; -import com.sparrowwallet.drongo.protocol.ScriptType; -import com.sparrowwallet.drongo.protocol.TransactionSignature; +import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTInput; @@ -67,6 +64,9 @@ public class FinalizingPSBTWallet extends Wallet { } } + setGapLimit(0); + purposeNode.setChildren(new TreeSet<>()); + setPolicyType(numSignatures == 1 ? PolicyType.SINGLE : PolicyType.MULTI); } @@ -125,4 +125,16 @@ public class FinalizingPSBTWallet extends Wallet { public boolean canSign(PSBT psbt) { return !getSigningNodes(psbt).isEmpty(); } + + @Override + public boolean isWalletTxo(TransactionInput txInput) { + for(PSBTInput psbtInput : signedInputNodes.keySet()) { + TransactionInput psbtTxInput = psbtInput.getInput(); + if(psbtTxInput.getOutpoint().getHash().equals(txInput.getOutpoint().getHash()) && psbtTxInput.getOutpoint().getIndex() == txInput.getOutpoint().getIndex()) { + return true; + } + } + + return false; + } } \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java b/src/main/java/com/sparrowwallet/drongo/wallet/Keystore.java index 1334367..6045866 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 { +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,8 +26,16 @@ public class Keystore { 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 transient ExtendedKey bip47ExtendedPrivateKey; + + //Avoid performing repeated expensive seed derivation checks + private transient boolean extendedPublicKeyChecked; + public Keystore() { this(DEFAULT_LABEL); } @@ -32,7 +49,7 @@ public class Keystore { } public String getScriptName() { - return label.replace(" ", "").toLowerCase(); + return label.replace(" ", ""); } public void setLabel(String label) { @@ -69,6 +86,31 @@ public class Keystore { public void setExtendedPublicKey(ExtendedKey extendedPublicKey) { this.extendedPublicKey = extendedPublicKey; + this.extendedPublicKeyChecked = false; + } + + public PaymentCode getExternalPaymentCode() { + return externalPaymentCode; + } + + public void setExternalPaymentCode(PaymentCode paymentCode) { + this.externalPaymentCode = paymentCode; + } + + public boolean hasMasterPrivateExtendedKey() { + return masterPrivateExtendedKey != null; + } + + public MasterPrivateExtendedKey getMasterPrivateExtendedKey() { + return masterPrivateExtendedKey; + } + + public void setMasterPrivateExtendedKey(MasterPrivateExtendedKey masterPrivateExtendedKey) { + this.masterPrivateExtendedKey = masterPrivateExtendedKey; + } + + public boolean hasSeed() { + return seed != null; } public DeterministicSeed getSeed() { @@ -79,16 +121,60 @@ public class Keystore { this.seed = seed; } + public boolean hasMasterPrivateKey() { + return hasSeed() || hasMasterPrivateExtendedKey(); + } + + public boolean hasPrivateKey() { + return hasMasterPrivateKey() || (source == KeystoreSource.SW_PAYMENT_CODE && bip47ExtendedPrivateKey != null); + } + + public boolean needsPassphrase() { + if(seed != null) { + return seed.needsPassphrase(); + } + + return false; + } + + 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) { - throw new IllegalArgumentException("Keystore does not contain a seed"); + if(seed == null && masterPrivateExtendedKey == null) { + throw new IllegalArgumentException("Keystore does not contain a master private key, or seed to derive one from"); } - if(seed.isEncrypted()) { - throw new IllegalArgumentException("Seed is encrypted"); + if(seed != null) { + if(seed.isEncrypted()) { + throw new IllegalArgumentException("Seed is encrypted"); + } + + return HDKeyDerivation.createMasterPrivateKey(seed.getSeedBytes()); } - return HDKeyDerivation.createMasterPrivateKey(seed.getSeedBytes()); + if(masterPrivateExtendedKey.isEncrypted()) { + throw new IllegalArgumentException("Master private key is encrypted"); + } + + return masterPrivateExtendedKey.getPrivateKey(); } public ExtendedKey getExtendedMasterPrivateKey() throws MnemonicException { @@ -107,22 +193,44 @@ public class Keystore { 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); } @@ -178,11 +286,11 @@ public class Keystore { } if(source == KeystoreSource.SW_SEED) { - if(seed == null) { - throw new InvalidKeystoreException("Source of " + source + " but no seed is present"); + if(seed == null && masterPrivateExtendedKey == null) { + throw new InvalidKeystoreException("Source of " + source + " but no seed or master private key is present"); } - if(!seed.isEncrypted()) { + if(!extendedPublicKeyChecked && ((seed != null && !seed.isEncrypted()) || (masterPrivateExtendedKey != null && !masterPrivateExtendedKey.isEncrypted()))) { try { List derivation = getKeyDerivation().getDerivation(); DeterministicKey derivedKey = getExtendedMasterPrivateKey().getKey(derivation); @@ -191,15 +299,27 @@ public class Keystore { if(!xpub.equals(getExtendedPublicKey())) { throw new InvalidKeystoreException("Specified extended public key does not match public key derived from seed"); } + extendedPublicKeyChecked = true; } catch(MnemonicException e) { throw new InvalidKeystoreException("Invalid mnemonic specified for seed", e); } } } + + 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() { Keystore copy = new Keystore(label); + copy.setId(getId()); copy.setSource(source); copy.setWalletModel(walletModel); if(keyDerivation != null) { @@ -208,59 +328,94 @@ public class Keystore { if(extendedPublicKey != null) { copy.setExtendedPublicKey(extendedPublicKey.copy()); } + if(masterPrivateExtendedKey != null) { + copy.setMasterPrivateExtendedKey(masterPrivateExtendedKey.copy()); + } if(seed != null) { copy.setSeed(seed.copy()); } + if(externalPaymentCode != null) { + copy.setExternalPaymentCode(externalPaymentCode.copy()); + } + if(bip47ExtendedPrivateKey != null) { + copy.setBip47ExtendedPrivateKey(bip47ExtendedPrivateKey.copy()); + } return copy; } public static Keystore fromSeed(DeterministicSeed seed, List derivation) throws MnemonicException { Keystore keystore = new Keystore(); keystore.setSeed(seed); + keystore.setLabel(seed.getType().name()); + rederiveKeystoreFromMaster(keystore, derivation); + return keystore; + } + + public static Keystore fromMasterPrivateExtendedKey(MasterPrivateExtendedKey masterPrivateExtendedKey, List derivation) throws MnemonicException { + Keystore keystore = new Keystore(); + keystore.setMasterPrivateExtendedKey(masterPrivateExtendedKey); + keystore.setLabel("Master Key"); + rederiveKeystoreFromMaster(keystore, derivation); + return keystore; + } + + private static void rederiveKeystoreFromMaster(Keystore keystore, List derivation) throws MnemonicException { ExtendedKey xprv = keystore.getExtendedMasterPrivateKey(); String masterFingerprint = Utils.bytesToHex(xprv.getKey().getFingerprint()); DeterministicKey derivedKey = xprv.getKey(derivation); DeterministicKey derivedKeyPublicOnly = derivedKey.dropPrivateBytes().dropParent(); ExtendedKey xpub = new ExtendedKey(derivedKeyPublicOnly, derivedKey.getParentFingerprint(), derivation.isEmpty() ? ChildNumber.ZERO : derivation.get(derivation.size() - 1)); - keystore.setLabel(seed.getType().name()); keystore.setSource(KeystoreSource.SW_SEED); keystore.setWalletModel(WalletModel.SPARROW); keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(derivation))); keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub.toString())); - return keystore; - } - - public boolean hasSeed() { - return seed != null; + 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() { - return seed != null && seed.isEncrypted(); + return (seed != null && seed.isEncrypted()) || (masterPrivateExtendedKey != null && masterPrivateExtendedKey.isEncrypted()); } public void encrypt(Key key) { if(hasSeed() && !seed.isEncrypted()) { seed = seed.encrypt(key); } + if(hasMasterPrivateExtendedKey() && !masterPrivateExtendedKey.isEncrypted()) { + masterPrivateExtendedKey = masterPrivateExtendedKey.encrypt(key); + } } public void decrypt(CharSequence password) { if(hasSeed() && seed.isEncrypted()) { seed = seed.decrypt(password); } + if(hasMasterPrivateExtendedKey() && masterPrivateExtendedKey.isEncrypted()) { + masterPrivateExtendedKey = masterPrivateExtendedKey.decrypt(password); + } } public void decrypt(Key key) { if(hasSeed() && seed.isEncrypted()) { seed = seed.decrypt(key); } + if(hasMasterPrivateExtendedKey() && masterPrivateExtendedKey.isEncrypted()) { + masterPrivateExtendedKey = masterPrivateExtendedKey.decrypt(key); + } } public void clearPrivate() { if(hasSeed()) { seed.clear(); } + if(hasMasterPrivateExtendedKey()) { + masterPrivateExtendedKey.clear(); + } } } 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/KnapsackUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/KnapsackUtxoSelector.java index 93c615f..5bc33b6 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/KnapsackUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/KnapsackUtxoSelector.java @@ -5,7 +5,7 @@ import com.sparrowwallet.drongo.protocol.Transaction; import java.util.*; import java.util.stream.Collectors; -public class KnapsackUtxoSelector implements UtxoSelector { +public class KnapsackUtxoSelector extends SingleSetUtxoSelector { private static final long MIN_CHANGE = Transaction.SATOSHIS_PER_BITCOIN / 1000; private final long noInputsFee; diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/MasterPrivateExtendedKey.java b/src/main/java/com/sparrowwallet/drongo/wallet/MasterPrivateExtendedKey.java new file mode 100644 index 0000000..4f5520a --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/MasterPrivateExtendedKey.java @@ -0,0 +1,144 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.crypto.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +public class MasterPrivateExtendedKey extends Persistable implements EncryptableItem { + private final byte[] privateKey; + private final byte[] chainCode; + + private final EncryptedData encryptedKey; + + public MasterPrivateExtendedKey(byte[] privateKey, byte[] chainCode) { + this.privateKey = privateKey; + this.chainCode = chainCode; + this.encryptedKey = null; + } + + public MasterPrivateExtendedKey(EncryptedData encryptedKey) { + this.privateKey = null; + this.chainCode = null; + this.encryptedKey = encryptedKey; + } + + public DeterministicKey getPrivateKey() { + return HDKeyDerivation.createMasterPrivKeyFromBytes(privateKey, chainCode); + } + + public ExtendedKey getExtendedPrivateKey() { + return new ExtendedKey(getPrivateKey(), new byte[4], ChildNumber.ZERO); + } + + @Override + public boolean isEncrypted() { + if((privateKey != null || chainCode != null) && encryptedKey != null) { + throw new IllegalStateException("Cannot be in a encrypted and unencrypted state"); + } + + return encryptedKey != null; + } + + @Override + public byte[] getSecretBytes() { + if(privateKey == null || chainCode == null) { + throw new IllegalStateException("Cannot get secret bytes for null or encrypted key"); + } + + ByteBuffer byteBuffer = ByteBuffer.allocate(64); + byteBuffer.put(privateKey); + byteBuffer.put(chainCode); + return byteBuffer.array(); + } + + @Override + public EncryptedData getEncryptedData() { + return encryptedKey; + } + + @Override + public EncryptionType getEncryptionType() { + return new EncryptionType(EncryptionType.Deriver.ARGON2, EncryptionType.Crypter.AES_CBC_PKCS7); + } + + @Override + public long getCreationTimeSeconds() { + return 0; + } + + public MasterPrivateExtendedKey encrypt(Key key) { + if(encryptedKey != null) { + throw new IllegalArgumentException("Trying to encrypt twice"); + } + if(privateKey == null || chainCode == null) { + throw new IllegalArgumentException("Private key data missing so cannot encrypt"); + } + + KeyCrypter keyCrypter = getEncryptionType().getCrypter().getKeyCrypter(); + byte[] secretBytes = getSecretBytes(); + EncryptedData encryptedKeyData = keyCrypter.encrypt(secretBytes, null, key); + Arrays.fill(secretBytes != null ? secretBytes : new byte[0], (byte)0); + + MasterPrivateExtendedKey mpek = new MasterPrivateExtendedKey(encryptedKeyData); + mpek.setId(getId()); + + return mpek; + } + + public MasterPrivateExtendedKey decrypt(CharSequence password) { + if(!isEncrypted()) { + throw new IllegalStateException("Cannot decrypt unencrypted master private key"); + } + + KeyDeriver keyDeriver = getEncryptionType().getDeriver().getKeyDeriver(encryptedKey.getKeySalt()); + Key key = keyDeriver.deriveKey(password); + MasterPrivateExtendedKey mpek = decrypt(key); + mpek.setId(getId()); + key.clear(); + + return mpek; + } + + public MasterPrivateExtendedKey decrypt(Key key) { + if(!isEncrypted()) { + throw new IllegalStateException("Cannot decrypt unencrypted master private key"); + } + + KeyCrypter keyCrypter = getEncryptionType().getCrypter().getKeyCrypter(); + byte[] decrypted = keyCrypter.decrypt(encryptedKey, key); + try { + MasterPrivateExtendedKey mpek = new MasterPrivateExtendedKey(Arrays.copyOfRange(decrypted, 0, 32), Arrays.copyOfRange(decrypted, 32, 64)); + mpek.setId(getId()); + return mpek; + } finally { + Arrays.fill(decrypted, (byte)0); + } + } + + public MasterPrivateExtendedKey copy() { + MasterPrivateExtendedKey copy; + if(isEncrypted()) { + copy = new MasterPrivateExtendedKey(encryptedKey.copy()); + } else { + copy = new MasterPrivateExtendedKey(Arrays.copyOf(privateKey, 32), Arrays.copyOf(chainCode, 32)); + } + + copy.setId(getId()); + return copy; + } + + public void clear() { + if(privateKey != null) { + Arrays.fill(privateKey, (byte)0); + } + if(chainCode != null) { + Arrays.fill(chainCode, (byte)0); + } + } + + public MasterPrivateExtendedKey fromXprv(ExtendedKey xprv) { + return new MasterPrivateExtendedKey(xprv.getKey().getPrivKeyBytes(), xprv.getKey().getChainCode()); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/MaxUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/MaxUtxoSelector.java index 787fa1f..0c8dd7c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/MaxUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/MaxUtxoSelector.java @@ -3,9 +3,9 @@ package com.sparrowwallet.drongo.wallet; import java.util.Collection; import java.util.stream.Collectors; -public class MaxUtxoSelector implements UtxoSelector { +public class MaxUtxoSelector extends SingleSetUtxoSelector { @Override public Collection select(long targetValue, Collection candidates) { - return candidates.stream().flatMap(outputGroup -> outputGroup.getUtxos().stream()).collect(Collectors.toUnmodifiableList()); + return candidates.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).flatMap(outputGroup -> outputGroup.getUtxos().stream()).collect(Collectors.toUnmodifiableList()); } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/MixConfig.java b/src/main/java/com/sparrowwallet/drongo/wallet/MixConfig.java new file mode 100644 index 0000000..387a626 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/MixConfig.java @@ -0,0 +1,96 @@ +package com.sparrowwallet.drongo.wallet; + +import java.io.File; + +public class MixConfig extends Persistable { + private String scode; + private Boolean mixOnStartup; + private String indexRange; + private File mixToWalletFile; + private String mixToWalletName; + private Integer minMixes; + private int receiveIndex; + private int changeIndex; + + public MixConfig() { + } + + public MixConfig(String scode, Boolean mixOnStartup, String indexRange, File mixToWalletFile, String mixToWalletName, Integer minMixes, int receiveIndex, int changeIndex) { + this.scode = scode; + this.mixOnStartup = mixOnStartup; + this.indexRange = indexRange; + this.mixToWalletFile = mixToWalletFile; + this.mixToWalletName = mixToWalletName; + this.minMixes = minMixes; + this.receiveIndex = receiveIndex; + this.changeIndex = changeIndex; + } + + public String getScode() { + return scode; + } + + public void setScode(String scode) { + this.scode = scode; + } + + public Boolean getMixOnStartup() { + return mixOnStartup; + } + + public void setMixOnStartup(Boolean mixOnStartup) { + this.mixOnStartup = mixOnStartup; + } + + public String getIndexRange() { + return indexRange; + } + + public void setIndexRange(String indexRange) { + this.indexRange = indexRange; + } + + public File getMixToWalletFile() { + return mixToWalletFile; + } + + public void setMixToWalletFile(File mixToWalletFile) { + this.mixToWalletFile = mixToWalletFile; + } + + public String getMixToWalletName() { + return mixToWalletName; + } + + public void setMixToWalletName(String mixToWalletName) { + this.mixToWalletName = mixToWalletName; + } + + public Integer getMinMixes() { + return minMixes; + } + + public void setMinMixes(Integer minMixes) { + this.minMixes = minMixes; + } + + public int getReceiveIndex() { + return receiveIndex; + } + + public void setReceiveIndex(int receiveIndex) { + this.receiveIndex = receiveIndex; + } + + public int getChangeIndex() { + return changeIndex; + } + + public void setChangeIndex(int changeIndex) { + this.changeIndex = changeIndex; + } + + public MixConfig copy() { + return new MixConfig(scode, mixOnStartup, indexRange, mixToWalletFile, mixToWalletName, minMixes, receiveIndex, changeIndex); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java b/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java index 02a1cb6..6155acf 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/MnemonicException.java @@ -39,4 +39,16 @@ public class MnemonicException extends Exception { this.badWord = badWord; } } + + /** + * Thrown when the mnemonic is valid, but for for the expected standard + */ + public static class MnemonicTypeException extends MnemonicException { + public final DeterministicSeed.Type invalidType; + + public MnemonicTypeException(DeterministicSeed.Type invalidType) { + super(); + this.invalidType = invalidType; + } + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java b/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java index 42f74d9..59b703c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/OutputGroup.java @@ -1,5 +1,7 @@ package com.sparrowwallet.drongo.wallet; +import com.sparrowwallet.drongo.protocol.ScriptType; + import java.util.ArrayList; import java.util.List; @@ -7,6 +9,7 @@ import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR public class OutputGroup { private final List utxos = new ArrayList<>(); + private final ScriptType scriptType; private final int walletBlockHeight; private final long inputWeightUnits; private final double feeRate; @@ -17,15 +20,17 @@ public class OutputGroup { private long longTermFee = 0; private int depth = Integer.MAX_VALUE; private boolean allInputsFromWallet = true; + private boolean spendLast; - public OutputGroup(int walletBlockHeight, long inputWeightUnits, double feeRate, double longTermFeeRate) { + public OutputGroup(ScriptType scriptType, int walletBlockHeight, long inputWeightUnits, double feeRate, double longTermFeeRate) { + this.scriptType = scriptType; this.walletBlockHeight = walletBlockHeight; this.inputWeightUnits = inputWeightUnits; this.feeRate = feeRate; this.longTermFeeRate = longTermFeeRate; } - public void add(BlockTransactionHashIndex utxo, boolean allInputsFromWallet) { + public void add(BlockTransactionHashIndex utxo, boolean allInputsFromWallet, boolean spendLast) { utxos.add(utxo); value += utxo.getValue(); effectiveValue += utxo.getValue() - (long)(inputWeightUnits * feeRate / WITNESS_SCALE_FACTOR); @@ -33,6 +38,7 @@ public class OutputGroup { longTermFee += (long)(inputWeightUnits * longTermFeeRate / WITNESS_SCALE_FACTOR); depth = utxo.getHeight() <= 0 ? 0 : Math.min(depth, walletBlockHeight - utxo.getHeight() + 1); this.allInputsFromWallet &= allInputsFromWallet; + this.spendLast |= spendLast; } public void remove(BlockTransactionHashIndex utxo) { @@ -48,6 +54,10 @@ public class OutputGroup { return utxos; } + public ScriptType getScriptType() { + return scriptType; + } + public long getValue() { return value; } @@ -72,21 +82,27 @@ public class OutputGroup { return allInputsFromWallet; } + public boolean isSpendLast() { + return spendLast; + } + public static class Filter { private final int minWalletConfirmations; private final int minExternalConfirmations; + private final boolean includeSpendLast; - public Filter(int minWalletConfirmations, int minExternalConfirmations) { + public Filter(int minWalletConfirmations, int minExternalConfirmations, boolean includeSpendLast) { this.minWalletConfirmations = minWalletConfirmations; this.minExternalConfirmations = minExternalConfirmations; + this.includeSpendLast = includeSpendLast; } public boolean isEligible(OutputGroup outputGroup) { if(outputGroup.isAllInputsFromWallet()) { - return outputGroup.getDepth() >= minWalletConfirmations; + return outputGroup.getDepth() >= minWalletConfirmations && (includeSpendLast || !outputGroup.isSpendLast()); } - return outputGroup.getDepth() >= minExternalConfirmations; + return outputGroup.getDepth() >= minExternalConfirmations && (includeSpendLast || !outputGroup.isSpendLast()); } } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Payment.java b/src/main/java/com/sparrowwallet/drongo/wallet/Payment.java index ce3998b..2d0c428 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Payment.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Payment.java @@ -7,12 +7,18 @@ public class Payment { private String label; private long amount; private boolean sendMax; + private Type type; public Payment(Address address, String label, long amount, boolean sendMax) { + this(address, label, amount, sendMax, Type.DEFAULT); + } + + public Payment(Address address, String label, long amount, boolean sendMax, Type type) { this.address = address; this.label = label; this.amount = amount; this.sendMax = sendMax; + this.type = type; } public Address getAddress() { @@ -46,4 +52,16 @@ public class Payment { public void setSendMax(boolean sendMax) { this.sendMax = sendMax; } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public enum Type { + DEFAULT, WHIRLPOOL_FEE, FAKE_MIX, MIX; + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/Persistable.java b/src/main/java/com/sparrowwallet/drongo/wallet/Persistable.java new file mode 100644 index 0000000..9d6712f --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Persistable.java @@ -0,0 +1,13 @@ +package com.sparrowwallet.drongo.wallet; + +public class Persistable { + private Long id; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java index da48706..090014c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PresetUtxoSelector.java @@ -5,11 +5,18 @@ import java.util.Collection; import java.util.List; import java.util.stream.Collectors; -public class PresetUtxoSelector implements UtxoSelector { +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 implements UtxoSelector { } } + 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/PriorityUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java index 3ca1125..6d5d297 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/PriorityUtxoSelector.java @@ -4,7 +4,7 @@ import java.math.BigInteger; import java.util.*; import java.util.stream.Collectors; -public class PriorityUtxoSelector implements UtxoSelector { +public class PriorityUtxoSelector extends SingleSetUtxoSelector { private final int currentBlockHeight; public PriorityUtxoSelector(int currentBlockHeight) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java b/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java new file mode 100644 index 0000000..30231cd --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/SeedQR.java @@ -0,0 +1,58 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.Utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class SeedQR { + public static DeterministicSeed getSeed(String seedQr) { + if(seedQr.length() < 48 || seedQr.length() > 96 || seedQr.length() % 4 > 0) { + throw new IllegalArgumentException("Invalid SeedQR length: " + seedQr.length()); + } + + if(!seedQr.chars().allMatch(c -> c >= '0' && c <= '9')) { + throw new IllegalArgumentException("SeedQR contains non-digit characters: " + seedQr); + } + + List indexes = IntStream.iterate(0, i -> i + 4).limit((int)Math.ceil(seedQr.length() / 4.0)) + .mapToObj(i -> seedQr.substring(i, Math.min(i + 4, seedQr.length()))) + .map(Integer::parseInt) + .collect(Collectors.toList()); + + List words = new ArrayList<>(indexes.size()); + for(Integer index : indexes) { + words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index)); + } + + return new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39); + } + + public static DeterministicSeed getSeed(byte[] compactSeedQr) { + if(compactSeedQr[0] != 0x41 && compactSeedQr[0] != 0x42) { + throw new IllegalArgumentException("Invalid CompactSeedQR header"); + } + + if(compactSeedQr.length < 19) { + throw new IllegalArgumentException("Invalid CompactSeedQR length"); + } + + String qrHex = Utils.bytesToHex(compactSeedQr); + String seedHex; + if(qrHex.endsWith("0ec")) { + seedHex = qrHex.substring(3, qrHex.length() - 3); + } else { + seedHex = qrHex.substring(3, qrHex.length() - 1); + } + + byte[] seed = Utils.hexToBytes(seedHex); + + if(seed.length < 16 || seed.length > 32 || seed.length % 4 > 0) { + throw new IllegalArgumentException("Invalid CompactSeedQR length: " + compactSeedQr.length); + } + + return new DeterministicSeed(seed, null, System.currentTimeMillis()); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/SingleSetUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/SingleSetUtxoSelector.java new file mode 100644 index 0000000..f28175e --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/SingleSetUtxoSelector.java @@ -0,0 +1,13 @@ +package com.sparrowwallet.drongo.wallet; + +import java.util.Collection; +import java.util.List; + +public abstract class SingleSetUtxoSelector implements UtxoSelector { + @Override + public List> selectSets(long targetValue, Collection candidates) { + return List.of(select(targetValue, candidates)); + } + + public abstract Collection select(long targetValue, Collection candidates); +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/StandardAccount.java b/src/main/java/com/sparrowwallet/drongo/wallet/StandardAccount.java new file mode 100644 index 0000000..bcc95d3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/StandardAccount.java @@ -0,0 +1,70 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.protocol.ScriptType; + +import java.util.List; + +public enum StandardAccount { + ACCOUNT_0("Account #0", new ChildNumber(0, true)), + ACCOUNT_1("Account #1", new ChildNumber(1, true)), + ACCOUNT_2("Account #2", new ChildNumber(2, true)), + ACCOUNT_3("Account #3", new ChildNumber(3, true)), + ACCOUNT_4("Account #4", new ChildNumber(4, true)), + ACCOUNT_5("Account #5", new ChildNumber(5, true)), + ACCOUNT_6("Account #6", new ChildNumber(6, true)), + ACCOUNT_7("Account #7", new ChildNumber(7, true)), + ACCOUNT_8("Account #8", new ChildNumber(8, true)), + ACCOUNT_9("Account #9", new ChildNumber(9, true)), + WHIRLPOOL_PREMIX("Premix", new ChildNumber(2147483645, true), ScriptType.P2WPKH, null), + WHIRLPOOL_POSTMIX("Postmix", new ChildNumber(2147483646, true), ScriptType.P2WPKH, Wallet.DEFAULT_LOOKAHEAD * 2), + WHIRLPOOL_BADBANK("Badbank", new ChildNumber(2147483644, true), ScriptType.P2WPKH, null); + + public static final List MIXABLE_ACCOUNTS = List.of(ACCOUNT_0, WHIRLPOOL_BADBANK); + public static final List WHIRLPOOL_ACCOUNTS = List.of(WHIRLPOOL_PREMIX, WHIRLPOOL_POSTMIX, WHIRLPOOL_BADBANK); + public static final List WHIRLPOOL_MIX_ACCOUNTS = List.of(WHIRLPOOL_PREMIX, WHIRLPOOL_POSTMIX); + + StandardAccount(String name, ChildNumber childNumber) { + this.name = name; + this.childNumber = childNumber; + this.requiredScriptType = null; + this.minimumGapLimit = null; + } + + StandardAccount(String name, ChildNumber childNumber, ScriptType requiredScriptType, Integer minimumGapLimit) { + this.name = name; + this.childNumber = childNumber; + this.requiredScriptType = requiredScriptType; + this.minimumGapLimit = minimumGapLimit; + } + + private final String name; + private final ChildNumber childNumber; + private final ScriptType requiredScriptType; + private final Integer minimumGapLimit; + + public String getName() { + return name; + } + + public ChildNumber getChildNumber() { + return childNumber; + } + + public int getAccountNumber() { + return childNumber.num(); + } + + public ScriptType getRequiredScriptType() { + return requiredScriptType; + } + + public Integer getMinimumGapLimit() { + return minimumGapLimit; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/StonewallUtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/StonewallUtxoSelector.java new file mode 100644 index 0000000..0c3330d --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/StonewallUtxoSelector.java @@ -0,0 +1,93 @@ +package com.sparrowwallet.drongo.wallet; + +import com.sparrowwallet.drongo.protocol.ScriptType; + +import java.util.*; +import java.util.stream.Collectors; + +public class StonewallUtxoSelector implements UtxoSelector { + private final ScriptType preferredScriptType; + private final long noInputsFee; + + //Use the same seed so the UTXO selection is deterministic + private final Random random = new Random(42); + + public StonewallUtxoSelector(ScriptType preferredScriptType, long noInputsFee) { + this.preferredScriptType = preferredScriptType; + this.noInputsFee = noInputsFee; + } + + @Override + public List> selectSets(long targetValue, Collection candidates) { + long actualTargetValue = targetValue + noInputsFee; + + List uniqueCandidates = new ArrayList<>(); + for(OutputGroup candidate : candidates) { + OutputGroup existingTxGroup = getTransactionAlreadySelected(uniqueCandidates, candidate); + if(existingTxGroup != null) { + if(candidate.getValue() > existingTxGroup.getValue()) { + uniqueCandidates.remove(existingTxGroup); + uniqueCandidates.add(candidate); + } + } else { + uniqueCandidates.add(candidate); + } + } + + List preferredCandidates = uniqueCandidates.stream().filter(outputGroup -> outputGroup.getScriptType().equals(preferredScriptType)).collect(Collectors.toList()); + List> preferredSets = selectSets(targetValue, preferredCandidates, actualTargetValue); + if(!preferredSets.isEmpty()) { + return preferredSets; + } + + return selectSets(targetValue, uniqueCandidates, actualTargetValue); + } + + private List> selectSets(long targetValue, List uniqueCandidates, long actualTargetValue) { + for(int i = 0; i < 15; i++) { + List randomized = new ArrayList<>(uniqueCandidates); + Collections.shuffle(randomized, random); + + List set1 = new ArrayList<>(); + long selectedValue1 = getUtxoSet(actualTargetValue, set1, randomized); + + List set2 = new ArrayList<>(); + long selectedValue2 = getUtxoSet(actualTargetValue, set2, randomized); + + if(selectedValue1 >= targetValue && selectedValue2 >= targetValue) { + return List.of(getUtxos(set1), getUtxos(set2)); + } + } + + return Collections.emptyList(); + } + + private long getUtxoSet(long targetValue, List selectedSet, List randomized) { + long selectedValue = 0; + while(selectedValue <= targetValue && !randomized.isEmpty()) { + OutputGroup candidate = randomized.remove(0); + selectedSet.add(candidate); + selectedValue = selectedSet.stream().mapToLong(OutputGroup::getEffectiveValue).sum(); + } + + return selectedValue; + } + + private OutputGroup getTransactionAlreadySelected(List selected, OutputGroup candidateGroup) { + for(OutputGroup selectedGroup : selected) { + for(BlockTransactionHashIndex selectedUtxo : selectedGroup.getUtxos()) { + for(BlockTransactionHashIndex candidateUtxo : candidateGroup.getUtxos()) { + if(selectedUtxo.getHash().equals(candidateUtxo.getHash())) { + return selectedGroup; + } + } + } + } + + return null; + } + + private Collection getUtxos(List set) { + return set.stream().flatMap(outputGroup -> outputGroup.getUtxos().stream()).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoMixData.java b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoMixData.java new file mode 100644 index 0000000..1bf8764 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoMixData.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.drongo.wallet; + +public class UtxoMixData extends Persistable { + private final int mixesDone; + private final Long expired; + + public UtxoMixData(int mixesDone, Long expired) { + this.mixesDone = mixesDone; + this.expired = expired; + } + + public int getMixesDone() { + return mixesDone; + } + + public Long getExpired() { + return expired; + } + + @Override + public String toString() { + return "{mixesDone:" + mixesDone + ", expired: " + expired + "}"; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java index ca2e277..4d6efa5 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/UtxoSelector.java @@ -1,7 +1,11 @@ package com.sparrowwallet.drongo.wallet; import java.util.Collection; +import java.util.List; public interface UtxoSelector { - Collection select(long targetValue, Collection candidates); + 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 252d5c7..5e13524 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/Wallet.java @@ -1,9 +1,10 @@ package com.sparrowwallet.drongo.wallet; -import com.sparrowwallet.drongo.BitcoinUnit; -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; @@ -11,7 +12,9 @@ import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTInput; +import com.sparrowwallet.drongo.psbt.PSBTOutput; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -19,10 +22,14 @@ import java.util.stream.Collectors; import static com.sparrowwallet.drongo.protocol.ScriptType.*; import static com.sparrowwallet.drongo.protocol.Transaction.WITNESS_SCALE_FACTOR; -public class Wallet { +public class Wallet extends Persistable implements Comparable { public static final int DEFAULT_LOOKAHEAD = 20; + public static final String ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY = "com.sparrowwallet.allowDerivationsMatchingOtherScriptTypes"; private String name; + private String label; + private Wallet masterWallet; + private List childWallets = new ArrayList<>(); private Network network = Network.get(); private PolicyType policyType; private ScriptType scriptType; @@ -30,8 +37,13 @@ public class Wallet { private List keystores = new ArrayList<>(); private final TreeSet purposeNodes = new TreeSet<>(); private final Map transactions = new HashMap<>(); + private final Map detachedLabels = new HashMap<>(); + private WalletConfig walletConfig; + private MixConfig mixConfig; + private final Map utxoMixes = new HashMap<>(); private Integer storedBlockHeight; private Integer gapLimit; + private Integer watchLast; private Date birthDate; public Wallet() { @@ -58,10 +70,312 @@ public class Wallet { return name; } + public String getFullName() { + if(isMasterWallet()) { + return childWallets.isEmpty() ? name : name + "-" + (label != null && !label.isEmpty() ? label : getAutomaticName()); + } + + return getMasterWallet().getName() + "-" + getDisplayName(); + } + + public String getFullDisplayName() { + if(isMasterWallet()) { + return childWallets.isEmpty() ? name : name + " - " + (label != null && !label.isEmpty() ? label : getAutomaticName()); + } + + return getMasterWallet().getName() + " - " + getDisplayName(); + } + + public String getDisplayName() { + return label != null && !label.isEmpty() ? label : (isMasterWallet() ? getAutomaticName() : name); + } + + public String getAutomaticName() { + int account = getAccountIndex(); + return (account < 1 || account > 9) ? "Deposit" : "Account #" + account; + } + + public String getMasterName() { + if(isMasterWallet()) { + return name; + } + + return getMasterWallet().getName(); + } + + public Wallet addChildWallet(StandardAccount standardAccount) { + Wallet childWallet = this.copy(); + + if(!isMasterWallet()) { + throw new IllegalStateException("Cannot add child wallet to existing child wallet"); + } + + if(childWallet.containsMasterPrivateKeys() && childWallet.isEncrypted()) { + throw new IllegalStateException("Cannot derive child wallet xpub from encrypted wallet"); + } + + childWallet.setId(null); + childWallet.setName(standardAccount.getName()); + childWallet.setLabel(null); + childWallet.purposeNodes.clear(); + childWallet.transactions.clear(); + childWallet.detachedLabels.clear(); + childWallet.childWallets.clear(); + childWallet.storedBlockHeight = null; + childWallet.gapLimit = standardAccount.getMinimumGapLimit(); + childWallet.birthDate = null; + + if(standardAccount.getRequiredScriptType() != null) { + childWallet.setScriptType(standardAccount.getRequiredScriptType()); + } + + for(Keystore keystore : childWallet.getKeystores()) { + List derivation = standardAccount.getRequiredScriptType() != null ? standardAccount.getRequiredScriptType().getDefaultDerivation() : keystore.getKeyDerivation().getDerivation(); + List childDerivation; + if(childWallet.getScriptType().getAccount(KeyDerivation.writePath(derivation)) > -1) { + childDerivation = childWallet.getScriptType().getDefaultDerivation(standardAccount.getChildNumber().num()); + } else { + childDerivation = derivation.isEmpty() ? new ArrayList<>() : new ArrayList<>(derivation.subList(0, derivation.size() - 1)); + childDerivation.add(standardAccount.getChildNumber()); + } + + if(keystore.hasMasterPrivateKey()) { + try { + Keystore derivedKeystore = keystore.hasSeed() ? Keystore.fromSeed(keystore.getSeed(), childDerivation) : Keystore.fromMasterPrivateExtendedKey(keystore.getMasterPrivateExtendedKey(), childDerivation); + keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); + keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); + } catch(Exception e) { + throw new IllegalStateException("Cannot derive keystore for " + standardAccount + " account", e); + } + } else { + keystore.setKeyDerivation(new KeyDerivation(null, KeyDerivation.writePath(childDerivation))); + keystore.setExtendedPublicKey(null); + } + } + + childWallet.setMasterWallet(this); + getChildWallets().add(childWallet); + return childWallet; + } + + public Wallet getChildWallet(StandardAccount account) { + for(Wallet childWallet : getChildWallets()) { + if(!childWallet.isNested()) { + for(Keystore keystore : childWallet.getKeystores()) { + if(keystore.getKeyDerivation().getDerivation().get(keystore.getKeyDerivation().getDerivation().size() - 1).equals(account.getChildNumber())) { + return childWallet; + } + } + } + } + + return null; + } + + public Wallet addChildWallet(PaymentCode externalPaymentCode, ScriptType childScriptType, BlockTransactionHashIndex notificationOutput, BlockTransaction notificationTransaction, String label) { + Wallet bip47Wallet = addChildWallet(externalPaymentCode, childScriptType, label); + 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, String label) { + if(policyType != PolicyType.SINGLE) { + throw new IllegalStateException("Cannot add payment code wallet to " + policyType.getName() + " wallet"); + } + + if(!PaymentCode.SEGWIT_SCRIPT_TYPES.contains(scriptType)) { + 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.setLabel(label); + 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(); + allWallets.add(masterWallet); + for(Wallet childWallet : getChildWallets()) { + if(!childWallet.isNested()) { + allWallets.add(childWallet); + } + } + + return allWallets; + } + + public boolean hasPaymentCode() { + return getKeystores().size() == 1 && getKeystores().get(0).getBip47ExtendedPrivateKey() != null && policyType == PolicyType.SINGLE + && PaymentCode.SEGWIT_SCRIPT_TYPES.contains(scriptType); + } + + 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 = getWalletTransaction(txoEntry.getKey().getSpentBy().getHash()); + if(blockTransaction != null) { + TransactionInput txInput0 = blockTransaction.getTransaction().getInputs().get(0); + for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) { + if(notificationAddress.equals(txOutput.getScript().getToAddress()) + && txoEntry.getValue().getTransactionOutputs().stream().anyMatch(ref -> ref.getHash().equals(txInput0.getOutpoint().getHash()) && ref.getIndex() == txInput0.getOutpoint().getIndex())) { + try { + PaymentCode.getOpReturnData(blockTransaction.getTransaction()); + return Map.of(blockTransaction, txoEntry.getValue()); + } catch(Exception e) { + //ignore + } + } + } + } + } + } + + return Collections.emptyMap(); + } + + public boolean isNested() { + return isBip47(); + } + + 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); + } + + public int getAccountIndex() { + int index = -1; + + for(Keystore keystore : getKeystores()) { + if(keystore.getKeyDerivation() != null) { + int keystoreAccount = getScriptType().getAccount(keystore.getKeyDerivation().getDerivationPath()); + if(keystoreAccount != -1 && (index == -1 || keystoreAccount == index)) { + index = keystoreAccount; + } else if(!keystore.getKeyDerivation().getDerivation().isEmpty()) { + keystoreAccount = keystore.getKeyDerivation().getDerivation().get(keystore.getKeyDerivation().getDerivation().size() - 1).num(); + if(index == -1 || keystoreAccount == index) { + index = keystoreAccount; + } + } + } + } + + return index; + } + + public boolean isWhirlpoolMasterWallet() { + if(!isMasterWallet()) { + return false; + } + + Set whirlpoolAccounts = new HashSet<>(Set.of(StandardAccount.WHIRLPOOL_PREMIX, StandardAccount.WHIRLPOOL_POSTMIX, StandardAccount.WHIRLPOOL_BADBANK)); + for(Wallet childWallet : getChildWallets()) { + if(!childWallet.isNested()) { + whirlpoolAccounts.remove(childWallet.getStandardAccountType()); + } + } + + return whirlpoolAccounts.isEmpty(); + } + + public boolean isWhirlpoolChildWallet() { + return !isMasterWallet() && getStandardAccountType() != null && StandardAccount.WHIRLPOOL_ACCOUNTS.contains(getStandardAccountType()); + } + + public boolean isWhirlpoolMixWallet() { + return !isMasterWallet() && StandardAccount.WHIRLPOOL_MIX_ACCOUNTS.contains(getStandardAccountType()); + } + public void setName(String name) { this.name = name; } + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + public Network getNetwork() { return network; } @@ -104,8 +418,17 @@ public class Wallet { public synchronized void updateTransactions(Map updatedTransactions) { for(BlockTransaction blockTx : updatedTransactions.values()) { - Optional optionalLabel = transactions.values().stream().filter(oldBlTx -> oldBlTx.getHash().equals(blockTx.getHash())).map(BlockTransaction::getLabel).filter(Objects::nonNull).findFirst(); - optionalLabel.ifPresent(blockTx::setLabel); + if(!transactions.isEmpty()) { + Optional optionalLabel = transactions.values().stream().filter(oldBlTx -> oldBlTx.getHash().equals(blockTx.getHash())).map(BlockTransaction::getLabel).filter(Objects::nonNull).findFirst(); + optionalLabel.ifPresent(blockTx::setLabel); + } + + if(!detachedLabels.isEmpty()) { + String label = detachedLabels.remove(blockTx.getHashAsString()); + if(label != null && (blockTx.getLabel() == null || blockTx.getLabel().isEmpty())) { + blockTx.setLabel(label); + } + } } transactions.putAll(updatedTransactions); @@ -115,6 +438,62 @@ public class Wallet { } } + public Map getDetachedLabels() { + return detachedLabels; + } + + public WalletConfig getWalletConfig() { + return walletConfig; + } + + public WalletConfig getMasterWalletConfig() { + if(!isMasterWallet()) { + return getMasterWallet().getMasterWalletConfig(); + } + + if(walletConfig == null) { + walletConfig = new WalletConfig(); + } + + return walletConfig; + } + + public void setWalletConfig(WalletConfig walletConfig) { + this.walletConfig = walletConfig; + } + + public MixConfig getMixConfig() { + return mixConfig; + } + + public MixConfig getMasterMixConfig() { + if(!isMasterWallet()) { + return getMasterWallet().getMasterMixConfig(); + } + + if(mixConfig == null) { + mixConfig = new MixConfig(); + } + + return mixConfig; + } + + public void setMixConfig(MixConfig mixConfig) { + this.mixConfig = mixConfig; + } + + public UtxoMixData getUtxoMixData(BlockTransactionHashIndex utxo) { + return getUtxoMixData(Sha256Hash.of(utxo.toString().getBytes(StandardCharsets.UTF_8))); + } + + public UtxoMixData getUtxoMixData(Sha256Hash utxoKey) { + return utxoMixes.get(utxoKey); + } + + public Map getUtxoMixes() { + return utxoMixes; + } + public Integer getStoredBlockHeight() { return storedBlockHeight; } @@ -123,14 +502,30 @@ public class Wallet { this.storedBlockHeight = storedBlockHeight; } + public Integer gapLimit() { + return gapLimit; + } + public int getGapLimit() { return gapLimit == null ? DEFAULT_LOOKAHEAD : gapLimit; } + public void gapLimit(Integer gapLimit) { + this.gapLimit = gapLimit; + } + public void setGapLimit(int gapLimit) { this.gapLimit = gapLimit; } + public Integer getWatchLast() { + return watchLast; + } + + public void setWatchLast(Integer watchLast) { + this.watchLast = watchLast; + } + public Date getBirthDate() { return birthDate; } @@ -139,17 +534,45 @@ public class Wallet { this.birthDate = birthDate; } + public boolean isMasterWallet() { + return masterWallet == null; + } + + public Wallet getMasterWallet() { + return masterWallet; + } + + public void setMasterWallet(Wallet masterWallet) { + this.masterWallet = masterWallet; + } + + public Wallet getChildWallet(String name) { + return childWallets.stream().filter(wallet -> wallet.getName().equals(name)).findFirst().orElse(null); + } + + public List getChildWallets() { + return childWallets; + } + + public void setChildWallets(List childWallets) { + this.childWallets = childWallets; + } + + public TreeSet getPurposeNodes() { + return purposeNodes; + } + public synchronized WalletNode getNode(KeyPurpose keyPurpose) { WalletNode purposeNode; Optional optionalPurposeNode = purposeNodes.stream().filter(node -> node.getKeyPurpose().equals(keyPurpose)).findFirst(); if(optionalPurposeNode.isEmpty()) { - purposeNode = new WalletNode(keyPurpose); + purposeNode = new WalletNode(this, keyPurpose); purposeNodes.add(purposeNode); } else { purposeNode = optionalPurposeNode.get(); } - purposeNode.fillToIndex(getLookAheadIndex(purposeNode)); + purposeNode.fillToIndex(this, getLookAheadIndex(purposeNode)); return purposeNode; } @@ -181,7 +604,7 @@ public class Wallet { } if(index >= node.getChildren().size()) { - node.fillToIndex(index); + node.fillToIndex(this, index); } for(WalletNode childNode : node.getChildren()) { @@ -194,10 +617,6 @@ public class Wallet { } 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) { @@ -205,33 +624,25 @@ public class Wallet { } 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 = node.getPubKey(); return scriptType.getAddress(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = node.getPubKeys(); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getAddress(script); } else { @@ -240,15 +651,11 @@ public class Wallet { } 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 = node.getPubKey(); return scriptType.getOutputScript(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = node.getPubKeys(); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputScript(script); } else { @@ -257,15 +664,11 @@ public class Wallet { } 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 = node.getPubKey(); return scriptType.getOutputDescriptor(pubKey); } else if(policyType == PolicyType.MULTI) { - List pubKeys = getPubKeys(keyPurpose, index); + List pubKeys = node.getPubKeys(); Script script = ScriptType.MULTISIG.getOutputScript(defaultPolicy.getNumSignaturesRequired(), pubKeys); return scriptType.getOutputDescriptor(script); } else { @@ -273,20 +676,47 @@ public class Wallet { } } + 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<>(); + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { + getNode(keyPurpose).getChildren().forEach(childNode -> walletNodes.put(childNode, childNode.getTransactionOutputs())); + } + + return walletNodes; + } + public boolean isWalletAddress(Address address) { return getWalletAddresses().containsKey(address); } 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)); + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { + getWalletAddresses(walletAddresses, childWallet.getNode(keyPurpose)); + } + } + } + return walletAddresses; } private void getWalletAddresses(Map walletAddresses, WalletNode purposeNode) { for(WalletNode addressNode : purposeNode.getChildren()) { - walletAddresses.put(getAddress(addressNode), addressNode); + walletAddresses.put(addressNode.getAddress(), addressNode); } } @@ -295,31 +725,68 @@ public class Wallet { } 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)); } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { + if(keyPurposes.contains(keyPurpose)) { + getWalletOutputScripts(walletOutputScripts, childWallet.getNode(keyPurpose)); + } + } + } + } + return walletOutputScripts; } private void getWalletOutputScripts(Map walletOutputScripts, WalletNode purposeNode) { for(WalletNode addressNode : purposeNode.getChildren()) { - walletOutputScripts.put(getOutputScript(addressNode), addressNode); + walletOutputScripts.put(addressNode.getOutputScript(), addressNode); } } + public boolean isWalletTxo(TransactionInput txInput) { + return getWalletTxos().keySet().stream().anyMatch(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex()); + } + + public boolean isWalletTxo(TransactionOutput txOutput) { + return getWalletTxos().keySet().stream().anyMatch(ref -> ref.getHash().equals(txOutput.getHash()) && ref.getIndex() == txOutput.getIndex()); + } + public boolean isWalletTxo(BlockTransactionHashIndex txo) { return getWalletTxos().containsKey(txo); } 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)); + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { + getWalletTxos(walletTxos, childWallet.getNode(keyPurpose)); + } + } + } + return walletTxos; } @@ -335,21 +802,76 @@ public class Wallet { return getWalletUtxos(false); } - public Map getWalletUtxos(boolean includeMempoolInputs) { + public Map getWalletUtxos(boolean includeSpentMempoolOutputs) { Map walletUtxos = new TreeMap<>(); - getWalletUtxos(walletUtxos, getNode(KeyPurpose.RECEIVE), includeMempoolInputs); - getWalletUtxos(walletUtxos, getNode(KeyPurpose.CHANGE), includeMempoolInputs); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getWalletUtxos(walletUtxos, getNode(keyPurpose), includeSpentMempoolOutputs); + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { + getWalletUtxos(walletUtxos, childWallet.getNode(keyPurpose), includeSpentMempoolOutputs); + } + } + } + return walletUtxos; } - private void getWalletUtxos(Map walletUtxos, WalletNode purposeNode, boolean includeMempoolInputs) { + private void getWalletUtxos(Map walletUtxos, WalletNode purposeNode, boolean includeSpentMempoolOutputs) { for(WalletNode addressNode : purposeNode.getChildren()) { - for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeMempoolInputs)) { + for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) { walletUtxos.put(utxo, addressNode); } } } + public boolean hasTransactions() { + if(!transactions.isEmpty()) { + return true; + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + if(!childWallet.transactions.isEmpty()) { + return true; + } + } + } + + return false; + } + + public BlockTransaction getWalletTransaction(Sha256Hash txid) { + BlockTransaction blockTransaction = transactions.get(txid); + if(blockTransaction != null) { + return blockTransaction; + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + blockTransaction = childWallet.transactions.get(txid); + if(blockTransaction != null) { + return blockTransaction; + } + } + } + + return null; + } + + public Map getWalletTransactions() { + Map allTransactions = new HashMap<>(transactions); + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + allTransactions.putAll(childWallet.transactions); + } + } + + return allTransactions; + } + /** * Determines the dust threshold for creating a new change output in this wallet. * @@ -400,7 +922,7 @@ public class Wallet { public int getNoInputsWeightUnits(List payments) { Transaction transaction = new Transaction(); if(Arrays.asList(ScriptType.WITNESS_TYPES).contains(getScriptType())) { - transaction.setSegwitVersion(0); + transaction.setSegwitFlag(Transaction.DEFAULT_SEGWIT_FLAG); } for(Payment payment : payments) { transaction.addOutput(payment.getAmount(), payment.getAddress()); @@ -418,28 +940,28 @@ public class Wallet { } /** - * Return the number of vBytes required for an input created by this wallet. + * Return the number of weight units required for an input created by this wallet. * - * @return the number of vBytes + * @return the number of weight units (WU) */ public int getInputWeightUnits() { - //Estimate assuming an input spending from a fresh receive node - it does not matter this node has no real utxos - WalletNode receiveNode = getFreshNode(KeyPurpose.RECEIVE); + //Estimate assuming an input spending from the parent receive node - it does not matter this node has no real utxos + WalletNode receiveNode = new WalletNode(this, KeyPurpose.RECEIVE); Transaction transaction = new Transaction(); - TransactionOutput prevTxOut = transaction.addOutput(1L, getAddress(receiveNode)); + TransactionOutput prevTxOut = transaction.addOutput(1L, receiveNode.getAddress()); TransactionInput txInput = null; if(getPolicyType().equals(PolicyType.SINGLE)) { - ECKey pubKey = getPubKey(receiveNode); - TransactionSignature signature = TransactionSignature.dummy(); + ECKey pubKey = receiveNode.getPubKey(); + TransactionSignature signature = TransactionSignature.dummy(getScriptType().getSignatureType()); txInput = getScriptType().addSpendingInput(transaction, prevTxOut, pubKey, signature); } else if(getPolicyType().equals(PolicyType.MULTI)) { - List pubKeys = getPubKeys(receiveNode); + List pubKeys = receiveNode.getPubKeys(); int threshold = getDefaultPolicy().getNumSignaturesRequired(); Map pubKeySignatures = new TreeMap<>(new ECKey.LexicographicECKeyComparator()); for(int i = 0; i < pubKeys.size(); i++) { - pubKeySignatures.put(pubKeys.get(i), i < threshold ? TransactionSignature.dummy() : null); + pubKeySignatures.put(pubKeys.get(i), i < threshold ? TransactionSignature.dummy(getScriptType().getSignatureType()) : null); } txInput = getScriptType().addMultisigSpendingInput(transaction, prevTxOut, threshold, pubKeySignatures); } @@ -455,17 +977,39 @@ public class Wallet { public long getCostOfChange(double feeRate, double longTermFeeRate) { WalletNode changeNode = getFreshNode(KeyPurpose.CHANGE); - TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, getOutputScript(changeNode)); + TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, changeNode.getOutputScript()); return getFee(changeOutput, feeRate, longTermFeeRate); } - public WalletTransaction createWalletTransaction(List utxoSelectors, List utxoFilters, List payments, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolChange, boolean includeMempoolInputs) throws InsufficientFundsException { + public WalletTransaction createWalletTransaction(List utxoSelectors, List utxoFilters, List payments, List opReturns, Set excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) throws InsufficientFundsException { + boolean sendMax = payments.stream().anyMatch(Payment::isSendMax); long totalPaymentAmount = payments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(); - long valueRequiredAmt = totalPaymentAmount; + long totalUtxoValue = getWalletUtxos().keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + + if(fee != null && feeRate != Transaction.DEFAULT_MIN_RELAY_FEE) { + throw new IllegalArgumentException("Use an input fee rate of 1 sat/vB when using a defined fee amount so UTXO selectors overestimate effective value"); + } + + long maxSpendableAmt = getMaxSpendable(payments.stream().map(Payment::getAddress).collect(Collectors.toList()), feeRate, includeSpentMempoolOutputs); + if(maxSpendableAmt < 0) { + throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to the provided addresses at this fee rate"); + } + + //When a user fee is set, we can calculate the fees to spend all UTXOs because we assume all UTXOs are spendable at a fee rate of 1 sat/vB + //We can then add the user set fee less this amount as a "phantom payment amount" to the value required to find (which cannot include transaction fees) + long valueRequiredAmt = totalPaymentAmount + (fee != null ? fee - (totalUtxoValue - maxSpendableAmt) : 0); + if(maxSpendableAmt < valueRequiredAmt) { + throw new InsufficientFundsException("Not enough combined value in all available UTXOs to send a transaction to send the provided payments at the user set fee" + (fee == null ? " rate" : "")); + } while(true) { - Map selectedUtxos = selectInputs(utxoSelectors, utxoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolChange, includeMempoolInputs); + List> selectedUtxoSets = selectInputSets(utxoSelectors, utxoFilters, valueRequiredAmt, feeRate, longTermFeeRate, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs, sendMax); + Map selectedUtxos = new LinkedHashMap<>(); + selectedUtxoSets.forEach(selectedUtxos::putAll); long totalSelectedAmt = selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + int numSets = selectedUtxoSets.size(); + List txPayments = new ArrayList<>(payments); + Set txExcludedChangeNodes = new HashSet<>(excludedChangeNodes); Transaction transaction = new Transaction(); transaction.setVersion(2); @@ -475,7 +1019,7 @@ public class Wallet { //Add inputs for(Map.Entry selectedUtxo : selectedUtxos.entrySet()) { - Transaction prevTx = getTransactions().get(selectedUtxo.getKey().getHash()).getTransaction(); + Transaction prevTx = getWalletTransaction(selectedUtxo.getKey().getHash()).getTransaction(); TransactionOutput prevTxOut = prevTx.getOutputs().get((int)selectedUtxo.getKey().getIndex()); TransactionInput txInput = addDummySpendingInput(transaction, selectedUtxo.getValue(), prevTxOut); @@ -483,24 +1027,42 @@ public class Wallet { txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED); } + if(getScriptType() == P2TR && currentBlockHeight != null) { + applySequenceAntiFeeSniping(transaction, selectedUtxos, currentBlockHeight); + } + + for(int i = 1; i < numSets; i+=2) { + WalletNode mixNode = getFreshNode(getChangeKeyPurpose()); + txExcludedChangeNodes.add(mixNode); + Payment fakeMixPayment = new Payment(mixNode.getAddress(), ".." + mixNode + " (Fake Mix)", totalPaymentAmount, false); + fakeMixPayment.setType(Payment.Type.FAKE_MIX); + txPayments.add(fakeMixPayment); + } + //Add recipient outputs - for(Payment payment : payments) { + for(Payment payment : txPayments) { transaction.addOutput(payment.getAmount(), payment.getAddress()); } - int noChangeVSize = transaction.getVirtualSize(); - long noChangeFeeRequiredAmt = (fee == null ? (long)(feeRate * noChangeVSize) : fee); + //Add OP_RETURNs + for(byte[] opReturn : opReturns) { + transaction.addOutput(0L, new Script(List.of(ScriptChunk.fromOpcode(ScriptOpCodes.OP_RETURN), ScriptChunk.fromData(opReturn)))); + } + + double noChangeVSize = transaction.getVirtualSize(); + long noChangeFeeRequiredAmt = (fee == null ? (long)Math.floor(feeRate * noChangeVSize) : fee); //Add 1 satoshi to accommodate longer signatures when feeRate equals default min relay fee to ensure fee is sufficient - noChangeFeeRequiredAmt = (feeRate == Transaction.DEFAULT_MIN_RELAY_FEE ? noChangeFeeRequiredAmt + 1 : noChangeFeeRequiredAmt); + noChangeFeeRequiredAmt = (fee == null && feeRate == Transaction.DEFAULT_MIN_RELAY_FEE ? noChangeFeeRequiredAmt + 1 : noChangeFeeRequiredAmt); //If sending all selected utxos, set the recipient amount to equal to total of those utxos less the no change fee long maxSendAmt = totalSelectedAmt - noChangeFeeRequiredAmt; - Optional optMaxPayment = payments.stream().filter(payment -> payment.isSendMax()).findFirst(); + + Optional optMaxPayment = payments.stream().filter(Payment::isSendMax).findFirst(); if(optMaxPayment.isPresent()) { Payment maxPayment = optMaxPayment.get(); maxSendAmt = maxSendAmt - payments.stream().filter(payment -> !maxPayment.equals(payment)).map(Payment::getAmount).mapToLong(v -> v).sum(); - if(maxPayment.getAmount() != maxSendAmt) { + if(maxSendAmt > 0 && maxPayment.getAmount() != maxSendAmt) { maxPayment.setAmount(maxSendAmt); totalPaymentAmount = payments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(); continue; @@ -508,80 +1070,156 @@ public class Wallet { } //Calculate what is left over from selected utxos after paying recipient - long differenceAmt = totalSelectedAmt - totalPaymentAmount; + long differenceAmt = totalSelectedAmt - totalPaymentAmount * numSets; //If insufficient fee, increase value required from inputs to include the fee and try again if(differenceAmt < noChangeFeeRequiredAmt) { valueRequiredAmt = totalSelectedAmt + 1; + //If we haven't selected all UTXOs yet, don't require more than the max spendable amount + if(valueRequiredAmt > maxSpendableAmt && transaction.getInputs().size() < getWalletUtxos().size()) { + valueRequiredAmt = maxSpendableAmt; + } + continue; } //Determine if a change output is required by checking if its value is greater than its dust threshold - long changeAmt = differenceAmt - noChangeFeeRequiredAmt; - long costOfChangeAmt = getCostOfChange(feeRate, longTermFeeRate); - if(changeAmt > costOfChangeAmt) { + List setChangeAmts = getSetChangeAmounts(selectedUtxoSets, totalPaymentAmount, noChangeFeeRequiredAmt); + double noChangeFeeRate = (fee == null ? feeRate : noChangeFeeRequiredAmt / transaction.getVirtualSize()); + 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); - TransactionOutput changeOutput = new TransactionOutput(transaction, changeAmt, getOutputScript(changeNode)); - int changeVSize = noChangeVSize + changeOutput.getLength(); - long changeFeeRequiredAmt = (fee == null ? (long)(feeRate * changeVSize) : fee); - changeFeeRequiredAmt = (feeRate == Transaction.DEFAULT_MIN_RELAY_FEE ? changeFeeRequiredAmt + 1 : changeFeeRequiredAmt); - - //Recalculate the change amount with the new fee - changeAmt = differenceAmt - changeFeeRequiredAmt; - if(changeAmt < costOfChangeAmt) { - //The new fee has meant that the change output is now dust. We pay too high a fee without change, but change is dust when added. Increase value required from inputs and try again - valueRequiredAmt = totalSelectedAmt + 1; - continue; + WalletNode changeNode = getFreshNode(getChangeKeyPurpose()); + while(txExcludedChangeNodes.contains(changeNode)) { + changeNode = getFreshNode(getChangeKeyPurpose(), changeNode); + } + TransactionOutput changeOutput = new TransactionOutput(transaction, setChangeAmts.iterator().next(), changeNode.getOutputScript()); + double changeVSize = noChangeVSize + changeOutput.getLength() * numSets; + long changeFeeRequiredAmt = (fee == null ? (long)Math.floor(feeRate * changeVSize) : fee); + changeFeeRequiredAmt = (fee == null && feeRate == Transaction.DEFAULT_MIN_RELAY_FEE ? changeFeeRequiredAmt + 1 : changeFeeRequiredAmt); + while(changeFeeRequiredAmt % numSets > 0) { + changeFeeRequiredAmt++; } - //Add change output - transaction.addOutput(changeAmt, getOutputScript(changeNode)); + //Add change output(s) + Map changeMap = new LinkedHashMap<>(); + setChangeAmts = getSetChangeAmounts(selectedUtxoSets, totalPaymentAmount, changeFeeRequiredAmt); + for(Long setChangeAmt : setChangeAmts) { + transaction.addOutput(setChangeAmt, changeNode.getOutputScript()); + changeMap.put(changeNode, setChangeAmt); + changeNode = getFreshNode(getChangeKeyPurpose(), changeNode); + } - return new WalletTransaction(this, transaction, utxoSelectors, selectedUtxos, payments, changeNode, changeAmt, changeFeeRequiredAmt); + if(setChangeAmts.stream().anyMatch(amt -> amt < costOfChangeAmt)) { + //The new fee has meant that one of the change outputs is now dust. We pay too high a fee without change, but change is dust when added. + if(numSets > 1 && differenceAmt / transaction.getVirtualSize() < noChangeFeeRate * 2) { + //Maximize privacy. Pay a higher fee to keep multiple output sets. + return new WalletTransaction(this, transaction, utxoSelectors, selectedUtxoSets, txPayments, differenceAmt); + } else { + //Maxmize efficiency. Increase value required from inputs and try again. + valueRequiredAmt = totalSelectedAmt + 1; + continue; + } + } + + return new WalletTransaction(this, transaction, utxoSelectors, selectedUtxoSets, txPayments, changeMap, changeFeeRequiredAmt); } - return new WalletTransaction(this, transaction, utxoSelectors, selectedUtxos, payments, differenceAmt); + return new WalletTransaction(this, transaction, utxoSelectors, selectedUtxoSets, txPayments, differenceAmt); } } - public TransactionInput addDummySpendingInput(Transaction transaction, WalletNode walletNode, TransactionOutput prevTxOut) { - if(getPolicyType().equals(PolicyType.SINGLE)) { - ECKey pubKey = getPubKey(walletNode); - return getScriptType().addSpendingInput(transaction, prevTxOut, pubKey, TransactionSignature.dummy()); - } else if(getPolicyType().equals(PolicyType.MULTI)) { - List pubKeys = getPubKeys(walletNode); - int threshold = getDefaultPolicy().getNumSignaturesRequired(); + private void applySequenceAntiFeeSniping(Transaction transaction, Map selectedUtxos, int currentBlockHeight) { + Random random = new Random(); + boolean locktime = random.nextInt(2) == 0 || getScriptType() != P2TR || selectedUtxos.keySet().stream().anyMatch(utxo -> utxo.getConfirmations(currentBlockHeight) > 65535); + + if(locktime) { + transaction.setLocktime(currentBlockHeight); + if(random.nextInt(10) == 0) { + transaction.setLocktime(Math.max(0, currentBlockHeight - random.nextInt(100))); + } + } else { + transaction.setLocktime(0); + int inputIndex = random.nextInt(transaction.getInputs().size()); + TransactionInput txInput = transaction.getInputs().get(inputIndex); + BlockTransactionHashIndex utxo = selectedUtxos.keySet().stream().filter(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex()).findFirst().orElseThrow(); + txInput.setSequenceNumber(utxo.getConfirmations(currentBlockHeight)); + if(random.nextInt(10) == 0) { + txInput.setSequenceNumber(Math.max(0, txInput.getSequenceNumber() - random.nextInt(100))); + } + } + } + + private List getSetChangeAmounts(List> selectedUtxoSets, long totalPaymentAmount, long feeRequiredAmt) { + List changeAmts = new ArrayList<>(); + int numSets = selectedUtxoSets.size(); + for(Map selectedUtxoSet : selectedUtxoSets) { + long setAmt = selectedUtxoSet.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + long setChangeAmt = setAmt - (totalPaymentAmount + feeRequiredAmt / numSets); + changeAmts.add(setChangeAmt); + } + + return changeAmts; + } + + public static TransactionInput addDummySpendingInput(Transaction transaction, WalletNode walletNode, TransactionOutput prevTxOut) { + Wallet signingWallet = walletNode.getWallet(); + if(signingWallet.getPolicyType().equals(PolicyType.SINGLE)) { + ECKey pubKey = walletNode.getPubKey(); + return signingWallet.getScriptType().addSpendingInput(transaction, prevTxOut, pubKey, TransactionSignature.dummy(signingWallet.getScriptType().getSignatureType())); + } else if(signingWallet.getPolicyType().equals(PolicyType.MULTI)) { + List pubKeys = walletNode.getPubKeys(); + int threshold = signingWallet.getDefaultPolicy().getNumSignaturesRequired(); Map pubKeySignatures = new TreeMap<>(new ECKey.LexicographicECKeyComparator()); for(int i = 0; i < pubKeys.size(); i++) { - pubKeySignatures.put(pubKeys.get(i), i < threshold ? TransactionSignature.dummy() : null); + pubKeySignatures.put(pubKeys.get(i), i < threshold ? TransactionSignature.dummy(signingWallet.getScriptType().getSignatureType()) : null); } - return getScriptType().addMultisigSpendingInput(transaction, prevTxOut, threshold, pubKeySignatures); + return signingWallet.getScriptType().addMultisigSpendingInput(transaction, prevTxOut, threshold, pubKeySignatures); } else { - throw new UnsupportedOperationException("Cannot create transaction for policy type " + getPolicyType()); + throw new UnsupportedOperationException("Cannot create transaction for policy type " + signingWallet.getPolicyType()); } } - private Map selectInputs(List utxoSelectors, List utxoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolChange, boolean includeMempoolInputs) throws InsufficientFundsException { - List utxoPool = getGroupedUtxos(utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeMempoolInputs); + private List> selectInputSets(List utxoSelectors, List utxoFilters, Long targetValue, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs, boolean sendMax) throws InsufficientFundsException { + List utxoPool = getGroupedUtxos(utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); List filters = new ArrayList<>(); - filters.add(new OutputGroup.Filter(1, 6)); - filters.add(new OutputGroup.Filter(1, 1)); - if(includeMempoolChange) { - filters.add(new OutputGroup.Filter(0, 1)); + filters.add(new OutputGroup.Filter(1, 6, false)); + filters.add(new OutputGroup.Filter(1, 1, false)); + if(includeMempoolOutputs) { + filters.add(new OutputGroup.Filter(0, 0, false)); + filters.add(new OutputGroup.Filter(0, 0, true)); + } else { + filters.add(new OutputGroup.Filter(1, 1, true)); + } + + if(sendMax) { + Collections.reverse(filters); } for(OutputGroup.Filter filter : filters) { List filteredPool = utxoPool.stream().filter(filter::isEligible).collect(Collectors.toList()); for(UtxoSelector utxoSelector : utxoSelectors) { - Collection selectedInputs = utxoSelector.select(targetValue, filteredPool); - long total = selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); - if(total > targetValue) { - Map utxos = getWalletUtxos(includeMempoolInputs); - utxos.keySet().retainAll(selectedInputs); - return utxos; + List> selectedInputSets = utxoSelector.selectSets(targetValue, filteredPool); + List> selectedInputSetsList = new ArrayList<>(); + long total = 0; + Map utxos = getWalletUtxos(includeSpentMempoolOutputs); + for(Collection selectedInputs : selectedInputSets) { + total += selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + Map selectedInputsMap = new LinkedHashMap<>(); + List shuffledInputs = new ArrayList<>(selectedInputs); + if(utxoSelector.shuffleInputs()) { + Collections.shuffle(shuffledInputs); + } + for(BlockTransactionHashIndex shuffledInput : shuffledInputs) { + selectedInputsMap.put(shuffledInput, utxos.get(shuffledInput)); + } + selectedInputSetsList.add(selectedInputsMap); + } + + if(total > targetValue * selectedInputSetsList.size()) { + return selectedInputSetsList; } } } @@ -589,28 +1227,41 @@ public class Wallet { throw new InsufficientFundsException("Not enough combined value in UTXOs for output value " + targetValue); } - private List getGroupedUtxos(List utxoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolInputs) { + 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, includeMempoolInputs); - getGroupedUtxos(outputGroups, getNode(KeyPurpose.CHANGE), utxoFilters, feeRate, longTermFeeRate, groupByAddress, includeMempoolInputs); + Map walletTransactions = getWalletTransactions(); + Map walletTxos = getWalletTxos(); + for(KeyPurpose keyPurpose : getWalletKeyPurposes()) { + getGroupedUtxos(outputGroups, getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) { + childWallet.getGroupedUtxos(outputGroups, childWallet.getNode(keyPurpose), utxoFilters, walletTransactions, walletTxos, feeRate, longTermFeeRate, groupByAddress, includeSpentMempoolOutputs); + } + } + } + return outputGroups; } - private void getGroupedUtxos(List outputGroups, WalletNode purposeNode, List utxoFilters, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeMempoolInputs) { + private void getGroupedUtxos(List outputGroups, WalletNode purposeNode, List utxoFilters, Map walletTransactions, Map walletTxos, double feeRate, double longTermFeeRate, boolean groupByAddress, boolean includeSpentMempoolOutputs) { + int inputWeightUnits = getInputWeightUnits(); for(WalletNode addressNode : purposeNode.getChildren()) { OutputGroup outputGroup = null; - for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeMempoolInputs)) { + for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs(includeSpentMempoolOutputs)) { Optional matchedFilter = utxoFilters.stream().filter(utxoFilter -> !utxoFilter.isEligible(utxo)).findAny(); if(matchedFilter.isPresent()) { continue; } if(outputGroup == null || !groupByAddress) { - outputGroup = new OutputGroup(getStoredBlockHeight(), getInputWeightUnits(), feeRate, longTermFeeRate); + outputGroup = new OutputGroup(addressNode.getWallet().getScriptType(), getStoredBlockHeight(), inputWeightUnits, feeRate, longTermFeeRate); outputGroups.add(outputGroup); } - outputGroup.add(utxo, allInputsFromWallet(utxo.getHash())); + outputGroup.add(utxo, allInputsFromWallet(walletTransactions, walletTxos, utxo.getHash()), isNotificationChange(walletTransactions, utxo.getHash())); } } } @@ -622,7 +1273,13 @@ public class Wallet { * @return Whether the transaction was created entirely from inputs that reference outputs that belong to this wallet */ public boolean allInputsFromWallet(Sha256Hash txId) { - BlockTransaction utxoBlkTx = getTransactions().get(txId); + Map allTransactions = getWalletTransactions(); + Map allTxos = getWalletTxos(); + return allInputsFromWallet(allTransactions, allTxos, txId); + } + + private boolean allInputsFromWallet(Map walletTransactions, Map walletTxos, Sha256Hash txId) { + BlockTransaction utxoBlkTx = walletTransactions.get(txId); if(utxoBlkTx == null) { //Provided txId was not a wallet transaction return false; @@ -630,7 +1287,7 @@ public class Wallet { for(int i = 0; i < utxoBlkTx.getTransaction().getInputs().size(); i++) { TransactionInput utxoTxInput = utxoBlkTx.getTransaction().getInputs().get(i); - BlockTransaction prevBlkTx = getTransactions().get(utxoTxInput.getOutpoint().getHash()); + BlockTransaction prevBlkTx = walletTransactions.get(utxoTxInput.getOutpoint().getHash()); if(prevBlkTx == null) { return false; } @@ -639,7 +1296,7 @@ public class Wallet { TransactionOutput prevTxOut = prevBlkTx.getTransaction().getOutputs().get(index); BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(utxoBlkTx.getHash(), utxoBlkTx.getHeight(), utxoBlkTx.getDate(), utxoBlkTx.getFee(), i, prevTxOut.getValue()); BlockTransactionHashIndex spentTXO = new BlockTransactionHashIndex(prevBlkTx.getHash(), prevBlkTx.getHeight(), prevBlkTx.getDate(), prevBlkTx.getFee(), index, prevTxOut.getValue(), spendingTXI); - if(!isWalletTxo(spentTXO)) { + if(!walletTxos.containsKey(spentTXO)) { return false; } } @@ -647,6 +1304,49 @@ public class Wallet { return true; } + private boolean isNotificationChange(Map walletTransactions, Sha256Hash txId) { + BlockTransaction utxoBlkTx = walletTransactions.get(txId); + try { + PaymentCode.getOpReturnData(utxoBlkTx.getTransaction()); + return true; + } catch(IllegalArgumentException e) { + //ignore, not a notification tx + } + + return false; + } + + /** + * Determines the maximum total amount this wallet can send for the number and type of addresses at the given fee rate + * + * @param paymentAddresses the addresses to sent to (amounts are irrelevant) + * @param feeRate the fee rate in sats/vB + * @return the maximum spendable amount (can be negative if the fee is higher than the combined UTXO value) + */ + public long getMaxSpendable(List
paymentAddresses, double feeRate, boolean includeSpentMempoolOutputs) { + long maxInputValue = 0; + + Map cachedInputWeightUnits = new HashMap<>(); + Transaction transaction = new Transaction(); + for(Map.Entry utxo : getWalletUtxos(includeSpentMempoolOutputs).entrySet()) { + int inputWeightUnits = cachedInputWeightUnits.computeIfAbsent(utxo.getValue().getWallet(), Wallet::getInputWeightUnits); + long minInputValue = (long)Math.ceil(feeRate * inputWeightUnits / WITNESS_SCALE_FACTOR); + if(utxo.getKey().getValue() > minInputValue) { + Transaction prevTx = getWalletTransaction(utxo.getKey().getHash()).getTransaction(); + TransactionOutput prevTxOut = prevTx.getOutputs().get((int)utxo.getKey().getIndex()); + addDummySpendingInput(transaction, utxo.getValue(), prevTxOut); + maxInputValue += utxo.getKey().getValue(); + } + } + + for(Address address : paymentAddresses) { + transaction.addOutput(1L, address); + } + + long fee = (long)Math.floor(transaction.getVirtualSize() * feeRate); + return maxInputValue - fee; + } + public boolean canSign(Transaction transaction) { return isValid() && !getSigningNodes(transaction).isEmpty(); } @@ -662,7 +1362,7 @@ public class Wallet { Map walletOutputScripts = getWalletOutputScripts(); for(TransactionInput txInput : transaction.getInputs()) { - BlockTransaction blockTransaction = transactions.get(txInput.getOutpoint().getHash()); + BlockTransaction blockTransaction = getWalletTransaction(txInput.getOutpoint().getHash()); if(blockTransaction != null && blockTransaction.getTransaction().getOutputs().size() > txInput.getOutpoint().getIndex()) { TransactionOutput utxo = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); @@ -691,18 +1391,28 @@ public class Wallet { for(TransactionInput txInput : signingNodes.keySet()) { WalletNode walletNode = signingNodes.get(txInput); - Map keystoreKeysForNode = getKeystores().stream().collect(Collectors.toMap(keystore -> keystore.getPubKey(walletNode), Function.identity(), - (u, v) -> { throw new IllegalStateException("Duplicate keys from different keystores for node " + walletNode.getDerivationPath()); }, + Wallet signingWallet = walletNode.getWallet(); + Map keystoreKeysForNode = signingWallet.getKeystores().stream() + .collect(Collectors.toMap(keystore -> signingWallet.getScriptType().getOutputKey(keystore.getPubKey(walletNode)), Function.identity(), + (u, v) -> { throw new IllegalStateException("Duplicate keys from different keystores for node " + walletNode); }, LinkedHashMap::new)); Map keySignatureMap = new LinkedHashMap<>(); - BlockTransaction blockTransaction = transactions.get(txInput.getOutpoint().getHash()); + BlockTransaction blockTransaction = signingWallet.transactions.get(txInput.getOutpoint().getHash()); if(blockTransaction != null && blockTransaction.getTransaction().getOutputs().size() > txInput.getOutpoint().getIndex()) { TransactionOutput spentTxo = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); Script signingScript = getSigningScript(txInput, spentTxo); - Sha256Hash hash = txInput.hasWitness() ? transaction.hashForWitnessSignature(txInput.getIndex(), signingScript, spentTxo.getValue(), SigHash.ALL) : transaction.hashForLegacySignature(txInput.getIndex(), signingScript, SigHash.ALL); + Sha256Hash hash; + if(signingWallet.getScriptType() == P2TR) { + List spentOutputs = transaction.getInputs().stream().map(input -> signingWallet.transactions.get(input.getOutpoint().getHash()).getTransaction().getOutputs().get((int)input.getOutpoint().getIndex())).collect(Collectors.toList()); + hash = transaction.hashForTaprootSignature(spentOutputs, txInput.getIndex(), !P2TR.isScriptType(signingScript), signingScript, SigHash.DEFAULT, null); + } else if(txInput.hasWitness()) { + hash = transaction.hashForWitnessSignature(txInput.getIndex(), signingScript, spentTxo.getValue(), SigHash.ALL); + } else { + hash = transaction.hashForLegacySignature(txInput.getIndex(), signingScript, SigHash.ALL); + } for(ECKey sigPublicKey : keystoreKeysForNode.keySet()) { for(TransactionSignature signature : txInput.hasWitness() ? txInput.getWitness().getSignatures() : txInput.getScriptSig().getSignatures()) { @@ -783,12 +1493,14 @@ public class Wallet { for(PSBTInput psbtInput : signingNodes.keySet()) { WalletNode walletNode = signingNodes.get(psbtInput); - Map keystoreKeysForNode = getKeystores().stream().collect(Collectors.toMap(keystore -> keystore.getPubKey(walletNode), Function.identity(), - (u, v) -> { throw new IllegalStateException("Duplicate keys from different keystores for node " + walletNode.getDerivationPath()); }, + Wallet signingWallet = walletNode.getWallet(); + Map keystoreKeysForNode = signingWallet.getKeystores().stream() + .collect(Collectors.toMap(keystore -> signingWallet.getScriptType().getOutputKey(keystore.getPubKey(walletNode)), Function.identity(), + (u, v) -> { throw new IllegalStateException("Duplicate keys from different keystores for node " + walletNode); }, LinkedHashMap::new)); Map keySignatureMap; - if(psbt.isFinalized()) { + if(psbt.isFinalized() || psbtInput.isTaproot()) { keySignatureMap = psbtInput.getSigningKeys(keystoreKeysForNode.keySet()); } else { keySignatureMap = psbtInput.getPartialSignatures(); @@ -809,10 +1521,11 @@ public class Wallet { public void sign(PSBT psbt) throws MnemonicException { Map signingNodes = getSigningNodes(psbt); - for(Keystore keystore : getKeystores()) { - if(keystore.hasSeed()) { - for(Map.Entry signingEntry : signingNodes.entrySet()) { - ECKey privKey = keystore.getKey(signingEntry.getValue()); + for(Map.Entry signingEntry : signingNodes.entrySet()) { + Wallet signingWallet = signingEntry.getValue().getWallet(); + for(Keystore keystore : signingWallet.getKeystores()) { + if(keystore.hasPrivateKey()) { + ECKey privKey = signingWallet.getScriptType().getOutputKey(keystore.getKey(signingEntry.getValue())); PSBTInput psbtInput = signingEntry.getKey(); if(!psbtInput.isSigned()) { @@ -850,20 +1563,22 @@ public class Wallet { } }; - if(psbtInput.getPartialSignatures().size() >= threshold && signingNode != null) { + //TODO: Handle taproot scriptpath spending + int signaturesAvailable = psbtInput.isTaproot() ? (psbtInput.getTapKeyPathSignature() != null ? 1 : 0) : psbtInput.getPartialSignatures().size(); + if(signaturesAvailable >= threshold && signingNode != null) { Transaction transaction = new Transaction(); TransactionInput finalizedTxInput; if(getPolicyType().equals(PolicyType.SINGLE)) { - ECKey pubKey = getPubKey(signingNode); - TransactionSignature transactionSignature = psbtInput.getPartialSignature(pubKey); + ECKey pubKey = signingNode.getPubKey(); + TransactionSignature transactionSignature = psbtInput.isTaproot() ? psbtInput.getTapKeyPathSignature() : psbtInput.getPartialSignature(pubKey); if(transactionSignature == null) { throw new IllegalArgumentException("Pubkey of partial signature does not match wallet pubkey"); } - finalizedTxInput = getScriptType().addSpendingInput(transaction, utxo, pubKey, transactionSignature); + finalizedTxInput = signingNode.getWallet().getScriptType().addSpendingInput(transaction, utxo, pubKey, transactionSignature); } else if(getPolicyType().equals(PolicyType.MULTI)) { - List pubKeys = getPubKeys(signingNode); + List pubKeys = signingNode.getPubKeys(); Map pubKeySignatures = new TreeMap<>(new ECKey.LexicographicECKeyComparator()); for(ECKey pubKey : pubKeys) { @@ -875,7 +1590,7 @@ public class Wallet { throw new IllegalArgumentException("Pubkeys of partial signatures do not match wallet pubkeys"); } - finalizedTxInput = getScriptType().addMultisigSpendingInput(transaction, utxo, threshold, pubKeySignatures); + finalizedTxInput = signingNode.getWallet().getScriptType().addMultisigSpendingInput(transaction, utxo, threshold, pubKeySignatures); } else { throw new UnsupportedOperationException("Cannot finalise PSBT for policy type " + getPolicyType()); } @@ -885,6 +1600,8 @@ public class Wallet { psbtInput.clearNonFinalFields(); } } + + psbt.getPsbtOutputs().forEach(PSBTOutput::clearNonFinalFields); } public BitcoinUnit getAutoUnit() { @@ -901,19 +1618,56 @@ public class Wallet { return BitcoinUnit.SATOSHIS; } - public void clearNodes() { + public void clearNodes(Wallet previousWallet) { + detachedLabels.putAll(previousWallet.getDetachedLabels(true)); purposeNodes.clear(); transactions.clear(); storedBlockHeight = 0; } public void clearHistory() { + detachedLabels.putAll(getDetachedLabels(false)); for(WalletNode purposeNode : purposeNodes) { purposeNode.clearHistory(); } transactions.clear(); storedBlockHeight = 0; + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + childWallet.clearHistory(); + } + } + } + + private Map getDetachedLabels(boolean includeAddresses) { + Map labels = new HashMap<>(); + for(BlockTransaction blockTransaction : transactions.values()) { + if(blockTransaction.getLabel() != null && !blockTransaction.getLabel().isEmpty()) { + labels.put(blockTransaction.getHashAsString(), blockTransaction.getLabel()); + } + } + + for(WalletNode purposeNode : purposeNodes) { + for(WalletNode addressNode : purposeNode.getChildren()) { + if(includeAddresses && addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) { + labels.put(addressNode.getAddress().toString(), addressNode.getLabel()); + } + + for(BlockTransactionHashIndex output : addressNode.getTransactionOutputs()) { + if(output.getLabel() != null && !output.getLabel().isEmpty()) { + labels.put(output.getHash().toString() + "<" + output.getIndex(), output.getLabel()); + } + + if(output.isSpent() && output.getSpentBy().getLabel() != null && !output.getSpentBy().getLabel().isEmpty()) { + labels.put(output.getSpentBy().getHash() + ">" + output.getSpentBy().getIndex(), output.getSpentBy().getLabel()); + } + } + } + } + + return labels; } public boolean isValid() { @@ -977,9 +1731,17 @@ public class Wallet { throw new InvalidWalletException("Keystore " + keystore.getLabel() + " derivation of " + keystore.getKeyDerivation().getDerivationPath() + " in " + scriptType.getName() + " wallet matches another default script type."); } } + + if(containsDuplicateExtendedKeys()) { + throw new InvalidWalletException("Wallet keystores have duplicate extended public keys"); + } } public boolean derivationMatchesAnotherScriptType(String derivationPath) { + if(Boolean.TRUE.toString().equals(System.getProperty(ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY))) { + return false; + } + if(scriptType != null && scriptType.getAccount(derivationPath) > -1) { return false; } @@ -995,6 +1757,14 @@ public class Wallet { return !keystores.stream().map(Keystore::getLabel).allMatch(new HashSet<>()::add); } + public boolean containsDuplicateExtendedKeys() { + if(keystores.size() <= 1) { + return false; + } + + return !keystores.stream().map(Keystore::getExtendedPublicKey).allMatch(new HashSet<>()::add); + } + public void makeLabelsUnique(Keystore newKeystore) { makeLabelsUnique(newKeystore, false); } @@ -1032,29 +1802,52 @@ public class Wallet { } public Wallet copy() { + return copy(true); + } + + public Wallet copy(boolean includeHistory) { Wallet copy = new Wallet(name); + copy.setId(getId()); + copy.setLabel(label); + copy.setMasterWallet(masterWallet); + for(Wallet childWallet : childWallets) { + Wallet copyChildWallet = childWallet.copy(includeHistory); + copyChildWallet.setMasterWallet(copy); + copy.childWallets.add(copyChildWallet); + } copy.setPolicyType(policyType); copy.setScriptType(scriptType); copy.setDefaultPolicy(defaultPolicy.copy()); for(Keystore keystore : keystores) { copy.getKeystores().add(keystore.copy()); } - for(WalletNode node : purposeNodes) { - copy.purposeNodes.add(node.copy()); - } - for(Sha256Hash hash : transactions.keySet()) { - copy.transactions.put(hash, transactions.get(hash)); + if(includeHistory) { + for(WalletNode node : purposeNodes) { + copy.purposeNodes.add(node.copy(copy)); + } + for(Sha256Hash hash : transactions.keySet()) { + copy.transactions.put(hash, transactions.get(hash)); + } + for(String entry : detachedLabels.keySet()) { + copy.detachedLabels.put(entry, detachedLabels.get(entry)); + } + for(Sha256Hash hash : utxoMixes.keySet()) { + copy.utxoMixes.put(hash, utxoMixes.get(hash)); + } } + copy.setWalletConfig(walletConfig == null ? null : walletConfig.copy()); + copy.setMixConfig(mixConfig == null ? null : mixConfig.copy()); copy.setStoredBlockHeight(getStoredBlockHeight()); copy.gapLimit = gapLimit; + copy.watchLast = watchLast; copy.birthDate = birthDate; return copy; } - public boolean containsSeeds() { + public boolean containsMasterPrivateKeys() { for(Keystore keystore : keystores) { - if(keystore.hasSeed()) { + if(keystore.hasMasterPrivateKey()) { return true; } } @@ -1079,6 +1872,12 @@ public class Wallet { } } + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested() && childWallet.isEncrypted()) { + return true; + } + } + return false; } @@ -1086,28 +1885,81 @@ public class Wallet { for(Keystore keystore : keystores) { keystore.encrypt(key); } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + childWallet.encrypt(key); + } + } } public void decrypt(CharSequence password) { for(Keystore keystore : keystores) { keystore.decrypt(password); } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + childWallet.decrypt(password); + } + } } public void decrypt(Key key) { for(Keystore keystore : keystores) { keystore.decrypt(key); } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + childWallet.decrypt(key); + } + } } public void clearPrivate() { for(Keystore keystore : keystores) { keystore.clearPrivate(); } + + for(Wallet childWallet : getChildWallets()) { + if(childWallet.isNested()) { + childWallet.clearPrivate(); + } + } + } + + @Override + public int compareTo(Wallet other) { + if(isMasterWallet() && !other.isMasterWallet()) { + return -1; + } + + if(!isMasterWallet() && other.isMasterWallet()) { + return 1; + } + + if(getStandardAccountType() != null && other.getStandardAccountType() != null) { + int standardAccountDiff = getStandardAccountType().ordinal() - other.getStandardAccountType().ordinal(); + if(standardAccountDiff != 0) { + return standardAccountDiff; + } + } + + int accountIndexDiff = getAccountIndex() - other.getAccountIndex(); + if(accountIndexDiff != 0) { + return accountIndexDiff; + } + + if(name != null && other.name != null) { + return name.compareTo(other.name); + } + + return 0; } @Override public String toString() { - return getName(); + return getFullName(); } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletConfig.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletConfig.java new file mode 100644 index 0000000..11e58fb --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletConfig.java @@ -0,0 +1,41 @@ +package com.sparrowwallet.drongo.wallet; + +public class WalletConfig extends Persistable { + private byte[] iconData; + private boolean userIcon; + private boolean usePayNym; + + public WalletConfig() { + } + + public WalletConfig(byte[] iconData, boolean userIcon, boolean usePayNym) { + this.iconData = iconData; + this.userIcon = userIcon; + this.usePayNym = usePayNym; + } + + public byte[] getIconData() { + return iconData; + } + + public boolean isUserIcon() { + return userIcon; + } + + public void setIconData(byte[] iconData, boolean userIcon) { + this.iconData = iconData; + this.userIcon = userIcon; + } + + public boolean isUsePayNym() { + return usePayNym; + } + + public void setUsePayNym(boolean usePayNym) { + this.usePayNym = usePayNym; + } + + public WalletConfig copy() { + return new WalletConfig(iconData, userIcon, usePayNym); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java index 9273d8a..2df27a7 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletModel.java @@ -1,10 +1,12 @@ package com.sparrowwallet.drongo.wallet; +import java.util.Locale; + public enum WalletModel { - SEED, SPARROW, BITCOIN_CORE, ELECTRUM, TREZOR_1, TREZOR_T, COLDCARD, LEDGER_NANO_S, LEDGER_NANO_X, DIGITALBITBOX_01, KEEPKEY, SPECTER_DESKTOP, COBO_VAULT, BITBOX_02, SPECTER_DIY, PASSPORT; + SEED, SPARROW, BITCOIN_CORE, ELECTRUM, TREZOR_1, TREZOR_T, COLDCARD, LEDGER_NANO_S, LEDGER_NANO_X, DIGITALBITBOX_01, KEEPKEY, SPECTER_DESKTOP, COBO_VAULT, BITBOX_02, SPECTER_DIY, PASSPORT, BLUE_WALLET, KEYSTONE, SEEDSIGNER, CARAVAN, GORDIAN_SEED_TOOL, JADE, LEDGER_NANO_S_PLUS, EPS; public static WalletModel getModel(String model) { - return valueOf(model.toUpperCase()); + return valueOf(model.toUpperCase(Locale.ROOT)); } public String getType() { @@ -12,7 +14,7 @@ public enum WalletModel { return "trezor"; } - if(this == LEDGER_NANO_S || this == LEDGER_NANO_X) { + if(this == LEDGER_NANO_S || this == LEDGER_NANO_X || this == LEDGER_NANO_S_PLUS) { return "ledger"; } @@ -20,6 +22,10 @@ public enum WalletModel { return "digitalbitbox"; } + if(this == BITCOIN_CORE) { + return "bitcoincore"; + } + if(this == BITBOX_02) { return "bitbox02"; } @@ -32,11 +38,19 @@ public enum WalletModel { return "specter"; } - return this.toString().toLowerCase(); + if(this == BLUE_WALLET) { + return "bluewallet"; + } + + if(this == GORDIAN_SEED_TOOL) { + return "seedtool"; + } + + return this.toString().toLowerCase(Locale.ROOT); } public boolean alwaysIncludeNonWitnessUtxo() { - if(this == COLDCARD || this == COBO_VAULT || this == PASSPORT) { + if(this == COLDCARD || this == COBO_VAULT || this == PASSPORT || this == KEYSTONE || this == GORDIAN_SEED_TOOL) { return false; } @@ -62,7 +76,7 @@ public enum WalletModel { } public String toDisplayString() { - String line = this.toString().toLowerCase(); + String line = this.toString().toLowerCase(Locale.ROOT); String[] words = line.split("_"); StringBuilder builder = new StringBuilder(); for(String word : words) { diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java index 0725a68..484e5cd 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletNode.java @@ -2,40 +2,68 @@ package com.sparrowwallet.drongo.wallet; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.Script; import java.util.*; import java.util.stream.Collectors; -public class WalletNode implements Comparable { +public class WalletNode extends Persistable implements Comparable { private final String derivationPath; private String label; + private Address address; private TreeSet children = new TreeSet<>(); private TreeSet transactionOutputs = new TreeSet<>(); + private transient Wallet wallet; private transient KeyPurpose keyPurpose; private transient int index = -1; private transient List derivation; + //Cache pubkeys for BIP47 wallets to avoid time-consuming ECDH calculations + private transient ECKey cachedPubKey; + + //Note use of this constructor must be followed by setting the wallet field public WalletNode(String derivationPath) { this.derivationPath = derivationPath; parseDerivation(); } - public WalletNode(KeyPurpose keyPurpose) { + public WalletNode(Wallet wallet, String derivationPath) { + this.wallet = wallet; + this.derivationPath = derivationPath; + parseDerivation(); + } + + public WalletNode(Wallet wallet, KeyPurpose keyPurpose) { + this.wallet = wallet; this.derivation = List.of(keyPurpose.getPathIndex()); this.derivationPath = KeyDerivation.writePath(derivation); this.keyPurpose = keyPurpose; this.index = keyPurpose.getPathIndex().num(); } - public WalletNode(KeyPurpose keyPurpose, int index) { + public WalletNode(Wallet wallet, KeyPurpose keyPurpose, int index) { + this.wallet = wallet; this.derivation = List.of(keyPurpose.getPathIndex(), new ChildNumber(index)); this.derivationPath = KeyDerivation.writePath(derivation); this.keyPurpose = keyPurpose; this.index = index; } + public Wallet getWallet() { + return wallet; + } + + public void setWallet(Wallet wallet) { + this.wallet = wallet; + for(WalletNode childNode : getChildren()) { + childNode.setWallet(wallet); + } + } + public String getDerivationPath() { return derivationPath; } @@ -87,25 +115,47 @@ public class WalletNode implements Comparable { } public Set getChildren() { - return children == null ? null : Collections.unmodifiableSet(children); + return children; } public void setChildren(TreeSet children) { this.children = children; } + public boolean isUsed() { + return !transactionOutputs.isEmpty(); + } + public Set getTransactionOutputs() { - return transactionOutputs == null ? null : Collections.unmodifiableSet(transactionOutputs); + return transactionOutputs; } public void setTransactionOutputs(TreeSet transactionOutputs) { this.transactionOutputs = transactionOutputs; } - public synchronized void updateTransactionOutputs(Set updatedOutputs) { + public synchronized void updateTransactionOutputs(Wallet wallet, Set updatedOutputs) { for(BlockTransactionHashIndex txo : updatedOutputs) { - Optional optionalLabel = transactionOutputs.stream().filter(oldTxo -> oldTxo.getHash().equals(txo.getHash()) && oldTxo.getIndex() == txo.getIndex()).map(BlockTransactionHash::getLabel).filter(Objects::nonNull).findFirst(); - optionalLabel.ifPresent(txo::setLabel); + if(!transactionOutputs.isEmpty()) { + Optional optionalLabel = transactionOutputs.stream().filter(oldTxo -> oldTxo.getHash().equals(txo.getHash()) && oldTxo.getIndex() == txo.getIndex()).map(BlockTransactionHash::getLabel).filter(Objects::nonNull).findFirst(); + optionalLabel.ifPresent(txo::setLabel); + Optional optionalStatus = transactionOutputs.stream().filter(oldTxo -> oldTxo.getHash().equals(txo.getHash()) && oldTxo.getIndex() == txo.getIndex()).map(BlockTransactionHashIndex::getStatus).filter(Objects::nonNull).findFirst(); + optionalStatus.ifPresent(txo::setStatus); + } + + if(!wallet.getDetachedLabels().isEmpty()) { + String label = wallet.getDetachedLabels().remove(txo.getHash().toString() + "<" + txo.getIndex()); + if(label != null && (txo.getLabel() == null || txo.getLabel().isEmpty())) { + txo.setLabel(label); + } + + if(txo.isSpent()) { + String spentByLabel = wallet.getDetachedLabels().remove(txo.getSpentBy().getHash() + ">" + txo.getSpentBy().getIndex()); + if(spentByLabel != null && (txo.getSpentBy().getLabel() == null || txo.getSpentBy().getLabel().isEmpty())) { + txo.getSpentBy().setLabel(spentByLabel); + } + } + } } transactionOutputs.clear(); @@ -116,9 +166,14 @@ public class WalletNode implements Comparable { return getUnspentTransactionOutputs(false); } - public Set getUnspentTransactionOutputs(boolean includeMempoolInputs) { + public Set getUnspentTransactionOutputs(boolean includeSpentMempoolOutputs) { + if(transactionOutputs.isEmpty()) { + return Collections.emptySet(); + } + Set unspentTXOs = new TreeSet<>(transactionOutputs); - return unspentTXOs.stream().filter(txo -> !txo.isSpent() || (includeMempoolInputs && txo.getSpentBy().getHeight() <= 0)).collect(Collectors.toCollection(HashSet::new)); + unspentTXOs.removeIf(txo -> txo.isSpent() && (!includeSpentMempoolOutputs || txo.getSpentBy().getHeight() > 0)); + return unspentTXOs; } public long getUnspentValue() { @@ -130,11 +185,55 @@ public class WalletNode implements Comparable { return value; } - public synchronized void fillToIndex(int index) { - for(int i = 0; i <= index; i++) { - WalletNode node = new WalletNode(getKeyPurpose(), i); - children.add(node); + public Set fillToIndex(Wallet wallet, int index) { + Set newNodes = fillToIndex(index); + if(wallet.isValid()) { + if(!wallet.getDetachedLabels().isEmpty()) { + for(WalletNode newNode : newNodes) { + String label = wallet.getDetachedLabels().remove(newNode.getAddress().toString()); + if(label != null && (newNode.getLabel() == null || newNode.getLabel().isEmpty())) { + newNode.setLabel(label); + } + } + } + + if(wallet.isBip47() && keyPurpose == KeyPurpose.RECEIVE && wallet.getLabel() != null && !newNodes.isEmpty()) { + String suffix = " " + wallet.getScriptType().getName(); + for(WalletNode newNode : newNodes) { + if((newNode.getLabel() == null || newNode.getLabel().isEmpty()) && wallet.getLabel().endsWith(suffix)) { + newNode.setLabel("From " + wallet.getLabel().substring(0, wallet.getLabel().length() - suffix.length())); + } + } + } } + + return newNodes; + } + + public synchronized Set fillToIndex(int index) { + //Optimization to check if child nodes already monotonically increment to the desired index + int indexCheck = 0; + for(WalletNode childNode : getChildren()) { + if(childNode.index == indexCheck) { + indexCheck++; + } else { + break; + } + + if(childNode.index == index) { + return Collections.emptySet(); + } + } + + Set newNodes = new TreeSet<>(); + for(int i = 0; i <= index; i++) { + WalletNode node = new WalletNode(wallet, getKeyPurpose(), i); + if(children.add(node)) { + newNodes.add(node); + } + } + + return newNodes; } /** @@ -151,9 +250,56 @@ public class WalletNode implements Comparable { return highestNode == null ? null : highestNode.index; } + public ECKey getPubKey() { + if(cachedPubKey != null) { + return cachedPubKey; + } + + if(wallet.isBip47()) { + cachedPubKey = wallet.getPubKey(this); + return cachedPubKey; + } + + return wallet.getPubKey(this); + } + + public List getPubKeys() { + return wallet.getPubKeys(this); + } + + public Address getAddress() { + if(address != null) { + return address; + } + + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + if(masterWallet.getKeystores().stream().noneMatch(Keystore::needsPassphrase)) { + address = wallet.getAddress(this); + return address; + } + + return wallet.getAddress(this); + } + + public byte[] getAddressData() { + return address == null ? null : address.getData(); + } + + public void setAddress(Address address) { + this.address = address; + } + + public Script getOutputScript() { + return getAddress().getOutputScript(); + } + + public String getOutputDescriptor() { + return wallet.getOutputDescriptor(this); + } + @Override public String toString() { - return derivationPath; + return derivationPath.replace("m", ".."); } @Override @@ -161,12 +307,12 @@ public class WalletNode implements Comparable { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; WalletNode node = (WalletNode) o; - return derivationPath.equals(node.derivationPath); + return Objects.equals(wallet, node.wallet) && derivationPath.equals(node.derivationPath); } @Override public int hashCode() { - return Objects.hash(derivationPath); + return Objects.hash(wallet, derivationPath); } @Override @@ -193,12 +339,14 @@ public class WalletNode implements Comparable { } } - public WalletNode copy() { - WalletNode copy = new WalletNode(derivationPath); + public WalletNode copy(Wallet walletCopy) { + WalletNode copy = new WalletNode(walletCopy, derivationPath); + copy.setId(getId()); copy.setLabel(label); + copy.setAddress(address); for(WalletNode child : getChildren()) { - copy.children.add(child.copy()); + copy.children.add(child.copy(walletCopy)); } for(BlockTransactionHashIndex txo : getTransactionOutputs()) { @@ -207,4 +355,69 @@ public class WalletNode implements Comparable { return copy; } + + public static String nodeRangesToString(Set nodes) { + return nodeRangesToString(nodes.stream().map(WalletNode::getDerivationPath).collect(Collectors.toList())); + } + + public static String nodeRangesToString(Collection nodeDerivations) { + List sortedDerivations = new ArrayList<>(nodeDerivations); + + if(nodeDerivations.isEmpty()) { + return "[]"; + } + + List> contiguous = splitToContiguous(sortedDerivations); + + String abbrev = "["; + for(Iterator> iter = contiguous.iterator(); iter.hasNext(); ) { + List range = iter.next(); + abbrev += range.get(0); + if(range.size() > 1) { + abbrev += "-" + range.get(range.size() - 1); + } + if(iter.hasNext()) { + abbrev += ", "; + } + } + abbrev += "]"; + + return abbrev; + } + + private static List> splitToContiguous(List input) { + List> result = new ArrayList<>(); + int prev = 0; + + int keyPurpose = getKeyPurpose(input.get(0)); + int index = getIndex(input.get(0)); + + for (int cur = 0; cur < input.size(); cur++) { + if(getKeyPurpose(input.get(cur)) != keyPurpose || getIndex(input.get(cur)) != index) { + result.add(input.subList(prev, cur)); + prev = cur; + } + index = getIndex(input.get(cur)) + 1; + keyPurpose = getKeyPurpose(input.get(cur)); + } + result.add(input.subList(prev, input.size())); + + return result; + } + + private static int getKeyPurpose(String path) { + List childNumbers = KeyDerivation.parsePath(path); + if(childNumbers.isEmpty()) { + return -1; + } + return childNumbers.get(0).num(); + } + + private static int getIndex(String path) { + List childNumbers = KeyDerivation.parsePath(path); + if(childNumbers.isEmpty()) { + return -1; + } + return childNumbers.get(childNumbers.size() - 1).num(); + } } diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java index 8c5bce1..546eede 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java @@ -1,12 +1,12 @@ package com.sparrowwallet.drongo.wallet; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.Script; +import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; /** * WalletTransaction contains a draft transaction along with associated metadata. The draft transaction has empty signatures but is otherwise complete. @@ -16,25 +16,31 @@ public class WalletTransaction { private final Wallet wallet; private final Transaction transaction; private final List utxoSelectors; - private final Map selectedUtxos; + private final List> selectedUtxoSets; private final List payments; - private final WalletNode changeNode; - private final long changeAmount; + private final Map changeMap; private final long fee; + private final Map inputTransactions; - public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, Map selectedUtxos, List payments, long fee) { - this(wallet, transaction, utxoSelectors, selectedUtxos, payments, null, 0L, fee); + private Map> addressNodeMap = new HashMap<>(); + + public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, List> selectedUtxoSets, List payments, long fee) { + this(wallet, transaction, utxoSelectors, selectedUtxoSets, payments, Collections.emptyMap(), fee); } - public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, Map selectedUtxos, List payments, WalletNode changeNode, long changeAmount, long fee) { + public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, List> selectedUtxoSets, List payments, Map changeMap, long fee) { + this(wallet, transaction, utxoSelectors, selectedUtxoSets, payments, changeMap, fee, Collections.emptyMap()); + } + + public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, List> selectedUtxoSets, List payments, Map changeMap, long fee, Map inputTransactions) { this.wallet = wallet; this.transaction = transaction; this.utxoSelectors = utxoSelectors; - this.selectedUtxos = selectedUtxos; + this.selectedUtxoSets = selectedUtxoSets; this.payments = payments; - this.changeNode = changeNode; - this.changeAmount = changeAmount; + this.changeMap = changeMap; this.fee = fee; + this.inputTransactions = inputTransactions; } public PSBT createPSBT() { @@ -54,23 +60,29 @@ public class WalletTransaction { } public Map getSelectedUtxos() { + if(selectedUtxoSets.size() == 1) { + return selectedUtxoSets.get(0); + } + + Map selectedUtxos = new LinkedHashMap<>(); + selectedUtxoSets.forEach(selectedUtxos::putAll); return selectedUtxos; } + public List> getSelectedUtxoSets() { + return selectedUtxoSets; + } + public List getPayments() { return payments; } - public WalletNode getChangeNode() { - return changeNode; + public Map getChangeMap() { + return changeMap; } - public Address getChangeAddress() { - return getWallet().getAddress(getChangeNode()); - } - - public long getChangeAmount() { - return changeAmount; + public Address getChangeAddress(WalletNode changeNode) { + return changeNode.getAddress(); } public long getFee() { @@ -82,7 +94,15 @@ public class WalletTransaction { } public long getTotal() { - return selectedUtxos.keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + return inputAmountsValid() ? getSelectedUtxos().keySet().stream().mapToLong(BlockTransactionHashIndex::getValue).sum() : 0; + } + + private boolean inputAmountsValid() { + return getSelectedUtxos().keySet().stream().allMatch(ref -> ref.getValue() > 0); + } + + public Map getInputTransactions() { + return inputTransactions; } /** @@ -90,32 +110,73 @@ public class WalletTransaction { * @return the fee percentage */ public double getFeePercentage() { - return (double)getFee() / (getTotal() - getFee()); + return getFee() <= 0 || getTotal() <= 0 ? 0 : (double)getFee() / (getTotal() - getFee()); } public boolean isCoinControlUsed() { return !utxoSelectors.isEmpty() && utxoSelectors.get(0) instanceof PresetUtxoSelector; } + public boolean isTwoPersonCoinjoin() { + return !utxoSelectors.isEmpty() && utxoSelectors.get(0) instanceof StonewallUtxoSelector; + } + public boolean isConsolidationSend(Payment payment) { - if(payment.getAddress() != null && getWallet() != null) { - return getWallet().isWalletOutputScript(payment.getAddress().getOutputScript()); + return isWalletSend(getWallet(), payment); + } + + public boolean isPremixSend(Payment payment) { + return isWalletSend(StandardAccount.WHIRLPOOL_PREMIX, payment); + } + + public boolean isBadbankSend(Payment payment) { + return isWalletSend(StandardAccount.WHIRLPOOL_BADBANK, payment); + } + + private boolean isWalletSend(StandardAccount childAccount, Payment payment) { + if(getWallet() != null) { + return isWalletSend(getWallet().getChildWallet(childAccount), payment); } return false; } - public List getConsolidationSendNodes() { - List walletNodes = new ArrayList<>(); + public boolean isWalletSend(Wallet wallet, Payment payment) { + if(wallet == null) { + return false; + } + + return getAddressNodeMap(wallet).get(payment.getAddress()) != null; + } + + public void updateAddressNodeMap(Map> addressNodeMap, Wallet wallet) { + this.addressNodeMap = addressNodeMap; + getAddressNodeMap(wallet); + } + + public Map getAddressNodeMap() { + return getAddressNodeMap(getWallet()); + } + + public Map getAddressNodeMap(Wallet wallet) { + Map walletAddresses = null; + + Map walletAddressNodeMap = addressNodeMap.computeIfAbsent(wallet, w -> new LinkedHashMap<>()); for(Payment payment : payments) { - if(payment.getAddress() != null && getWallet() != null) { - WalletNode walletNode = getWallet().getWalletOutputScripts().get(payment.getAddress().getOutputScript()); - if(walletNode != null) { - walletNodes.add(walletNode); + if(walletAddressNodeMap.containsKey(payment.getAddress())) { + continue; + } + + if(payment.getAddress() != null && wallet != null) { + if(walletAddresses == null) { + walletAddresses = wallet.getWalletAddresses(); } + + WalletNode walletNode = walletAddresses.get(payment.getAddress()); + walletAddressNodeMap.put(payment.getAddress(), walletNode); } } - return walletNodes; + return walletAddressNodeMap; } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index fbd2c9f..8e48428 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,8 +1,9 @@ open module com.sparrowwallet.drongo { requires org.bouncycastle.provider; - requires de.mkammerer.argon2; - requires slf4j.api; + requires de.mkammerer.argon2.nolibs; + requires org.slf4j; requires logback.core; + requires logback.classic; requires json.simple; requires jeromq; exports com.sparrowwallet.drongo; @@ -13,4 +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/main/java/org/bitcoin/NativeSecp256k1.java b/src/main/java/org/bitcoin/NativeSecp256k1.java new file mode 100644 index 0000000..2cda777 --- /dev/null +++ b/src/main/java/org/bitcoin/NativeSecp256k1.java @@ -0,0 +1,910 @@ +/* + * Copyright 2013 Google Inc. + * Copyright 2014-2016 the libsecp256k1 contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.bitcoin; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import java.math.BigInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import static org.bitcoin.NativeSecp256k1Util.*; + +/** + *

This class holds native methods to handle ECDSA verification.

+ * + *

You can find an example library that can be used for this at https://github.com/bitcoin-core/secp256k1

+ * + *

To build secp256k1 for use with bitcoinj, run + * `./configure --enable-jni --enable-experimental --enable-module-ecdh` + * and `make` then copy `.libs/libsecp256k1.so` to your system library path + * or point the JVM to the folder containing it with -Djava.library.path + *

+ */ +public class NativeSecp256k1 { + + private static final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); + private static final Lock r = rwl.readLock(); + private static final Lock w = rwl.writeLock(); + private static ThreadLocal nativeECDSABuffer = new ThreadLocal(); + /** + * Verifies the given secp256k1 signature in native code. + * Calling when enabled == false is undefined (probably library not loaded) + * + * @param data The data which was signed, must be exactly 32 bytes + * @param signature The signature + * @param pub The public key which did the signing + */ + public static boolean verify(byte[] data, byte[] signature, byte[] pub) { + checkArgument(data.length == 32 && signature.length <= 520 && pub.length <= 520); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 520) { + byteBuff = ByteBuffer.allocateDirect(520); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(data); + byteBuff.put(signature); + byteBuff.put(pub); + + r.lock(); + try { + return secp256k1_ecdsa_verify(byteBuff, Secp256k1Context.getContext(), signature.length, pub.length) == 1; + } finally { + r.unlock(); + } + } + + /** + * libsecp256k1 Create an ECDSA signature. + * + * @param data Message hash, 32 bytes + * @param seckey ECDSA Secret key, 32 bytes + * @return sig byte array of signature + */ + public static byte[] sign(byte[] data, byte[] seckey) throws AssertFailException{ + checkArgument(data.length == 32 && seckey.length <= 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(data); + byteBuff.put(seckey); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ecdsa_sign(byteBuff, Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] sigArr = retByteArray[0]; + int sigLen = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(sigArr.length, sigLen, "Got bad signature length."); + + return retVal == 0 ? new byte[0] : sigArr; + } + + /** + * libsecp256k1 Create an ECDSA signature adding specified entropy. + * + * This can be used to include your own entropy to nonce generation + * in addition to the message and private key, while still doing so deterministically. + * + * In particular, this is used when generating low R signatures. + * See https://github.com/bitcoin/bitcoin/pull/13666/ + * + * @param data Message hash, 32 bytes + * @param seckey ECDSA Secret key, 32 bytes + * @param entropy 32 bytes of entropy + * @return sig byte array of signature + */ + public static byte[] signWithEntropy(byte[] data, byte[] seckey, byte[] entropy) throws AssertFailException{ + checkArgument(data.length == 32 && seckey.length == 32 && entropy.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32 + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32 + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + safeRewind(byteBuff); + byteBuff.put(data); + byteBuff.put(seckey); + byteBuff.put(entropy); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ecdsa_sign_with_entropy(byteBuff, Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] sigArr = retByteArray[0]; + int sigLen = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(sigArr.length, sigLen, "Got bad signature length."); + + return retVal == 0 ? new byte[0] : sigArr; + } + + /** + * libsecp256k1 Seckey Verify - Verifies an ECDSA secret key + * + * @param seckey ECDSA Secret key, 32 bytes + * @return true if valid, false if invalid + */ + public static boolean secKeyVerify(byte[] seckey) { + checkArgument(seckey.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < seckey.length) { + byteBuff = ByteBuffer.allocateDirect(seckey.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(seckey); + + r.lock(); + try { + return secp256k1_ec_seckey_verify(byteBuff,Secp256k1Context.getContext()) == 1; + } finally { + r.unlock(); + } + } + + + /** + * libsecp256k1 Compute Pubkey - computes public key from secret key + * + * @param seckey ECDSA Secret key, 32 bytes + * @param compressed Should the generated public key be compressed + * @return pubkey ECDSA Public key, 33 or 65 bytes + */ + public static byte[] computePubkey(byte[] seckey, boolean compressed) throws AssertFailException{ + checkArgument(seckey.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < seckey.length) { + byteBuff = ByteBuffer.allocateDirect(seckey.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(seckey); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ec_pubkey_create(byteBuff, Secp256k1Context.getContext(), compressed); + } finally { + r.unlock(); + } + + byte[] pubArr = retByteArray[0]; + int pubLen = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pubArr.length, pubLen, "Got bad pubkey length."); + + return retVal == 0 ? new byte[0]: pubArr; + } + + /** + * libsecp256k1 Cleanup - This destroys the secp256k1 context object + * This should be called at the end of the program for proper cleanup of the context. + */ + public static synchronized void cleanup() { + w.lock(); + try { + secp256k1_destroy_context(Secp256k1Context.getContext()); + } finally { + w.unlock(); + } + } + + public static long cloneContext() { + r.lock(); + try { + return secp256k1_ctx_clone(Secp256k1Context.getContext()); + } finally { r.unlock(); } + } + + /** + * libsecp256k1 PrivKey Tweak-Mul - Tweak seckey by multiplying to it + * + * @param seckey ECDSA Secret key, 32 bytes + * @param tweak some bytes to tweak with + */ + public static byte[] privKeyTweakMul(byte[] seckey, byte[] tweak) throws AssertFailException{ + checkArgument(seckey.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < seckey.length + tweak.length) { + byteBuff = ByteBuffer.allocateDirect(seckey.length + tweak.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(seckey); + byteBuff.put(tweak); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_privkey_tweak_mul(byteBuff,Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] privArr = retByteArray[0]; + + int privLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(privArr.length, privLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return privArr; + } + + /** + * libsecp256k1 PrivKey Tweak-Add - Tweak seckey by adding to it + * + * @param seckey ECDSA Secret key, 32 bytes + * @param tweak some bytes to tweak with + */ + public static byte[] privKeyTweakAdd(byte[] seckey, byte[] tweak) throws AssertFailException{ + checkArgument(seckey.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < seckey.length + tweak.length) { + byteBuff = ByteBuffer.allocateDirect(seckey.length + tweak.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(seckey); + byteBuff.put(tweak); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_privkey_tweak_add(byteBuff,Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] privArr = retByteArray[0]; + + int privLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(privArr.length, privLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return privArr; + } + + /** + * libsecp256k1 PubKey Tweak-Add - Tweak pubkey by adding to it + * + * @param pubkey ECDSA Public key, 33 or 65 bytes + * @param tweak some bytes to tweak with + * @param compressed should the output public key be compressed + */ + public static byte[] pubKeyTweakAdd(byte[] pubkey, byte[] tweak, boolean compressed) throws AssertFailException{ + checkArgument(pubkey.length == 33 || pubkey.length == 65); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < pubkey.length + tweak.length) { + byteBuff = ByteBuffer.allocateDirect(pubkey.length + tweak.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(pubkey); + byteBuff.put(tweak); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_pubkey_tweak_add(byteBuff, Secp256k1Context.getContext(), pubkey.length, compressed); + } finally { + r.unlock(); + } + + byte[] pubArr = retByteArray[0]; + + int pubLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pubArr.length, pubLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return pubArr; + } + + /** + * libsecp256k1 PubKey Tweak-Mul - Tweak pubkey by multiplying to it + * + * @param pubkey ECDSA Public key, 33 or 65 bytes + * @param tweak some bytes to tweak with + * @param compressed should the output public key be compressed + */ + public static byte[] pubKeyTweakMul(byte[] pubkey, byte[] tweak, boolean compressed) throws AssertFailException{ + checkArgument(pubkey.length == 33 || pubkey.length == 65); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < pubkey.length + tweak.length) { + byteBuff = ByteBuffer.allocateDirect(pubkey.length + tweak.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(pubkey); + byteBuff.put(tweak); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_pubkey_tweak_mul(byteBuff,Secp256k1Context.getContext(), pubkey.length, compressed); + } finally { + r.unlock(); + } + + byte[] pubArr = retByteArray[0]; + + int pubLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pubArr.length, pubLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return pubArr; + } + + /** + * libsecp256k1 PubKey Combine - Add pubkeys together + * + * @param pubkeys array of ECDSA Public key, 33 or 65 bytes each + * @param compressed should the output public key be compressed + */ + public static byte[] pubKeyCombine(byte[][] pubkeys, boolean compressed) throws AssertFailException{ + int numKeys = pubkeys.length; + checkArgument(numKeys > 0); + + int pubkeyLength = pubkeys[0].length; + checkArgument(pubkeyLength == 33 || pubkeyLength == 65); + + for (byte[] pubkey : pubkeys) { + checkArgument(pubkey.length == pubkeyLength); + } + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < numKeys * pubkeyLength) { + byteBuff = ByteBuffer.allocateDirect(numKeys * pubkeyLength); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + for (byte[] pubkey : pubkeys) { + byteBuff.put(pubkey); + } + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_ec_pubkey_combine(byteBuff,Secp256k1Context.getContext(), pubkeyLength, numKeys, compressed); + } finally { + r.unlock(); + } + + byte[] pubArr = retByteArray[0]; + + int pubLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pubArr.length, pubLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return pubArr; + } + + /** + * libsecp256k1 Decompress - Parse and decompress a variable-length pub key + * + * @param pubkey ECDSA Public key, 33 or 65 bytes + */ + public static byte[] decompress(byte[] pubkey) throws AssertFailException{ + checkArgument(pubkey.length == 33 || pubkey.length == 65); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < pubkey.length) { + byteBuff = ByteBuffer.allocateDirect(pubkey.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(pubkey); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_ec_pubkey_decompress(byteBuff, Secp256k1Context.getContext(), pubkey.length); + } finally { + r.unlock(); + } + + byte[] pubArr = retByteArray[0]; + + int pubLen = (byte) new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pubArr.length, pubLen, "Got bad pubkey length."); + + assertEquals(retVal, 1, "Failed return value check."); + + return pubArr; + } + + /** + * libsecp256k1 IsValidPubKey - Checks if a pubkey is valid + * + * @param pubkey ECDSA Public key, 33 or 65 bytes + */ + public static boolean isValidPubKey(byte[] pubkey) { + if (!(pubkey.length == 33 || pubkey.length == 65)) { + return false; + } + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < pubkey.length) { + byteBuff = ByteBuffer.allocateDirect(pubkey.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(pubkey); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_ec_pubkey_decompress(byteBuff, Secp256k1Context.getContext(), pubkey.length); + } finally { + r.unlock(); + } + + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + return retVal == 1; + } + + /** + * libsecp256k1 create ECDH secret - constant time ECDH calculation + * + * @param seckey byte array of secret key used in exponentiaion + * @param pubkey byte array of public key used in exponentiaion + */ + public static byte[] createECDHSecret(byte[] seckey, byte[] pubkey) throws AssertFailException{ + checkArgument(seckey.length <= 32 && pubkey.length <= 65); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + pubkey.length) { + byteBuff = ByteBuffer.allocateDirect(32 + pubkey.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + + safeRewind(byteBuff); + byteBuff.put(seckey); + byteBuff.put(pubkey); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_ecdh(byteBuff, Secp256k1Context.getContext(), pubkey.length); + } finally { + r.unlock(); + } + + byte[] resArr = retByteArray[0]; + int retVal = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + + assertEquals(resArr.length, 32, "Got bad result length."); + assertEquals(retVal, 1, "Failed return value check."); + + return resArr; + } + + /** + * libsecp256k1 schnorr sign - generates a BIP 340 Schnorr signature + * + * @param data message to sign + * @param secKey key to sign with + */ + public static byte[] schnorrSign(byte[] data, byte[] secKey, byte[] auxRand) throws AssertFailException { + checkArgument(data.length == 32 && secKey.length == 32 && auxRand.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32 + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32 + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(data); + byteBuff.put(secKey); + byteBuff.put(auxRand); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_schnorrsig_sign(byteBuff, Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] sigArray = retByteArray[0]; + int retVal = new BigInteger(new byte[]{retByteArray[1][0]}).intValue(); + + assertEquals(retVal, 1, "Failed return value check."); + + return sigArray; + } + + /** + * libsecp256k1 schnorr sign - generates a BIP 340 Schnorr signature + * + * @param data message to sign + * @param secKey key to sign with + * @param nonce the nonce (k value) used in signing + */ + public static byte[] schnorrSignWithNonce(byte[] data, byte[] secKey, byte[] nonce) throws AssertFailException { + checkArgument(data.length == 32 && secKey.length == 32 && nonce.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32 + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32 + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(data); + byteBuff.put(secKey); + byteBuff.put(nonce); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_schnorrsig_sign_with_nonce(byteBuff, Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] sigArray = retByteArray[0]; + int retVal = new BigInteger(new byte[]{retByteArray[1][0]}).intValue(); + + assertEquals(retVal, 1, "Failed return value check."); + + return sigArray; + } + + /* + public static byte[] schnorrComputeSigPoint(byte[] data, byte[] nonce, byte[] pubkey, boolean compressed) throws AssertFailException { + checkArgument(data.length == 32 && nonce.length == 32 && pubkey.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32 + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32 + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(data); + byteBuff.put(nonce); + byteBuff.put(pubkey); + + byte[][] retByteArray; + r.lock(); + try { + retByteArray = secp256k1_schnorrsig_compute_sigpoint(byteBuff, Secp256k1Context.getContext(), compressed); + } finally { + r.unlock(); + } + + byte[] pointArray = retByteArray[0]; + int outputLen = new BigInteger(new byte[] { retByteArray[1][0] }).intValue() & 0xFF; + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(pointArray.length, outputLen, "Got bad point length."); + assertEquals(retVal, 1, "Failed return value check."); + + return pointArray; + } + */ + + /** + * libsecp256k1 schnorr verify - verifies BIP 340 Schnorr signatures + * + * @param sig signature to verify + * @param data message the signature has signed + * @param pubx the key that did the signing + */ + public static boolean schnorrVerify(byte[] sig, byte[] data, byte[] pubx) throws AssertFailException { + checkArgument(sig.length == 64 && data.length == 32 && pubx.length == 32); + + ByteBuffer byteBuffer = nativeECDSABuffer.get(); + if (byteBuffer == null || byteBuffer.capacity() < 64 + 32 + 32) { + byteBuffer = ByteBuffer.allocateDirect(64 + 32 + 32); + byteBuffer.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuffer); + } + byteBuffer.rewind(); + byteBuffer.put(sig); + byteBuffer.put(data); + byteBuffer.put(pubx); + + r.lock(); + try { + return secp256k1_schnorrsig_verify(byteBuffer, Secp256k1Context.getContext()) == 1; + } finally { + r.unlock(); + } + } + + public static byte[] adaptorSign(byte[] seckey, byte[] adaptorPoint, byte[] data, byte[] auxRand) throws AssertFailException{ + checkArgument(seckey.length == 32 && + data.length == 32 && + (adaptorPoint.length == 33 || adaptorPoint.length == 65) && + auxRand.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 32 + adaptorPoint.length + 32) { + byteBuff = ByteBuffer.allocateDirect(32 + 32 + adaptorPoint.length + 32); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(seckey); + byteBuff.put(adaptorPoint); + byteBuff.put(data); + byteBuff.put(auxRand); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ecdsa_adaptor_sign(byteBuff, Secp256k1Context.getContext(), adaptorPoint.length); + } finally { + r.unlock(); + } + + byte[] sigArr = retByteArray[0]; + int retVal = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + + if (retVal == 0) { + return new byte[]{}; + } else { + return sigArr; + } + } + + public static boolean adaptorVerify(byte[] adaptorSig, byte[] pubKey, byte[] data, byte[] adaptorPoint) throws AssertFailException{ + checkArgument(data.length == 32 && + adaptorSig.length == 162 && + (pubKey.length == 33 || pubKey.length == 65) && + adaptorPoint.length == pubKey.length); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + int buffLen = 32 + 162 + pubKey.length + adaptorPoint.length; + if (byteBuff == null || byteBuff.capacity() < buffLen) { + byteBuff = ByteBuffer.allocateDirect(buffLen); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(adaptorSig); + byteBuff.put(pubKey); + byteBuff.put(data); + byteBuff.put(adaptorPoint); + + r.lock(); + try { + return secp256k1_ecdsa_adaptor_sig_verify(byteBuff, Secp256k1Context.getContext(), pubKey.length) == 1; + } finally { + r.unlock(); + } + } + + public static byte[] adaptorAdapt(byte[] adaptorSec, byte[] adaptorSig) throws AssertFailException{ + checkArgument(adaptorSec.length == 32 && adaptorSig.length == 162); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < 32 + 162) { + byteBuff = ByteBuffer.allocateDirect(32 + 162); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(adaptorSec); + byteBuff.put(adaptorSig); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ecdsa_adaptor_adapt(byteBuff, Secp256k1Context.getContext()); + } finally { + r.unlock(); + } + + byte[] sigArr = retByteArray[0]; + int sigLen = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + int retVal = new BigInteger(new byte[] { retByteArray[1][1] }).intValue(); + + assertEquals(sigArr.length, sigLen, "Got bad signature length."); + + return retVal == 0 ? new byte[0] : sigArr; + } + + public static byte[] adaptorExtractSecret(byte[] sig, byte[] adaptorSig, byte[] adaptor) throws AssertFailException{ + checkArgument(sig.length <= 520 && (adaptor.length == 33 || adaptor.length == 65) && adaptorSig.length == 162); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < sig.length + adaptor.length + 162) { + byteBuff = ByteBuffer.allocateDirect(sig.length + adaptor.length + 162); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + byteBuff.rewind(); + byteBuff.put(sig); + byteBuff.put(adaptorSig); + byteBuff.put(adaptor); + + byte[][] retByteArray; + + r.lock(); + try { + retByteArray = secp256k1_ecdsa_adaptor_extract_secret(byteBuff, Secp256k1Context.getContext(), sig.length, adaptor.length); + } finally { + r.unlock(); + } + + byte[] sigArr = retByteArray[0]; + int retVal = new BigInteger(new byte[] { retByteArray[1][0] }).intValue(); + + return retVal == 0 ? new byte[0] : sigArr; + } + + /** + * libsecp256k1 randomize - updates the context randomization + * + * @param seed 32-byte random seed + */ + public static synchronized boolean randomize(byte[] seed) throws AssertFailException{ + checkArgument(seed.length == 32); + + ByteBuffer byteBuff = nativeECDSABuffer.get(); + if (byteBuff == null || byteBuff.capacity() < seed.length) { + byteBuff = ByteBuffer.allocateDirect(seed.length); + byteBuff.order(ByteOrder.nativeOrder()); + nativeECDSABuffer.set(byteBuff); + } + safeRewind(byteBuff); + byteBuff.put(seed); + + w.lock(); + try { + return secp256k1_context_randomize(byteBuff, Secp256k1Context.getContext()) == 1; + } finally { + w.unlock(); + } + } + + /** + * This helper method is needed to resolve issue 1524 on bitcoin-s + * This is because the API changed for ByteBuffer between jdks < 9 and jdk >= 9 + * In the earlier versions of the jdk, a [[java.nio.Buffer]] is returned, but greather than jdk 8 + * returns a [[ByteBuffer]]. This causes issues when compiling with jdk 11 but running with jdk 8 + * as the APIs are incompatible. + * @see https://github.com/bitcoin-s/bitcoin-s/issues/1524 + * @param byteBuff + */ + private static void safeRewind(ByteBuffer byteBuff) { + ((Buffer) byteBuff).rewind(); + } + + private static native long secp256k1_ctx_clone(long context); + + private static native int secp256k1_context_randomize(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_privkey_tweak_add(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_privkey_tweak_mul(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_pubkey_tweak_add(ByteBuffer byteBuff, long context, int pubLen, boolean compressed); + + private static native byte[][] secp256k1_pubkey_tweak_mul(ByteBuffer byteBuff, long context, int pubLen, boolean compressed); + + private static native void secp256k1_destroy_context(long context); + + private static native int secp256k1_ecdsa_verify(ByteBuffer byteBuff, long context, int sigLen, int pubLen); + + private static native byte[][] secp256k1_ecdsa_sign(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_ecdsa_sign_with_entropy(ByteBuffer byteBuff, long context); + + private static native int secp256k1_ec_seckey_verify(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_ec_pubkey_create(ByteBuffer byteBuff, long context, boolean compressed); + + private static native byte[][] secp256k1_ec_pubkey_combine(ByteBuffer byteBuff, long context, int pubLen, int numKeys, boolean compressed); + + private static native byte[][] secp256k1_ec_pubkey_decompress(ByteBuffer byteBuff, long context, int inputLen); + + private static native byte[][] secp256k1_ecdh(ByteBuffer byteBuff, long context, int inputLen); + + private static native byte[][] secp256k1_schnorrsig_sign(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_schnorrsig_sign_with_nonce(ByteBuffer byteBuff, long context); + + //private static native byte[][] secp256k1_schnorrsig_compute_sigpoint(ByteBuffer byteBuff, long context, boolean compressed); + + private static native int secp256k1_schnorrsig_verify(ByteBuffer byteBuffer, long context); + + private static native byte[][] secp256k1_ecdsa_adaptor_sign(ByteBuffer byteBuff, long context, int adaptorLen); + + private static native int secp256k1_ecdsa_adaptor_sig_verify(ByteBuffer byteBuff, long context, int pubKeyLen); + + private static native byte[][] secp256k1_ecdsa_adaptor_adapt(ByteBuffer byteBuff, long context); + + private static native byte[][] secp256k1_ecdsa_adaptor_extract_secret(ByteBuffer byteBuff, long context, int sigLen, int adaptorLen); +} diff --git a/src/main/java/org/bitcoin/NativeSecp256k1Util.java b/src/main/java/org/bitcoin/NativeSecp256k1Util.java new file mode 100644 index 0000000..e8bbe09 --- /dev/null +++ b/src/main/java/org/bitcoin/NativeSecp256k1Util.java @@ -0,0 +1,51 @@ +/* + * Copyright 2014-2016 the libsecp256k1 contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.bitcoin; + +public class NativeSecp256k1Util{ + + public static void assertEquals( int val, int val2, String message ) throws AssertFailException{ + if( val != val2 ) + throw new AssertFailException("FAIL: " + message); + } + + public static void assertEquals( boolean val, boolean val2, String message ) throws AssertFailException{ + if( val != val2 ) + throw new AssertFailException("FAIL: " + message); + else + System.out.println("PASS: " + message); + } + + public static void assertEquals( String val, String val2, String message ) throws AssertFailException{ + if( !val.equals(val2) ) + throw new AssertFailException("FAIL: " + message); + else + System.out.println("PASS: " + message); + } + + public static class AssertFailException extends Exception { + public AssertFailException(String message) { + super( message ); + } + } + + public static void checkArgument(boolean expression) { + if (!expression) { + throw new IllegalArgumentException(); + } + } +} diff --git a/src/main/java/org/bitcoin/Secp256k1Context.java b/src/main/java/org/bitcoin/Secp256k1Context.java new file mode 100644 index 0000000..c5276d9 --- /dev/null +++ b/src/main/java/org/bitcoin/Secp256k1Context.java @@ -0,0 +1,60 @@ +package org.bitcoin; + +import com.sparrowwallet.drongo.NativeUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class Secp256k1Context { + + private static final boolean enabled; // true if the library is loaded + private static final long context; // ref to pointer to context obj + + private static final Logger log = LoggerFactory.getLogger(Secp256k1Context.class); + + static { // static initializer + enabled = loadLibrary(); + if(enabled) { + context = secp256k1_init_context(); + } else { + context = -1; + } + } + + public static boolean isEnabled() { + return enabled; + } + + public static long getContext() { + if (!enabled) + return -1; // sanity check + return context; + } + + private static boolean loadLibrary() { + try { + String osName = System.getProperty("os.name"); + String osArch = System.getProperty("os.arch"); + if(osName.startsWith("Mac") && osArch.equals("aarch64")) { + NativeUtils.loadLibraryFromJar("/native/osx/aarch64/libsecp256k1.dylib"); + } else if(osName.startsWith("Mac")) { + NativeUtils.loadLibraryFromJar("/native/osx/x64/libsecp256k1.dylib"); + } else if(osName.startsWith("Windows")) { + NativeUtils.loadLibraryFromJar("/native/windows/x64/libsecp256k1-0.dll"); + } else if(osArch.equals("aarch64")) { + NativeUtils.loadLibraryFromJar("/native/linux/aarch64/libsecp256k1.so"); + } else { + NativeUtils.loadLibraryFromJar("/native/linux/x64/libsecp256k1.so"); + } + + return true; + } catch(IOException e) { + log.error("Error loading libsecp256k1 library", e); + } + + return false; + } + + private static native long secp256k1_init_context(); +} diff --git a/src/main/resources/native/linux/aarch64/libsecp256k1.so b/src/main/resources/native/linux/aarch64/libsecp256k1.so new file mode 100644 index 0000000..5b39860 Binary files /dev/null and b/src/main/resources/native/linux/aarch64/libsecp256k1.so differ diff --git a/src/main/resources/native/linux/x64/libsecp256k1.so b/src/main/resources/native/linux/x64/libsecp256k1.so new file mode 100755 index 0000000..cbeadb7 Binary files /dev/null and b/src/main/resources/native/linux/x64/libsecp256k1.so differ diff --git a/src/main/resources/native/osx/aarch64/libsecp256k1.dylib b/src/main/resources/native/osx/aarch64/libsecp256k1.dylib new file mode 100755 index 0000000..02aa2ff Binary files /dev/null and b/src/main/resources/native/osx/aarch64/libsecp256k1.dylib differ diff --git a/src/main/resources/native/osx/x64/libsecp256k1.dylib b/src/main/resources/native/osx/x64/libsecp256k1.dylib new file mode 100755 index 0000000..1f4c932 Binary files /dev/null and b/src/main/resources/native/osx/x64/libsecp256k1.dylib differ diff --git a/src/main/resources/native/windows/x64/libsecp256k1-0.dll b/src/main/resources/native/windows/x64/libsecp256k1-0.dll new file mode 100755 index 0000000..479a343 Binary files /dev/null and b/src/main/resources/native/windows/x64/libsecp256k1-0.dll differ 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/address/AddressTest.java b/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java index ea93994..745f33b 100644 --- a/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java +++ b/src/test/java/com/sparrowwallet/drongo/address/AddressTest.java @@ -45,6 +45,18 @@ public class AddressTest { Address address9 = Address.fromString(Network.TESTNET, "2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A"); Assert.assertTrue(address9 instanceof P2SHAddress); Assert.assertEquals("2NCZUtUt6gzXyBiPEQi5yQyrgR6f6F6Ki6A", address9.toString(Network.TESTNET)); + + 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 @@ -70,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 @@ -82,7 +98,7 @@ public class AddressTest { Address address = (i % 2 == 0 ? new P2PKHAddress(values) : new P2WPKHAddress(values)); String strAddress = address.toString(); Address checkAddress = Address.fromString(strAddress); - Assert.assertArrayEquals(values, checkAddress.getHash()); + Assert.assertArrayEquals(values, checkAddress.getData()); } byte[] values32 = new byte[32]; @@ -91,7 +107,7 @@ public class AddressTest { Address address = new P2WSHAddress(values32); String strAddress = address.toString(); Address checkAddress = Address.fromString(strAddress); - Assert.assertArrayEquals(values32, checkAddress.getHash()); + Assert.assertArrayEquals(values32, checkAddress.getData()); } } @@ -115,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); 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..f8e5809 --- /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, "Alice"); + 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 = sendNode0.getAddress(); + Assert.assertEquals("141fi7TY3h936vRUKh1qfUZr8rSBuYbVBK", address0.toString()); + + WalletNode sendNode1 = aliceBip47Wallet.getFreshNode(KeyPurpose.SEND, sendNode0); + Address address1 = sendNode1.getAddress(); + Assert.assertEquals("12u3Uued2fuko2nY4SoSFGCoGLCBUGPkk6", address1.toString()); + + WalletNode sendNode2 = aliceBip47Wallet.getFreshNode(KeyPurpose.SEND, sendNode1); + Address address2 = sendNode2.getAddress(); + 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, "Bob"); + Assert.assertEquals(paymentCodeBob.toString(), bobBip47Wallet.getKeystores().get(0).getPaymentCode().toString()); + Assert.assertEquals("1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV", paymentCodeBob.getNotificationAddress().toString()); + + WalletNode receiveNode0 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE); + Address receiveAddress0 = receiveNode0.getAddress(); + Assert.assertEquals("141fi7TY3h936vRUKh1qfUZr8rSBuYbVBK", receiveAddress0.toString()); + + WalletNode receiveNode1 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE, receiveNode0); + Address receiveAddress1 = receiveNode1.getAddress(); + Assert.assertEquals("12u3Uued2fuko2nY4SoSFGCoGLCBUGPkk6", receiveAddress1.toString()); + + WalletNode receiveNode2 = bobBip47Wallet.getFreshNode(KeyPurpose.RECEIVE, receiveNode1); + Address receiveAddress2 = receiveNode2.getAddress(); + 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/crypto/BIP38Test.java b/src/test/java/com/sparrowwallet/drongo/crypto/BIP38Test.java new file mode 100644 index 0000000..8282f7b --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/crypto/BIP38Test.java @@ -0,0 +1,33 @@ +package com.sparrowwallet.drongo.crypto; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; + +public class BIP38Test { + @Test + public void testNoCompressionNoEC() throws GeneralSecurityException, UnsupportedEncodingException { + Assert.assertEquals("5KN7MzqK5wt2TP1fQCYyHBtDrXdJuXbUzm4A9rKAteGu3Qi5CVR", BIP38.decrypt("TestingOneTwoThree", "6PRVWUbkzzsbcVac2qwfssoUJAN1Xhrg6bNk8J7Nzm5H7kxEbn2Nh2ZoGg").toString()); ; + Assert.assertEquals("5HtasZ6ofTHP6HCwTqTkLDuLQisYPah7aUnSKfC7h4hMUVw2gi5", BIP38.decrypt("Satoshi", "6PRNFFkZc2NZ6dJqFfhRoFNMR9Lnyj7dYGrzdgXXVMXcxoKTePPX1dWByq").toString()); ; + } + + @Test + public void testCompressionNoEC() throws GeneralSecurityException, UnsupportedEncodingException { + Assert.assertEquals("L44B5gGEpqEDRS9vVPz7QT35jcBG2r3CZwSwQ4fCewXAhAhqGVpP", BIP38.decrypt("TestingOneTwoThree", "6PYNKZ1EAgYgmQfmNVamxyXVWHzK5s6DGhwP4J5o44cvXdoY7sRzhtpUeo").toString()); ; + Assert.assertEquals("KwYgW8gcxj1JWJXhPSu4Fqwzfhp5Yfi42mdYmMa4XqK7NJxXUSK7", BIP38.decrypt("Satoshi", "6PYLtMnXvfG3oJde97zRyLYFZCYizPU5T3LwgdYJz1fRhh16bU7u6PPmY7").toString()); ; + } + + @Test + public void testCompressionEC() throws GeneralSecurityException, UnsupportedEncodingException { + Assert.assertEquals("5K4caxezwjGCGfnoPTZ8tMcJBLB7Jvyjv4xxeacadhq8nLisLR2", BIP38.decrypt("TestingOneTwoThree", "6PfQu77ygVyJLZjfvMLyhLMQbYnu5uguoJJ4kMCLqWwPEdfpwANVS76gTX").toString()); ; + Assert.assertEquals("5KJ51SgxWaAYR13zd9ReMhJpwrcX47xTJh2D3fGPG9CM8vkv5sH", BIP38.decrypt("Satoshi", "6PfLGnQs6VZnrNpmVKfjotbnQuaJK4KZoPFrAjx1JMJUa1Ft8gnf5WxfKd").toString()); ; + } + + @Test + public void testCompressionECLot() throws GeneralSecurityException, UnsupportedEncodingException { + Assert.assertEquals("5JLdxTtcTHcfYcmJsNVy1v2PMDx432JPoYcBTVVRHpPaxUrdtf8", BIP38.decrypt("MOLON LABE", "6PgNBNNzDkKdhkT6uJntUXwwzQV8Rr2tZcbkDcuC9DZRsS6AtHts4Ypo1j").toString()); ; + Assert.assertEquals("5KMKKuUmAkiNbA3DazMQiLfDq47qs8MAEThm4yL8R2PhV1ov33D", BIP38.decrypt("ΜΟΛΩΝ ΛΑΒΕ", "6PgGWtx25kUg8QWvwuJAgorN6k9FbE25rv5dMRwu5SKMnfpfVe5mar2ngH").toString()); ; + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/crypto/ECKeyTest.java b/src/test/java/com/sparrowwallet/drongo/crypto/ECKeyTest.java new file mode 100644 index 0000000..3897aca --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/crypto/ECKeyTest.java @@ -0,0 +1,41 @@ +package com.sparrowwallet.drongo.crypto; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.policy.Policy; +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.*; +import org.junit.Assert; +import org.junit.Test; + +public class ECKeyTest { + @Test + public void testGrindLowR() throws MnemonicException { + String words = "absent essay fox snake vast pumpkin height crouch silent bulb excuse razor"; + DeterministicSeed seed = new DeterministicSeed(words, "", 0, DeterministicSeed.Type.BIP39); + Wallet wallet = new Wallet(); + wallet.setPolicyType(PolicyType.SINGLE); + wallet.setScriptType(ScriptType.P2PKH); + Keystore keystore = Keystore.fromSeed(seed, wallet.getScriptType().getDefaultDerivation()); + wallet.getKeystores().add(keystore); + wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, wallet.getKeystores(), 1)); + + WalletNode firstReceive = wallet.getNode(KeyPurpose.RECEIVE).getChildren().iterator().next(); + Address address = firstReceive.getAddress(); + Assert.assertEquals("14JmU9a7SzieZNEtBnsZo688rt3mGrw6hr", address.toString()); + ECKey privKey = keystore.getKey(firstReceive); + + //1 attempt required for low R + String signature1 = privKey.signMessage("Test2", ScriptType.P2PKH); + Assert.assertEquals("IHra0jSywF1TjIJ5uf7IDECae438cr4o3VmG6Ri7hYlDL+pUEXyUfwLwpiAfUQVqQFLgs6OaX0KsoydpuwRI71o=", signature1); + + //2 attempts required for low R + String signature2 = privKey.signMessage("Test", ScriptType.P2PKH); + Assert.assertEquals("IDgMx1ljPhLHlKUOwnO/jBIgK+K8n8mvDUDROzTgU8gOaPDMs+eYXJpNXXINUx5WpeV605p5uO6B3TzBVcvs478=", signature2); + + //3 attempts required for low R + String signature3 = privKey.signMessage("Test1", ScriptType.P2PKH); + Assert.assertEquals("IEt/v9K95YVFuRtRtWaabPVwWOFv1FSA/e874I8ABgYMbRyVvHhSwLFz0RZuO87ukxDd4TOsRdofQwMEA90LCgI=", signature3); + } +} diff --git a/src/test/java/com/sparrowwallet/drongo/protocol/TransactionTest.java b/src/test/java/com/sparrowwallet/drongo/protocol/TransactionTest.java index b7bd516..6bd3425 100644 --- a/src/test/java/com/sparrowwallet/drongo/protocol/TransactionTest.java +++ b/src/test/java/com/sparrowwallet/drongo/protocol/TransactionTest.java @@ -4,11 +4,13 @@ import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.SchnorrSignature; import org.junit.Assert; import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Locale; import java.util.Map; import java.util.TreeMap; @@ -20,7 +22,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357")); Sha256Hash hash = transaction.hashForWitnessSignature(1, ScriptType.P2PKH.getOutputScript(pubKey.getPubKeyHash()),600000000L, SigHash.ALL); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("304402203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a0220573a954c4518331561406f90300e8f3358f51928d43c212a8caed02de67eebee"), false, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("304402203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a0220573a954c4518331561406f90300e8f3358f51928d43c212a8caed02de67eebee"), false); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -31,7 +33,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("03ad1d8e89212f0b92c74d23bb710c00662ad1470198ac48c43f7d6f93a2a26873")); Sha256Hash hash = transaction.hashForWitnessSignature(0, ScriptType.P2PKH.getOutputScript(pubKey.getPubKeyHash()),1000000000L, SigHash.ALL); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022047ac8e878352d3ebbde1c94ce3a10d057c24175747116f8288e5d794d12d482f0220217f36a485cae903c713331d877c1f64677e3622ad4010726870540656fe9dcb01"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022047ac8e878352d3ebbde1c94ce3a10d057c24175747116f8288e5d794d12d482f0220217f36a485cae903c713331d877c1f64677e3622ad4010726870540656fe9dcb01"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -43,7 +45,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("026dccc749adc2a9d0d89497ac511f760f45c47dc5ed9cf352a58ac706453880ae")); Script script = new Script(Utils.hexToBytes("21026dccc749adc2a9d0d89497ac511f760f45c47dc5ed9cf352a58ac706453880aeadab210255a9626aebf5e29c0e6538428ba0d1dcf6ca98ffdf086aa8ced5e0d0215ea465ac")); Sha256Hash hash = transaction.hashForWitnessSignature(1, script,4900000000L, SigHash.SINGLE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022027dc95ad6b740fe5129e7e62a75dd00f291a2aeb1200b84b09d9e3789406b6c002201a9ecd315dd6a0e632ab20bbb98948bc0c6fb204f2c286963bb48517a7058e2703"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022027dc95ad6b740fe5129e7e62a75dd00f291a2aeb1200b84b09d9e3789406b6c002201a9ecd315dd6a0e632ab20bbb98948bc0c6fb204f2c286963bb48517a7058e2703"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -55,7 +57,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("0392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98")); Script script = new Script(Utils.hexToBytes("68210392972e2eb617b2388771abe27235fd5ac44af8e61693261550447a4c3e39da98ac")); Sha256Hash hash = transaction.hashForWitnessSignature(1, script,16777215L, SigHash.ANYONECANPAY_SINGLE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("30440220032521802a76ad7bf74d0e2c218b72cf0cbc867066e2e53db905ba37f130397e02207709e2188ed7f08f4c952d9d13986da504502b8c3be59617e043552f506c46ff83"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("30440220032521802a76ad7bf74d0e2c218b72cf0cbc867066e2e53db905ba37f130397e02207709e2188ed7f08f4c952d9d13986da504502b8c3be59617e043552f506c46ff83"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -67,7 +69,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("0307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba3")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.ALL); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("304402206ac44d672dac41f9b00e28f4df20c52eeb087207e8d758d76d92c6fab3b73e2b0220367750dbbe19290069cba53d096f44530e4f98acaa594810388cf7409a1870ce01"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("304402206ac44d672dac41f9b00e28f4df20c52eeb087207e8d758d76d92c6fab3b73e2b0220367750dbbe19290069cba53d096f44530e4f98acaa594810388cf7409a1870ce01"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -79,7 +81,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("03b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.NONE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022068c7946a43232757cbdf9176f009a928e1cd9a1a8c212f15c1e11ac9f2925d9002205b75f937ff2f9f3c1246e547e54f62e027f64eefa2695578cc6432cdabce271502"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022068c7946a43232757cbdf9176f009a928e1cd9a1a8c212f15c1e11ac9f2925d9002205b75f937ff2f9f3c1246e547e54f62e027f64eefa2695578cc6432cdabce271502"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -91,7 +93,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.SINGLE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022059ebf56d98010a932cf8ecfec54c48e6139ed6adb0728c09cbe1e4fa0915302e022007cd986c8fa870ff5d2b3a89139c9fe7e499259875357e20fcbb15571c76795403"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3044022059ebf56d98010a932cf8ecfec54c48e6139ed6adb0728c09cbe1e4fa0915302e022007cd986c8fa870ff5d2b3a89139c9fe7e499259875357e20fcbb15571c76795403"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -103,7 +105,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f4")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.ANYONECANPAY_ALL); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3045022100fbefd94bd0a488d50b79102b5dad4ab6ced30c4069f1eaa69a4b5a763414067e02203156c6a5c9cf88f91265f5a942e96213afae16d83321c8b31bb342142a14d16381"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3045022100fbefd94bd0a488d50b79102b5dad4ab6ced30c4069f1eaa69a4b5a763414067e02203156c6a5c9cf88f91265f5a942e96213afae16d83321c8b31bb342142a14d16381"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -115,7 +117,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("03a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac16")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.ANYONECANPAY_NONE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3045022100a5263ea0553ba89221984bd7f0b13613db16e7a70c549a86de0cc0444141a407022005c360ef0ae5a5d4f9f2f87a56c1546cc8268cab08c73501d6b3be2e1e1a8a0882"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("3045022100a5263ea0553ba89221984bd7f0b13613db16e7a70c549a86de0cc0444141a407022005c360ef0ae5a5d4f9f2f87a56c1546cc8268cab08c73501d6b3be2e1e1a8a0882"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -127,7 +129,7 @@ public class TransactionTest { ECKey pubKey = ECKey.fromPublicOnly(Utils.hexToBytes("02d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b")); Script script = new Script(Utils.hexToBytes("56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae")); Sha256Hash hash = transaction.hashForWitnessSignature(0, script,987654321L, SigHash.ANYONECANPAY_SINGLE); - TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("30440220525406a1482936d5a21888260dc165497a90a15669636d8edca6b9fe490d309c022032af0c646a34a44d1f4576bf6a4a74b67940f8faa84c7df9abe12a01a11e2b4783"), true, true); + TransactionSignature signature = TransactionSignature.decodeFromBitcoin(Utils.hexToBytes("30440220525406a1482936d5a21888260dc165497a90a15669636d8edca6b9fe490d309c022032af0c646a34a44d1f4576bf6a4a74b67940f8faa84c7df9abe12a01a11e2b4783"), true); Assert.assertTrue(pubKey.verify(hash, signature)); } @@ -162,7 +164,7 @@ public class TransactionTest { Transaction transaction = new Transaction(); transaction.setVersion(parsedTransaction.getVersion()); - transaction.setSegwitVersion(parsedTransaction.getSegwitVersion()); + transaction.setSegwitFlag(parsedTransaction.getSegwitFlag()); for(TransactionInput txInput : parsedTransaction.getInputs()) { transaction.addInput(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex(), txInput.getScriptSig(), txInput.getWitness()); } @@ -188,7 +190,7 @@ public class TransactionTest { Transaction transaction = new Transaction(); transaction.setVersion(parsedTransaction.getVersion()); - transaction.setSegwitVersion(parsedTransaction.getSegwitVersion()); + transaction.setSegwitFlag(parsedTransaction.getSegwitFlag()); transaction.setLocktime(parsedTransaction.getLocktime()); for(TransactionInput txInput : parsedTransaction.getInputs()) { TransactionInput newInput = transaction.addInput(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex(), txInput.getScriptSig(), txInput.getWitness()); @@ -340,11 +342,11 @@ public class TransactionTest { TransactionInput input0 = spendingTransaction.getInputs().get(0); Script spendingScript = input0.getScriptSig(); TransactionWitness witness0 = input0.getWitness(); - TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(0), false, false); + TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(0), false); ECKey pubKey0 = ECKey.fromPublicOnly(witness0.getPushes().get(1)); Transaction transaction = new Transaction(); - transaction.setSegwitVersion(1); + transaction.setSegwitFlag(1); TransactionInput input = ScriptType.P2SH_P2WPKH.addSpendingInput(transaction, spent0Output, pubKey0, signature0); input.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED); @@ -375,8 +377,8 @@ public class TransactionTest { TransactionInput input0 = spendingTransaction.getInputs().get(0); TransactionWitness witness0 = input0.getWitness(); - TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(1), false, false); - TransactionSignature signature1 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(2), false, false); + TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(1), false); + TransactionSignature signature1 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(2), false); Script witnessScript = new Script(witness0.getPushes().get(3)); ECKey key0 = witnessScript.getChunks().get(1).getPubKey(); ECKey key1 = witnessScript.getChunks().get(2).getPubKey(); @@ -388,7 +390,7 @@ public class TransactionTest { pubKeySignatures.put(key2, null); Transaction transaction = new Transaction(); - transaction.setSegwitVersion(1); + transaction.setSegwitFlag(1); TransactionInput input = ScriptType.P2SH_P2WSH.addMultisigSpendingInput(transaction, spent0Output, 2, pubKeySignatures); transaction.addOutput(59287429, Address.fromString("3PBjKH4FRuEKy4sD3NfL7tqfZTG5K42owu")); @@ -417,12 +419,12 @@ public class TransactionTest { TransactionInput input0 = spendingTransaction.getInputs().get(0); TransactionWitness witness0 = input0.getWitness(); - TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(0), false, false); + TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(0), false); ECKey key0 = ECKey.fromPublicOnly(witness0.getPushes().get(1)); Transaction transaction = new Transaction(); transaction.setVersion(2); - transaction.setSegwitVersion(1); + transaction.setSegwitFlag(1); spent0ScriptType.addSpendingInput(transaction, spent0Output, key0, signature0); transaction.addOutput(211584990, Address.fromString("bc1q9k6aan6ncahvlslw8w54jzv897k55zh077un6s")); @@ -451,8 +453,8 @@ public class TransactionTest { TransactionInput input0 = spendingTransaction.getInputs().get(0); TransactionWitness witness0 = input0.getWitness(); - TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(1), false, false); - TransactionSignature signature1 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(2), false, false); + TransactionSignature signature0 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(1), false); + TransactionSignature signature1 = TransactionSignature.decodeFromBitcoin(witness0.getPushes().get(2), false); Script witnessScript = new Script(witness0.getPushes().get(3)); ECKey key0 = witnessScript.getChunks().get(1).getPubKey(); ECKey key1 = witnessScript.getChunks().get(2).getPubKey(); @@ -464,7 +466,7 @@ public class TransactionTest { pubKeySignatures.put(key2, null); Transaction transaction = new Transaction(); - transaction.setSegwitVersion(1); + transaction.setSegwitFlag(1); spent0ScriptType.addMultisigSpendingInput(transaction, spent0Output, 2, pubKeySignatures); transaction.addOutput(10900000, Address.fromString("3Dt17mpd8FDXBjP56rCD7a4Sx7wpL91uhn")); @@ -479,4 +481,27 @@ public class TransactionTest { Assert.assertEquals(spendingHex, constructedHex); } + + @Test + public void signBip340() { + ECKey privKey = ECKey.fromPrivate(Utils.hexToBytes("0000000000000000000000000000000000000000000000000000000000000003")); + Assert.assertEquals("F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", Utils.bytesToHex(privKey.getPubKeyXCoord()).toUpperCase(Locale.ROOT)); + SchnorrSignature sig = privKey.signSchnorr(Sha256Hash.ZERO_HASH); + Assert.assertEquals("E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0", Utils.bytesToHex(sig.encode()).toUpperCase(Locale.ROOT)); + } + + @Test + public void signTaprootKeypath() { + Transaction tx = new Transaction(Utils.hexToBytes("02000000000101786ed355f998b98f8ef8ef2acf461577325cf170a9133d48a17aba957eb97ff00000000000ffffffff0100e1f50500000000220020693a94699e6e41ab302fd623a9bf5a5b2d6606cbfb35c550d1cb4300451356a102473044022004cc317c20eb9e372cb0e640f51eb2b8311616125321b11dbaa5671db5a3ca2a02207ae3d2771b565be98ae56e21045b9629c94b6ca8f4e3932260e54d4f0e2016b30121032da1692a41a61ad14f3795b31d33431abf8d6ee161b997d004c26a37bc20083500000000")); + Transaction spendingTx = new Transaction(Utils.hexToBytes("01000000011af4dca4a6bc6da092edca5390355891da9bbe76d2be1c04d067ec9c3a3d22b10000000000000000000180f0fa0200000000160014a3bcb5f272025cc66dc42e7518a5846bd60a9c9600000000")); + + Sha256Hash hash = spendingTx.hashForTaprootSignature(tx.getOutputs(), 0, false, null, SigHash.DEFAULT, null); + ECKey privateKey = ECKey.fromPrivate(Utils.hexToBytes("d9bc817b92916a24b87d25dc48ef466b4fcd6c89cf90afbc17cba40eb8b91330")); + SchnorrSignature sig = privateKey.signSchnorr(hash); + + Assert.assertEquals("7b04f59bc8f5c2c33c9b8acbf94743de74cc25a6052b52ff61a516f7c5ca19cc68345ba99b354f22bfaf5c04de395b9223f3bf0a5c351fc1cc68c224f4e5b202", Utils.bytesToHex(sig.encode())); + + ECKey pubKey = ECKey.fromPublicOnly(privateKey); + Assert.assertTrue(pubKey.verify(hash, new TransactionSignature(sig, SigHash.DEFAULT))); + } } diff --git a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java index bb77ecc..04f8f5b 100644 --- a/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java +++ b/src/test/java/com/sparrowwallet/drongo/psbt/PSBTTest.java @@ -11,6 +11,9 @@ import org.junit.After; import org.junit.Assert; import org.junit.Test; +import java.util.List; +import java.util.Map; + public class PSBTTest { @Test(expected = PSBTParseException.class) @@ -19,12 +22,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="; @@ -312,7 +309,8 @@ public class PSBTTest { String psbtStr4 = "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQD9pQEBAAAAAAECiaPHHqtNIOA3G7ukzGmPopXJRjr6Ljl/hTPMti+VZ+UBAAAAFxYAFL4Y0VKpsBIDna89p95PUzSe7LmF/////4b4qkOnHf8USIk6UwpyN+9rRgi7st0tAXHmOuxqSJC0AQAAABcWABT+Pp7xp0XpdNkCxDVZQ6vLNL1TU/////8CAMLrCwAAAAAZdqkUhc/xCX/Z4Ai7NK9wnGIZeziXikiIrHL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAkcwRAIgJxK+IuAnDzlPVoMR3HyppolwuAJf3TskAinwf4pfOiQCIAGLONfc0xTnNMkna9b7QPZzMlvEuqFEyADS8vAtsnZcASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUCSDBFAiEA0SuFLYXc2WHS9fSrZgZU327tzHlMDDPOXMMJ/7X85Y0CIGczio4OFyXBl/saiK9Z9R5E5CVbIBZ8hoQDHAXR8lkqASECI7cr7vCWXRC+B3jv7NYfysb3mk6haTkzgHNEZPhPKrMAAAAAAQMEAQAAAAAAAA=="; PSBT psbt4 = PSBT.fromString(psbtStr4); - Assert.assertEquals(psbtStr4, psbt4.toBase64String()); + String noWitnessDataPsbt = "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQDMAQAAAAKJo8ceq00g4Dcbu6TMaY+ilclGOvouOX+FM8y2L5Vn5QEAAAAXFgAUvhjRUqmwEgOdrz2n3k9TNJ7suYX/////hviqQ6cd/xRIiTpTCnI372tGCLuy3S0BceY67GpIkLQBAAAAFxYAFP4+nvGnRel02QLENVlDq8s0vVNT/////wIAwusLAAAAABl2qRSFz/EJf9ngCLs0r3CcYhl7OJeKSIiscv74TiwAAAAXqRQzlyW6Ie/WKsdTqbzQZ9bHpqOdBYcAAAAAAQMEAQAAAAAAAA=="; + Assert.assertEquals(noWitnessDataPsbt, psbt4.toBase64String()); } @Test @@ -376,6 +374,31 @@ public class PSBTTest { Assert.assertEquals("0200000000010258e87a21b56daf0c23be8e7070456c336f7cbaa5c8757924f545887bb2abdd7500000000da00473044022074018ad4180097b873323c0015720b3684cc8123891048e7dbcd9b55ad679c99022073d369b740e3eb53dcefa33823c8070514ca55a7dd9544f157c167913261118c01483045022100f61038b308dc1da865a34852746f015772934208c6d24454393cd99bdf2217770220056e675a675a6d0a02b85b14e5e29074d8a25a9b5760bea2816f661910a006ea01475221029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f2102dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d752aeffffffff838d0427d0ec650a68aa46bb0b098aea4422c071b2ca78352a077959d07cea1d01000000232200208c2353173743b595dfb4a07b72ba8e42e3797da74e87fe7d9d7497e3b2028903ffffffff0270aaf00800000000160014d85c2b71d0060b09c9886aeb815e50991dda124d00e1f5050000000016001400aea9a2e5f0f876a588df5546e8742d1d87008f000400473044022062eb7a556107a7c73f45ac4ab5a1dddf6f7075fb1275969a7f383efff784bcb202200c05dbb7470dbf2f08557dd356c7325c1ed30913e996cd3840945db12228da5f01473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d20147522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae00000000", Utils.bytesToHex(transaction.bitcoinSerialize())); } + @Test + public void isPSBT() { + String s = null; + Assert.assertFalse(PSBT.isPSBT(s)); + Assert.assertFalse(PSBT.isPSBT("")); + Assert.assertFalse(PSBT.isPSBT("x")); + } + + @Test + public void testTaproot() throws PSBTParseException { + Network.set(Network.TESTNET); + String strSignedPsbt = "cHNidP8BAH0CAAAAAdPUSBYKaQKOqAMgU2IcGuM6z7JtbkLe69OmYZoa4UxYAAAAAADmdQAAAp4CAAAAAAAAFgAUg+/zyGWbtKbIJb8ZasbGYDtItZhgrgEAAAAAACJRIJMQKWeQsI/WiRS+lmeeyeJaKFVnlVoBtXeuGTO7XIbrAAAAAE8BBDWHzwM27GqkgAAAAO1YZge2AP67ozhdoF9wgg2hpJw1jbVEXLKfxRQUNGEIAlog/wK83w7jxD37prhWrPenLxjGAJzkJKrj6h0ZPK8WED/ZeB1WAACAAQAAgAAAAIAAAQCJAgAAAAGBuBuu5OIheS4SKtYJufScCJDTWWLopBoXtFWhPDuRWQEAAAAA/f///wKNsQEAAAAAACJRIAjXFSnHwcD+J/obgd9CxVneHsUyFKM9xU7NY5K3DgCxHwIAAAAAAAAiUSAhUe66hJMCB1esHqXtxRVJHmviQ4ZjzFIwDWDPk+1KY6VyIQABASuNsQEAAAAAACJRIAjXFSnHwcD+J/obgd9CxVneHsUyFKM9xU7NY5K3DgCxAQMEAAAAAAETQCG/ZGuefjDVqBhmgVEuV1HbdxoZKDDWWTvTUrq6MJreRzj22k/WcFni6yPn9PGZkptZSNx9waf8ouP28ogJz24hFnRZPMvN82XI+lnim7dRKwFgpHnqiDoMGnIoFoSQRX7bGQA/2XgdVgAAgAEAAIAAAACAAQAAAAIAAAABFyB0WTzLzfNlyPpZ4pu3USsBYKR56og6DBpyKBaEkEV+2wAAIQflpK6JYWolG3K2DU7FU3hlkwSdU/69bglhZxaprqSvyxkAP9l4HVYAAIABAACAAAAAgAEAAAADAAAAAQUg5aSuiWFqJRtytg1OxVN4ZZMEnVP+vW4JYWcWqa6kr8sA"; + PSBT psbt = PSBT.fromString(strSignedPsbt); + + Assert.assertEquals(0, psbt.getPsbtInputs().get(0).getDerivedPublicKeys().size()); + Assert.assertEquals("74593ccbcdf365c8fa59e29bb7512b0160a479ea883a0c1a7228168490457edb", Utils.bytesToHex(psbt.getPsbtInputs().get(0).getTapDerivedPublicKeys().keySet().iterator().next().getPubKeyXCoord())); + Map> tapInKeyDerivations = psbt.getPsbtInputs().get(0).getTapDerivedPublicKeys().values().iterator().next(); + Assert.assertEquals("3fd9781d", tapInKeyDerivations.keySet().iterator().next().getMasterFingerprint()); + + Assert.assertEquals(0, psbt.getPsbtOutputs().get(0).getDerivedPublicKeys().size()); + Assert.assertEquals("e5a4ae89616a251b72b60d4ec553786593049d53febd6e09616716a9aea4afcb", Utils.bytesToHex(psbt.getPsbtOutputs().get(1).getTapDerivedPublicKeys().keySet().iterator().next().getPubKeyXCoord())); + Map> tapOutKeyDerivations = psbt.getPsbtOutputs().get(1).getTapDerivedPublicKeys().values().iterator().next(); + Assert.assertEquals("3fd9781d", tapOutKeyDerivations.keySet().iterator().next().getMasterFingerprint()); + } + @After public void tearDown() throws Exception { Network.set(null); diff --git a/src/test/java/com/sparrowwallet/drongo/uri/BitcoinUriTest.java b/src/test/java/com/sparrowwallet/drongo/uri/BitcoinUriTest.java index 786303b..e6bd578 100644 --- a/src/test/java/com/sparrowwallet/drongo/uri/BitcoinUriTest.java +++ b/src/test/java/com/sparrowwallet/drongo/uri/BitcoinUriTest.java @@ -3,13 +3,15 @@ package com.sparrowwallet.drongo.uri; import org.junit.Assert; import org.junit.Test; +import java.util.Locale; + public class BitcoinUriTest { @Test public void testSamourai() throws BitcoinURIParseException { String uri = "groestlcoin:BC1QT4NRM47695YWDG9N30N68JARMXRJNKFMR36994?amount=0,001"; BitcoinURI bitcoinURI = new BitcoinURI(uri); - Assert.assertEquals("BC1QT4NRM47695YWDG9N30N68JARMXRJNKFMR36994".toLowerCase(), bitcoinURI.getAddress().toString()); + Assert.assertEquals("BC1QT4NRM47695YWDG9N30N68JARMXRJNKFMR36994".toLowerCase(Locale.ROOT), bitcoinURI.getAddress().toString()); Assert.assertEquals(Long.valueOf(100000), bitcoinURI.getAmount()); } } diff --git a/src/test/java/com/sparrowwallet/drongo/wallet/PolicyTest.java b/src/test/java/com/sparrowwallet/drongo/wallet/PolicyTest.java index 0fe5c40..a0f985d 100644 --- a/src/test/java/com/sparrowwallet/drongo/wallet/PolicyTest.java +++ b/src/test/java/com/sparrowwallet/drongo/wallet/PolicyTest.java @@ -7,6 +7,7 @@ import org.junit.Assert; import org.junit.Test; import java.util.List; +import java.util.Locale; public class PolicyTest { @Test @@ -16,27 +17,27 @@ public class PolicyTest { Keystore keystore3 = new Keystore("Keystore 3"); Policy policy = Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2PKH, List.of(keystore1), 1); - Assert.assertEquals("pkh(keystore1)", policy.getMiniscript().toString()); + Assert.assertEquals("pkh(keystore1)", policy.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(1, policy.getNumSignaturesRequired()); Policy policy2 = Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2SH_P2WPKH, List.of(keystore1), 1); - Assert.assertEquals("sh(wpkh(keystore1))", policy2.getMiniscript().toString()); + Assert.assertEquals("sh(wpkh(keystore1))", policy2.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(1, policy2.getNumSignaturesRequired()); Policy policy3 = Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, List.of(keystore1), 1); - Assert.assertEquals("wpkh(keystore1)", policy3.getMiniscript().toString()); + Assert.assertEquals("wpkh(keystore1)", policy3.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(1, policy3.getNumSignaturesRequired()); Policy policy4 = Policy.getPolicy(PolicyType.MULTI, ScriptType.P2SH, List.of(keystore1, keystore2, keystore3), 2); - Assert.assertEquals("sh(sortedmulti(2,keystore1,keystore2,keystore3))", policy4.getMiniscript().toString()); + Assert.assertEquals("sh(sortedmulti(2,keystore1,keystore2,keystore3))", policy4.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(2, policy4.getNumSignaturesRequired()); Policy policy5 = Policy.getPolicy(PolicyType.MULTI, ScriptType.P2SH_P2WSH, List.of(keystore1, keystore2, keystore3), 2); - Assert.assertEquals("sh(wsh(sortedmulti(2,keystore1,keystore2,keystore3)))", policy5.getMiniscript().toString()); + Assert.assertEquals("sh(wsh(sortedmulti(2,keystore1,keystore2,keystore3)))", policy5.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(2, policy5.getNumSignaturesRequired()); Policy policy6 = Policy.getPolicy(PolicyType.MULTI, ScriptType.P2WSH, List.of(keystore1, keystore2, keystore3), 2); - Assert.assertEquals("wsh(sortedmulti(2,keystore1,keystore2,keystore3))", policy6.getMiniscript().toString()); + Assert.assertEquals("wsh(sortedmulti(2,keystore1,keystore2,keystore3))", policy6.getMiniscript().toString().toLowerCase(Locale.ROOT)); Assert.assertEquals(2, policy6.getNumSignaturesRequired()); } } diff --git a/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java b/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java index 92804b8..2dc3155 100644 --- a/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java +++ b/src/test/java/com/sparrowwallet/drongo/wallet/WalletTest.java @@ -104,8 +104,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("12kTQjuWDp7Uu6PwY6CsS1KLTt3d1DBHZa", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.RECEIVE, 1); + Assert.assertEquals("1HbQwQCitHQxVtP39isXmUdHx7hQCZovrK", receive1.getAddress().toString()); } @Test @@ -119,8 +121,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("3NZLE4TntsjtcZ5MbrfxwtYo9meBVybVQj", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.RECEIVE, 1); + Assert.assertEquals("32YBBuRsp8XTeLx4T6BmD2L4nANGaNDkSg", receive1.getAddress().toString()); } @Test @@ -134,8 +138,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("bc1quvxdut936uswuxwxrk6nvjmgwxh463r0fjwn55", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.RECEIVE, 1); + Assert.assertEquals("bc1q95j2862dz7mqpraw6qdjc70gumyu5z7adgq9x9", receive1.getAddress().toString()); } @Test @@ -159,8 +165,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("38kq6yz4VcYymTExQPY3gppbz38mtPLveK", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.CHANGE, 1); + Assert.assertEquals("3EdKaNsnjBTBggWcSMRyVju6GbHWy68mAH", receive1.getAddress().toString()); } @Test @@ -184,8 +192,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("3Mw8xqAHh8g3eBvh7q1UEUmoexqdXDK9Tf", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.CHANGE, 1); + Assert.assertEquals("35dFo1ivJ8jyHpyf42MWvnYf5LBU8Siren", receive1.getAddress().toString()); } @Test @@ -209,8 +219,10 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("bc1q20e4vm656h5lvmngz9ztz6hjzftvh39yzngqhuqzk8qzj7tqnzaqgclrwc", receive0.getAddress().toString()); + WalletNode receive1 = new WalletNode(wallet, KeyPurpose.CHANGE, 1); + Assert.assertEquals("bc1q2epdx7dplwaas2jucfrzmxm8350rqh68hs6vqreysku80ye44mfqla85f2", receive1.getAddress().toString()); } @Test @@ -227,6 +239,7 @@ 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()); + WalletNode receive0 = new WalletNode(wallet, KeyPurpose.RECEIVE, 0); + Assert.assertEquals("bc1qarzeu6ncapyvjzdeayjq8vnzp6uvcn4eaeuuqq", receive0.getAddress().toString()); } }