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 super ModuleInfo> 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