initial commit

This commit is contained in:
Craig Raw 2025-08-16 11:34:30 +02:00
commit 87fa8f011a
47 changed files with 2486 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.idea
.gradle
*iml
build
/*.properties
out
*.log
build-*.sh
.DS_Store

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "drongo"]
path = drongo
url = ../../sparrowwallet/drongo.git

177
LICENSE Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# Frigate Electrum Server
Frigate is an experimental Electrum Server testing Silent Payments scanning with ephemeral client keys.

70
build.gradle Normal file
View File

@ -0,0 +1,70 @@
plugins {
id 'application'
id 'org.gradlex.extra-java-module-info' version '1.13'
}
group = 'com.sparrowwallet.frigate'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation(project(':drongo'))
implementation('com.google.guava:guava:33.0.0-jre')
implementation('com.google.code.gson:gson:2.9.1')
implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.3') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
}
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('org.jcommander:jcommander:2.0')
implementation ('org.slf4j:slf4j-api:2.0.12')
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
test {
useJUnitPlatform()
}
application {
mainModule = 'com.sparrowwallet.frigate'
mainClass = 'com.sparrowwallet.frigate.Frigate'
}
extraJavaModuleInfo {
module('com.github.arteam:simple-json-rpc-core', 'simple.json.rpc.core') {
exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core')
requires('com.fasterxml.jackson.annotation')
requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations')
}
module('com.github.arteam:simple-json-rpc-client', 'simple.json.rpc.client') {
exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception')
requires('com.fasterxml.jackson.core')
requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core')
}
module('com.github.arteam:simple-json-rpc-server', 'simple.json.rpc.server') {
exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core')
requires('com.google.common')
requires('org.slf4j')
requires('com.fasterxml.jackson.databind')
}
module('com.google.guava:listenablefuture|empty-to-avoid-conflict-with-guava', 'com.google.guava.listenablefuture')
module('com.google.code.findbugs:jsr305', 'com.google.code.findbugs.jsr305')
module('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
module('org.jcommander:jcommander', 'org.jcommander') {
exports('com.beust.jcommander')
}
}

1
drongo Submodule

@ -0,0 +1 @@
Subproject commit 23f2b9197a42d799120abe16db9131601d63dcff

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# 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.
# You may obtain a copy of the License at
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# 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/HEAD/platforms/jvm/plugins-application/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
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
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
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 ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# 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
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
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
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# 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.
# 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
# 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"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# 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" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
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!
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
:omega

2
settings.gradle Normal file
View File

@ -0,0 +1,2 @@
rootProject.name = 'frigate'
include 'drongo'

View File

@ -0,0 +1,44 @@
package com.sparrowwallet.frigate;
import com.beust.jcommander.Parameter;
import com.sparrowwallet.drongo.Network;
import org.slf4j.event.Level;
import java.util.ArrayList;
import java.util.List;
public class Args {
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
public String dir;
@Parameter(names = { "--network", "-n" }, description = "Network to use")
public Network network;
@Parameter(names = { "--level", "-l" }, description = "Set log level")
public Level level;
@Parameter(names = { "--version", "-v" }, description = "Show version", arity = 0)
public boolean version;
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
public List<String> toParams() {
List<String> params = new ArrayList<>();
if(dir != null) {
params.add("-d");
params.add(dir);
}
if(network != null) {
params.add("-n");
params.add(network.toString());
}
if(level != null) {
params.add("-l");
params.add(level.toString());
}
return params;
}
}

View File

@ -0,0 +1,11 @@
package com.sparrowwallet.frigate;
public class ConfigurationException extends RuntimeException {
public ConfigurationException(String message) {
super(message);
}
public ConfigurationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,122 @@
package com.sparrowwallet.frigate;
import com.beust.jcommander.JCommander;
import com.google.common.eventbus.EventBus;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.frigate.electrum.ElectrumServerRunnable;
import com.sparrowwallet.frigate.index.BitcoindClient;
import com.sparrowwallet.frigate.io.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.Locale;
public class Frigate {
public static final String SERVER_NAME = "Frigate";
public static final String SERVER_VERSION = "0.0.1";
public static final String APP_HOME_PROPERTY = "frigate.home";
public static final String NETWORK_ENV_PROPERTY = "FRIGATE_NETWORK";
private static final EventBus EVENT_BUS = new EventBus();
private BitcoindClient bitcoindClient;
private ElectrumServerRunnable electrumServer;
private boolean running;
public void start() {
bitcoindClient = new BitcoindClient();
bitcoindClient.initialize();
electrumServer = new ElectrumServerRunnable(bitcoindClient);
Thread electrumServerThread = new Thread(electrumServer, "Frigate Electrum Server");
electrumServerThread.setDaemon(true);
electrumServerThread.start();
running = true;
}
public boolean isRunning() {
return running;
}
public void stop() {
if(bitcoindClient != null) {
bitcoindClient.stop();
}
if(electrumServer != null) {
electrumServer.stop();
}
running = false;
}
public static EventBus getEventBus() {
return EVENT_BUS;
}
private static Logger getLogger() {
return LoggerFactory.getLogger(Frigate.class);
}
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(SERVER_NAME.toLowerCase(Locale.ROOT)).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.version) {
System.out.println(SERVER_NAME + " " + SERVER_VERSION);
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase(Locale.ROOT)));
} catch(Exception e) {
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
File testnetFlag = new File(Storage.getFrigateHome(), "network-" + Network.TESTNET.getName());
if(testnetFlag.exists()) {
Network.set(Network.TESTNET);
}
File testnet4Flag = new File(Storage.getFrigateHome(), "network-" + Network.TESTNET4.getName());
if(testnet4Flag.exists()) {
Network.set(Network.TESTNET4);
}
File signetFlag = new File(Storage.getFrigateHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
getLogger().info("Using " + Network.get() + " configuration");
}
Frigate frigate = new Frigate();
frigate.start();
}
}

View File

@ -0,0 +1,20 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcErrorData;
import com.google.common.base.Throwables;
@JsonRpcError(code=-32000, message="Could not connect to Bitcoin Core RPC")
public class BitcoindIOException extends Exception {
@JsonRpcErrorData
private final String rootCause;
public BitcoindIOException(Throwable rootCause) {
super("Could not connect to Bitcoin Core RPC");
this.rootCause = Throwables.getRootCause(rootCause).getMessage();
}
public String getRootCause() {
return rootCause;
}
}

View File

@ -0,0 +1,18 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
@JsonRpcError(code=-32002)
public class BlockNotFoundException extends Exception {
private final String message;
public BlockNotFoundException(ErrorMessage errorMessage) {
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
}
@Override
public String getMessage() {
return message;
}
}

View File

@ -0,0 +1,17 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
@JsonRpcError(code=-32003)
public class BroadcastFailedException extends Exception {
private final String message;
public BroadcastFailedException(ErrorMessage errorMessage) {
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
}
public String getMessage() {
return message;
}
}

View File

@ -0,0 +1,5 @@
package com.sparrowwallet.frigate.electrum;
public record ElectrumBlockHeader(int height, String hex) {
}

View File

@ -0,0 +1,15 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
@JsonRpcService
public interface ElectrumNotificationService {
@JsonRpcMethod("blockchain.headers.subscribe")
void notifyHeaders(@JsonRpcParam("header") ElectrumBlockHeader electrumBlockHeader);
@JsonRpcMethod("blockchain.scripthash.subscribe")
void notifyScriptHash(@JsonRpcParam("scripthash") String scriptHash, @JsonRpcOptional @JsonRpcParam("status") String status);
}

View File

@ -0,0 +1,26 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.client.Transport;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class ElectrumNotificationTransport implements Transport {
private final Socket clientSocket;
public ElectrumNotificationTransport(Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public String pass(String request) throws IOException {
PrintWriter out = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8));
out.println(request);
out.flush();
return "{\"result\":{},\"error\":null,\"id\":1}";
}
}

View File

@ -0,0 +1,77 @@
package com.sparrowwallet.frigate.electrum;
import com.sparrowwallet.frigate.index.BitcoindClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ElectrumServerRunnable implements Runnable {
private static final Logger log = LoggerFactory.getLogger(ElectrumServerRunnable.class);
private final BitcoindClient bitcoindClient;
protected ServerSocket serverSocket = null;
protected boolean stopped = false;
protected Thread runningThread = null;
protected ExecutorService threadPool = Executors.newFixedThreadPool(10, r -> {
Thread t = Executors.defaultThreadFactory().newThread(r);
t.setDaemon(true);
return t;
});
public ElectrumServerRunnable(BitcoindClient bitcoindClient) {
this.bitcoindClient = bitcoindClient;
openServerSocket();
}
public int getPort() {
return serverSocket.getLocalPort();
}
public void run() {
synchronized(this) {
this.runningThread = Thread.currentThread();
}
while(!isStopped()) {
Socket clientSocket;
try {
clientSocket = this.serverSocket.accept();
} catch(IOException e) {
if(isStopped()) {
break;
}
throw new RuntimeException("Error accepting client connection", e);
}
RequestHandler requestHandler = new RequestHandler(clientSocket, bitcoindClient);
this.threadPool.execute(requestHandler);
}
this.threadPool.shutdown();
}
private synchronized boolean isStopped() {
return stopped;
}
public synchronized void stop() {
stopped = true;
try {
serverSocket.close();
} catch(IOException e) {
throw new RuntimeException("Error closing server", e);
}
}
private void openServerSocket() {
try {
serverSocket = new ServerSocket(0);
} catch(IOException e) {
throw new RuntimeException("Cannot open electrum server port", e);
}
}
}

View File

@ -0,0 +1,166 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.drongo.Version;
import com.sparrowwallet.frigate.Frigate;
import com.sparrowwallet.frigate.index.BitcoindClient;
import com.sparrowwallet.frigate.index.BlockStats;
import com.sparrowwallet.frigate.index.FeeInfo;
import com.sparrowwallet.frigate.index.MempoolInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
@JsonRpcService
public class ElectrumServerService {
private static final Logger log = LoggerFactory.getLogger(ElectrumServerService.class);
private static final Version VERSION = new Version("1.4");
private static final double DEFAULT_FEE_RATE = 0.00001d;
private final BitcoindClient bitcoindClient;
private final RequestHandler requestHandler;
public ElectrumServerService(BitcoindClient bitcoindClient, RequestHandler requestHandler) {
this.bitcoindClient = bitcoindClient;
this.requestHandler = requestHandler;
}
@JsonRpcMethod("server.version")
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String[] protocolVersion) throws UnsupportedVersionException {
String version = protocolVersion.length > 1 ? protocolVersion[1] : protocolVersion[0];
Version clientVersion = new Version(version);
if(clientVersion.compareTo(VERSION) < 0) {
throw new UnsupportedVersionException(version);
}
return List.of(Frigate.SERVER_NAME + " " + Frigate.SERVER_VERSION, VERSION.get());
}
@JsonRpcMethod("server.banner")
public String getServerBanner() {
return Frigate.SERVER_NAME + " " + Frigate.SERVER_VERSION + "\n" + bitcoindClient.getNetworkInfo().subversion() + (bitcoindClient.getNetworkInfo().networkactive() ? "" : " (disconnected)");
}
@JsonRpcMethod("blockchain.estimatefee")
public Double estimateFee(@JsonRpcParam("number") int blocks) throws BitcoindIOException {
try {
FeeInfo feeInfo = bitcoindClient.getBitcoindService().estimateSmartFee(blocks);
if(feeInfo == null || feeInfo.feerate() == null) {
return DEFAULT_FEE_RATE;
}
return feeInfo.feerate();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.relayfee")
public Double getRelayFee() throws BitcoindIOException {
try {
MempoolInfo mempoolInfo = bitcoindClient.getBitcoindService().getMempoolInfo();
return mempoolInfo.minrelaytxfee();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.headers.subscribe")
public ElectrumBlockHeader subscribeHeaders() {
requestHandler.setHeadersSubscribed(true);
return bitcoindClient.getTip();
}
@JsonRpcMethod("server.ping")
public void ping() throws BitcoindIOException {
try {
bitcoindClient.getBitcoindService().uptime();
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.block.header")
public String getBlockHeader(@JsonRpcParam("height") int height) throws BitcoindIOException, BlockNotFoundException {
try {
String blockHash = bitcoindClient.getBitcoindService().getBlockHash(height);
return bitcoindClient.getBitcoindService().getBlockHeader(blockHash, false);
} catch(JsonRpcException e) {
throw new BlockNotFoundException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.block.stats")
public BlockStats getBlockStats(@JsonRpcParam("height") int height) throws BitcoindIOException, BlockNotFoundException {
try {
return bitcoindClient.getBitcoindService().getBlockStats(height);
} catch(JsonRpcException e) {
throw new BlockNotFoundException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.transaction.get")
@SuppressWarnings("unchecked")
public Object getTransaction(@JsonRpcParam("tx_hash") String tx_hash, @JsonRpcParam("verbose") @JsonRpcOptional boolean verbose) throws BitcoindIOException, TransactionNotFoundException {
if(verbose) {
try {
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, true);
} catch(JsonRpcException e) {
try {
Map<String, Object> txInfo = bitcoindClient.getBitcoindService().getTransaction(tx_hash, true, true);
Object decoded = txInfo.get("decoded");
if(decoded instanceof Map<?, ?>) {
Map<String, Object> decodedMap = (Map<String, Object>)decoded;
decodedMap.put("hex", txInfo.get("hex"));
decodedMap.put("confirmations", txInfo.get("confirmations"));
decodedMap.put("blockhash", txInfo.get("blockhash"));
decodedMap.put("time", txInfo.get("time"));
decodedMap.put("blocktime", txInfo.get("blocktime"));
return decoded;
}
throw new TransactionNotFoundException(e.getErrorMessage());
} catch(JsonRpcException ex) {
throw new TransactionNotFoundException(ex.getErrorMessage());
} catch(IllegalStateException ex) {
throw new BitcoindIOException(ex);
}
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
} else {
try {
return bitcoindClient.getBitcoindService().getTransaction(tx_hash, true, false).get("hex");
} catch(JsonRpcException e) {
try {
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, false);
} catch(JsonRpcException ex) {
throw new TransactionNotFoundException(ex.getErrorMessage());
} catch(IllegalStateException ex) {
throw new BitcoindIOException(e);
}
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
}
@JsonRpcMethod("blockchain.transaction.broadcast")
public String broadcastTransaction(@JsonRpcParam("raw_tx") String rawTx) throws BitcoindIOException, BroadcastFailedException {
try {
return bitcoindClient.getBitcoindService().sendRawTransaction(rawTx, 0d);
} catch(JsonRpcException e) {
throw new BroadcastFailedException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
}

View File

@ -0,0 +1,87 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.frigate.Frigate;
import com.sparrowwallet.frigate.index.BitcoindClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
public class RequestHandler implements Runnable {
private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);
private final Socket clientSocket;
private final ElectrumServerService electrumServerService;
private final JsonRpcServer rpcServer = new JsonRpcServer();
private boolean headersSubscribed;
private final Set<String> scriptHashesSubscribed = new HashSet<>();
public RequestHandler(Socket clientSocket, BitcoindClient bitcoindClient) {
this.clientSocket = clientSocket;
this.electrumServerService = new ElectrumServerService(bitcoindClient, this);
}
public void run() {
Frigate.getEventBus().register(this);
try {
InputStream input = clientSocket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
OutputStream output = clientSocket.getOutputStream();
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)));
while(true) {
String request = reader.readLine();
if(request == null) {
break;
}
String response = rpcServer.handle(request, electrumServerService);
out.println(response);
out.flush();
}
} catch(IOException e) {
log.error("Could not communicate with client socket", e);
}
Frigate.getEventBus().unregister(this);
}
public void setHeadersSubscribed(boolean headersSubscribed) {
this.headersSubscribed = headersSubscribed;
}
public void subscribeScriptHash(String scriptHash) {
scriptHashesSubscribed.add(scriptHash);
}
public boolean isScriptHashSubscribed(String scriptHash) {
return scriptHashesSubscribed.contains(scriptHash);
}
@Subscribe
public void newBlock(ElectrumBlockHeader electrumBlockHeader) {
if(headersSubscribed) {
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyHeaders(electrumBlockHeader);
}
}
@Subscribe
public void scriptHashStatus(ScriptHashStatus scriptHashStatus) {
if(isScriptHashSubscribed(scriptHashStatus.scriptHash())) {
ElectrumNotificationTransport electrumNotificationTransport = new ElectrumNotificationTransport(clientSocket);
JsonRpcClient jsonRpcClient = new JsonRpcClient(electrumNotificationTransport);
jsonRpcClient.onDemand(ElectrumNotificationService.class).notifyScriptHash(scriptHashStatus.scriptHash(), scriptHashStatus.status());
}
}
}

View File

@ -0,0 +1,5 @@
package com.sparrowwallet.frigate.electrum;
public record ScriptHashStatus(String scriptHash, String status) {
}

View File

@ -0,0 +1,17 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
@JsonRpcError(code=-32001)
public class TransactionNotFoundException extends Exception {
private final String message;
public TransactionNotFoundException(ErrorMessage errorMessage) {
this.message = errorMessage == null ? "" : errorMessage.getMessage() + (errorMessage.getData() == null ? "" : " (" + errorMessage.getData() + ")");
}
public String getMessage() {
return message;
}
}

View File

@ -0,0 +1,18 @@
package com.sparrowwallet.frigate.electrum;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcError;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcErrorData;
@JsonRpcError(code=-32003, message="Unsupported version")
public class UnsupportedVersionException extends Exception {
@JsonRpcErrorData
private final String version;
public UnsupportedVersionException(String version) {
this.version = version;
}
public String getVersion() {
return version;
}
}

View File

@ -0,0 +1,162 @@
package com.sparrowwallet.frigate.index;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.sparrowwallet.frigate.ConfigurationException;
import com.sparrowwallet.frigate.Frigate;
import com.sparrowwallet.frigate.electrum.ElectrumBlockHeader;
import com.sparrowwallet.frigate.io.Config;
import com.sparrowwallet.frigate.io.CoreAuthType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BitcoindClient {
private static final Logger log = LoggerFactory.getLogger(BitcoindClient.class);
private final JsonRpcClient jsonRpcClient;
private final Timer timer = new Timer(false);
private NetworkInfo networkInfo;
private String lastBlock;
private ElectrumBlockHeader tip;
private Exception lastPollException;
private final Lock syncingLock = new ReentrantLock();
private final Condition syncingCondition = syncingLock.newCondition();
private boolean syncing;
private boolean stopped;
public BitcoindClient() {
BitcoindTransport bitcoindTransport;
Config config = Config.get();
if((config.getCoreAuthType() == CoreAuthType.COOKIE || config.getCoreAuth() == null || config.getCoreAuth().length() < 2) && config.getCoreDataDir() != null) {
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), config.getCoreDataDir());
} else if(config.getCoreAuth() != null) {
bitcoindTransport = new BitcoindTransport(config.getCoreServer(), config.getCoreAuth());
} else {
throw new ConfigurationException("Bitcoin Core data folder or user and password is required");
}
this.jsonRpcClient = new JsonRpcClient(bitcoindTransport);
}
public void initialize() {
networkInfo = getBitcoindService().getNetworkInfo();
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
tip = blockHeader.getBlockHeader();
timer.schedule(new PollTask(), 5000, 5000);
if(blockchainInfo.initialblockdownload() && networkInfo.networkactive()) {
syncingLock.lock();
try {
syncing = true;
syncingCondition.await();
if(syncing) {
if(lastPollException instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new RuntimeException("Error while waiting for sync to complete", lastPollException);
}
} catch(InterruptedException e) {
throw new RuntimeException("Interrupted while waiting for sync to complete");
} finally {
syncingLock.unlock();
}
blockchainInfo = getBitcoindService().getBlockchainInfo();
blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
tip = blockHeader.getBlockHeader();
}
lastBlock = blockchainInfo.bestblockhash();
}
public void stop() {
timer.cancel();
stopped = true;
}
public BitcoindClientService getBitcoindService() {
return jsonRpcClient.onDemand(BitcoindClientService.class);
}
public NetworkInfo getNetworkInfo() {
return networkInfo;
}
public ElectrumBlockHeader getTip() {
return tip;
}
private class PollTask extends TimerTask {
@Override
public void run() {
if(stopped) {
timer.cancel();
}
try {
if(syncing) {
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
if(blockchainInfo.initialblockdownload() && !isEmptyBlockchain(blockchainInfo)) {
return;
} else {
syncing = false;
syncingLock.lock();
try {
syncingCondition.signal();
} finally {
syncingLock.unlock();
}
}
}
if(lastBlock != null && tip != null) {
String blockhash = getBitcoindService().getBlockHash(tip.height());
if(!lastBlock.equals(blockhash)) {
log.warn("Reorg detected, block height " + tip.height() + " was " + lastBlock + " and now is " + blockhash);
lastBlock = null;
}
}
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
String currentBlock = lastBlock;
if(currentBlock == null || !currentBlock.equals(blockchainInfo.bestblockhash())) {
VerboseBlockHeader blockHeader = getBitcoindService().getBlockHeader(blockchainInfo.bestblockhash());
tip = blockHeader.getBlockHeader();
log.warn("New block height " + tip.height());
Frigate.getEventBus().post(tip);
}
lastBlock = blockchainInfo.bestblockhash();
} catch(Exception e) {
lastPollException = e;
log.warn("Error polling Bitcoin Core", e);
if(syncing) {
syncingLock.lock();
try {
syncingCondition.signal();
} finally {
syncingLock.unlock();
}
}
}
}
}
private boolean isEmptyBlockchain(BlockchainInfo blockchainInfo) {
return blockchainInfo.blocks() == 0 && blockchainInfo.getProgressPercent() == 100;
}
}

View File

@ -0,0 +1,63 @@
package com.sparrowwallet.frigate.index;
import com.github.arteam.simplejsonrpc.client.JsonRpcParams;
import com.github.arteam.simplejsonrpc.client.ParamsType;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import java.util.List;
import java.util.Map;
import java.util.Set;
@JsonRpcService
@JsonRpcParams(ParamsType.ARRAY)
public interface BitcoindClientService {
@JsonRpcMethod("uptime")
long uptime();
@JsonRpcMethod("getnetworkinfo")
NetworkInfo getNetworkInfo();
@JsonRpcMethod("estimatesmartfee")
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
@JsonRpcMethod("getrawmempool")
Set<Sha256Hash> getRawMempool();
@JsonRpcMethod("getrawmempool")
Map<Sha256Hash, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getmempoolinfo")
MempoolInfo getMempoolInfo();
@JsonRpcMethod("getblockchaininfo")
BlockchainInfo getBlockchainInfo();
@JsonRpcMethod("getblockhash")
String getBlockHash(@JsonRpcParam("height") int height);
@JsonRpcMethod("getblockheader")
String getBlockHeader(@JsonRpcParam("blockhash") String blockhash, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getblockheader")
VerboseBlockHeader getBlockHeader(@JsonRpcParam("blockhash") String blockhash);
@JsonRpcMethod("getblockstats")
BlockStats getBlockStats(@JsonRpcParam("blockhash") int hash_or_height);
@JsonRpcMethod("getrawtransaction")
Object getRawTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("gettransaction")
Map<String, Object> getTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("include_watchonly") boolean includeWatchOnly, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getmempoolentry")
MempoolEntry getMempoolEntry(@JsonRpcParam("txid") String txid);
@JsonRpcMethod("sendrawtransaction")
String sendRawTransaction(@JsonRpcParam("hexstring") String rawTx, @JsonRpcParam("maxfeerate") Double maxFeeRate);
}

View File

@ -0,0 +1,163 @@
package com.sparrowwallet.frigate.index;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.frigate.io.Server;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Base64;
public class BitcoindTransport implements Transport {
private static final Logger log = LoggerFactory.getLogger(BitcoindTransport.class);
public static final String COOKIE_FILENAME = ".cookie";
private final Server bitcoindServer;
private URL bitcoindUrl;
private File cookieFile;
private Long cookieFileTimestamp;
private String bitcoindAuthEncoded;
public BitcoindTransport(Server bitcoindServer, String bitcoindAuth) {
this(bitcoindServer);
this.bitcoindAuthEncoded = Base64.getEncoder().encodeToString(bitcoindAuth.getBytes(StandardCharsets.UTF_8));
}
public BitcoindTransport(Server bitcoindServer, File bitcoindDir) {
this(bitcoindServer);
this.cookieFile = new File(getCookieDir(bitcoindDir), COOKIE_FILENAME);
}
private BitcoindTransport(Server bitcoindServer) {
this.bitcoindServer = bitcoindServer;
try {
String serverUrl = bitcoindServer.getUrl();
if(!bitcoindServer.getHostAndPort().hasPort()) {
serverUrl += ":" + Network.get().getDefaultPort();
}
this.bitcoindUrl = new URI(serverUrl).toURL();
} catch(MalformedURLException | URISyntaxException e) {
log.error("Malformed Bitcoin Core RPC URL", e);
}
}
@Override
public String pass(String request) throws IOException {
HttpURLConnection connection = (HttpURLConnection)bitcoindUrl.openConnection();
if(connection instanceof HttpsURLConnection httpsURLConnection) {
SSLSocketFactory sslSocketFactory = getTrustAllSocketFactory();
if(sslSocketFactory != null) {
httpsURLConnection.setSSLSocketFactory(sslSocketFactory);
}
}
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
String auth = getBitcoindAuthEncoded();
if(auth != null) {
connection.setRequestProperty("Authorization", "Basic " + auth);
}
connection.setDoOutput(true);
log.debug("> " + request);
try(OutputStream os = connection.getOutputStream()) {
byte[] jsonBytes = request.getBytes(StandardCharsets.UTF_8);
os.write(jsonBytes);
}
int statusCode = connection.getResponseCode();
if(statusCode == 401) {
throw new IOException((cookieFile == null ? "User/pass" : "Cookie file") + " authentication failed");
}
InputStream inputStream = connection.getErrorStream() == null ? connection.getInputStream() : connection.getErrorStream();
StringBuilder res = new StringBuilder();
try(BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String responseLine;
while((responseLine = br.readLine()) != null) {
if(statusCode == 500) {
responseLine = responseLine.replace("\"result\":null,", "");
}
res.append(responseLine.trim());
}
}
String response = res.toString();
log.debug("< " + response);
return response;
}
private String getBitcoindAuthEncoded() throws IOException {
if(cookieFile != null) {
if(!cookieFile.exists()) {
throw new IOException("Cannot find Bitcoin Core cookie file at " + cookieFile.getAbsolutePath());
}
if(cookieFileTimestamp == null || cookieFile.lastModified() != cookieFileTimestamp) {
try {
String userPass = Files.readAllLines(cookieFile.toPath()).get(0);
bitcoindAuthEncoded = Base64.getEncoder().encodeToString(userPass.getBytes(StandardCharsets.UTF_8));
cookieFileTimestamp = cookieFile.lastModified();
} catch(Exception e) {
log.warn("Cannot read Bitcoin Core .cookie file", e);
}
}
}
return bitcoindAuthEncoded;
}
private static File getCookieDir(File bitcoindDir) {
if(Network.get() == Network.TESTNET && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "testnet3", COOKIE_FILENAME))) {
return new File(bitcoindDir, "testnet3");
} else if(Network.get() == Network.TESTNET4 && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "testnet4", COOKIE_FILENAME))) {
return new File(bitcoindDir, "testnet4");
} else if(Network.get() == Network.REGTEST && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "regtest", COOKIE_FILENAME))) {
return new File(bitcoindDir, "regtest");
} else if(Network.get() == Network.SIGNET && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "signet", COOKIE_FILENAME))) {
return new File(bitcoindDir, "signet");
}
return bitcoindDir;
}
private SSLSocketFactory getTrustAllSocketFactory() {
TrustManager[] trustAllCerts = new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
}
};
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
return sslContext.getSocketFactory();
} catch (Exception e) {
log.error("Error creating SSL socket factory", e);
}
return null;
}
}

View File

@ -0,0 +1,13 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BlockStats(int height, String blockhash, double[] feerate_percentiles, int total_weight, int txs, long time) {
public BlockSummary toBlockSummary() {
Double medianFee = feerate_percentiles != null && feerate_percentiles.length > 0 ? feerate_percentiles[feerate_percentiles.length / 2] : null;
return new BlockSummary(height, new Date(time * 1000), medianFee, txs, total_weight);
}
}

View File

@ -0,0 +1,76 @@
package com.sparrowwallet.frigate.index;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;
public class BlockSummary implements Comparable<BlockSummary> {
private final Integer height;
private final Date timestamp;
private final Double medianFee;
private final Integer transactionCount;
private final Integer weight;
public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null, null);
}
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount, Integer weight) {
this.height = height;
this.timestamp = timestamp;
this.medianFee = medianFee;
this.transactionCount = transactionCount;
this.weight = weight;
}
public Integer getHeight() {
return height;
}
public Date getTimestamp() {
return timestamp;
}
public Optional<Double> getMedianFee() {
return medianFee == null ? Optional.empty() : Optional.of(medianFee);
}
public Optional<Integer> getTransactionCount() {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
}
public Optional<Integer> getWeight() {
return weight == null ? Optional.empty() : Optional.of(weight);
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public String getElapsed() {
long elapsed = calculateElapsedSeconds(getTimestamp().getTime());
if(elapsed < 0) {
return "now";
} else if(elapsed < 60) {
return elapsed + "s";
} else if(elapsed < 3600) {
return elapsed / 60 + "m";
} else if(elapsed < 86400) {
return elapsed / 3600 + "h";
} else {
return elapsed / 86400 + "d";
}
}
public String toString() {
return getElapsed() + ":" + getMedianFee();
}
@Override
public int compareTo(BlockSummary o) {
return o.height - height;
}
}

View File

@ -0,0 +1,16 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BlockchainInfo(int blocks, int headers, String bestblockhash, boolean initialblockdownload, long time, Double verificationprogress, boolean pruned, Integer pruneheight) {
public Date getTip() {
return new Date(time * 1000);
}
public int getProgressPercent() {
return (int) Math.floor(verificationprogress * 100);
}
}

View File

@ -0,0 +1,10 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record FeeInfo(Double feerate, List<String> errors, int blocks) {
}

View File

@ -0,0 +1,7 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record FeesMempoolEntry(double base, double ancestor) {
}

View File

@ -0,0 +1,18 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MempoolEntry(int vsize, int ancestorsize, boolean bip125_replaceable, FeesMempoolEntry fees) {
public boolean hasUnconfirmedParents() {
return vsize != ancestorsize;
}
public TxEntry getTxEntry(String txid) {
return new TxEntry(hasUnconfirmedParents() ? -1 : 0, 0, txid, fees().base());
}
public VsizeFeerate getVsizeFeerate() {
return new VsizeFeerate(vsize, fees().base());
}
}

View File

@ -0,0 +1,7 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MempoolInfo(double minrelaytxfee) {
}

View File

@ -0,0 +1,8 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record NetworkInfo(int version, String subversion, boolean networkactive) {
}

View File

@ -0,0 +1,80 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.sparrowwallet.drongo.protocol.Transaction;
import java.util.Objects;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TxEntry implements Comparable<TxEntry> {
public final int height;
private final transient int index;
public final String tx_hash;
public final Long fee;
public TxEntry(int height, int index, String tx_hash) {
this.height = height;
this.index = index;
this.tx_hash = tx_hash;
this.fee = null;
}
public TxEntry(int height, int index, String tx_hash, double btcFee) {
this.height = height;
this.index = index;
this.tx_hash = tx_hash;
this.fee = btcFee > 0.0 ? (long)(btcFee * Transaction.SATOSHIS_PER_BITCOIN) : null;
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(!(o instanceof TxEntry txEntry)) {
return false;
}
return height == txEntry.height && tx_hash.equals(txEntry.tx_hash) && Objects.equals(fee, txEntry.fee);
}
@Override
public int hashCode() {
int result = height;
result = 31 * result + tx_hash.hashCode();
result = 31 * result + Objects.hashCode(fee);
return result;
}
@Override
public int compareTo(TxEntry o) {
if(height <= 0 && o.height > 0) {
return 1;
}
if(height > 0 && o.height <= 0) {
return -1;
}
if(height != o.height) {
return height - o.height;
}
if(height <= 0) {
return tx_hash.compareTo(o.tx_hash);
}
return index - o.index;
}
@Override
public String toString() {
return "TxEntry{" +
"height=" + height +
", index=" + index +
", tx_hash='" + tx_hash + '\'' +
", fee=" + fee +
'}';
}
}

View File

@ -0,0 +1,19 @@
package com.sparrowwallet.frigate.index;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.frigate.electrum.ElectrumBlockHeader;
import java.math.BigInteger;
@JsonIgnoreProperties(ignoreUnknown = true)
public record VerboseBlockHeader(String hash, int confirmations, int height, int version, String versionHex, String merkleroot, long time, long mediantime, long nonce,
String bits, double difficulty, String chainwork, int nTx, String previousblockhash) {
public ElectrumBlockHeader getBlockHeader() {
BigInteger nBits = new BigInteger(bits, 16);
BlockHeader blockHeader = new BlockHeader(version, previousblockhash == null ? Sha256Hash.ZERO_HASH : Sha256Hash.wrap(previousblockhash), Sha256Hash.wrap(merkleroot), null, time, nBits.longValue(), nonce);
return new ElectrumBlockHeader(height, Utils.bytesToHex(blockHeader.bitcoinSerialize()));
}
}

View File

@ -0,0 +1,28 @@
package com.sparrowwallet.frigate.index;
import com.sparrowwallet.drongo.protocol.Transaction;
public class VsizeFeerate implements Comparable<VsizeFeerate> {
private final int vsize;
private final float feerate;
public VsizeFeerate(int vsize, double fee) {
this.vsize = vsize;
double feeRate = fee / vsize * Transaction.SATOSHIS_PER_BITCOIN;
//Round down to 0.1 sats/vb precision
this.feerate = (float) (Math.floor(10 * feeRate) / 10);
}
public int getVsize() {
return vsize;
}
public double getFeerate() {
return feerate;
}
@Override
public int compareTo(VsizeFeerate o) {
return Float.compare(o.feerate, feerate);
}
}

View File

@ -0,0 +1,145 @@
package com.sparrowwallet.frigate.io;
import com.google.gson.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
public static final String CONFIG_FILENAME = "config";
private Server coreServer;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private static Config INSTANCE;
private static Gson getGson() {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(File.class, new FileSerializer());
gsonBuilder.registerTypeAdapter(File.class, new FileDeserializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerSerializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerDeserializer());
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
private static File getConfigFile() {
File sparrowDir = Storage.getFrigateDir();
return new File(sparrowDir, CONFIG_FILENAME);
}
private static Config load() {
File configFile = getConfigFile();
if(configFile.exists()) {
try {
Reader reader = new FileReader(configFile);
Config config = getGson().fromJson(reader, Config.class);
reader.close();
if(config != null) {
return config;
}
} catch(Exception e) {
log.error("Error opening " + configFile.getAbsolutePath(), e);
//Ignore and assume no config
}
}
return new Config();
}
public static synchronized Config get() {
if(INSTANCE == null) {
INSTANCE = load();
}
return INSTANCE;
}
public Server getCoreServer() {
return coreServer;
}
public void setCoreServer(Server coreServer) {
this.coreServer = coreServer;
flush();
}
public CoreAuthType getCoreAuthType() {
return coreAuthType;
}
public void setCoreAuthType(CoreAuthType coreAuthType) {
this.coreAuthType = coreAuthType;
flush();
}
public File getCoreDataDir() {
return coreDataDir;
}
public void setCoreDataDir(File coreDataDir) {
this.coreDataDir = coreDataDir;
flush();
}
public String getCoreAuth() {
return coreAuth;
}
public void setCoreAuth(String coreAuth) {
this.coreAuth = coreAuth;
flush();
}
private synchronized void flush() {
Gson gson = getGson();
try {
File configFile = getConfigFile();
if(!configFile.exists()) {
Storage.createOwnerOnlyFile(configFile);
}
Writer writer = new FileWriter(configFile);
gson.toJson(this, writer);
writer.flush();
writer.close();
} catch (IOException e) {
//Ignore
}
}
private static class FileSerializer implements JsonSerializer<File> {
@Override
public JsonElement serialize(File src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getAbsolutePath());
}
}
private static class FileDeserializer implements JsonDeserializer<File> {
@Override
public File deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return new File(json.getAsJsonPrimitive().getAsString());
}
}
private static class ServerSerializer implements JsonSerializer<Server> {
@Override
public JsonElement serialize(Server src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ServerDeserializer implements JsonDeserializer<Server> {
@Override
public Server deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Server.fromString(json.getAsJsonPrimitive().getAsString());
}
}
}

View File

@ -0,0 +1,6 @@
package com.sparrowwallet.frigate.io;
public enum CoreAuthType {
COOKIE, USERPASS;
}

View File

@ -0,0 +1,104 @@
package com.sparrowwallet.frigate.io;
import com.google.common.net.HostAndPort;
import java.io.File;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.Locale;
public enum Protocol {
TCP(50001),
SSL(50002),
HTTP(80),
HTTPS(443);
private final int defaultPort;
Protocol(int defaultPort) {
this.defaultPort = defaultPort;
}
public int getDefaultPort() {
return defaultPort;
}
public HostAndPort getServerHostAndPort(String url) {
String lessProtocol = url.endsWith(":t") || url.endsWith(":s") ? url.substring(0, url.length() - 2) : url.substring(this.toUrlString().length());
int pathStart = lessProtocol.indexOf('/');
return HostAndPort.fromString(pathStart < 0 ? lessProtocol : lessProtocol.substring(0, pathStart));
}
public String toUrlString() {
return toString().toLowerCase(Locale.ROOT) + "://";
}
public String toUrlString(String host) {
return toUrlString(HostAndPort.fromHost(host));
}
public String toUrlString(String host, int port) {
return toUrlString(HostAndPort.fromParts(host, port));
}
public String toUrlString(HostAndPort hostAndPort) {
return toUrlString() + hostAndPort.toString();
}
public static boolean isOnionHost(String host) {
return host != null && host.toLowerCase(Locale.ROOT).endsWith(".onion");
}
public static boolean isOnionAddress(Server server) {
if(server != null) {
return isOnionAddress(server.getHostAndPort());
}
return false;
}
public static boolean isOnionAddress(HostAndPort server) {
return isOnionHost(server.getHost());
}
public static boolean isOnionAddress(String address) {
if(address != null) {
Protocol protocol = Protocol.getProtocol(address);
if(protocol != null) {
return isOnionAddress(protocol.getServerHostAndPort(address));
}
}
return false;
}
public static Protocol getProtocol(String url) {
if(url.startsWith("tcp://") || url.endsWith(":t")) {
return TCP;
}
if(url.startsWith("ssl://") || url.endsWith(":s")) {
return SSL;
}
if(url.startsWith("http://")) {
return HTTP;
}
if(url.startsWith("https://")) {
return HTTPS;
}
return null;
}
public static String getHost(String url) {
Protocol protocol = getProtocol(url);
if(protocol != null) {
return protocol.getServerHostAndPort(url).getHost();
}
return null;
}
}

View File

@ -0,0 +1,107 @@
package com.sparrowwallet.frigate.io;
import com.google.common.net.HostAndPort;
import java.util.Arrays;
public class Server {
private final String url;
private final String alias;
public Server(String url) {
this(url, null);
}
public Server(String url, String alias) {
if(url == null) {
throw new IllegalArgumentException("Url cannot be null");
}
if(url.isEmpty()) {
throw new IllegalArgumentException("Url cannot be empty");
}
if(Protocol.getProtocol(url) == null) {
throw new IllegalArgumentException("Unknown protocol for url " + url + ", must be one of " + Arrays.toString(Protocol.values()));
}
if(Protocol.getHost(url) == null) {
throw new IllegalArgumentException("Cannot determine host for url " + url);
}
if(alias != null && alias.isEmpty()) {
throw new IllegalArgumentException("Server alias cannot be an empty string");
}
this.url = url;
this.alias = alias;
}
public String getUrl() {
return url;
}
public Protocol getProtocol() {
return Protocol.getProtocol(url);
}
public HostAndPort getHostAndPort() {
return getProtocol().getServerHostAndPort(url);
}
public String getHost() {
return getHostAndPort().getHost();
}
public boolean isOnionAddress() {
return Protocol.isOnionAddress(getHostAndPort());
}
public String getAlias() {
return alias;
}
public String getDisplayName() {
return alias == null ? url : alias;
}
public String toString() {
return url + (alias == null ? "" : "|" + alias);
}
public boolean portEquals(String port) {
if(port == null) {
return !getHostAndPort().hasPort();
}
return port.equals(getHostAndPort().hasPort() ? Integer.toString(getHostAndPort().getPort()) : "");
}
public static Server fromString(String server) {
String[] parts = server.split("\\|");
if(parts.length >= 2) {
return new Server(parts[0], parts[1]);
}
return new Server(parts[0], null);
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
Server server = (Server)o;
return url.equals(server.url);
}
@Override
public int hashCode() {
return url.hashCode();
}
}

View File

@ -0,0 +1,147 @@
package com.sparrowwallet.frigate.io;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.frigate.Frigate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.EnumSet;
import java.util.Set;
public class Storage {
private static final Logger log = LoggerFactory.getLogger(Storage.class);
public static final String FRIGATE_DIR = ".frigate";
public static final String WINDOWS_FRIGATE_DIR = "Frigate";
public static File getFrigateDir() {
File frigateDir;
Network network = Network.get();
if(network != Network.MAINNET) {
frigateDir = new File(getFrigateHome(), network.getHome());
if(!network.getName().equals(network.getHome()) && !frigateDir.exists()) {
File networkNameDir = new File(getFrigateHome(), network.getName());
if(networkNameDir.exists() && networkNameDir.isDirectory() && !Files.isSymbolicLink(networkNameDir.toPath())) {
try {
if(networkNameDir.renameTo(frigateDir) && !isWindows()) {
Files.createSymbolicLink(networkNameDir.toPath(), Path.of(frigateDir.getName()));
}
} catch(Exception e) {
log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + frigateDir.getName(), e);
}
}
}
} else {
frigateDir = getFrigateHome();
}
if(!frigateDir.exists()) {
createOwnerOnlyDirectory(frigateDir);
}
if(!network.getName().equals(network.getHome()) && !isWindows()) {
try {
Path networkNamePath = getFrigateHome().toPath().resolve(network.getName());
if(Files.isSymbolicLink(networkNamePath)) {
Path symlinkTarget = getFrigateHome().toPath().resolve(Files.readSymbolicLink(networkNamePath));
if(!Files.isSameFile(frigateDir.toPath(), symlinkTarget)) {
Files.delete(networkNamePath);
Files.createSymbolicLink(networkNamePath, Path.of(frigateDir.getName()));
}
} else if(!Files.exists(networkNamePath)) {
Files.createSymbolicLink(networkNamePath, Path.of(frigateDir.getName()));
}
} catch(Exception e) {
log.debug("Error updating symlink from " + network.getName() + " to " + frigateDir.getName(), e);
}
}
return frigateDir;
}
public static File getFrigateHome() {
return getFrigateHome(false);
}
public static File getFrigateHome(boolean useDefault) {
if(!useDefault && System.getProperty(Frigate.APP_HOME_PROPERTY) != null) {
return new File(System.getProperty(Frigate.APP_HOME_PROPERTY));
}
if(isWindows()) {
return new File(getHomeDir(), WINDOWS_FRIGATE_DIR);
}
return new File(getHomeDir(), FRIGATE_DIR);
}
static File getHomeDir() {
if(isWindows()) {
return new File(System.getenv("APPDATA"));
}
return new File(System.getProperty("user.home"));
}
public static boolean createOwnerOnlyDirectory(File directory) {
try {
if(isWindows()) {
Files.createDirectories(directory.toPath());
return true;
}
Files.createDirectories(directory.toPath(), PosixFilePermissions.asFileAttribute(getDirectoryOwnerOnlyPosixFilePermissions()));
return true;
} catch(UnsupportedOperationException e) {
return directory.mkdirs();
} catch(IOException e) {
log.error("Could not create directory " + directory.getAbsolutePath(), e);
}
return false;
}
public static boolean createOwnerOnlyFile(File file) {
try {
if(isWindows()) {
Files.createFile(file.toPath());
return true;
}
Files.createFile(file.toPath(), PosixFilePermissions.asFileAttribute(getFileOwnerOnlyPosixFilePermissions()));
return true;
} catch(UnsupportedOperationException e) {
return false;
} catch(IOException e) {
log.error("Could not create file " + file.getAbsolutePath(), e);
}
return false;
}
private static Set<PosixFilePermission> getDirectoryOwnerOnlyPosixFilePermissions() {
Set<PosixFilePermission> ownerOnly = getFileOwnerOnlyPosixFilePermissions();
ownerOnly.add(PosixFilePermission.OWNER_EXECUTE);
return ownerOnly;
}
private static Set<PosixFilePermission> getFileOwnerOnlyPosixFilePermissions() {
Set<PosixFilePermission> ownerOnly = EnumSet.noneOf(PosixFilePermission.class);
ownerOnly.add(PosixFilePermission.OWNER_READ);
ownerOnly.add(PosixFilePermission.OWNER_WRITE);
return ownerOnly;
}
private static boolean isWindows() {
return OsType.getCurrent() == OsType.WINDOWS;
}
}

View File

@ -0,0 +1,15 @@
module com.sparrowwallet.frigate {
requires com.sparrowwallet.drongo;
requires com.fasterxml.jackson.annotation;
requires simple.json.rpc.core;
requires simple.json.rpc.client;
requires simple.json.rpc.server;
requires com.google.gson;
requires com.google.common;
requires org.jcommander;
requires org.slf4j;
exports com.sparrowwallet.frigate;
exports com.sparrowwallet.frigate.io;
exports com.sparrowwallet.frigate.index;
opens com.sparrowwallet.frigate.io;
}

View File

@ -0,0 +1,27 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<logger name="sun.net.www.protocol.http.HttpURLConnection" level="INFO" />
<define name="appDir" class="com.sparrowwallet.drongo.PropertyDefiner">
<application>frigate</application>
</define>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${appDir}/frigate.log</file>
<encoder>
<pattern>%date %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date %level %msg%n</pattern>
</encoder>
</appender>
<root level="warn">
<appender-ref ref="FILE" />
<appender-ref ref="STDOUT" />
</root>
</configuration>