mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 12:06:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9c3eb737 | ||
|
|
ef2c2cde2b | ||
|
|
0dce4783ef | ||
|
|
cf797ea0f4 | ||
|
|
969dd72d6d | ||
|
|
2d667d118a | ||
|
|
1b8e9b5229 | ||
|
|
aeb0eeb11a | ||
|
|
febece9e9b | ||
|
|
432758d22a | ||
|
|
8b410ad005 | ||
|
|
abc0a4f2b7 | ||
|
|
6b30e6a3ec | ||
|
|
a5f705f185 | ||
|
|
d7c726782c | ||
|
|
b3d51bce34 | ||
|
|
d8e7b54cae | ||
|
|
078af174af | ||
|
|
4b8a4594b0 | ||
|
|
9a870ba522 | ||
|
|
b0591d7afe | ||
|
|
e3db43679f | ||
|
|
fb10ac3aa2 | ||
|
|
08b36abb6a | ||
|
|
fa9194a032 | ||
|
|
73897fc53a | ||
|
|
72c1d822a4 | ||
|
|
a13016609d | ||
|
|
c7798d920e | ||
|
|
5ae36a549f | ||
|
|
f7f6b59d6d | ||
|
|
771f2dc9bc | ||
|
|
a9e144bb0c | ||
|
|
0b8199948d | ||
|
|
f697aa610b | ||
|
|
37bab9f321 | ||
|
|
3dc50682a3 | ||
|
|
d5b119e338 | ||
|
|
b457caa5d2 | ||
|
|
61ed816c87 | ||
|
|
9a603d7547 | ||
|
|
ac666545be | ||
|
|
c4c77d84e0 | ||
|
|
8d126869a6 | ||
|
|
464fade68f | ||
|
|
79517da131 | ||
|
|
086297436e | ||
|
|
d69e274b18 | ||
|
|
d1e67ad4a0 | ||
|
|
37ca98c2b0 | ||
|
|
287c943b44 | ||
|
|
cb92f76546 | ||
|
|
24e9c39cb8 | ||
|
|
754ebf7bbf | ||
|
|
bc7a0be87e | ||
|
|
87af1ed9f5 | ||
|
|
da476c9d77 |
@@ -0,0 +1,110 @@
|
||||
name: Update Gradle verification metadata
|
||||
description: Restore optional previous Gradle verification files, update them, validate them, and upload the result.
|
||||
|
||||
inputs:
|
||||
output-artifact:
|
||||
description: Name of the artifact to upload with the updated verification files.
|
||||
required: true
|
||||
previous-artifact:
|
||||
description: Optional previous verification artifact to restore before running Gradle.
|
||||
required: false
|
||||
default: ''
|
||||
clean-start:
|
||||
description: Remove existing verification files before bootstrapping metadata.
|
||||
required: false
|
||||
default: 'false'
|
||||
cache-gradle-wrapper-distribution:
|
||||
description: Cache only the Gradle wrapper distribution, not Gradle dependency artifacts.
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download previous verification files
|
||||
if: ${{ inputs.previous-artifact != '' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ inputs.previous-artifact }}
|
||||
path: build/previous-verification
|
||||
|
||||
- name: Restore previous verification files
|
||||
if: ${{ inputs.previous-artifact != '' }}
|
||||
shell: bash
|
||||
run: cp build/previous-verification/gradle/verification-* gradle/
|
||||
|
||||
- name: Bootstrap verification metadata
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p gradle
|
||||
if [ "${{ inputs.clean-start }}" = "true" ]; then
|
||||
rm -f gradle/verification-metadata.xml gradle/verification-keyring.keys
|
||||
fi
|
||||
if [ ! -f gradle/verification-metadata.xml ]; then
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://schema.gradle.org/dependency-verification https://schema.gradle.org/dependency-verification/dependency-verification-1.3.xsd">'
|
||||
printf '%s\n' ' <configuration>'
|
||||
printf '%s\n' ' <verify-metadata>true</verify-metadata>'
|
||||
printf '%s\n' ' <verify-signatures>true</verify-signatures>'
|
||||
printf '%s\n' ' <keyring-format>armored</keyring-format>'
|
||||
printf '%s\n' ' <key-servers>'
|
||||
printf '%s\n' ' <key-server uri="https://keyserver.ubuntu.com"/>'
|
||||
printf '%s\n' ' <key-server uri="https://keys.openpgp.org"/>'
|
||||
printf '%s\n' ' </key-servers>'
|
||||
printf '%s\n' ' <trusted-artifacts>'
|
||||
printf '%s\n' ' <trust file=".*-javadoc[.]jar" regex="true"/>'
|
||||
printf '%s\n' ' <trust file=".*-sources[.]jar" regex="true"/>'
|
||||
printf '%s\n' ' </trusted-artifacts>'
|
||||
printf '%s\n' ' </configuration>'
|
||||
printf '%s\n' ' <components>'
|
||||
printf '%s\n' ' </components>'
|
||||
printf '%s\n' '</verification-metadata>'
|
||||
} > gradle/verification-metadata.xml
|
||||
fi
|
||||
|
||||
- name: Clear Java tool-cache for reproducibility
|
||||
shell: bash
|
||||
run: rm -rf "$RUNNER_TOOL_CACHE"/Java_*
|
||||
|
||||
- name: Set up JDK 25.0.2
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25.0.2'
|
||||
|
||||
- name: Cache Gradle wrapper distribution
|
||||
if: ${{ inputs.cache-gradle-wrapper-distribution == 'true' }}
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.gradle/wrapper/dists
|
||||
key: gradle-wrapper-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
|
||||
|
||||
- name: Generate dependency verification metadata
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew --write-verification-metadata sha512 --refresh-keys \
|
||||
dependencies :drongo:dependencies :lark:dependencies jpackageImage
|
||||
./gradlew --write-verification-metadata pgp,sha512 --refresh-keys \
|
||||
dependencies :drongo:dependencies :lark:dependencies jpackageImage
|
||||
./gradlew --export-keys help
|
||||
|
||||
- name: Validate dependency verification metadata
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew help dependencies :drongo:dependencies :lark:dependencies \
|
||||
--dependency-verification strict --refresh-keys
|
||||
|
||||
- name: Stage verification files
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p build/dependency-verification/gradle
|
||||
cp gradle/verification-metadata.xml build/dependency-verification/gradle/
|
||||
cp gradle/verification-keyring.keys build/dependency-verification/gradle/
|
||||
|
||||
- name: Upload verification files
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.output-artifact }}
|
||||
path: build/dependency-verification
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Dependency Verification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '17 3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ALLOW_ADDITIONAL_ARTIFACT_CHECKSUMS: 'false'
|
||||
CLEAN_STALE_VERIFICATION_METADATA: 'false'
|
||||
CACHE_GRADLE_WRAPPER_DISTRIBUTION: 'true'
|
||||
JDK_JAVAC_OPTIONS: '-Xlint:-module'
|
||||
|
||||
concurrency:
|
||||
group: dependency-verification-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
linux-x64:
|
||||
name: Generate (Linux x64)
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/update-gradle-verification
|
||||
with:
|
||||
cache-gradle-wrapper-distribution: ${{ env.CACHE_GRADLE_WRAPPER_DISTRIBUTION }}
|
||||
clean-start: ${{ env.CLEAN_STALE_VERIFICATION_METADATA }}
|
||||
output-artifact: gradle-verification-linux-x64
|
||||
|
||||
linux-arm64:
|
||||
name: Generate (Linux arm64)
|
||||
runs-on: ubuntu-22.04-arm
|
||||
needs: linux-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/update-gradle-verification
|
||||
with:
|
||||
cache-gradle-wrapper-distribution: ${{ env.CACHE_GRADLE_WRAPPER_DISTRIBUTION }}
|
||||
previous-artifact: gradle-verification-linux-x64
|
||||
output-artifact: gradle-verification-linux-arm64
|
||||
|
||||
windows-x64:
|
||||
name: Generate (Windows x64)
|
||||
runs-on: windows-2022
|
||||
needs: linux-arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/update-gradle-verification
|
||||
with:
|
||||
cache-gradle-wrapper-distribution: ${{ env.CACHE_GRADLE_WRAPPER_DISTRIBUTION }}
|
||||
previous-artifact: gradle-verification-linux-arm64
|
||||
output-artifact: gradle-verification-windows-x64
|
||||
|
||||
macos-x64:
|
||||
name: Generate (macOS x64)
|
||||
runs-on: macos-15-intel
|
||||
needs: windows-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/update-gradle-verification
|
||||
with:
|
||||
cache-gradle-wrapper-distribution: ${{ env.CACHE_GRADLE_WRAPPER_DISTRIBUTION }}
|
||||
previous-artifact: gradle-verification-windows-x64
|
||||
output-artifact: gradle-verification-macos-x64
|
||||
|
||||
macos-arm64:
|
||||
name: Generate (macOS arm64)
|
||||
runs-on: macos-14
|
||||
needs: macos-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/update-gradle-verification
|
||||
with:
|
||||
cache-gradle-wrapper-distribution: ${{ env.CACHE_GRADLE_WRAPPER_DISTRIBUTION }}
|
||||
previous-artifact: gradle-verification-macos-x64
|
||||
output-artifact: gradle-verification-final
|
||||
|
||||
update-pr:
|
||||
name: Open update PR
|
||||
runs-on: ubuntu-22.04
|
||||
needs: macos-arm64
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download final verification files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gradle-verification-final
|
||||
path: build/final-verification
|
||||
|
||||
- name: Create or update pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH_NAME: automation/update-gradle-verification-metadata
|
||||
run: |
|
||||
verification_metadata_diff() {
|
||||
if git ls-files --error-unmatch gradle/verification-metadata.xml >/dev/null 2>&1; then
|
||||
git diff -- gradle/verification-metadata.xml
|
||||
else
|
||||
git diff --no-index -- /dev/null gradle/verification-metadata.xml || true
|
||||
fi
|
||||
}
|
||||
|
||||
reset_verification_files() {
|
||||
for file in gradle/verification-metadata.xml gradle/verification-keyring.keys; do
|
||||
if git ls-files --error-unmatch "$file" >/dev/null 2>&1; then
|
||||
git restore "$file"
|
||||
else
|
||||
rm -f "$file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
mkdir -p /tmp/dependency-verification/gradle
|
||||
cp build/final-verification/gradle/verification-* /tmp/dependency-verification/gradle/
|
||||
cp /tmp/dependency-verification/gradle/verification-* gradle/
|
||||
|
||||
if [ -z "$(git status --porcelain -- gradle/verification-metadata.xml gradle/verification-keyring.keys)" ]; then
|
||||
echo "No dependency verification changes detected."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$ALLOW_ADDITIONAL_ARTIFACT_CHECKSUMS" != "true" ] && verification_metadata_diff | grep -q '^[+] *<also-trust '; then
|
||||
echo "Generated verification metadata adds also-trust checksums."
|
||||
echo "This indicates an existing artifact coordinate resolved to a checksum not currently trusted by the repository."
|
||||
echo "Set ALLOW_ADDITIONAL_ARTIFACT_CHECKSUMS to 'true' to allow opening an automated metadata update PR for this case."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git fetch origin "+refs/heads/$BRANCH_NAME:refs/remotes/origin/$BRANCH_NAME"; then
|
||||
mkdir -p /tmp/dependency-verification/existing/gradle
|
||||
if git show "refs/remotes/origin/$BRANCH_NAME:gradle/verification-metadata.xml" > /tmp/dependency-verification/existing/gradle/verification-metadata.xml &&
|
||||
git show "refs/remotes/origin/$BRANCH_NAME:gradle/verification-keyring.keys" > /tmp/dependency-verification/existing/gradle/verification-keyring.keys &&
|
||||
cmp -s /tmp/dependency-verification/gradle/verification-metadata.xml /tmp/dependency-verification/existing/gradle/verification-metadata.xml &&
|
||||
cmp -s /tmp/dependency-verification/gradle/verification-keyring.keys /tmp/dependency-verification/existing/gradle/verification-keyring.keys &&
|
||||
git merge-base --is-ancestor HEAD "refs/remotes/origin/$BRANCH_NAME"; then
|
||||
echo "Existing automation branch already contains these verification changes."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
reset_verification_files
|
||||
git checkout -B "$BRANCH_NAME"
|
||||
cp /tmp/dependency-verification/gradle/verification-* gradle/
|
||||
git add gradle/verification-metadata.xml gradle/verification-keyring.keys
|
||||
git commit -m "Update Gradle dependency verification metadata"
|
||||
git push --force-with-lease origin "HEAD:$BRANCH_NAME"
|
||||
|
||||
if gh pr list --base master --head "$BRANCH_NAME" --json number --jq '.[0].number' | grep -q .; then
|
||||
echo "Existing dependency verification PR found; branch was updated."
|
||||
else
|
||||
gh pr create \
|
||||
--base master \
|
||||
--head "$BRANCH_NAME" \
|
||||
--title "Update Gradle dependency verification metadata" \
|
||||
--body "Regenerates Gradle dependency verification metadata across Linux, Windows, and macOS using Gradle's incremental verification metadata update flow."
|
||||
fi
|
||||
@@ -5,6 +5,9 @@ on: workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
JDK_JAVAC_OPTIONS: '-Xlint:-module'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -12,14 +15,14 @@ jobs:
|
||||
matrix:
|
||||
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-15-intel, macos-14]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Clear Java tool-cache for reproducibility
|
||||
shell: bash
|
||||
run: rm -rf "$RUNNER_TOOL_CACHE"/Java_*
|
||||
- name: Set up JDK 25.0.2
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25.0.2'
|
||||
@@ -29,7 +32,7 @@ jobs:
|
||||
run: ./gradlew jpackage
|
||||
- name: Codesign, package and notarize macOS distribution
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
uses: sparrowwallet/github-actions/codesign-macos@v1
|
||||
uses: sparrowwallet/github-actions/codesign-macos@6eeb7bf9b882cf89ff96b77f99bcf487b9d33992 # v1
|
||||
with:
|
||||
app-name: Sparrow
|
||||
certificate: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
@@ -47,7 +50,7 @@ jobs:
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./repackage.sh
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
|
||||
path: |
|
||||
@@ -65,7 +68,7 @@ jobs:
|
||||
run: ./repackage.sh
|
||||
- name: Upload Headless Artifact
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }} Headless
|
||||
path: |
|
||||
|
||||
@@ -81,12 +81,24 @@ When not explicitly configured using the command line argument above, Sparrow st
|
||||
|
||||
| Platform | Location |
|
||||
|----------| -------- |
|
||||
| OSX | ~/.sparrow |
|
||||
| macOS | ~/.sparrow |
|
||||
| Linux | ~/.sparrow |
|
||||
| Windows | %APPDATA%/Sparrow |
|
||||
|
||||
Testnet3, testnet4, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
|
||||
|
||||
On macOS and Linux, Sparrow also supports the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/).
|
||||
This is opt in: for each category below, if the corresponding directory already exists, Sparrow uses it, otherwise it continues to use the home folder above. Categories are resolved independently, so files can be moved across one at a time.
|
||||
|
||||
| Category | Location | Contents |
|
||||
|----------| -------- |---------------------------------------|
|
||||
| Config | `$XDG_CONFIG_HOME/sparrow` (default `~/.config/sparrow`) | `config`, `network-*` markers |
|
||||
| Data | `$XDG_DATA_HOME/sparrow` (default `~/.local/share/sparrow`) | `wallets`, `certs`, `lark` |
|
||||
| State | `$XDG_STATE_HOME/sparrow` (default `~/.local/state/sparrow`) | `sparrow.log`, `tor/work`, lock files |
|
||||
| Cache | `$XDG_CACHE_HOME/sparrow` (default `~/.cache/sparrow`) | `tor/cache` |
|
||||
|
||||
Specifying a home folder with the `-d` argument disables XDG resolution entirely, and stores all files in the given folder.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Please use the [Issues](https://github.com/sparrowwallet/sparrow/issues) tab above to report an issue. If possible, look in the sparrow.log file in the configuration directory for information helpful in debugging.
|
||||
|
||||
+33
-35
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org.openjfx.javafxplugin' version '0.1.0'
|
||||
id 'org.beryx.jlink' version '3.2.1'
|
||||
id 'org.beryx.jlink' version '4.0.2'
|
||||
id 'org.gradlex.extra-java-module-info' version '1.13.1'
|
||||
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.21.0'
|
||||
}
|
||||
@@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
|
||||
def headless = "true".equals(System.getProperty("java.awt.headless"))
|
||||
|
||||
group = 'com.sparrowwallet'
|
||||
version = '2.5.0'
|
||||
version = '2.5.4'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -73,7 +73,7 @@ dependencies {
|
||||
implementation('com.fasterxml.jackson.core:jackson-databind:2.21.1')
|
||||
implementation('com.sparrowwallet:hummingbird:1.7.4')
|
||||
implementation('co.nstant.in:cbor:0.9')
|
||||
implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
|
||||
implementation('io.github.doblon8:openpnp-capture-java:0.0.3')
|
||||
implementation("io.matthewnelson.kmp-tor:runtime:2.5.0")
|
||||
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.21.0")
|
||||
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.2') {
|
||||
@@ -104,7 +104,7 @@ dependencies {
|
||||
implementation('org.apache.commons:commons-lang3:3.20.0')
|
||||
implementation('org.apache.commons:commons-compress:1.28.0')
|
||||
implementation('com.github.librepdf:openpdf:1.3.43')
|
||||
implementation('com.googlecode.lanterna:lanterna:3.1.3')
|
||||
implementation('com.googlecode.lanterna:lanterna:3.1.5')
|
||||
implementation('net.coobird:thumbnailator:0.4.21')
|
||||
implementation('com.github.hervegirod:fxsvgimage:1.1')
|
||||
implementation('com.sparrowwallet:toucan:0.9.0')
|
||||
@@ -126,7 +126,7 @@ compileJava {
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED"]
|
||||
jvmArgs = ["--enable-native-access=ALL-UNNAMED"]
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -140,6 +140,7 @@ application {
|
||||
"--enable-native-access=com.fazecast.jSerialComm",
|
||||
"--enable-native-access=org.usb4java",
|
||||
"--enable-native-access=io.github.doblon8.jzbar",
|
||||
"--enable-native-access=io.github.doblon8.openpnp.capture",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
|
||||
@@ -153,7 +154,6 @@ application {
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.io=com.google.gson",
|
||||
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
|
||||
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
|
||||
"--add-reads=org.flywaydb.core=java.desktop"]
|
||||
@@ -166,6 +166,11 @@ application {
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
//Add jdk.unsupported for IDE agents requiring sun.misc
|
||||
jvmArgs += ["--add-modules=jdk.unsupported"]
|
||||
}
|
||||
|
||||
jlink {
|
||||
mergedModule {
|
||||
requires 'javafx.graphics'
|
||||
@@ -204,10 +209,8 @@ jlink {
|
||||
'glob:/org.hid4java/darwin-*/**,' +
|
||||
'glob:/org.hid4java/linux-*/**,' +
|
||||
'glob:/org.hid4java/win32-*/**,' +
|
||||
'glob:/openpnp.capture.java/darwin-*/**,' +
|
||||
'glob:/openpnp.capture.java/linux-*/**,' +
|
||||
'glob:/openpnp.capture.java/win32-*/**,' +
|
||||
'glob:/io.github.doblon8.jzbar/native/**']
|
||||
'glob:/io.github.doblon8.jzbar/native/**,' +
|
||||
'glob:/io.github.doblon8.openpnp.capture/native/**']
|
||||
launcher {
|
||||
name = 'sparrow'
|
||||
jvmArgs = ["--enable-native-access=com.sparrowwallet.drongo",
|
||||
@@ -217,6 +220,7 @@ jlink {
|
||||
"--enable-native-access=com.fazecast.jSerialComm",
|
||||
"--enable-native-access=org.usb4java",
|
||||
"--enable-native-access=io.github.doblon8.jzbar",
|
||||
"--enable-native-access=io.github.doblon8.openpnp.capture",
|
||||
"--enable-native-access=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
|
||||
@@ -231,7 +235,6 @@ jlink {
|
||||
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.io=com.google.gson",
|
||||
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
|
||||
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
|
||||
"--add-reads=com.sparrowwallet.merged.module=java.sql",
|
||||
@@ -250,13 +253,13 @@ jlink {
|
||||
"--add-reads=org.flywaydb.core=java.desktop"]
|
||||
|
||||
if(os.windows) {
|
||||
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
|
||||
jvmArgs.addAll(["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"])
|
||||
}
|
||||
if(os.macOsX) {
|
||||
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
|
||||
jvmArgs.addAll(["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"])
|
||||
}
|
||||
if(headless) {
|
||||
jvmArgs += ["-Dglass.platform=Headless"]
|
||||
jvmArgs.addAll(["-Dglass.platform=Headless"])
|
||||
}
|
||||
}
|
||||
addExtraDependencies("javafx")
|
||||
@@ -268,8 +271,8 @@ jlink {
|
||||
imageOptions = []
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--license-file', 'LICENSE']
|
||||
if(os.windows) {
|
||||
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
|
||||
installerOptions.addAll(['--win-per-user-install', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/'])
|
||||
imageOptions.addAll(['--icon', 'src/main/deploy/package/windows/sparrow.ico'])
|
||||
installerType = "msi"
|
||||
}
|
||||
if(os.linux) {
|
||||
@@ -278,14 +281,14 @@ jlink {
|
||||
installerOptions = ['--license-file', 'LICENSE']
|
||||
} else {
|
||||
installerName = "sparrowwallet"
|
||||
installerOptions += ['--linux-shortcut', '--linux-menu-group', 'Sparrow']
|
||||
installerOptions.addAll(['--linux-shortcut', '--linux-menu-group', 'Sparrow'])
|
||||
}
|
||||
installerOptions += ['--resource-dir', layout.buildDirectory.dir('deploy/package').get().asFile.toString(), '--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
|
||||
installerOptions.addAll(['--resource-dir', layout.buildDirectory.dir('deploy/package').get().asFile.toString(), '--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com'])
|
||||
imageOptions.addAll(['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/'])
|
||||
}
|
||||
if(os.macOsX) {
|
||||
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/macos/sparrow.icns', '--resource-dir', 'src/main/deploy/package/macos/']
|
||||
installerOptions.addAll(['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)'])
|
||||
imageOptions.addAll(['--icon', 'src/main/deploy/package/macos/sparrow.icns', '--resource-dir', 'src/main/deploy/package/macos/'])
|
||||
installerType = "dmg"
|
||||
}
|
||||
}
|
||||
@@ -396,23 +399,24 @@ def serialArch = osArch == "aarch64" ? "aarch64" : "x86_64"
|
||||
|
||||
// Map of JAR name prefix to the include glob for platform-specific natives inside the JAR.
|
||||
def nativeLibJars = [
|
||||
'jna-' : "com/sun/jna/${jnaPlatform}/*",
|
||||
'argon2-jvm-2' : "${jnaPlatform}/*",
|
||||
'hid4java-' : "${jnaPlatform}/*",
|
||||
'openpnp-capture-java': "${jnaPlatform}/*",
|
||||
'jSerialComm-' : "${serialOs}/${serialArch}/*",
|
||||
'usb4java-' : "org/usb4java/${jnaPlatform}/*",
|
||||
'jzbar-' : "native/${osName}/${osArch}/*",
|
||||
'jna-' : "com/sun/jna/${jnaPlatform}/*",
|
||||
'argon2-jvm-2' : "${jnaPlatform}/*",
|
||||
'hid4java-' : "${jnaPlatform}/*",
|
||||
'jSerialComm-' : "${serialOs}/${serialArch}/*",
|
||||
'usb4java-' : "org/usb4java/${jnaPlatform}/*",
|
||||
'jzbar-' : "native/${osName}/${osArch}/*",
|
||||
'openpnp-capture-java-': "native/${osName}/${osArch}/*",
|
||||
]
|
||||
|
||||
tasks.register('extractNativeLibraries') {
|
||||
dependsOn 'jlink'
|
||||
def drongoNativeDir = "${project(':drongo').projectDir}/src/main/resources/native/${osName}/${osArch}"
|
||||
doLast {
|
||||
def imageLib = file("$buildDir/image/lib")
|
||||
|
||||
// Project-owned natives
|
||||
copy {
|
||||
from "${project(':drongo').projectDir}/src/main/resources/native/${osName}/${osArch}", "src/main/resources/native/${osName}/${osArch}"
|
||||
from drongoNativeDir, "src/main/resources/native/${osName}/${osArch}"
|
||||
into imageLib
|
||||
eachFile { it.permissions { unix('rw-r--r--') } }
|
||||
}
|
||||
@@ -478,12 +482,6 @@ extraJavaModuleInfo {
|
||||
requires('org.slf4j')
|
||||
requires('com.fasterxml.jackson.databind')
|
||||
}
|
||||
module('org.openpnp:openpnp-capture-java', 'openpnp.capture.java') {
|
||||
exports('org.openpnp.capture')
|
||||
exports('org.openpnp.capture.library')
|
||||
requires('java.desktop')
|
||||
requires('com.sun.jna')
|
||||
}
|
||||
module('net.sourceforge.javacsv:javacsv', 'net.sourceforge.javacsv') {
|
||||
exports('com.csvreader')
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
|
||||
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
|
||||
|
||||
```shell
|
||||
GIT_TAG="2.4.2"
|
||||
GIT_TAG="2.5.3"
|
||||
```
|
||||
|
||||
The project can then be initially cloned as follows:
|
||||
|
||||
+1
-1
Submodule drongo updated: cc55b5f13a...fa1bb4eec2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
Submodule lark updated: 2a8c73c131...00fdebc715
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.5.0</string>
|
||||
<string>2.5.4</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!--
|
||||
This file is a copy of the jpackage WiX template (src/jdk.jpackage/windows/classes/jdk/jpackage/internal/resources/main.wxs in the JDK sources),
|
||||
customized only to add the RegistryEntries and CapabilitiesEntries components registering the auth47, bitcoin and lightning URI schemes, and their ComponentRefs in the Feature.
|
||||
When upgrading the build JDK, re-diff this file against that JDK's template and re-apply those blocks, keeping the component GUIDs unchanged.
|
||||
-->
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
|
||||
@@ -14,6 +20,17 @@
|
||||
|
||||
<?include $(var.JpConfigDir)/overrides.wxi ?>
|
||||
|
||||
<?ifdef JpAllowUpgrades ?>
|
||||
<?define JpUpgradeVersionOnlyDetectUpgrade="no"?>
|
||||
<?else?>
|
||||
<?define JpUpgradeVersionOnlyDetectUpgrade="yes"?>
|
||||
<?endif?>
|
||||
<?ifdef JpAllowDowngrades ?>
|
||||
<?define JpUpgradeVersionOnlyDetectDowngrade="no"?>
|
||||
<?else?>
|
||||
<?define JpUpgradeVersionOnlyDetectDowngrade="yes"?>
|
||||
<?endif?>
|
||||
|
||||
<Product
|
||||
Id="$(var.JpProductCode)"
|
||||
Name="$(var.JpAppName)"
|
||||
@@ -32,45 +49,31 @@
|
||||
|
||||
<Media Id="1" Cabinet="Data.cab" EmbedCab="yes" />
|
||||
|
||||
<?ifdef JpAllowDowngrades ?>
|
||||
<?ifdef JpAllowUpgrades ?>
|
||||
<MajorUpgrade
|
||||
AllowDowngrades="yes"
|
||||
Disallow="no"
|
||||
/>
|
||||
<?endif?>
|
||||
<?endif?>
|
||||
<Upgrade Id="$(var.JpProductUpgradeCode)">
|
||||
<UpgradeVersion
|
||||
OnlyDetect="$(var.JpUpgradeVersionOnlyDetectUpgrade)"
|
||||
Property="JP_UPGRADABLE_FOUND"
|
||||
Maximum="$(var.JpAppVersion)"
|
||||
MigrateFeatures="yes"
|
||||
IncludeMaximum="$(var.JpUpgradeVersionOnlyDetectUpgrade)" />
|
||||
<UpgradeVersion
|
||||
OnlyDetect="$(var.JpUpgradeVersionOnlyDetectDowngrade)"
|
||||
Property="JP_DOWNGRADABLE_FOUND"
|
||||
Minimum="$(var.JpAppVersion)"
|
||||
MigrateFeatures="yes"
|
||||
IncludeMinimum="$(var.JpUpgradeVersionOnlyDetectDowngrade)" />
|
||||
</Upgrade>
|
||||
|
||||
<?ifdef JpAllowDowngrades ?>
|
||||
<?ifndef JpAllowUpgrades ?>
|
||||
<MajorUpgrade
|
||||
AllowDowngrades="yes"
|
||||
Disallow="yes"
|
||||
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
|
||||
/>
|
||||
<CustomAction Id="JpDisallowUpgrade" Error="!(loc.DisallowUpgradeErrorMessage)" />
|
||||
<?endif?>
|
||||
<?ifndef JpAllowDowngrades ?>
|
||||
<CustomAction Id="JpDisallowDowngrade" Error="!(loc.DowngradeErrorMessage)" />
|
||||
<?endif?>
|
||||
|
||||
<?ifndef JpAllowDowngrades ?>
|
||||
<?ifdef JpAllowUpgrades ?>
|
||||
<MajorUpgrade
|
||||
AllowDowngrades="no"
|
||||
Disallow="no"
|
||||
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
|
||||
/>
|
||||
<?endif?>
|
||||
<?endif?>
|
||||
<Binary Id="JpCaDll" SourceFile="wixhelper.dll"/>
|
||||
|
||||
<?ifndef JpAllowDowngrades ?>
|
||||
<?ifndef JpAllowUpgrades ?>
|
||||
<MajorUpgrade
|
||||
AllowDowngrades="no"
|
||||
Disallow="yes"
|
||||
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
|
||||
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
|
||||
/>
|
||||
<?endif?>
|
||||
<?endif?>
|
||||
<CustomAction Id="JpFindRelatedProducts" BinaryKey="JpCaDll" DllEntry="FindRelatedProductsEx" />
|
||||
|
||||
<!-- Standard required root -->
|
||||
<Directory Id="TARGETDIR" Name="SourceDir"/>
|
||||
@@ -81,7 +84,7 @@
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:Auth47 Authentication URI"/>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
|
||||
<RegistryValue Type="string" Value="[INSTALLDIR]$(var.JpAppName).exe" />
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[INSTALLDIR]$(var.JpAppName).exe" "%1"" />
|
||||
@@ -91,7 +94,7 @@
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
|
||||
<RegistryValue Type="string" Value="[INSTALLDIR]$(var.JpAppName).exe" />
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[INSTALLDIR]$(var.JpAppName).exe" "%1"" />
|
||||
@@ -101,73 +104,89 @@
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:LNURL URI"/>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
|
||||
<RegistryValue Type="string" Value="[INSTALLDIR]$(var.JpAppName).exe" />
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[INSTALLDIR]$(var.JpAppName).exe" "%1"" />
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<Component Id="CapabilitiesEntries" Guid="{BE92DD88-F799-47CC-A256-4165F9183A46}">
|
||||
<RegistryKey Root="HKMU" Key="Software\$(var.JpAppName)\Capabilities" Action="createAndRemoveOnUninstall">
|
||||
<RegistryValue Type="string" Name="ApplicationName" Value="$(var.JpAppName)"/>
|
||||
<RegistryValue Type="string" Name="ApplicationDescription" Value="$(var.JpAppDescription)"/>
|
||||
<RegistryKey Key="URLAssociations">
|
||||
<RegistryValue Type="string" Name="auth47" Value="auth47"/>
|
||||
<RegistryValue Type="string" Name="bitcoin" Value="bitcoin"/>
|
||||
<RegistryValue Type="string" Name="lightning" Value="lightning"/>
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
<RegistryValue Root="HKMU" Key="Software\RegisteredApplications" Name="$(var.JpAppName)" Type="string" Value="Software\$(var.JpAppName)\Capabilities"/>
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<Feature Id="DefaultFeature" Title="!(loc.MainFeatureTitle)" Level="1">
|
||||
<ComponentGroupRef Id="Shortcuts"/>
|
||||
<ComponentGroupRef Id="Files"/>
|
||||
<ComponentGroupRef Id="FileAssociations"/>
|
||||
<ComponentGroupRef Id="FragmentOsCondition"/>
|
||||
<ComponentRef Id="RegistryEntries"/>
|
||||
<ComponentRef Id="CapabilitiesEntries"/>
|
||||
</Feature>
|
||||
|
||||
<?ifdef JpInstallDirChooser ?>
|
||||
<Binary Id="JpCaDll" SourceFile="wixhelper.dll"/>
|
||||
<CustomAction Id="JpCheckInstallDir" BinaryKey="JpCaDll" DllEntry="CheckInstallDir" />
|
||||
<CustomAction Id="JpSetARPINSTALLLOCATION" Property="ARPINSTALLLOCATION" Value="[INSTALLDIR]" />
|
||||
<CustomAction Id="JpSetARPCOMMENTS" Property="ARPCOMMENTS" Value="$(var.JpAppDescription)" />
|
||||
<CustomAction Id="JpSetARPCONTACT" Property="ARPCONTACT" Value="$(var.JpAppVendor)" />
|
||||
<CustomAction Id="JpSetARPSIZE" Property="ARPSIZE" Value="$(var.JpAppSizeKb)" />
|
||||
|
||||
<?ifdef JpHelpURL ?>
|
||||
<CustomAction Id="JpSetARPHELPLINK" Property="ARPHELPLINK" Value="$(var.JpHelpURL)" />
|
||||
<?endif?>
|
||||
|
||||
<UI>
|
||||
<?ifdef JpInstallDirChooser ?>
|
||||
<Dialog Id="JpInvalidInstallDir" Width="300" Height="85" Title="[ProductName] Setup" NoMinimize="yes">
|
||||
<Control Id="JpInvalidInstallDirYes" Type="PushButton" X="100" Y="55" Width="50" Height="15" Default="no" Cancel="no" Text="Yes">
|
||||
<Publish Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
|
||||
</Control>
|
||||
<Control Id="JpInvalidInstallDirNo" Type="PushButton" X="150" Y="55" Width="50" Height="15" Default="yes" Cancel="yes" Text="No">
|
||||
<Publish Event="NewDialog" Value="InstallDirDlg">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Text" Type="Text" X="25" Y="15" Width="250" Height="30" TabSkip="no">
|
||||
<Text>!(loc.message.install.dir.exist)</Text>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<!--
|
||||
Run WixUI_InstallDir dialog in the default install directory.
|
||||
-->
|
||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR"/>
|
||||
<UIRef Id="WixUI_InstallDir" />
|
||||
|
||||
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="JpCheckInstallDir" Order="3">1</Publish>
|
||||
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="JpInvalidInstallDir" Order="5">INSTALLDIR_VALID="0"</Publish>
|
||||
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="5">INSTALLDIR_VALID="1"</Publish>
|
||||
|
||||
<?ifndef JpLicenseRtf ?>
|
||||
<!--
|
||||
No license file provided.
|
||||
Override the dialog sequence in built-in dialog set "WixUI_InstallDir"
|
||||
to exclude license dialog.
|
||||
-->
|
||||
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2">1</Publish>
|
||||
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">1</Publish>
|
||||
<?endif?>
|
||||
|
||||
<?else?>
|
||||
|
||||
<?ifdef JpLicenseRtf ?>
|
||||
<UIRef Id="WixUI_Minimal" />
|
||||
<?endif?>
|
||||
|
||||
<?endif?>
|
||||
</UI>
|
||||
|
||||
<?ifdef JpLicenseRtf ?>
|
||||
<WixVariable Id="WixUILicenseRtf" Value="$(var.JpLicenseRtf)"/>
|
||||
<?ifdef JpAboutURL ?>
|
||||
<CustomAction Id="JpSetARPURLINFOABOUT" Property="ARPURLINFOABOUT" Value="$(var.JpAboutURL)" />
|
||||
<?endif?>
|
||||
|
||||
<?ifdef JpUpdateURL ?>
|
||||
<CustomAction Id="JpSetARPURLUPDATEINFO" Property="ARPURLUPDATEINFO" Value="$(var.JpUpdateURL)" />
|
||||
<?endif?>
|
||||
|
||||
<?ifdef JpIcon ?>
|
||||
<Property Id="ARPPRODUCTICON" Value="JpARPPRODUCTICON"/>
|
||||
<Icon Id="JpARPPRODUCTICON" SourceFile="$(var.JpIcon)"/>
|
||||
<?endif?>
|
||||
|
||||
<UIRef Id="JpUI"/>
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="JpSetARPINSTALLLOCATION" After="CostFinalize">Not Installed</Custom>
|
||||
<Custom Action="JpSetARPCOMMENTS" After="CostFinalize">Not Installed</Custom>
|
||||
<Custom Action="JpSetARPCONTACT" After="CostFinalize">Not Installed</Custom>
|
||||
<Custom Action="JpSetARPSIZE" After="CostFinalize">Not Installed</Custom>
|
||||
<?ifdef JpHelpURL ?>
|
||||
<Custom Action="JpSetARPHELPLINK" After="CostFinalize">Not Installed</Custom>
|
||||
<?endif?>
|
||||
<?ifdef JpAboutURL ?>
|
||||
<Custom Action="JpSetARPURLINFOABOUT" After="CostFinalize">Not Installed</Custom>
|
||||
<?endif?>
|
||||
<?ifdef JpUpdateURL ?>
|
||||
<Custom Action="JpSetARPURLUPDATEINFO" After="CostFinalize">Not Installed</Custom>
|
||||
<?endif?>
|
||||
|
||||
<?ifndef JpAllowUpgrades ?>
|
||||
<Custom Action="JpDisallowUpgrade" After="JpFindRelatedProducts">JP_UPGRADABLE_FOUND</Custom>
|
||||
<?endif?>
|
||||
<?ifndef JpAllowDowngrades ?>
|
||||
<Custom Action="JpDisallowDowngrade" After="JpFindRelatedProducts">JP_DOWNGRADABLE_FOUND</Custom>
|
||||
<?endif?>
|
||||
<LaunchConditions Before="AppSearch"/>
|
||||
<RemoveExistingProducts Before="CostInitialize"/>
|
||||
<Custom Action="JpFindRelatedProducts" After="FindRelatedProducts"/>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<InstallUISequence>
|
||||
<Custom Action="JpFindRelatedProducts" After="FindRelatedProducts"/>
|
||||
</InstallUISequence>
|
||||
|
||||
</Product>
|
||||
</Wix>
|
||||
</Wix>
|
||||
|
||||
@@ -524,7 +524,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void showLogFile(ActionEvent event) throws IOException {
|
||||
File logFile = new File(Storage.getSparrowHome(), "sparrow.log");
|
||||
File logFile = new File(Storage.getStateHome(), "sparrow.log");
|
||||
if(logFile.exists()) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument(logFile.toPath().toUri().toString());
|
||||
} else {
|
||||
@@ -614,6 +614,10 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void openTransactionFromFile(ActionEvent event) {
|
||||
openTransactionFromFile(event, null);
|
||||
}
|
||||
|
||||
private void openTransactionFromFile(ActionEvent event, PSBT contextPsbt) {
|
||||
Stage window = new Stage();
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
@@ -628,19 +632,21 @@ public class AppController implements Initializable {
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
openTransactionFile(file);
|
||||
openTransactionFile(file, contextPsbt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openTransactionFile(File file) {
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof TransactionTabData) {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
if(file.equals(transactionTabData.getFile())) {
|
||||
tabs.getSelectionModel().select(tab);
|
||||
return;
|
||||
private void openTransactionFile(File file, PSBT contextPsbt) {
|
||||
if(contextPsbt == null) {
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof TransactionTabData) {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
if(file.equals(transactionTabData.getFile())) {
|
||||
tabs.getSelectionModel().select(tab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,9 +657,9 @@ public class AppController implements Initializable {
|
||||
String name = file.getName();
|
||||
|
||||
if(Utils.isHex(bytes) || Utils.isBase64(bytes)) {
|
||||
addTransactionTab(name, file, new String(bytes, StandardCharsets.UTF_8).trim());
|
||||
addTransactionTab(name, file, new String(bytes, StandardCharsets.UTF_8).trim(), contextPsbt);
|
||||
} else {
|
||||
addTransactionTab(name, file, bytes);
|
||||
addTransactionTab(name, file, bytes, contextPsbt);
|
||||
}
|
||||
} catch(IOException e) {
|
||||
showErrorDialog("Error opening file", e.getMessage());
|
||||
@@ -675,7 +681,7 @@ public class AppController implements Initializable {
|
||||
Optional<String> text = dialog.showAndWait();
|
||||
if(text.isPresent() && !text.get().isEmpty()) {
|
||||
try {
|
||||
addTransactionTab(null, null, text.get().trim());
|
||||
addTransactionTab(null, null, text.get().trim(), null);
|
||||
} catch(PSBTParseException e) {
|
||||
showErrorDialog("Invalid PSBT", e.getMessage());
|
||||
} catch(TransactionParseException e) {
|
||||
@@ -1034,7 +1040,7 @@ public class AppController implements Initializable {
|
||||
Stage window = new Stage();
|
||||
DirectoryChooser directoryChooser = new DirectoryChooser();
|
||||
directoryChooser.setTitle("Choose Sparrow Home Folder");
|
||||
directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getSparrowHome() : initialDir);
|
||||
directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getDefaultHome() : initialDir);
|
||||
File newHome = directoryChooser.showDialog(window);
|
||||
|
||||
if(newHome != null) {
|
||||
@@ -1091,7 +1097,7 @@ public class AppController implements Initializable {
|
||||
verifyOpened = true;
|
||||
}
|
||||
} else {
|
||||
openTransactionFile(file);
|
||||
openTransactionFile(file, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1511,7 +1517,7 @@ public class AppController implements Initializable {
|
||||
bitcoinUnit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
|
||||
sendToManyDialog = new SendToManyDialog(bitcoinUnit, Config.get().getUnitFormat(), initialPayments);
|
||||
sendToManyDialog.initModality(Modality.NONE);
|
||||
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
|
||||
sendToManyDialog = null;
|
||||
@@ -1931,25 +1937,29 @@ public class AppController implements Initializable {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
|
||||
private void addTransactionTab(String name, File file, String string, PSBT contextPsbt) throws ParseException, PSBTParseException, TransactionParseException {
|
||||
if(Utils.isBase64(string) && !Utils.isHex(string)) {
|
||||
addTransactionTab(name, file, Base64.getDecoder().decode(string));
|
||||
addTransactionTab(name, file, Base64.getDecoder().decode(string), contextPsbt);
|
||||
} else if(Utils.isHex(string)) {
|
||||
addTransactionTab(name, file, Utils.hexToBytes(string));
|
||||
addTransactionTab(name, file, Utils.hexToBytes(string), contextPsbt);
|
||||
} else {
|
||||
throw new ParseException("Input is not base64 or hex", 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void addTransactionTab(String name, File file, byte[] bytes) throws PSBTParseException, ParseException, TransactionParseException {
|
||||
private void addTransactionTab(String name, File file, byte[] bytes, PSBT contextPsbt) throws PSBTParseException, ParseException, TransactionParseException {
|
||||
if(PSBT.isPSBT(bytes)) {
|
||||
//Don't verify signatures here - provided PSBT may omit UTXO data that can be found when combining with an existing PSBT
|
||||
PSBT psbt = new PSBT(bytes, false);
|
||||
addTransactionTab(name, file, psbt);
|
||||
if(verifyTransactionContext(contextPsbt, null, psbt, "loaded")) {
|
||||
addTransactionTab(name, file, psbt);
|
||||
}
|
||||
} else if(Transaction.isTransaction(bytes)) {
|
||||
try {
|
||||
Transaction transaction = new Transaction(bytes);
|
||||
addTransactionTab(name, file, transaction);
|
||||
if(verifyTransactionContext(contextPsbt, transaction, null, "loaded")) {
|
||||
addTransactionTab(name, file, transaction);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new TransactionParseException(e.getMessage());
|
||||
}
|
||||
@@ -2058,6 +2068,34 @@ public class AppController implements Initializable {
|
||||
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
psbt.verifySigHashes();
|
||||
} catch(PSBTSignatureException e) {
|
||||
Optional<ButtonType> result = AppServices.showWarningDialog("Non-Default Sighash",
|
||||
e.getMessage() + "\n\nReview this PSBT carefully before signing.\n\nOpen the transaction?", ButtonType.YES, ButtonType.NO);
|
||||
if(result.isEmpty() || result.get() != ButtonType.YES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Skip the warning for already-confirmed transactions loaded for inspection
|
||||
if(blockTransaction == null) {
|
||||
List<TransactionOutput> unknownScriptOutputs = new ArrayList<>();
|
||||
for(int i = 0; i < transaction.getOutputs().size(); i++) {
|
||||
TransactionOutput txOutput = transaction.getOutputs().get(i);
|
||||
if(txOutput.getValue() > 0 && txOutput.getScript().getToAddress() == null) {
|
||||
//Silent payment outputs have an empty script and non-zero value until the recipient script is computed
|
||||
if(psbt != null && i < psbt.getPsbtOutputs().size() && psbt.getPsbtOutputs().get(i).getSilentPaymentAddress() != null) {
|
||||
continue;
|
||||
}
|
||||
unknownScriptOutputs.add(txOutput);
|
||||
}
|
||||
}
|
||||
if(!unknownScriptOutputs.isEmpty() && !confirmUnknownScriptOutputs(unknownScriptOutputs)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2154,6 +2192,37 @@ public class AppController implements Initializable {
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
|
||||
private boolean verifyTransactionContext(PSBT contextPsbt, Transaction transaction, PSBT psbt, String source) {
|
||||
if(contextPsbt == null || matchesOpenTransactionTab(transaction, psbt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if(psbt == null && contextPsbt.possibleUnverifiableSilentPaymentsTransaction(transaction)) {
|
||||
AppServices.showErrorDialog("Silent Payments Transaction", "This transaction pays a silent payment address.\n\nThe signing device must return the PSBT rather than the final transaction, so the silent payment outputs can be verified.");
|
||||
} else {
|
||||
AppServices.showErrorDialog("Mismatched Transaction", "The " + source + " transaction does not match the transaction in this or any other open tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean matchesOpenTransactionTab(Transaction transaction, PSBT psbt) {
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof TransactionTabData transactionTabData) {
|
||||
if(transactionTabData.getPsbt() != null) {
|
||||
if(psbt != null ? transactionTabData.getPsbt().matches(psbt) : transactionTabData.getPsbt().matches(transaction)) {
|
||||
return true;
|
||||
}
|
||||
} else if(transactionTabData.getTransaction().calculateTxId(false).equals(psbt != null ? psbt.getTransaction().getTxId() : transaction.getTxId())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean openUnverifiableTransaction(String tabName) {
|
||||
Optional<ButtonType> result = AppServices.showWarningDialog(
|
||||
"Unverifiable Silent Payments Transaction",
|
||||
@@ -2164,6 +2233,23 @@ public class AppController implements Initializable {
|
||||
return result.isPresent() && result.get() == ButtonType.YES;
|
||||
}
|
||||
|
||||
private boolean confirmUnknownScriptOutputs(List<TransactionOutput> unknownScriptOutputs) {
|
||||
long totalAmount = unknownScriptOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = totalAmount >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS;
|
||||
}
|
||||
String amount = unit.equals(BitcoinUnit.BTC) ? format.formatBtcValue(totalAmount) + " BTC" : format.formatSatsValue(totalAmount) + " sats";
|
||||
String outputDesc = unknownScriptOutputs.size() == 1 ? "an output" : unknownScriptOutputs.size() + " outputs";
|
||||
Optional<ButtonType> result = AppServices.showWarningDialog("Unknown Script Type",
|
||||
"This transaction contains " + outputDesc + " of a non-standard or unrecognised script type, totalling " + amount + ".\n\n" +
|
||||
"Sparrow cannot resolve these outputs to addresses, so they will not appear in the transaction diagram. " +
|
||||
"Review the individual output(s) in the transaction tree carefully before signing or broadcasting.\n\n" +
|
||||
"Open the transaction?", ButtonType.YES, ButtonType.NO);
|
||||
return result.isPresent() && result.get() == ButtonType.YES;
|
||||
}
|
||||
|
||||
private String getTabName(Tab tab) {
|
||||
return ((Label)tab.getGraphic()).getText();
|
||||
}
|
||||
@@ -3175,7 +3261,7 @@ public class AppController implements Initializable {
|
||||
if(tabs.getScene().getWindow().equals(event.getWindow())) {
|
||||
if(event.getBlockTransaction() != null) {
|
||||
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
|
||||
} else {
|
||||
} else if(verifyTransactionContext(event.getContextPsbt(), event.getTransaction(), null, "scanned")) {
|
||||
addTransactionTab(event.getTransaction(), event.getInitialView(), event.getInitialIndex());
|
||||
}
|
||||
}
|
||||
@@ -3184,7 +3270,9 @@ public class AppController implements Initializable {
|
||||
@Subscribe
|
||||
public void viewPSBT(ViewPSBTEvent event) {
|
||||
if(tabs.getScene().getWindow().equals(event.getWindow())) {
|
||||
addTransactionTab(event.getLabel(), event.getFile(), event.getPsbt());
|
||||
if(verifyTransactionContext(event.getContextPsbt(), null, event.getPsbt(), "scanned")) {
|
||||
addTransactionTab(event.getLabel(), event.getFile(), event.getPsbt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3245,9 +3333,9 @@ public class AppController implements Initializable {
|
||||
public void requestTransactionOpen(RequestTransactionOpenEvent event) {
|
||||
if(tabs.getScene().getWindow().equals(event.getWindow())) {
|
||||
if(event.getFile() != null) {
|
||||
openTransactionFile(event.getFile());
|
||||
openTransactionFile(event.getFile(), event.getContextPsbt());
|
||||
} else {
|
||||
openTransactionFromFile(null);
|
||||
openTransactionFromFile(null, event.getContextPsbt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.JCommander;
|
||||
import com.sparrowwallet.drongo.ApplicationDir;
|
||||
import com.sparrowwallet.drongo.Drongo;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
@@ -18,9 +19,9 @@ import java.util.*;
|
||||
public class SparrowWallet {
|
||||
public static final String APP_ID = "sparrow";
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "2.5.0";
|
||||
public static final String APP_VERSION = "2.5.4";
|
||||
public static final String APP_VERSION_SUFFIX = "";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String APP_HOME_PROPERTY = ApplicationDir.getHomeProperty(APP_NAME);
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
public static final String JPACKAGE_APP_PATH = "jpackage.app-path";
|
||||
|
||||
@@ -71,17 +72,19 @@ public class SparrowWallet {
|
||||
}
|
||||
}
|
||||
|
||||
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
|
||||
Storage.logApplicationDirs();
|
||||
|
||||
File testnetFlag = new File(Storage.getConfigHome(), "network-" + Network.TESTNET.getName());
|
||||
if(testnetFlag.exists()) {
|
||||
Network.set(Network.TESTNET);
|
||||
}
|
||||
|
||||
File testnet4Flag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET4.getName());
|
||||
File testnet4Flag = new File(Storage.getConfigHome(), "network-" + Network.TESTNET4.getName());
|
||||
if(testnet4Flag.exists()) {
|
||||
Network.set(Network.TESTNET4);
|
||||
}
|
||||
|
||||
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
|
||||
File signetFlag = new File(Storage.getConfigHome(), "network-" + Network.SIGNET.getName());
|
||||
if(signetFlag.exists()) {
|
||||
Network.set(Network.SIGNET);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.Date;
|
||||
public class DateAxisFormatter extends StringConverter<Number> {
|
||||
private static final DateFormat HOUR_FORMAT = new SimpleDateFormat("HH:mm");
|
||||
private static final DateFormat DAY_FORMAT = new SimpleDateFormat("d MMM");
|
||||
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yy");
|
||||
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yyyy");
|
||||
|
||||
private final DateFormat dateFormat;
|
||||
private int oddCounter;
|
||||
|
||||
@@ -203,7 +203,9 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
for(Wallet wallet : wallets) {
|
||||
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
|
||||
Keystore keystore = wallet.getKeystores().get(0);
|
||||
keystore.setLabel(importer.getName().replace(" Multisig", ""));
|
||||
if(Keystore.DEFAULT_LABEL.equals(keystore.getLabel())) {
|
||||
keystore.setLabel(importer.getName().replace(" Multisig", ""));
|
||||
}
|
||||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(importer.getWalletModel());
|
||||
return keystore;
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
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.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletImportEvent;
|
||||
@@ -67,9 +68,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
|
||||
if(types.size() == 1) {
|
||||
Wallet wallet = wallets.stream().filter(w -> w.getPolicyType() == types.getFirst().policyType() && w.getScriptType() == types.getFirst().scriptType()).findFirst().orElseThrow(ImportException::new);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), null));
|
||||
wallet.setName(importer.getName());
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
importScannedWallet(wallet);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -91,9 +90,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
|
||||
if(wallets != null && !wallets.isEmpty()) {
|
||||
Wallet wallet = wallets.stream().filter(w -> w.getPolicyType() == policyType && w.getScriptType() == scriptType).findFirst().orElseThrow(ImportException::new);
|
||||
wallet.setName(importer.getName());
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(policyType, scriptType, wallet.getKeystores(), null));
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
importScannedWallet(wallet);
|
||||
} else {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
|
||||
Keystore keystore = importer.getKeystore(policyType, scriptType, bais, password);
|
||||
@@ -109,6 +106,22 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void importScannedWallet(Wallet wallet) {
|
||||
wallet.setName(importer.getName());
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), null));
|
||||
|
||||
Keystore keystore = wallet.getKeystores().getFirst();
|
||||
if(keystore.getSource() == KeystoreSource.SW_WATCH) {
|
||||
if(Keystore.DEFAULT_LABEL.equals(keystore.getLabel())) {
|
||||
keystore.setLabel(importer.getName());
|
||||
}
|
||||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(importer.getWalletModel());
|
||||
}
|
||||
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
}
|
||||
|
||||
private Node getScriptTypeEntry(List<PolicyAndScriptType> types) {
|
||||
Label label = new Label("Type:");
|
||||
|
||||
|
||||
@@ -160,6 +160,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
signature.setStyle("-fx-pref-height: 80px");
|
||||
signature.setWrapText(true);
|
||||
signature.setOnMouseClicked(event -> signature.selectAll());
|
||||
|
||||
ContextMenu signatureMenu = new ContextMenu();
|
||||
MenuItem copyItem = new MenuItem("Copy");
|
||||
copyItem.setOnAction(e -> signature.copy());
|
||||
MenuItem pasteItem = new MenuItem("Paste");
|
||||
pasteItem.setOnAction(e -> signature.paste());
|
||||
MenuItem clearItem = new MenuItem("Clear");
|
||||
clearItem.setOnAction(e -> signature.clear());
|
||||
signatureMenu.getItems().addAll(copyItem, pasteItem, clearItem);
|
||||
signature.setContextMenu(signatureMenu);
|
||||
|
||||
signatureField.getInputs().add(signature);
|
||||
|
||||
Field formatField = new Field();
|
||||
@@ -532,6 +543,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
|
||||
private String extractBip322Signature(PSBT signedPsbt) {
|
||||
String psbtMessage = signedPsbt.getGenericSignedMessage();
|
||||
if(psbtMessage != null && !psbtMessage.equals(message.getText().trim())) {
|
||||
Optional<ButtonType> response = AppServices.showWarningDialog("Message mismatch",
|
||||
"The message in the signed PSBT does not match the message in this dialog.\n\nPSBT message: " + psbtMessage +
|
||||
"\n\nContinue extracting the signature?", ButtonType.NO, ButtonType.YES);
|
||||
if(response.isEmpty() || response.get() != ButtonType.YES) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Wallet signingWallet = walletNode.getWallet();
|
||||
if(signingWallet.getPolicyType() == PolicyType.SINGLE_SP) {
|
||||
return Bip322.getBip322SignatureFromPsbtSp(signedPsbt);
|
||||
@@ -565,8 +586,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
if(result.psbt != null) {
|
||||
try {
|
||||
String sig = extractBip322Signature(result.psbt);
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
if(sig != null) {
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Error extracting BIP-322 signature from PSBT", e);
|
||||
AppServices.showErrorDialog("Error extracting signature", e.getMessage());
|
||||
@@ -666,8 +689,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
byte[] psbtBytes = Files.readAllBytes(file.toPath());
|
||||
PSBT signedPsbt = new PSBT(psbtBytes, false);
|
||||
String sig = extractBip322Signature(signedPsbt);
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
if(sig != null) {
|
||||
signature.clear();
|
||||
signature.appendText(sig);
|
||||
}
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
if(file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt")) {
|
||||
|
||||
@@ -68,6 +68,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
private final ComboBox<Wallet> toWallet;
|
||||
private final FeeRangeSlider feeRange;
|
||||
private final CopyableLabel feeRate;
|
||||
private final UnlabeledToggleSwitch ignoreDust;
|
||||
private SilentPaymentAddress silentPaymentAddress;
|
||||
|
||||
public PrivateKeySweepDialog(Wallet wallet) {
|
||||
@@ -170,7 +171,12 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
feeRange.setFeeRate(AppServices.getDefaultFeeRate());
|
||||
updateFeeRate();
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField);
|
||||
Field ignoreDustField = new Field();
|
||||
ignoreDustField.setText("Ignore dust:");
|
||||
ignoreDust = new UnlabeledToggleSwitch();
|
||||
ignoreDustField.getInputs().add(ignoreDust);
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField, ignoreDustField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
@@ -383,7 +389,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since);
|
||||
addressUtxosService.setOnSucceeded(successEvent -> {
|
||||
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), payment);
|
||||
List<TransactionOutput> utxos = addressUtxosService.getValue();
|
||||
if(ignoreDust.isSelected()) {
|
||||
utxos = removeDust(utxos);
|
||||
if(utxos.isEmpty()) {
|
||||
AppServices.showErrorDialog("No outputs to sweep", "All of the unspent outputs for this private key have been ignored as dust.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
createTransaction(privateKey.getKey(), scriptType, utxos, payment);
|
||||
});
|
||||
addressUtxosService.setOnFailed(failedEvent -> {
|
||||
Throwable rootCause = Throwables.getRootCause(failedEvent.getSource().getException());
|
||||
@@ -403,6 +418,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
}
|
||||
}
|
||||
|
||||
private List<TransactionOutput> removeDust(List<TransactionOutput> txOutputs) {
|
||||
long dustAttackThreshold = Config.get().getDustAttackThreshold();
|
||||
return txOutputs.stream().filter(txOutput -> txOutput.getValue() > dustAttackThreshold).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void createTransaction(ECKey privKey, ScriptType scriptType, List<TransactionOutput> txOutputs, Payment payment) {
|
||||
Address destAddress = payment instanceof SilentPayment silentPayment ? computeSilentPaymentAddress(privKey, scriptType, txOutputs, silentPayment) : payment.getAddress();
|
||||
ECKey pubKey = ECKey.fromPublicOnly(privKey);
|
||||
|
||||
@@ -47,7 +47,7 @@ import javafx.scene.layout.*;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.openpnp.capture.CaptureDevice;
|
||||
import io.github.doblon8.openpnp.capture.CaptureDevice;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import com.sparrowwallet.drongo.dns.DnsPayment;
|
||||
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
|
||||
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
|
||||
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
|
||||
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
@@ -28,6 +28,8 @@ import javafx.event.ActionEvent;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.util.StringConverter;
|
||||
@@ -35,20 +37,26 @@ import org.controlsfx.control.spreadsheet.*;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
private final BitcoinUnit bitcoinUnit;
|
||||
private final UnitFormat unitFormat;
|
||||
private final UnitFormatDoubleCellType amountCellType;
|
||||
private final SpreadsheetView spreadsheetView;
|
||||
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
|
||||
|
||||
public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
|
||||
public SendToManyDialog(BitcoinUnit bitcoinUnit, UnitFormat unitFormat, List<Payment> payments) {
|
||||
this.bitcoinUnit = bitcoinUnit;
|
||||
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
|
||||
this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat);
|
||||
|
||||
final DialogPane dialogPane = new SendToManyDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
@@ -119,11 +127,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
addressCell.getStyleClass().add("fixed-width");
|
||||
list.add(addressCell);
|
||||
|
||||
double amount = (double)sendToPayment.payment().getAmount();
|
||||
if(bitcoinUnit == BitcoinUnit.BTC) {
|
||||
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
|
||||
}
|
||||
SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
|
||||
long rawAmount = sendToPayment.payment().getAmount();
|
||||
Double amount = rawAmount < 0 ? null : bitcoinUnit.getValue(rawAmount);
|
||||
SpreadsheetCell amountCell = amountCellType.createCell(row, 1, 1, 1, amount);
|
||||
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
|
||||
amountCell.getStyleClass().add("number-value");
|
||||
if(OsType.getCurrent() == OsType.MACOS) {
|
||||
@@ -177,7 +183,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
|
||||
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
|
||||
SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
|
||||
if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
|
||||
if(sendToAddress != null && sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -216,12 +222,15 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
}
|
||||
|
||||
try {
|
||||
String rawAmount = csvReader.get(1).trim();
|
||||
String groupingStripped = rawAmount.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
|
||||
long amount;
|
||||
if(bitcoinUnit == BitcoinUnit.BTC) {
|
||||
double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
|
||||
amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN);
|
||||
String normalised = groupingStripped.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
|
||||
double doubleAmount = Double.parseDouble(normalised);
|
||||
amount = bitcoinUnit.getSatsValue(doubleAmount);
|
||||
} else {
|
||||
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
|
||||
amount = Long.parseLong(groupingStripped);
|
||||
}
|
||||
String label = csvReader.get(2);
|
||||
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
|
||||
@@ -359,6 +368,160 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
}
|
||||
};
|
||||
|
||||
private static class UnitFormatDoubleCellType extends SpreadsheetCellType<Double> {
|
||||
private final UnitFormat unitFormat;
|
||||
|
||||
UnitFormatDoubleCellType(UnitFormat unitFormat) {
|
||||
super(new UnitFormatDoubleConverter(unitFormat));
|
||||
this.unitFormat = unitFormat;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "double";
|
||||
}
|
||||
|
||||
public SpreadsheetCell createCell(int row, int column, int rowSpan, int columnSpan, Double value) {
|
||||
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
|
||||
cell.setItem(value);
|
||||
return cell;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
|
||||
return new UnitFormatDoubleEditor(view, unitFormat);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean match(Object value, Object... options) {
|
||||
if(value == null || value instanceof Number) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
String s = value.toString();
|
||||
return s == null || s.isEmpty() || converter.fromString(s) != null;
|
||||
} catch(Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double convertValue(Object value) {
|
||||
if(value instanceof Double d) {
|
||||
return d;
|
||||
}
|
||||
if(value instanceof Number n) {
|
||||
return n.doubleValue();
|
||||
}
|
||||
return converter.fromString(value == null ? null : value.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Double item) {
|
||||
return converter.toString(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Double item, String format) {
|
||||
return ((StringConverterWithFormat<Double>)converter).toStringFormat(item, format);
|
||||
}
|
||||
}
|
||||
|
||||
private static class UnitFormatDoubleConverter extends StringConverterWithFormat<Double> {
|
||||
private final UnitFormat unitFormat;
|
||||
|
||||
UnitFormatDoubleConverter(UnitFormat unitFormat) {
|
||||
this.unitFormat = unitFormat;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double fromString(String str) {
|
||||
if(str == null || str.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String normalised = str.trim()
|
||||
.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "")
|
||||
.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
|
||||
try {
|
||||
return Double.valueOf(normalised);
|
||||
} catch(NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Double item) {
|
||||
return toStringFormat(item, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toStringFormat(Double item, String format) {
|
||||
if(item == null || item.isNaN()) {
|
||||
return "";
|
||||
}
|
||||
if(format == null || format.isEmpty()) {
|
||||
return Double.toString(item);
|
||||
}
|
||||
return new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(item);
|
||||
}
|
||||
}
|
||||
|
||||
private static class UnitFormatDoubleEditor extends SpreadsheetCellEditor {
|
||||
private final UnitFormat unitFormat;
|
||||
private final TextField textField;
|
||||
|
||||
UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat) {
|
||||
super(view);
|
||||
this.unitFormat = unitFormat;
|
||||
this.textField = new TextField();
|
||||
this.textField.setTextFormatter(new CoinTextFormatter(unitFormat));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startEdit(Object item, String format, Object... options) {
|
||||
if(item instanceof Double d && !d.isNaN()) {
|
||||
String text = (format == null || format.isEmpty())
|
||||
? Double.toString(d)
|
||||
: new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(d);
|
||||
textField.setText(text);
|
||||
} else {
|
||||
textField.setText("");
|
||||
}
|
||||
textField.getStyleClass().removeAll("error");
|
||||
textField.setOnKeyPressed(this::onKeyPressed);
|
||||
textField.requestFocus();
|
||||
textField.selectAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void end() {
|
||||
textField.setOnKeyPressed(null);
|
||||
textField.setOnKeyReleased(null);
|
||||
textField.getStyleClass().removeAll("error");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextField getEditor() {
|
||||
return textField;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getControlValue() {
|
||||
String raw = textField.getText();
|
||||
return raw == null ? "" : raw.trim();
|
||||
}
|
||||
|
||||
private void onKeyPressed(KeyEvent event) {
|
||||
if(event.getCode() == KeyCode.ENTER) {
|
||||
endEdit(true);
|
||||
event.consume();
|
||||
} else if(event.getCode() == KeyCode.ESCAPE) {
|
||||
endEdit(false);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class SendToAddress {
|
||||
private final String hrn;
|
||||
private final Address address;
|
||||
@@ -487,11 +650,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
}
|
||||
|
||||
if(sendToAddress != null && value != null) {
|
||||
if(bitcoinUnit == BitcoinUnit.BTC) {
|
||||
value = value * Transaction.SATOSHIS_PER_BITCOIN;
|
||||
}
|
||||
|
||||
payments.add(sendToAddress.toPayment(label, value.longValue(), false));
|
||||
payments.add(sendToAddress.toPayment(label, bitcoinUnit.getSatsValue(value), false));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ public class WalletImportDialog extends Dialog<List<Wallet>> {
|
||||
|
||||
importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(),
|
||||
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai(), new KeycardShellSinglesig());
|
||||
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai(), new KeycardShellSinglesig(), new EraSinglesig());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import org.openpnp.capture.CaptureFormat;
|
||||
import io.github.doblon8.openpnp.capture.CaptureFormat;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@@ -61,7 +61,7 @@ public enum WebcamResolution implements Comparable<WebcamResolution> {
|
||||
|
||||
public static WebcamResolution from(CaptureFormat captureFormat) {
|
||||
for(WebcamResolution resolution : values()) {
|
||||
if(captureFormat.getFormatInfo().width == resolution.width && captureFormat.getFormatInfo().height == resolution.height) {
|
||||
if(captureFormat.formatInfo().width() == resolution.width && captureFormat.formatInfo().height() == resolution.height) {
|
||||
return resolution;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.scene.image.Image;
|
||||
import org.openpnp.capture.*;
|
||||
import org.openpnp.capture.library.OpenpnpCaptureLibrary;
|
||||
import io.github.doblon8.openpnp.capture.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -25,6 +24,7 @@ import java.awt.*;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.WritableRaster;
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Semaphore;
|
||||
@@ -62,38 +62,46 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
private final Bokmakierie bokmakierie;
|
||||
|
||||
static {
|
||||
if(log.isTraceEnabled()) {
|
||||
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(8);
|
||||
} else if(log.isDebugEnabled()) {
|
||||
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(7);
|
||||
} else if(log.isInfoEnabled()) {
|
||||
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(6);
|
||||
String javaHome = System.getProperty("java.home");
|
||||
if(javaHome != null) {
|
||||
File libFile = new File(new File(javaHome, "lib"), System.mapLibraryName("openpnp-capture"));
|
||||
if(libFile.exists()) {
|
||||
System.load(libFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
OpenpnpCaptureLibrary.INSTANCE.Cap_installCustomLogFunction((level, ptr) -> {
|
||||
if(log.isTraceEnabled()) {
|
||||
OpenPnpCapture.setLogLevel(LogLevel.VERBOSE);
|
||||
} else if(log.isDebugEnabled()) {
|
||||
OpenPnpCapture.setLogLevel(LogLevel.DEBUG);
|
||||
} else if(log.isInfoEnabled()) {
|
||||
OpenPnpCapture.setLogLevel(LogLevel.INFO);
|
||||
}
|
||||
|
||||
OpenPnpCapture.installCustomLogFunction((level, message) -> {
|
||||
switch(level) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
String err = ptr.getString(0).trim();
|
||||
case LogLevel.EMERGENCY:
|
||||
case LogLevel.ALERT:
|
||||
case LogLevel.CRITICAL:
|
||||
case LogLevel.ERROR:
|
||||
String err = message.trim();
|
||||
if(err.equals("tjDecompressHeader2 failed: No error") || err.matches("getPropertyLimits.*failed on.*")) { //Safe to ignore
|
||||
log.debug(err);
|
||||
} else {
|
||||
log.error(err);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
case 5:
|
||||
case 6:
|
||||
log.info(ptr.getString(0).trim());
|
||||
case LogLevel.WARNING:
|
||||
case LogLevel.NOTICE:
|
||||
case LogLevel.INFO:
|
||||
log.info(message.trim());
|
||||
break;
|
||||
case 7:
|
||||
log.debug(ptr.getString(0).trim());
|
||||
case LogLevel.DEBUG:
|
||||
log.debug(message.trim());
|
||||
break;
|
||||
case 8:
|
||||
case LogLevel.VERBOSE:
|
||||
default:
|
||||
log.trace(ptr.getString(0).trim());
|
||||
log.trace(message.trim());
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -167,8 +175,8 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
//On macOS and Windows, camera pixel format is largely abstracted away
|
||||
if(OsType.getCurrent() == OsType.UNIX) {
|
||||
deviceFormats.sort((f1, f2) -> {
|
||||
WebcamPixelFormat pf1 = WebcamPixelFormat.fromFourCC(f1.getFormatInfo().fourcc);
|
||||
WebcamPixelFormat pf2 = WebcamPixelFormat.fromFourCC(f2.getFormatInfo().fourcc);
|
||||
WebcamPixelFormat pf1 = WebcamPixelFormat.fromFourCC(f1.formatInfo().fourcc());
|
||||
WebcamPixelFormat pf2 = WebcamPixelFormat.fromFourCC(f2.formatInfo().fourcc());
|
||||
return Integer.compare(WebcamPixelFormat.getPriority(pf1), WebcamPixelFormat.getPriority(pf2));
|
||||
});
|
||||
}
|
||||
@@ -185,17 +193,17 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
format = supportedResolutions.get(resolution);
|
||||
} else {
|
||||
format = device.getFormats().getFirst();
|
||||
log.warn("Could not get standard capture resolution, using " + format.getFormatInfo().width + "x" + format.getFormatInfo().height);
|
||||
log.warn("Could not get standard capture resolution, using " + format.formatInfo().width() + "x" + format.formatInfo().height());
|
||||
}
|
||||
}
|
||||
|
||||
//On Linux, formats not defined in WebcamPixelFormat are unsupported
|
||||
if(OsType.getCurrent() == OsType.UNIX && WebcamPixelFormat.fromFourCC(format.getFormatInfo().fourcc) == null) {
|
||||
log.warn("Unsupported camera pixel format " + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc));
|
||||
if(OsType.getCurrent() == OsType.UNIX && WebcamPixelFormat.fromFourCC(format.formatInfo().fourcc()) == null) {
|
||||
log.warn("Unsupported camera pixel format " + WebcamPixelFormat.fourCCToString(format.formatInfo().fourcc()));
|
||||
}
|
||||
|
||||
if(log.isDebugEnabled()) {
|
||||
log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")");
|
||||
log.debug("Opening capture stream on " + device + " with format " + format.formatInfo().width() + "x" + format.formatInfo().height() + " (" + WebcamPixelFormat.fourCCToString(format.formatInfo().fourcc()) + ")");
|
||||
}
|
||||
|
||||
opening.set(true);
|
||||
@@ -203,7 +211,7 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
opening.set(false);
|
||||
|
||||
try {
|
||||
zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom);
|
||||
zoomLimits = stream.getPropertyLimits(CaptureProperty.ZOOM);
|
||||
} catch(Throwable e) {
|
||||
log.debug("Error getting zoom limits on " + device + ", assuming no zoom function");
|
||||
}
|
||||
@@ -285,7 +293,7 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
public int getZoom() {
|
||||
if(stream != null && zoomLimits != null) {
|
||||
try {
|
||||
return stream.getProperty(CaptureProperty.Zoom);
|
||||
return stream.getProperty(CaptureProperty.ZOOM);
|
||||
} catch(Exception e) {
|
||||
log.error("Error getting zoom property on " + device, e);
|
||||
}
|
||||
@@ -297,7 +305,7 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
public void setZoom(int value) {
|
||||
if(stream != null && zoomLimits != null) {
|
||||
try {
|
||||
stream.setProperty(CaptureProperty.Zoom, value);
|
||||
stream.setProperty(CaptureProperty.ZOOM, value);
|
||||
} catch(Exception e) {
|
||||
log.error("Error setting zoom property on " + device, e);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ public class WebcamView {
|
||||
int currentZoom = service.getZoom();
|
||||
if(currentZoom >= 0) {
|
||||
int newZoom = scrollEvent.getDeltaY() > 0 ? Math.round(currentZoom * 1.1f) : Math.round(currentZoom * 0.9f);
|
||||
newZoom = Math.max(newZoom, service.getZoomLimits().getMin());
|
||||
newZoom = Math.min(newZoom, service.getZoomLimits().getMax());
|
||||
newZoom = Math.max(newZoom, service.getZoomLimits().min());
|
||||
newZoom = Math.min(newZoom, service.getZoomLimits().max());
|
||||
if(newZoom != currentZoom) {
|
||||
service.setZoom(newZoom);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import javafx.stage.Window;
|
||||
|
||||
import java.io.File;
|
||||
@@ -10,15 +11,20 @@ import java.io.File;
|
||||
public class RequestTransactionOpenEvent {
|
||||
private final Window window;
|
||||
private final File file;
|
||||
private final PSBT contextPsbt;
|
||||
|
||||
public RequestTransactionOpenEvent(Window window) {
|
||||
this.window = window;
|
||||
this.file = null;
|
||||
this(window, null, null);
|
||||
}
|
||||
|
||||
public RequestTransactionOpenEvent(Window window, File file) {
|
||||
this(window, file, null);
|
||||
}
|
||||
|
||||
public RequestTransactionOpenEvent(Window window, File file, PSBT contextPsbt) {
|
||||
this.window = window;
|
||||
this.file = file;
|
||||
this.contextPsbt = contextPsbt;
|
||||
}
|
||||
|
||||
public Window getWindow() {
|
||||
@@ -28,4 +34,8 @@ public class RequestTransactionOpenEvent {
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public PSBT getContextPsbt() {
|
||||
return contextPsbt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,28 @@ public class ViewPSBTEvent {
|
||||
private final String label;
|
||||
private final File file;
|
||||
private final PSBT psbt;
|
||||
private final PSBT contextPsbt;
|
||||
private final TransactionView initialView;
|
||||
private final Integer initialIndex;
|
||||
|
||||
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt) {
|
||||
this(window, label, file, psbt, TransactionView.HEADERS, null);
|
||||
this(window, label, file, psbt, null, TransactionView.HEADERS, null);
|
||||
}
|
||||
|
||||
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, PSBT contextPsbt) {
|
||||
this(window, label, file, psbt, contextPsbt, TransactionView.HEADERS, null);
|
||||
}
|
||||
|
||||
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, TransactionView initialView, Integer initialIndex) {
|
||||
this(window, label, file, psbt, null, initialView, initialIndex);
|
||||
}
|
||||
|
||||
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, PSBT contextPsbt, TransactionView initialView, Integer initialIndex) {
|
||||
this.window = window;
|
||||
this.label = label;
|
||||
this.file = file;
|
||||
this.psbt = psbt;
|
||||
this.contextPsbt = contextPsbt;
|
||||
this.initialView = initialView;
|
||||
this.initialIndex = initialIndex;
|
||||
}
|
||||
@@ -43,6 +53,10 @@ public class ViewPSBTEvent {
|
||||
return psbt;
|
||||
}
|
||||
|
||||
public PSBT getContextPsbt() {
|
||||
return contextPsbt;
|
||||
}
|
||||
|
||||
public TransactionView getInitialView() {
|
||||
return initialView;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionView;
|
||||
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
|
||||
@@ -9,13 +10,19 @@ import javafx.stage.Window;
|
||||
public class ViewTransactionEvent {
|
||||
private final Window window;
|
||||
private final Transaction transaction;
|
||||
private final PSBT contextPsbt;
|
||||
private final BlockTransaction blockTransaction;
|
||||
private final TransactionView initialView;
|
||||
private final Integer initialIndex;
|
||||
|
||||
public ViewTransactionEvent(Window window, Transaction transaction) {
|
||||
this(window, transaction, null);
|
||||
}
|
||||
|
||||
public ViewTransactionEvent(Window window, Transaction transaction, PSBT contextPsbt) {
|
||||
this.window = window;
|
||||
this.transaction = transaction;
|
||||
this.contextPsbt = contextPsbt;
|
||||
this.blockTransaction = null;
|
||||
this.initialView = TransactionView.HEADERS;
|
||||
this.initialIndex = null;
|
||||
@@ -32,6 +39,7 @@ public class ViewTransactionEvent {
|
||||
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
|
||||
this.window = window;
|
||||
this.transaction = blockTransaction.getTransaction();
|
||||
this.contextPsbt = null;
|
||||
this.blockTransaction = blockTransaction;
|
||||
this.initialView = initialView;
|
||||
this.initialIndex = initialIndex;
|
||||
@@ -45,6 +53,10 @@ public class ViewTransactionEvent {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public PSBT getContextPsbt() {
|
||||
return contextPsbt;
|
||||
}
|
||||
|
||||
public BlockTransaction getBlockTransaction() {
|
||||
return blockTransaction;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class Instance {
|
||||
@@ -31,6 +32,8 @@ public abstract class Instance {
|
||||
|
||||
private Selector selector;
|
||||
private ServerSocketChannel serverChannel;
|
||||
private Path acquiredLockFile;
|
||||
private Path acquiredLockFilePointer;
|
||||
|
||||
public Instance(final String applicationId) {
|
||||
this(applicationId, true);
|
||||
@@ -65,6 +68,7 @@ public abstract class Instance {
|
||||
serverChannel.configureBlocking(false);
|
||||
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
|
||||
lockFile.toFile().deleteOnExit();
|
||||
acquiredLockFile = lockFile;
|
||||
} catch(Exception e) {
|
||||
throw new InstanceException("Could not open UNIX socket lock file for instance at " + lockFile.toAbsolutePath(), e);
|
||||
}
|
||||
@@ -145,26 +149,27 @@ public abstract class Instance {
|
||||
|
||||
private Path getLockFile(boolean findExisting) {
|
||||
if(findExisting) {
|
||||
Path pointer = getUserLockFilePointer();
|
||||
try {
|
||||
if(pointer != null && Files.exists(pointer)) {
|
||||
if(Files.isSymbolicLink(pointer)) {
|
||||
return Files.readSymbolicLink(pointer);
|
||||
} else {
|
||||
Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8));
|
||||
if(Files.exists(lockFile)) {
|
||||
return lockFile;
|
||||
for(Path pointer : getUserLockFilePointers()) {
|
||||
try {
|
||||
if(Files.exists(pointer)) {
|
||||
if(Files.isSymbolicLink(pointer)) {
|
||||
return Files.readSymbolicLink(pointer);
|
||||
} else {
|
||||
Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8));
|
||||
if(Files.exists(lockFile)) {
|
||||
return lockFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not find lock file at " + pointer.toAbsolutePath());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.warn("Could not find lock file at " + pointer.toAbsolutePath());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
return Storage.getSparrowDir().toPath().resolve(applicationId + ".lock");
|
||||
return Storage.getStateDir().toPath().resolve(applicationId + ".lock");
|
||||
}
|
||||
|
||||
private void createSymlink(Path lockFile) {
|
||||
@@ -173,6 +178,7 @@ public abstract class Instance {
|
||||
if(pointer != null && !Files.exists(pointer, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createSymbolicLink(pointer, lockFile);
|
||||
pointer.toFile().deleteOnExit();
|
||||
acquiredLockFilePointer = pointer;
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.debug("Could not create symlink " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath() + ", writing as normal file", e);
|
||||
@@ -180,6 +186,7 @@ public abstract class Instance {
|
||||
try {
|
||||
Files.writeString(pointer, lockFile.toAbsolutePath().toString(), StandardCharsets.UTF_8);
|
||||
pointer.toFile().deleteOnExit();
|
||||
acquiredLockFilePointer = pointer;
|
||||
} catch(IOException ex) {
|
||||
log.warn("Could not create pointer " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath(), ex);
|
||||
}
|
||||
@@ -188,24 +195,44 @@ public abstract class Instance {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the location this instance writes its pointer to, ignoring any configured home so that instances started with
|
||||
* and without one still find each other.
|
||||
*/
|
||||
private Path getUserLockFilePointer() {
|
||||
if(Boolean.parseBoolean(System.getenv(LINK_ENV_PROPERTY))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
File sparrowHome = Storage.getSparrowHome(true);
|
||||
if(!sparrowHome.exists()) {
|
||||
Storage.createOwnerOnlyDirectory(sparrowHome);
|
||||
sparrowHome.deleteOnExit(); //Will only delete on exit if empty
|
||||
File stateHome = Storage.getStateHome(true);
|
||||
if(!stateHome.exists()) {
|
||||
Storage.createOwnerOnlyDirectory(stateHome);
|
||||
stateHome.deleteOnExit(); //Will only delete on exit if empty
|
||||
}
|
||||
|
||||
return sparrowHome.toPath().resolve(applicationId + ".default");
|
||||
return stateHome.toPath().resolve(applicationId + ".default");
|
||||
} catch(Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locations that may hold a pointer to a running instance, without creating any of them.
|
||||
*
|
||||
* An instance started before the state directory was migrated writes its pointer to the default home, so that is searched
|
||||
* as well, ensuring migrating does not hide an instance that is already running.
|
||||
*/
|
||||
private List<Path> getUserLockFilePointers() {
|
||||
Path statePointer = getUserLockFilePointer();
|
||||
if(statePointer == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Path defaultPointer = Storage.getDefaultHome().toPath().resolve(applicationId + ".default");
|
||||
return statePointer.equals(defaultPointer) ? List.of(statePointer) : List.of(statePointer, defaultPointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Free the lock if possible. This is only required to be called from the first instance.
|
||||
*
|
||||
@@ -216,10 +243,13 @@ public abstract class Instance {
|
||||
if(serverChannel != null && serverChannel.isOpen()) {
|
||||
serverChannel.close();
|
||||
}
|
||||
if(getUserLockFilePointer() != null) {
|
||||
Files.deleteIfExists(getUserLockFilePointer());
|
||||
//Free the paths this instance acquired, which are not necessarily those that would be resolved now
|
||||
if(acquiredLockFilePointer != null) {
|
||||
Files.deleteIfExists(acquiredLockFilePointer);
|
||||
}
|
||||
if(acquiredLockFile != null) {
|
||||
Files.deleteIfExists(acquiredLockFile);
|
||||
}
|
||||
Files.deleteIfExists(getLockFile(false));
|
||||
} catch(Exception e) {
|
||||
throw new InstanceException(e);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.ENUMERATE_HW_PERIOD_SECS;
|
||||
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
|
||||
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
|
||||
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SATS;
|
||||
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SP_SATS;
|
||||
|
||||
public class Config {
|
||||
private static final Logger log = LoggerFactory.getLogger(Config.class);
|
||||
@@ -64,6 +64,7 @@ public class Config {
|
||||
private List<File> recentWalletFiles;
|
||||
private Integer keyDerivationPeriod;
|
||||
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
|
||||
private long dustAttackThresholdSp = DUST_ATTACK_THRESHOLD_SP_SATS;
|
||||
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
|
||||
private QRDensity qrDensity;
|
||||
private QREncoding qrEncoding;
|
||||
@@ -107,8 +108,8 @@ public class Config {
|
||||
}
|
||||
|
||||
private static File getConfigFile() {
|
||||
File sparrowDir = Storage.getSparrowDir();
|
||||
return new File(sparrowDir, CONFIG_FILENAME);
|
||||
File configDir = Storage.getConfigDir();
|
||||
return new File(configDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
private static Config load() {
|
||||
@@ -448,6 +449,10 @@ public class Config {
|
||||
return dustAttackThreshold;
|
||||
}
|
||||
|
||||
public long getDustAttackThresholdSp() {
|
||||
return dustAttackThresholdSp;
|
||||
}
|
||||
|
||||
public int getEnumerateHwPeriod() {
|
||||
return enumerateHwPeriod;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class EraSinglesig extends KeystoneSinglesig {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ERA Wallet";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import QR created on your ERA Wallet by opening your wallet and selecting Link > Sparrow from the wallet menu.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.ERA_WALLET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(PolicyType policyType, ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = super.getKeystore(policyType, scriptType, inputStream, password);
|
||||
keystore.setLabel("ERA Wallet");
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ public class Hwi {
|
||||
private static void deleteHwiDir() {
|
||||
try {
|
||||
if(OsType.getCurrent() == OsType.MACOS || OsType.getCurrent() == OsType.WINDOWS) {
|
||||
File hwiHomeDir = new File(Storage.getSparrowDir(), HWI_HOME_DIR);
|
||||
File hwiHomeDir = new File(Storage.getCacheDir(), HWI_HOME_DIR);
|
||||
if(hwiHomeDir.exists()) {
|
||||
IOUtils.deleteDirectory(hwiHomeDir);
|
||||
}
|
||||
@@ -544,7 +544,7 @@ public class Hwi {
|
||||
private BitBoxPairingDialog pairingDialog;
|
||||
|
||||
public BitBoxFxNoiseConfig() {
|
||||
super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, BITBOX_FILENAME).toFile());
|
||||
super(Path.of(Storage.getDataHome().getAbsolutePath(), LARK_HOME_DIR, BITBOX_FILENAME).toFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -594,7 +594,7 @@ public class Hwi {
|
||||
private String deviceInfo;
|
||||
|
||||
public TrezorFxNoiseConfig() {
|
||||
super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile());
|
||||
super(Path.of(Storage.getDataHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -4,18 +4,32 @@ import com.google.gson.*;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.FileType;
|
||||
import com.sparrowwallet.drongo.IOUtils;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.bip47.PaymentCode;
|
||||
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
|
||||
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.EncryptedData;
|
||||
import com.sparrowwallet.drongo.crypto.EncryptionType;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
|
||||
import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
|
||||
import com.sparrowwallet.drongo.policy.Miniscript;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
|
||||
import com.sparrowwallet.drongo.wallet.UtxoMixData;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTable;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
@@ -340,6 +354,34 @@ public class JsonPersistence implements Persistence {
|
||||
gsonBuilder.registerTypeAdapter(Address.class, new AddressDeserializer());
|
||||
gsonBuilder.registerTypeAdapter(PolicyType.class, new PolicyTypeSerializer());
|
||||
gsonBuilder.registerTypeAdapter(PolicyType.class, new PolicyTypeDeserializer());
|
||||
gsonBuilder.registerTypeAdapter(File.class, new FileSerializer());
|
||||
gsonBuilder.registerTypeAdapter(File.class, new FileDeserializer());
|
||||
gsonBuilder.registerTypeAdapter(SilentPaymentAddress.class, new SilentPaymentAddressSerializer());
|
||||
gsonBuilder.registerTypeAdapter(SilentPaymentAddress.class, new SilentPaymentAddressDeserializer());
|
||||
gsonBuilder.registerTypeAdapter(SilentPaymentScanAddress.class, new SilentPaymentScanAddressSerializer());
|
||||
gsonBuilder.registerTypeAdapter(SilentPaymentScanAddress.class, new SilentPaymentScanAddressDeserializer());
|
||||
|
||||
//Reflection on any class outside this project fails at Gson adapter construction when running on the module path, unless its module opens the package to Gson
|
||||
//Blocking these classes here ensures classpath-based tests fail in the same way production does, ensuring custom serializers are registered above as necessary
|
||||
gsonBuilder.addReflectionAccessFilter(rawClass -> rawClass.getName().startsWith("com.sparrowwallet.") ? ReflectionAccessFilter.FilterResult.INDECISIVE : ReflectionAccessFilter.FilterResult.BLOCK_ALL);
|
||||
|
||||
//Instance creators are required for classes without no-args constructors, since Gson's fallback of sun.misc.Unsafe is unavailable on the module path (jdk.unsupported is not resolved)
|
||||
//Unsafe is also explicitly disabled below so that classpath-based tests fail in the same way production does if a class is missed here
|
||||
gsonBuilder.disableJdkUnsafe();
|
||||
gsonBuilder.registerTypeAdapter(Policy.class, (InstanceCreator<Policy>) type -> new Policy(null));
|
||||
gsonBuilder.registerTypeAdapter(Miniscript.class, (InstanceCreator<Miniscript>) type -> new Miniscript(null));
|
||||
gsonBuilder.registerTypeAdapter(KeyDerivation.class, (InstanceCreator<KeyDerivation>) type -> new KeyDerivation(null, (String)null));
|
||||
gsonBuilder.registerTypeAdapter(WalletNode.class, (InstanceCreator<WalletNode>) type -> new WalletNode("m/0"));
|
||||
gsonBuilder.registerTypeAdapter(BlockTransaction.class, (InstanceCreator<BlockTransaction>) type -> new BlockTransaction(null, 0, null, null, null));
|
||||
gsonBuilder.registerTypeAdapter(BlockTransactionHashIndex.class, (InstanceCreator<BlockTransactionHashIndex>) type -> new BlockTransactionHashIndex(null, 0, null, null, 0, 0));
|
||||
gsonBuilder.registerTypeAdapter(DeterministicSeed.class, (InstanceCreator<DeterministicSeed>) type -> new DeterministicSeed((EncryptedData)null, false, 0, null));
|
||||
gsonBuilder.registerTypeAdapter(MasterPrivateExtendedKey.class, (InstanceCreator<MasterPrivateExtendedKey>) type -> new MasterPrivateExtendedKey((EncryptedData)null));
|
||||
gsonBuilder.registerTypeAdapter(EncryptedData.class, (InstanceCreator<EncryptedData>) type -> new EncryptedData(new byte[0], new byte[0], null, (EncryptionType)null));
|
||||
gsonBuilder.registerTypeAdapter(EncryptionType.class, (InstanceCreator<EncryptionType>) type -> new EncryptionType(null, null));
|
||||
gsonBuilder.registerTypeAdapter(PaymentCode.class, (InstanceCreator<PaymentCode>) type -> new PaymentCode(new byte[33], new byte[32]));
|
||||
gsonBuilder.registerTypeAdapter(UtxoMixData.class, (InstanceCreator<UtxoMixData>) type -> new UtxoMixData(0, null));
|
||||
gsonBuilder.registerTypeAdapter(WalletTable.class, (InstanceCreator<WalletTable>) type -> new WalletTable(null, null, 0, null));
|
||||
|
||||
if(includeWalletSerializers) {
|
||||
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
|
||||
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
|
||||
@@ -403,6 +445,51 @@ public class JsonPersistence implements Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
//Maintains the same format as the previous reflective serialization of java.io.File, which requires java.base/java.io to be opened to Gson
|
||||
private static class FileSerializer implements JsonSerializer<File> {
|
||||
@Override
|
||||
public JsonElement serialize(File src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.addProperty("path", src.getPath());
|
||||
return jsonObject;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileDeserializer implements JsonDeserializer<File> {
|
||||
@Override
|
||||
public File deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return new File(json.isJsonObject() ? json.getAsJsonObject().get("path").getAsString() : json.getAsJsonPrimitive().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static class SilentPaymentAddressSerializer implements JsonSerializer<SilentPaymentAddress> {
|
||||
@Override
|
||||
public JsonElement serialize(SilentPaymentAddress src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
return new JsonPrimitive(src.getAddress());
|
||||
}
|
||||
}
|
||||
|
||||
private static class SilentPaymentAddressDeserializer implements JsonDeserializer<SilentPaymentAddress> {
|
||||
@Override
|
||||
public SilentPaymentAddress deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return SilentPaymentAddress.from(json.getAsJsonPrimitive().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static class SilentPaymentScanAddressSerializer implements JsonSerializer<SilentPaymentScanAddress> {
|
||||
@Override
|
||||
public JsonElement serialize(SilentPaymentScanAddress src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
return new JsonPrimitive(src.toKeyString());
|
||||
}
|
||||
}
|
||||
|
||||
private static class SilentPaymentScanAddressDeserializer implements JsonDeserializer<SilentPaymentScanAddress> {
|
||||
@Override
|
||||
public SilentPaymentScanAddress deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return SilentPaymentScanAddress.fromKeyString(json.getAsJsonPrimitive().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static class DateSerializer implements JsonSerializer<Date> {
|
||||
@Override
|
||||
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.common.io.Files;
|
||||
import com.sparrowwallet.drongo.IOUtils;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
@@ -11,6 +11,7 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -29,13 +30,14 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
|
||||
File tempDir = null;
|
||||
try {
|
||||
Wallet exportedWallet = !wallet.isMasterWallet() ? wallet.getMasterWallet() : wallet;
|
||||
PersistenceType persistenceType = PersistenceType.DB;
|
||||
Persistence persistence = persistenceType.getInstance();
|
||||
Storage storage = AppServices.get().getOpenWallets().get(exportedWallet);
|
||||
File tempFile = File.createTempFile(exportedWallet.getName() + "tmp", "." + persistenceType.getExtension());
|
||||
tempFile.delete();
|
||||
tempDir = Files.createTempDirectory("sparrow").toFile();
|
||||
File tempFile = new File(tempDir, exportedWallet.getName() + "." + persistenceType.getExtension());
|
||||
Storage tempStorage = new Storage(persistence, tempFile);
|
||||
tempStorage.setKeyDeriver(storage.getKeyDeriver());
|
||||
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
|
||||
@@ -46,12 +48,13 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
tempStorage.saveWallet(childWallet);
|
||||
}
|
||||
persistence.close();
|
||||
Files.copy(tempStorage.getWalletFile(), outputStream);
|
||||
Files.copy(tempStorage.getWalletFile().toPath(), outputStream);
|
||||
outputStream.flush();
|
||||
tempStorage.getWalletFile().delete();
|
||||
} catch(Exception e) {
|
||||
log.error("Error exporting Sparrow wallet file", e);
|
||||
throw new ExportException("Error exporting Sparrow wallet file", e);
|
||||
} finally {
|
||||
deleteTempDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +91,11 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
Storage storage = null;
|
||||
Wallet wallet = null;
|
||||
File tempFile = null;
|
||||
File tempDir = null;
|
||||
try {
|
||||
tempFile = File.createTempFile("sparrow", null);
|
||||
java.nio.file.Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
tempDir = Files.createTempDirectory("sparrow").toFile();
|
||||
File tempFile = new File(tempDir, "sparrow");
|
||||
Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
PersistenceType persistenceType = Storage.detectPersistenceType(tempFile);
|
||||
persistenceType = (persistenceType == null ? PersistenceType.JSON : persistenceType);
|
||||
if(persistenceType != PersistenceType.JSON || !isEncrypted(tempFile)) {
|
||||
@@ -102,6 +105,7 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
}
|
||||
|
||||
storage = new Storage(persistenceType, tempFile);
|
||||
Wallet wallet;
|
||||
if(!isEncrypted(tempFile)) {
|
||||
wallet = storage.loadUnencryptedWallet().getWallet();
|
||||
} else {
|
||||
@@ -118,19 +122,23 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
throw new ImportException("Error importing Sparrow wallet", e);
|
||||
} finally {
|
||||
if(storage != null) {
|
||||
storage.close();
|
||||
storage.closeAndWait();
|
||||
}
|
||||
|
||||
if(tempFile != null) {
|
||||
if(wallet != null) {
|
||||
File migratedWalletFile = Storage.getExistingWallet(tempFile.getParentFile(), wallet.getName());
|
||||
if(migratedWalletFile != null) {
|
||||
migratedWalletFile.delete();
|
||||
}
|
||||
deleteTempDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTempDirectory(File tempDir) {
|
||||
if(tempDir != null) {
|
||||
File[] tempFiles = tempDir.listFiles();
|
||||
if(tempFiles != null) {
|
||||
for(File file : tempFiles) {
|
||||
IOUtils.secureDelete(file);
|
||||
}
|
||||
|
||||
tempFile.delete();
|
||||
}
|
||||
|
||||
tempDir.delete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,23 +25,23 @@ import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class Storage {
|
||||
private static final Logger log = LoggerFactory.getLogger(Storage.class);
|
||||
public static final ECKey NO_PASSWORD_KEY = ECKey.fromPublicOnly(ECKey.fromPrivate(Utils.hexToBytes("885e5a09708a167ea356a252387aa7c4893d138d632e296df8fbf5c12798bd28")));
|
||||
|
||||
private static final DateFormat BACKUP_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
|
||||
private static final DateTimeFormatter BACKUP_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile(".+-([0-9]{14}?).*");
|
||||
|
||||
public static final String SPARROW_DIR = ".sparrow";
|
||||
public static final String WINDOWS_SPARROW_DIR = "Sparrow";
|
||||
public static final String WALLETS_DIR = "wallets";
|
||||
public static final String WALLETS_BACKUP_DIR = "backup";
|
||||
public static final String CERTS_DIR = "certs";
|
||||
@@ -140,6 +140,10 @@ public class Storage {
|
||||
closePersistenceService.start();
|
||||
}
|
||||
|
||||
public void closeAndWait() {
|
||||
persistence.close();
|
||||
}
|
||||
|
||||
public void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException {
|
||||
checkWalletNetwork(wallet);
|
||||
|
||||
@@ -234,9 +238,8 @@ public class Storage {
|
||||
private void backupWallet(String prefix) throws IOException {
|
||||
File backupDir = getWalletsBackupDir();
|
||||
|
||||
Date backupDate = new Date();
|
||||
String walletName = persistence.getWalletName(walletFile, null);
|
||||
String dateSuffix = "-" + BACKUP_DATE_FORMAT.format(backupDate);
|
||||
String dateSuffix = "-" + BACKUP_DATE_FORMAT.format(LocalDateTime.now());
|
||||
String backupName = walletName + dateSuffix + walletFile.getName().substring(walletName.length());
|
||||
|
||||
if(prefix != null) {
|
||||
@@ -271,9 +274,9 @@ public class Storage {
|
||||
|
||||
private boolean hasStartedSince(File lastBackup) {
|
||||
try {
|
||||
Date date = BACKUP_DATE_FORMAT.parse(getBackupDate(lastBackup.getName()));
|
||||
LocalDateTime date = LocalDateTime.parse(getBackupDate(lastBackup.getName()), BACKUP_DATE_FORMAT);
|
||||
ProcessHandle.Info processInfo = ProcessHandle.current().info();
|
||||
return (processInfo.startInstant().isPresent() && processInfo.startInstant().get().isAfter(date.toInstant()));
|
||||
return (processInfo.startInstant().isPresent() && processInfo.startInstant().get().isAfter(date.atZone(ZoneId.systemDefault()).toInstant()));
|
||||
} catch(Exception e) {
|
||||
log.error("Error parsing date for backup file " + lastBackup.getName(), e);
|
||||
return false;
|
||||
@@ -497,7 +500,7 @@ public class Storage {
|
||||
}
|
||||
}
|
||||
if(walletsDir == null) {
|
||||
walletsDir = new File(getSparrowDir(), WALLETS_DIR);
|
||||
walletsDir = new File(getDataDir(), WALLETS_DIR);
|
||||
}
|
||||
if(!walletsDir.exists()) {
|
||||
createOwnerOnlyDirectory(walletsDir);
|
||||
@@ -552,7 +555,7 @@ public class Storage {
|
||||
}
|
||||
|
||||
static File getCertsDir() {
|
||||
File certsDir = new File(getSparrowDir(), CERTS_DIR);
|
||||
File certsDir = new File(getDataDir(), CERTS_DIR);
|
||||
if(!certsDir.exists()) {
|
||||
createOwnerOnlyDirectory(certsDir);
|
||||
}
|
||||
@@ -560,73 +563,123 @@ public class Storage {
|
||||
return certsDir;
|
||||
}
|
||||
|
||||
public static File getSparrowDir() {
|
||||
File sparrowDir;
|
||||
/**
|
||||
* Returns the network specific directory containing the configuration file.
|
||||
*/
|
||||
public static File getConfigDir() {
|
||||
return getNetworkDir(getConfigHome());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the network specific directory containing wallets and certificates.
|
||||
*/
|
||||
public static File getDataDir() {
|
||||
return getNetworkDir(getDataHome());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the network specific directory containing regenerable files.
|
||||
*/
|
||||
public static File getCacheDir() {
|
||||
return getNetworkDir(getCacheHome());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the network specific directory containing logs and runtime state.
|
||||
*/
|
||||
public static File getStateDir() {
|
||||
return getNetworkDir(getStateHome());
|
||||
}
|
||||
|
||||
public static File getConfigHome() {
|
||||
return ApplicationDir.CONFIG.get(SparrowWallet.APP_NAME);
|
||||
}
|
||||
|
||||
public static File getDataHome() {
|
||||
return ApplicationDir.DATA.get(SparrowWallet.APP_NAME);
|
||||
}
|
||||
|
||||
public static File getCacheHome() {
|
||||
return ApplicationDir.CACHE.get(SparrowWallet.APP_NAME);
|
||||
}
|
||||
|
||||
public static File getStateHome() {
|
||||
return getStateHome(false);
|
||||
}
|
||||
|
||||
public static File getStateHome(boolean useDefault) {
|
||||
return ApplicationDir.STATE.get(SparrowWallet.APP_NAME, useDefault);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the single directory used when the XDG Base Directory Specification is not followed, ignoring any configured home.
|
||||
*
|
||||
* Provides a fixed location that does not move as categories are migrated, and is where earlier versions wrote all of their files.
|
||||
*/
|
||||
public static File getDefaultHome() {
|
||||
return ApplicationDir.getDefaultDir(SparrowWallet.APP_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the network specific directory within the given application directory, creating it if necessary.
|
||||
*
|
||||
* Where a network has been renamed, any existing directory under the previous name is moved and replaced with a symlink,
|
||||
* and a symlink under the previous name is otherwise maintained for convenience.
|
||||
*/
|
||||
private static File getNetworkDir(File applicationDir) {
|
||||
File networkDir;
|
||||
Network network = Network.get();
|
||||
if(network != Network.MAINNET) {
|
||||
sparrowDir = new File(getSparrowHome(), network.getHome());
|
||||
if(!network.getName().equals(network.getHome()) && !sparrowDir.exists()) {
|
||||
File networkNameDir = new File(getSparrowHome(), network.getName());
|
||||
networkDir = new File(applicationDir, network.getHome());
|
||||
if(!network.getName().equals(network.getHome()) && !networkDir.exists()) {
|
||||
File networkNameDir = new File(applicationDir, network.getName());
|
||||
if(networkNameDir.exists() && networkNameDir.isDirectory() && !Files.isSymbolicLink(networkNameDir.toPath())) {
|
||||
try {
|
||||
if(networkNameDir.renameTo(sparrowDir) && !isWindows()) {
|
||||
Files.createSymbolicLink(networkNameDir.toPath(), Path.of(sparrowDir.getName()));
|
||||
if(networkNameDir.renameTo(networkDir) && !isWindows()) {
|
||||
Files.createSymbolicLink(networkNameDir.toPath(), Path.of(networkDir.getName()));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + sparrowDir.getName(), e);
|
||||
log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + networkDir.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sparrowDir = getSparrowHome();
|
||||
networkDir = applicationDir;
|
||||
}
|
||||
|
||||
if(!sparrowDir.exists()) {
|
||||
createOwnerOnlyDirectory(sparrowDir);
|
||||
if(!networkDir.exists()) {
|
||||
createOwnerOnlyDirectory(networkDir);
|
||||
}
|
||||
|
||||
if(!network.getName().equals(network.getHome()) && !isWindows()) {
|
||||
try {
|
||||
Path networkNamePath = getSparrowHome().toPath().resolve(network.getName());
|
||||
Path networkNamePath = applicationDir.toPath().resolve(network.getName());
|
||||
if(Files.isSymbolicLink(networkNamePath)) {
|
||||
Path symlinkTarget = getSparrowHome().toPath().resolve(Files.readSymbolicLink(networkNamePath));
|
||||
if(!Files.isSameFile(sparrowDir.toPath(), symlinkTarget)) {
|
||||
Path symlinkTarget = applicationDir.toPath().resolve(Files.readSymbolicLink(networkNamePath));
|
||||
if(!Files.isSameFile(networkDir.toPath(), symlinkTarget)) {
|
||||
Files.delete(networkNamePath);
|
||||
Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName()));
|
||||
Files.createSymbolicLink(networkNamePath, Path.of(networkDir.getName()));
|
||||
}
|
||||
} else if(!Files.exists(networkNamePath)) {
|
||||
Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName()));
|
||||
Files.createSymbolicLink(networkNamePath, Path.of(networkDir.getName()));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.debug("Error updating symlink from " + network.getName() + " to " + sparrowDir.getName(), e);
|
||||
log.debug("Error updating symlink from " + network.getName() + " to " + networkDir.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
return sparrowDir;
|
||||
return networkDir;
|
||||
}
|
||||
|
||||
public static File getSparrowHome() {
|
||||
return getSparrowHome(false);
|
||||
}
|
||||
|
||||
public static File getSparrowHome(boolean useDefault) {
|
||||
if(!useDefault && System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) {
|
||||
return new File(System.getProperty(SparrowWallet.APP_HOME_PROPERTY));
|
||||
/**
|
||||
* Logs the application directories in use where they do not all resolve to the default application directory.
|
||||
*/
|
||||
public static void logApplicationDirs() {
|
||||
List<ApplicationDir> xdgDirs = Arrays.stream(ApplicationDir.values()).filter(applicationDir -> applicationDir.isXdg(SparrowWallet.APP_NAME)).toList();
|
||||
if(!xdgDirs.isEmpty() && log.isInfoEnabled()) {
|
||||
log.info("Using XDG base directories for " + xdgDirs.stream().map(applicationDir -> applicationDir.toString().toLowerCase(Locale.ROOT)).collect(Collectors.joining(", ")) +
|
||||
" (config: " + getConfigHome() + ", data: " + getDataHome() + ", cache: " + getCacheHome() + ", state: " + getStateHome() + ")");
|
||||
}
|
||||
|
||||
if(isWindows()) {
|
||||
return new File(getHomeDir(), WINDOWS_SPARROW_DIR);
|
||||
}
|
||||
|
||||
return new File(getHomeDir(), SPARROW_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) {
|
||||
|
||||
@@ -68,7 +68,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
String origin = outputDescriptor.toString(true, false, false);
|
||||
|
||||
for(Keystore keystore : exportWallet.getKeystores()) {
|
||||
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
|
||||
if(keystore.getLabel() != null && !keystore.getLabel().isBlank()) {
|
||||
if(exportWallet.getPolicyType() == PolicyType.SINGLE_SP && keystore.getSilentPaymentScanAddress() != null) {
|
||||
labels.add(new Label(Type.spscan, keystore.getSilentPaymentScanAddress().toKeyString(), keystore.getLabel(), null, null));
|
||||
} else if(keystore.getExtendedPublicKey() != null) {
|
||||
@@ -100,7 +100,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
for(Map.Entry<BlockTransactionHashIndex, WalletNode> txoEntry : exportWallet.getWalletTxos().entrySet()) {
|
||||
BlockTransactionHashIndex txo = txoEntry.getKey();
|
||||
WalletNode addressNode = txoEntry.getValue();
|
||||
Boolean spendable = (txo.isSpent() ? null : txo.getStatus() != Status.FROZEN);
|
||||
Boolean spendable = (txo.isSpent() || txo.getStatus() != Status.FROZEN) ? null : Boolean.FALSE;
|
||||
labels.add(new InputOutputLabel(Type.output, txo.toString(), txo.getLabel(), origin, spendable, addressNode.getDerivationPath().substring(1), txo.getValue(),
|
||||
confirmingTxs.contains(txo.getHash()) ? null : txo.getHeight(), txo.getDate(), getFiatValue(txo, fiatRates)));
|
||||
|
||||
@@ -186,10 +186,10 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
}
|
||||
|
||||
if(label.type == Type.output) {
|
||||
if((label.label == null || label.label.isEmpty()) && label.spendable == null) {
|
||||
if((label.label == null || label.label.isBlank()) && label.spendable == null) {
|
||||
continue;
|
||||
}
|
||||
} else if(label.label == null || label.label.isEmpty()) {
|
||||
} else if(label.label == null || label.label.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
|
||||
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|
||||
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
|
||||
if(label.label != null && !label.label.isEmpty()) {
|
||||
if(label.label != null && !label.label.isBlank()) {
|
||||
reference.setLabel(label.label);
|
||||
txioEntry.labelProperty().set(label.label);
|
||||
addChangedEntry(changedWalletEntries, txioEntry);
|
||||
@@ -332,7 +332,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
|
||||
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|
||||
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
|
||||
if(label.label != null && !label.label.isEmpty()) {
|
||||
if(label.label != null && !label.label.isBlank()) {
|
||||
hashIndexEntry.labelProperty().set(label.label);
|
||||
}
|
||||
}
|
||||
@@ -451,6 +451,10 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
}
|
||||
|
||||
private static class Label {
|
||||
public Label() {
|
||||
//required for Gson deserialization
|
||||
}
|
||||
|
||||
public Label(Type type, String ref, String label, String origin, Boolean spendable) {
|
||||
this.type = type;
|
||||
this.ref = ref;
|
||||
@@ -562,7 +566,9 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
public static Origin fromOutputDescriptor(OutputDescriptor outputDescriptor) {
|
||||
Origin origin = new Origin();
|
||||
origin.scriptType = outputDescriptor.getScriptType();
|
||||
origin.keyDerivations = new HashSet<>(outputDescriptor.getExtendedPublicKeysMap().values());
|
||||
origin.keyDerivations = outputDescriptor.getExtendedPublicKeysMap().values().stream()
|
||||
.map(keyDerivation -> new KeyDerivation(keyDerivation.getMasterFingerprint(), KeyDerivation.writePath(keyDerivation.getDerivation())))
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
return origin;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,16 @@ import org.apache.commons.lang3.concurrent.BasicThreadFactory;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
import org.flywaydb.core.api.exception.FlywayValidateException;
|
||||
import org.h2.api.JavaObjectSerializer;
|
||||
import org.h2.mvstore.Cursor;
|
||||
import org.h2.mvstore.MVMap;
|
||||
import org.h2.mvstore.MVStore;
|
||||
import org.h2.mvstore.MVStoreException;
|
||||
import org.h2.mvstore.WriteBuffer;
|
||||
import org.h2.mvstore.type.DataType;
|
||||
import org.h2.mvstore.type.LongDataType;
|
||||
import org.h2.tools.ChangeFileEncryption;
|
||||
import org.h2.util.JdbcUtils;
|
||||
import org.jdbi.v3.core.Jdbi;
|
||||
import org.jdbi.v3.core.h2.H2DatabasePlugin;
|
||||
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
|
||||
@@ -56,6 +65,19 @@ public class DbPersistence implements Persistence {
|
||||
private static final String H2_PASSWORD = "";
|
||||
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
|
||||
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
|
||||
private static final String H2_META_TABLE_MAP = "table.0";
|
||||
private static final Pattern INVALID_SCHEMA_DDL_PATTERN = Pattern.compile("LINKED\\s+TABLE|CREATE\\s+(?:FORCE\\s+|OR\\s+REPLACE\\s+)*(?:TRIGGER|ALIAS|AGGREGATE)"
|
||||
+ "|\\bENGINE\\s+[\"'\\w]|\\b(?:FILE_READ|FILE_WRITE|CSVWRITE|CSVREAD|RUNSCRIPT|LINK_SCHEMA)\\b", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern WALLET_SCHEMA_IDENTIFIER_PATTERN = Pattern.compile("\"wallet_[^\"\\x00-\\x1f]*\"");
|
||||
private static final Map<String, String> VALID_COLUMN_DEFAULTS = Map.of("UTXOMIXDATA.MIXESDONE", "0", "FLYWAY_SCHEMA_HISTORY.INSTALLED_ON", "CURRENT_TIMESTAMP");
|
||||
private static final String H2_BASE_TABLE_CLASS = "org.h2.mvstore.db.MVTable";
|
||||
private static final String H2_ALLOWED_CLASSES_PROPERTY = "h2.allowedClasses";
|
||||
private static final String H2_NO_ALLOWED_CLASSES = "com.sparrowwallet.sparrow.NONE";
|
||||
|
||||
static {
|
||||
System.setProperty(H2_ALLOWED_CLASSES_PROPERTY, H2_NO_ALLOWED_CLASSES);
|
||||
JdbcUtils.serializer = new NoDeserializationSerializer();
|
||||
}
|
||||
|
||||
private HikariDataSource dataSource;
|
||||
private AsymmetricKeyDeriver keyDeriver;
|
||||
@@ -82,25 +104,32 @@ public class DbPersistence implements Persistence {
|
||||
public WalletAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
|
||||
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
|
||||
|
||||
migrate(storage, MASTER_SCHEMA, encryptionKey);
|
||||
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
|
||||
try {
|
||||
validateStore(storage, encryptionKey);
|
||||
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
|
||||
migrate(storage, MASTER_SCHEMA, encryptionKey);
|
||||
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
|
||||
|
||||
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
|
||||
masterWallet = jdbi.withHandle(handle -> {
|
||||
WalletDao walletDao = handle.attach(WalletDao.class);
|
||||
return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
|
||||
});
|
||||
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
|
||||
masterWallet = jdbi.withHandle(handle -> {
|
||||
WalletDao walletDao = handle.attach(WalletDao.class);
|
||||
return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
|
||||
});
|
||||
|
||||
if(masterWallet == null) {
|
||||
throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
|
||||
if(masterWallet == null) {
|
||||
throw new StorageException("The wallet file was corrupted. Check the backups folder for previous copies.");
|
||||
}
|
||||
|
||||
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
|
||||
masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
|
||||
|
||||
createUpdateExecutor(masterWallet);
|
||||
|
||||
return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
|
||||
} catch(StorageException | RuntimeException e) {
|
||||
closeDataSource();
|
||||
throw e;
|
||||
}
|
||||
|
||||
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
|
||||
masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletAndKey::getWallet).collect(Collectors.toList()));
|
||||
|
||||
createUpdateExecutor(masterWallet);
|
||||
|
||||
return new WalletAndKey(masterWallet, encryptionKey, keyDeriver, childWallets);
|
||||
}
|
||||
|
||||
private Map<WalletAndKey, Storage> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws StorageException {
|
||||
@@ -112,6 +141,7 @@ public class DbPersistence implements Persistence {
|
||||
List<String> childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList());
|
||||
Map<WalletAndKey, Storage> childWallets = new TreeMap<>();
|
||||
for(String schema : childSchemas) {
|
||||
validateSchema(storage, schema, encryptionKey);
|
||||
migrate(storage, schema, encryptionKey);
|
||||
validateSchema(storage, schema, encryptionKey);
|
||||
|
||||
@@ -409,6 +439,55 @@ public class DbPersistence implements Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStore(Storage storage, ECKey encryptionKey) throws StorageException {
|
||||
File walletFile = storage.getWalletFile();
|
||||
if(!walletFile.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String filePassword = getFilePassword(encryptionKey);
|
||||
MVStore.Builder builder = new MVStore.Builder().fileName(walletFile.getAbsolutePath()).readOnly();
|
||||
if(filePassword != null) {
|
||||
builder.encryptionKey(filePassword.toCharArray());
|
||||
}
|
||||
|
||||
boolean storeOpened = false;
|
||||
byte[] metaPayload = null;
|
||||
try(MVStore store = builder.open()) {
|
||||
storeOpened = true;
|
||||
if(store.getMapNames().contains(H2_META_TABLE_MAP)) {
|
||||
ByteArrayOutputStream payload = new ByteArrayOutputStream();
|
||||
PageCapture capture = new PageCapture(payload);
|
||||
MVMap<Long, Object> metaTable = store.openMap(H2_META_TABLE_MAP, new MVMap.Builder<Long, Object>().keyType(LongDataType.INSTANCE).valueType(capture));
|
||||
Cursor<Long, Object> cursor = metaTable.cursor(null);
|
||||
while(cursor.hasNext()) {
|
||||
cursor.next();
|
||||
}
|
||||
|
||||
metaPayload = payload.toByteArray();
|
||||
}
|
||||
} catch(MVStoreException e) {
|
||||
if(metaPayload != null) {
|
||||
log.warn("Error closing wallet file store after validation", e);
|
||||
} else if(!storeOpened) {
|
||||
log.debug("Could not open wallet file store for validation, deferring to standard open", e);
|
||||
return;
|
||||
} else {
|
||||
throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
|
||||
}
|
||||
}
|
||||
|
||||
if(metaPayload == null) {
|
||||
throw new StorageException("This is not a valid wallet file.\n\nWallet file could not be validated.");
|
||||
}
|
||||
|
||||
String rawDdl = new String(metaPayload, StandardCharsets.ISO_8859_1);
|
||||
String schemaDdl = WALLET_SCHEMA_IDENTIFIER_PATTERN.matcher(rawDdl).replaceAll(" ").replaceAll("[^A-Za-z0-9_]", " ");
|
||||
if(INVALID_SCHEMA_DDL_PATTERN.matcher(schemaDdl).find()) {
|
||||
throw new StorageException("This is not a valid wallet file.\n\nWallet file contains unexpected database objects.");
|
||||
}
|
||||
}
|
||||
|
||||
private void migrate(Storage storage, String schema, ECKey encryptionKey) throws StorageException {
|
||||
File migrationDir = getMigrationDir();
|
||||
try {
|
||||
@@ -445,11 +524,29 @@ public class DbPersistence implements Persistence {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected check constraints: " + String.join(", ", checkConstraints) + "."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> nonBaseTables = handle.createQuery("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
|
||||
+ "AND TABLE_TYPE <> 'BASE TABLE' AND UPPER(TABLE_NAME) <> 'FLYWAY_SCHEMA_HISTORY'").bind("schema", schema).mapToMap().list();
|
||||
List<String> nonBaseTables = handle.createQuery("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
|
||||
+ "AND TABLE_TYPE <> 'BASE TABLE'").bind("schema", schema)
|
||||
.map((rs, ctx) -> rs.getString("TABLE_NAME") + " (" + rs.getString("TABLE_TYPE") + ")").list();
|
||||
if(!nonBaseTables.isEmpty()) {
|
||||
String detail = nonBaseTables.stream().map(m -> m.get("TABLE_NAME") + " (" + m.get("TABLE_TYPE") + ")").collect(Collectors.joining(", "));
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected database object types: " + detail + "."));
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected database object types: " + String.join(", ", nonBaseTables) + "."));
|
||||
}
|
||||
|
||||
List<String> linkedTables = handle.createQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND STORAGE_TYPE = 'TABLE LINK'")
|
||||
.bind("schema", schema).mapTo(String.class).list();
|
||||
if(!linkedTables.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected linked tables: " + String.join(", ", linkedTables) + "."));
|
||||
}
|
||||
|
||||
List<String> engineTables = handle.createQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
|
||||
+ "AND TABLE_TYPE = 'BASE TABLE' AND (TABLE_CLASS IS NULL OR TABLE_CLASS <> :tableClass)").bind("schema", schema).bind("tableClass", H2_BASE_TABLE_CLASS).mapTo(String.class).list();
|
||||
if(!engineTables.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected table storage engines: " + String.join(", ", engineTables) + "."));
|
||||
}
|
||||
|
||||
List<String> synonyms = handle.createQuery("SELECT SYNONYM_NAME FROM INFORMATION_SCHEMA.SYNONYMS WHERE UPPER(SYNONYM_SCHEMA) = UPPER(:schema)")
|
||||
.bind("schema", schema).mapTo(String.class).list();
|
||||
if(!synonyms.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected synonyms: " + String.join(", ", synonyms) + "."));
|
||||
}
|
||||
|
||||
List<String> generatedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND GENERATION_EXPRESSION IS NOT NULL")
|
||||
@@ -458,10 +555,36 @@ public class DbPersistence implements Persistence {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected generated columns: " + String.join(", ", generatedColumns) + "."));
|
||||
}
|
||||
|
||||
List<String[]> defaultColumns = handle.createQuery("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_DEFAULT, COLUMN_ON_UPDATE FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND (COLUMN_DEFAULT IS NOT NULL OR COLUMN_ON_UPDATE IS NOT NULL)").bind("schema", schema)
|
||||
.map((rs, ctx) -> new String[] {rs.getString("TABLE_NAME"), rs.getString("COLUMN_NAME"), rs.getString("COLUMN_DEFAULT"), rs.getString("COLUMN_ON_UPDATE")}).list();
|
||||
List<String> unexpectedDefaults = new ArrayList<>();
|
||||
for(String[] column : defaultColumns) {
|
||||
String qualifiedName = column[0] + "." + column[1];
|
||||
if(column[3] != null || !Objects.equals(VALID_COLUMN_DEFAULTS.get(qualifiedName.toUpperCase(Locale.ROOT)), column[2])) {
|
||||
unexpectedDefaults.add(qualifiedName);
|
||||
}
|
||||
}
|
||||
if(!unexpectedDefaults.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected column default or update expressions: " + String.join(", ", unexpectedDefaults) + "."));
|
||||
}
|
||||
|
||||
List<String> domains = handle.createQuery("SELECT DOMAIN_NAME FROM INFORMATION_SCHEMA.DOMAINS WHERE DOMAIN_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
|
||||
if(!domains.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected database domains: " + String.join(", ", domains) + "."));
|
||||
}
|
||||
|
||||
List<String> constants = handle.createQuery("SELECT CONSTANT_NAME FROM INFORMATION_SCHEMA.CONSTANTS WHERE CONSTANT_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
|
||||
if(!constants.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected database constants: " + String.join(", ", constants) + "."));
|
||||
}
|
||||
|
||||
List<String> serializedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND DATA_TYPE = 'JAVA_OBJECT' "
|
||||
+ "UNION SELECT OBJECT_NAME FROM INFORMATION_SCHEMA.ELEMENT_TYPES WHERE UPPER(OBJECT_SCHEMA) = UPPER(:schema) AND OBJECT_TYPE = 'TABLE' AND DATA_TYPE = 'JAVA_OBJECT'")
|
||||
.bind("schema", schema).mapTo(String.class).list();
|
||||
if(!serializedColumns.isEmpty()) {
|
||||
throw new RuntimeException(new StorageException("Wallet file contains unexpected serialized object columns: " + String.join(", ", serializedColumns) + "."));
|
||||
}
|
||||
});
|
||||
} catch(RuntimeException e) {
|
||||
if(e.getCause() instanceof StorageException) {
|
||||
@@ -944,4 +1067,77 @@ public class DbPersistence implements Persistence {
|
||||
"\nSilent payment addresses:" + silentPaymentAddresses;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoDeserializationSerializer implements JavaObjectSerializer {
|
||||
@Override
|
||||
public byte[] serialize(Object obj) {
|
||||
throw new UnsupportedOperationException("Serialization of Java objects is not supported in wallet files.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object deserialize(byte[] bytes) {
|
||||
throw new UnsupportedOperationException("Deserialization of Java objects is not supported in wallet files.");
|
||||
}
|
||||
}
|
||||
|
||||
private static class PageCapture implements DataType<Object> {
|
||||
private final ByteArrayOutputStream payload;
|
||||
|
||||
public PageCapture(ByteArrayOutputStream payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
private void capture(ByteBuffer buffer) {
|
||||
int remaining = buffer.remaining();
|
||||
if(remaining > 0) {
|
||||
byte[] bytes = new byte[remaining];
|
||||
buffer.get(bytes);
|
||||
payload.write(bytes, 0, bytes.length);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object read(ByteBuffer buffer) {
|
||||
capture(buffer);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void read(ByteBuffer buffer, Object storage, int len) {
|
||||
capture(buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(WriteBuffer buffer, Object obj) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(WriteBuffer buffer, Object storage, int len) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(Object a, Object b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int binarySearch(Object key, Object storage, int size, int initialGuess) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMemory(Object obj) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMemoryEstimationAllowed() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object[] createStorage(int size) {
|
||||
return new Object[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ public class KeycardCommandSet {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
|
||||
* Returns the application info as stored from the last sent SELECT command. Returns null if no successful SELECT
|
||||
* command has been sent using this command set.
|
||||
*
|
||||
* @return the application info object
|
||||
|
||||
@@ -27,7 +27,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
public void initializeView() {
|
||||
List<KeystoreFileImport> fileImporters = Collections.emptyList();
|
||||
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE_HD) || getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE_SP)) {
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault(), new KeycardShellSinglesig());
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault(), new KeycardShellSinglesig(), new EraSinglesig());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI_HD)) {
|
||||
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new JadeMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new KeycardShellMultisig());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
@@ -69,19 +70,17 @@ public class Auth47 {
|
||||
this.srbnName = srbnUrl.getUserInfo();
|
||||
this.callback = new URI(HTTPS_PROTOCOL + srbnUrl.getHost()).toURL();
|
||||
} else {
|
||||
this.callback = new URI(strCallback).toURL();
|
||||
URI callbackUri = new URI(strCallback);
|
||||
if(!Utils.isSecureUrl(callbackUri)) {
|
||||
throw new IllegalArgumentException("Invalid callback parameter (not https, http .onion or srbn): " + strCallback);
|
||||
}
|
||||
this.callback = callbackUri.toURL();
|
||||
}
|
||||
|
||||
this.expiry = parameterMap.get("e");
|
||||
this.resource = parameterMap.get("r");
|
||||
if(resource == null) {
|
||||
if(srbn) {
|
||||
this.resource = "srbn";
|
||||
} else if(strCallback.startsWith("http")) {
|
||||
this.resource = strCallback;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid callback parameter (not http/s or srbn): " + strCallback);
|
||||
}
|
||||
this.resource = srbn ? "srbn" : strCallback;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -877,6 +877,11 @@ public class ElectrumServer {
|
||||
|
||||
for(BlockTransactionHash reference : references.keySet()) {
|
||||
Transaction transaction = references.get(reference);
|
||||
if(transaction == null) {
|
||||
transactionMap.put(reference.getHash(), UNFETCHABLE_BLOCK_TRANSACTION);
|
||||
checkReferences.removeIf(ref -> ref.getHash().equals(reference.getHash()));
|
||||
continue;
|
||||
}
|
||||
|
||||
Date blockDate = null;
|
||||
if(reference.getHeight() > 0) {
|
||||
@@ -890,7 +895,7 @@ public class ElectrumServer {
|
||||
}
|
||||
|
||||
Long fee = reference.getFee();
|
||||
if(fee == null) {
|
||||
if(fee == null && wallet != null) {
|
||||
BlockTransaction cached = wallet.getWalletTransaction(reference.getHash());
|
||||
if(cached != null && cached.getFee() != null) {
|
||||
fee = cached.getFee();
|
||||
|
||||
@@ -41,9 +41,17 @@ public class LnurlAuth {
|
||||
public LnurlAuth(URI uri) throws MalformedURLException, URISyntaxException {
|
||||
String lnurl = uri.getSchemeSpecificPart();
|
||||
Bech32.Bech32Data bech32 = Bech32.decode(lnurl, 2000);
|
||||
if(!"lnurl".equals(bech32.hrp)) {
|
||||
throw new IllegalArgumentException("LNURL-auth bech32 prefix must be lnurl");
|
||||
}
|
||||
|
||||
byte[] urlBytes = Bech32.convertBits(bech32.data, 0, bech32.data.length, 5, 8, false);
|
||||
String strUrl = new String(urlBytes, StandardCharsets.UTF_8);
|
||||
this.url = new URI(strUrl).toURL();
|
||||
URI decodedUri = new URI(strUrl);
|
||||
if(!Utils.isSecureUrl(decodedUri)) {
|
||||
throw new IllegalArgumentException("LNURL-auth URL must be https or http .onion");
|
||||
}
|
||||
this.url = decodedUri.toURL();
|
||||
|
||||
Map<String, String> parameterMap = new LinkedHashMap<>();
|
||||
String query = url.getQuery();
|
||||
|
||||
@@ -40,15 +40,16 @@ public class Tor implements Closeable {
|
||||
private IPSocketAddress socksAddress;
|
||||
|
||||
public Tor(OnEvent<TorListeners> listener) {
|
||||
Path path = Path.of(Storage.getSparrowHome().getAbsolutePath()).resolve(TOR_DIR);
|
||||
File oldInstallDir = path.resolve(WORK_DIR).resolve(OLD_INSTALL_DIR).toFile();
|
||||
Path workPath = Path.of(Storage.getStateHome().getAbsolutePath()).resolve(TOR_DIR).resolve(WORK_DIR);
|
||||
Path cachePath = Path.of(Storage.getCacheHome().getAbsolutePath()).resolve(TOR_DIR).resolve(CACHE_DIR);
|
||||
File oldInstallDir = workPath.resolve(OLD_INSTALL_DIR).toFile();
|
||||
if(oldInstallDir.exists()) {
|
||||
IOUtils.deleteDirectory(oldInstallDir);
|
||||
}
|
||||
|
||||
TorRuntime.Environment env = TorRuntime.Environment.Builder(
|
||||
path.resolve(WORK_DIR).toFile(),
|
||||
path.resolve(CACHE_DIR).toFile(),
|
||||
workPath.toFile(),
|
||||
cachePath.toFile(),
|
||||
ResourceLoaderTorExec::getOrCreate
|
||||
);
|
||||
|
||||
|
||||
@@ -6,12 +6,19 @@ import com.sparrowwallet.sparrow.AppServices;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.*;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.FileSystemException;
|
||||
import java.nio.file.Files;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -19,13 +26,33 @@ public class TorUtils {
|
||||
private static final Logger log = LoggerFactory.getLogger(TorUtils.class);
|
||||
private static final Pattern TOR_OK = Pattern.compile("^2\\d{2}[ -]OK$");
|
||||
private static final Pattern TOR_AUTH_METHODS = Pattern.compile("^2\\d{2}[ -]AUTH METHODS=(\\S+)\\s?(COOKIEFILE=\"?(.+?)\"?)?$");
|
||||
private static final Pattern TOR_AUTH_CHALLENGE = Pattern.compile("^2\\d{2}[ -]AUTHCHALLENGE SERVERHASH=([0-9A-Fa-f]{64}) SERVERNONCE=([0-9A-Fa-f]{64})$");
|
||||
|
||||
private static final int COOKIE_LENGTH = 32;
|
||||
private static final int CLIENT_NONCE_LENGTH = 32;
|
||||
private static final byte[] SERVER_HASH_KEY = "Tor safe cookie authentication server-to-controller hash".getBytes(StandardCharsets.US_ASCII);
|
||||
private static final byte[] CLIENT_HASH_KEY = "Tor safe cookie authentication controller-to-server hash".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public static void changeIdentity(HostAndPort proxy) {
|
||||
if(AppServices.isTorRunning()) {
|
||||
Tor.getDefault().changeIdentity();
|
||||
} else {
|
||||
HostAndPort control = HostAndPort.fromParts(proxy.getHost(), proxy.getPort() + 1);
|
||||
try(Socket socket = new Socket(control.getHost(), control.getPort())) {
|
||||
InetAddress controlAddress;
|
||||
try {
|
||||
controlAddress = InetAddress.getByName(control.getHost());
|
||||
} catch(IOException e) {
|
||||
log.warn("Cannot resolve Tor ControlPort host " + control.getHost());
|
||||
return;
|
||||
}
|
||||
|
||||
//A Tor ControlPort is a loopback-only trust boundary; never open a plaintext control connection (and never read a cookie file) for a remote proxy
|
||||
if(!controlAddress.isLoopbackAddress()) {
|
||||
log.warn("Refusing to connect to non-loopback Tor ControlPort at " + control);
|
||||
return;
|
||||
}
|
||||
|
||||
try(Socket socket = new Socket(controlAddress, control.getPort())) {
|
||||
socket.setSoTimeout(1500);
|
||||
if(authenticate(socket)) {
|
||||
writeNewNym(socket);
|
||||
@@ -48,12 +75,13 @@ public class TorUtils {
|
||||
socket.getOutputStream().write("PROTOCOLINFO\r\n".getBytes());
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
|
||||
String line;
|
||||
String methods = "";
|
||||
File cookieFile = null;
|
||||
while((line = reader.readLine()) != null) {
|
||||
Matcher authMatcher = TOR_AUTH_METHODS.matcher(line);
|
||||
if(authMatcher.matches()) {
|
||||
String methods = authMatcher.group(1);
|
||||
if(methods.contains("COOKIE") && !authMatcher.group(3).isEmpty()) {
|
||||
methods = authMatcher.group(1);
|
||||
if(methods.contains("COOKIE") && authMatcher.group(3) != null && !authMatcher.group(3).isEmpty()) {
|
||||
cookieFile = new File(authMatcher.group(3));
|
||||
}
|
||||
}
|
||||
@@ -62,22 +90,69 @@ public class TorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if(cookieFile != null && cookieFile.exists()) {
|
||||
byte[] cookieBytes = Files.readAllBytes(cookieFile.toPath());
|
||||
String authentication = "AUTHENTICATE " + Utils.bytesToHex(cookieBytes) + "\r\n";
|
||||
socket.getOutputStream().write(authentication.getBytes());
|
||||
//Only use SAFECOOKIE, which authenticates via an HMAC challenge-response and never transmits the cookie contents. The deprecated COOKIE method is deliberately not supported.
|
||||
if(methods.contains("SAFECOOKIE") && cookieFile != null) {
|
||||
authenticateSafeCookie(socket, reader, cookieFile);
|
||||
} else {
|
||||
socket.getOutputStream().write("AUTHENTICATE \"\"\r\n".getBytes());
|
||||
}
|
||||
|
||||
line = reader.readLine();
|
||||
if(TOR_OK.matcher(line).matches()) {
|
||||
if(line != null && TOR_OK.matcher(line).matches()) {
|
||||
return true;
|
||||
} else {
|
||||
throw new TorAuthenticationException(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static void authenticateSafeCookie(Socket socket, BufferedReader reader, File cookieFile) throws IOException, TorAuthenticationException {
|
||||
if(!cookieFile.exists()) {
|
||||
throw new TorAuthenticationException("Cookie file " + cookieFile + " does not exist");
|
||||
}
|
||||
if(Files.size(cookieFile.toPath()) != COOKIE_LENGTH) {
|
||||
throw new TorAuthenticationException("Cookie file " + cookieFile + " is not a valid Tor cookie");
|
||||
}
|
||||
|
||||
byte[] cookieBytes = Files.readAllBytes(cookieFile.toPath());
|
||||
if(cookieBytes.length != COOKIE_LENGTH) {
|
||||
throw new TorAuthenticationException("Cookie file " + cookieFile + " is not a valid Tor cookie");
|
||||
}
|
||||
|
||||
byte[] clientNonce = new byte[CLIENT_NONCE_LENGTH];
|
||||
new SecureRandom().nextBytes(clientNonce);
|
||||
|
||||
socket.getOutputStream().write(("AUTHCHALLENGE SAFECOOKIE " + Utils.bytesToHex(clientNonce) + "\r\n").getBytes());
|
||||
String line = reader.readLine();
|
||||
Matcher challengeMatcher = line == null ? null : TOR_AUTH_CHALLENGE.matcher(line);
|
||||
if(challengeMatcher == null || !challengeMatcher.matches()) {
|
||||
throw new TorAuthenticationException(line);
|
||||
}
|
||||
|
||||
byte[] serverHash = Utils.hexToBytes(challengeMatcher.group(1));
|
||||
byte[] serverNonce = Utils.hexToBytes(challengeMatcher.group(2));
|
||||
byte[] hmacMessage = Utils.concat(Utils.concat(cookieBytes, clientNonce), serverNonce);
|
||||
|
||||
//Verify the server proves it already knows the cookie before sending our own hash, so a peer that cannot read the cookie file learns nothing derived from it.
|
||||
byte[] expectedServerHash = hmacSha256(SERVER_HASH_KEY, hmacMessage);
|
||||
if(!MessageDigest.isEqual(expectedServerHash, serverHash)) {
|
||||
throw new TorAuthenticationException("Tor ControlPort failed SAFECOOKIE server hash verification");
|
||||
}
|
||||
|
||||
byte[] clientHash = hmacSha256(CLIENT_HASH_KEY, hmacMessage);
|
||||
socket.getOutputStream().write(("AUTHENTICATE " + Utils.bytesToHex(clientHash) + "\r\n").getBytes());
|
||||
}
|
||||
|
||||
private static byte[] hmacSha256(byte[] key, byte[] message) throws IOException {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
|
||||
return mac.doFinal(message);
|
||||
} catch(GeneralSecurityException e) {
|
||||
throw new IOException("Could not compute SAFECOOKIE HMAC", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeNewNym(Socket socket) throws IOException {
|
||||
log.debug("Sending NEWNYM to " + socket);
|
||||
socket.getOutputStream().write("SIGNAL NEWNYM\r\n".getBytes());
|
||||
|
||||
@@ -1037,9 +1037,9 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.transaction != null) {
|
||||
EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction));
|
||||
EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction, headersForm.getPsbt()));
|
||||
} else if(result.psbt != null) {
|
||||
EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt));
|
||||
EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt, headersForm.getPsbt()));
|
||||
} else if(result.seed != null) {
|
||||
signFromSeed(result.seed);
|
||||
} else if(result.exception != null) {
|
||||
@@ -1110,7 +1110,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
ToggleButton toggleButton = (ToggleButton)event.getSource();
|
||||
toggleButton.setSelected(false);
|
||||
|
||||
EventManager.get().post(new RequestTransactionOpenEvent(toggleButton.getScene().getWindow()));
|
||||
EventManager.get().post(new RequestTransactionOpenEvent(toggleButton.getScene().getWindow(), null, headersForm.getPsbt()));
|
||||
}
|
||||
|
||||
public void signPSBT(ActionEvent event) {
|
||||
@@ -1455,21 +1455,10 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Address, SilentPaymentAddress> pending = new LinkedHashMap<>();
|
||||
for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) {
|
||||
SilentPaymentAddress spAddress = psbtOutput.getSilentPaymentAddress();
|
||||
if(spAddress != null) {
|
||||
Script script = psbtOutput.getScript();
|
||||
Address address = script == null ? null : script.getToAddress();
|
||||
if(address == null) {
|
||||
return;
|
||||
}
|
||||
pending.put(address, spAddress);
|
||||
}
|
||||
}
|
||||
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
|
||||
|
||||
boolean changed = false;
|
||||
for(Map.Entry<Address, SilentPaymentAddress> entry : pending.entrySet()) {
|
||||
for(Map.Entry<Address, SilentPaymentAddress> entry : verified.entrySet()) {
|
||||
if(!entry.getValue().equals(wallet.getSilentPaymentAddress(entry.getKey()))) {
|
||||
wallet.addSilentPaymentAddress(entry.getKey(), entry.getValue());
|
||||
changed = true;
|
||||
@@ -1893,4 +1882,4 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
return wallet.getKeystores().stream().map(keystore -> sourceOrder.indexOf(keystore.getSource())).mapToInt(v -> v).max().orElse(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +125,8 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
walletForm.getWallet().setPolicyType(policyType);
|
||||
|
||||
scriptType.setItems(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(policyType)));
|
||||
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
|
||||
|
||||
if(!initialising) {
|
||||
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
|
||||
clearKeystoreTabs();
|
||||
}
|
||||
initialising = false;
|
||||
|
||||
@@ -245,8 +245,11 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
|
||||
|
||||
public Long getVSizeFromTip() {
|
||||
if(!AppServices.getMempoolHistogram().isEmpty()) {
|
||||
Double feeRate = blockTransaction.getFeeRate();
|
||||
if(feeRate == null) {
|
||||
return null;
|
||||
}
|
||||
Set<MempoolRateSize> rateSizes = AppServices.getMempoolHistogram().get(AppServices.getMempoolHistogram().lastKey());
|
||||
double feeRate = blockTransaction.getFeeRate();
|
||||
return rateSizes.stream().filter(rateSize -> rateSize.getFee() > feeRate).mapToLong(MempoolRateSize::getVSize).sum();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
@@ -9,6 +10,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
public class WalletUtxosEntry extends Entry {
|
||||
public static final int DUST_ATTACK_THRESHOLD_SATS = 1000;
|
||||
public static final int DUST_ATTACK_THRESHOLD_SP_SATS = 5000;
|
||||
|
||||
public WalletUtxosEntry(Wallet wallet) {
|
||||
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
|
||||
@@ -50,14 +52,22 @@ public class WalletUtxosEntry extends Entry {
|
||||
}
|
||||
|
||||
protected void calculateDust() {
|
||||
long dustAttackThreshold = Config.get().getDustAttackThreshold();
|
||||
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
|
||||
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
|
||||
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
|
||||
if(getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
|
||||
long dustAttackThreshold = Config.get().getDustAttackThresholdSp();
|
||||
for(Entry entry : getChildren()) {
|
||||
UtxoEntry utxoEntry = (UtxoEntry) entry;
|
||||
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
|
||||
}
|
||||
} else {
|
||||
long dustAttackThreshold = Config.get().getDustAttackThreshold();
|
||||
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
|
||||
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
|
||||
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
|
||||
|
||||
for(Entry entry : getChildren()) {
|
||||
UtxoEntry utxoEntry = (UtxoEntry) entry;
|
||||
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
|
||||
for(Entry entry : getChildren()) {
|
||||
UtxoEntry utxoEntry = (UtxoEntry) entry;
|
||||
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ open module com.sparrowwallet.sparrow {
|
||||
requires com.h2database;
|
||||
requires com.sparrowwallet.hummingbird;
|
||||
requires org.fxmisc.flowless;
|
||||
requires openpnp.capture.java;
|
||||
requires nsmenufx;
|
||||
requires org.jcommander;
|
||||
requires jul.to.slf4j;
|
||||
@@ -55,6 +54,6 @@ open module com.sparrowwallet.sparrow {
|
||||
requires com.jcraft.jzlib;
|
||||
requires com.sparrowwallet.tern;
|
||||
requires com.sparrowwallet.lark;
|
||||
requires com.sun.jna;
|
||||
requires io.github.doblon8.jzbar;
|
||||
requires io.github.doblon8.openpnp.capture;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
|
||||
<g id="surface1" transform="rotate(-90 7.5 7.5)">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 7.121094 3.109375 L 7.5 3.488281 L 7.878906 3.109375 L 11.890625 7.121094 L 11.511719 7.5 L 11.890625 7.878906 L 7.878906 11.890625 L 7.5 11.511719 L 7.121094 11.890625 L 3.109375 7.878906 L 3.488281 7.5 L 3.109375 7.121094 Z M 7.878906 12.648438 L 8.257812 13.027344 L 13.03125 8.257812 L 12.648438 7.878906 L 13.03125 7.5 L 12.648438 7.121094 L 13.03125 6.742188 L 8.257812 1.972656 L 7.878906 2.351562 L 7.5 1.96875 L 7.121094 2.351562 L 6.742188 1.972656 L 1.972656 6.742188 L 2.351562 7.121094 L 1.96875 7.5 L 2.347656 7.878906 L 1.972656 8.257812 L 6.742188 13.027344 L 7.121094 12.648438 L 7.5 13.03125 Z M 7.878906 12.648438 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 6.726562 8.839844 L 5.023438 7.136719 L 4.371094 7.792969 L 6.214844 9.636719 L 6.726562 10.152344 L 8.910156 7.96875 L 8.253906 7.3125 Z M 6.726562 8.839844 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 7.679688 5.6875 L 5.976562 7.390625 L 6.632812 8.042969 L 8.335938 6.34375 L 9.863281 7.871094 L 10.519531 7.214844 L 8.335938 5.03125 Z M 7.679688 5.6875 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
|
||||
<g id="surface1" transform="rotate(-90 7.5 7.5)">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 7.121094 3.109375 L 7.5 3.488281 L 7.878906 3.109375 L 11.890625 7.121094 L 11.511719 7.5 L 11.890625 7.878906 L 7.878906 11.890625 L 7.5 11.511719 L 7.121094 11.890625 L 3.109375 7.878906 L 3.488281 7.5 L 3.109375 7.121094 Z M 7.878906 12.648438 L 8.257812 13.027344 L 13.03125 8.257812 L 12.648438 7.878906 L 13.03125 7.5 L 12.648438 7.121094 L 13.03125 6.742188 L 8.257812 1.972656 L 7.878906 2.351562 L 7.5 1.96875 L 7.121094 2.351562 L 6.742188 1.972656 L 1.972656 6.742188 L 2.351562 7.121094 L 1.96875 7.5 L 2.347656 7.878906 L 1.972656 8.257812 L 6.742188 13.027344 L 7.121094 12.648438 L 7.5 13.03125 Z M 7.878906 12.648438 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 6.726562 8.839844 L 5.023438 7.136719 L 4.371094 7.792969 L 6.214844 9.636719 L 6.726562 10.152344 L 8.910156 7.96875 L 8.253906 7.3125 Z M 6.726562 8.839844 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 7.679688 5.6875 L 5.976562 7.390625 L 6.632812 8.042969 L 8.335938 6.34375 L 9.863281 7.871094 L 10.519531 7.214844 L 8.335938 5.03125 Z M 7.679688 5.6875 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50px" height="50px" viewBox="0 0 50 50" version="1.1">
|
||||
<g id="surface1" transform="rotate(-90 25 25)">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 23.738281 10.359375 L 25 11.621094 L 26.261719 10.359375 L 39.640625 23.738281 L 38.378906 25 L 39.640625 26.265625 L 26.261719 39.640625 L 25 38.378906 L 23.738281 39.640625 L 10.363281 26.265625 L 11.621094 25.003906 L 10.359375 23.742188 Z M 26.265625 42.167969 L 27.527344 43.429688 L 43.429688 27.527344 L 42.167969 26.265625 L 43.429688 25 L 42.167969 23.738281 L 43.429688 22.472656 L 27.527344 6.570312 L 26.265625 7.832031 L 25 6.570312 L 23.734375 7.835938 L 22.472656 6.574219 L 6.570312 22.476562 L 7.832031 23.742188 L 6.570312 25.003906 L 7.828125 26.265625 L 6.570312 27.523438 L 22.472656 43.429688 L 23.734375 42.167969 L 25 43.433594 Z M 26.265625 42.167969 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 22.421875 29.46875 L 16.75 23.792969 L 14.566406 25.976562 L 20.714844 32.125 L 22.421875 33.835938 L 29.703125 26.554688 L 27.519531 24.371094 Z M 22.421875 29.46875 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(83.92157%,82.352942%,82.352942%);fill-opacity:1;" d="M 25.597656 18.957031 L 19.921875 24.632812 L 22.105469 26.816406 L 27.78125 21.144531 L 32.875 26.238281 L 35.058594 24.054688 L 27.78125 16.773438 Z M 25.597656 18.957031 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50px" height="50px" viewBox="0 0 50 50" version="1.1">
|
||||
<g id="surface1" transform="rotate(-90 25 25)">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 23.738281 10.359375 L 25 11.621094 L 26.261719 10.359375 L 39.640625 23.738281 L 38.378906 25 L 39.640625 26.265625 L 26.261719 39.640625 L 25 38.378906 L 23.738281 39.640625 L 10.363281 26.265625 L 11.621094 25.003906 L 10.359375 23.742188 Z M 26.265625 42.167969 L 27.527344 43.429688 L 43.429688 27.527344 L 42.167969 26.265625 L 43.429688 25 L 42.167969 23.738281 L 43.429688 22.472656 L 27.527344 6.570312 L 26.265625 7.832031 L 25 6.570312 L 23.734375 7.835938 L 22.472656 6.574219 L 6.570312 22.476562 L 7.832031 23.742188 L 6.570312 25.003906 L 7.828125 26.265625 L 6.570312 27.523438 L 22.472656 43.429688 L 23.734375 42.167969 L 25 43.433594 Z M 26.265625 42.167969 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 22.421875 29.46875 L 16.75 23.792969 L 14.566406 25.976562 L 20.714844 32.125 L 22.421875 33.835938 L 29.703125 26.554688 L 27.519531 24.371094 Z M 22.421875 29.46875 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(11.372549%,11.372549%,10.588235%);fill-opacity:1;" d="M 25.597656 18.957031 L 19.921875 24.632812 L 22.105469 26.816406 L 27.78125 21.144531 L 32.875 26.238281 L 35.058594 24.054688 L 27.78125 16.773438 Z M 25.597656 18.957031 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,64 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class DateAxisFormatterTest {
|
||||
private static final long HOUR = 60 * 60 * 1000L;
|
||||
private static final long DAY = 24 * HOUR;
|
||||
private static final long YEAR = 365 * DAY;
|
||||
|
||||
private static String thirdLabel(DateAxisFormatter formatter, long timestamp) {
|
||||
formatter.toString(timestamp);
|
||||
formatter.toString(timestamp);
|
||||
return formatter.toString(timestamp);
|
||||
}
|
||||
|
||||
private static long timestamp(int year, int month, int day) {
|
||||
Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
|
||||
cal.clear();
|
||||
cal.set(year, month, day);
|
||||
return cal.getTimeInMillis();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiYearDurationIncludesFourDigitYear() {
|
||||
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
|
||||
long ts = timestamp(2024, Calendar.AUGUST, 15);
|
||||
String actual = thirdLabel(formatter, ts);
|
||||
String fourDigitYear = new SimpleDateFormat("yyyy").format(new Date(ts));
|
||||
Assertions.assertTrue(actual.contains(fourDigitYear),
|
||||
"Expected 4-digit year " + fourDigitYear + " in label, got: " + actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subYearDurationUsesDayMonth() {
|
||||
DateAxisFormatter formatter = new DateAxisFormatter(30 * DAY);
|
||||
long ts = timestamp(2024, Calendar.AUGUST, 15);
|
||||
Assertions.assertEquals(new SimpleDateFormat("d MMM").format(new Date(ts)),
|
||||
thirdLabel(formatter, ts));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subDayDurationUsesHourMinute() {
|
||||
DateAxisFormatter formatter = new DateAxisFormatter(2 * HOUR);
|
||||
long ts = timestamp(2024, Calendar.AUGUST, 15);
|
||||
Assertions.assertEquals(new SimpleDateFormat("HH:mm").format(new Date(ts)),
|
||||
thirdLabel(formatter, ts));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyThirdLabelIsRendered() {
|
||||
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
|
||||
long ts = timestamp(2024, Calendar.AUGUST, 15);
|
||||
Assertions.assertEquals("", formatter.toString(ts));
|
||||
Assertions.assertEquals("", formatter.toString(ts));
|
||||
Assertions.assertNotEquals("", formatter.toString(ts));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
import java.util.Comparator;
|
||||
|
||||
public class DbPersistenceTest {
|
||||
private Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
tempDir = Files.createTempDirectory("sprw-persistence");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
if(tempDir != null) {
|
||||
Files.walk(tempDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
|
||||
}
|
||||
}
|
||||
|
||||
private File buildWalletFile(String... schemaObjects) throws Exception {
|
||||
String base = tempDir.resolve("wallet").toString();
|
||||
try(Connection connection = DriverManager.getConnection("jdbc:h2:" + base + ";DATABASE_TO_UPPER=false;DB_CLOSE_ON_EXIT=FALSE", "sa", ""); Statement statement = connection.createStatement()) {
|
||||
statement.execute("create schema wallet_master");
|
||||
statement.execute("create table wallet_master.wallet(id identity not null, name varchar(255) not null)");
|
||||
for(String ddl : schemaObjects) {
|
||||
statement.execute(ddl);
|
||||
}
|
||||
}
|
||||
return new File(base + ".mv.db");
|
||||
}
|
||||
|
||||
private void assertRejected(File walletFile) {
|
||||
Storage storage = new Storage(PersistenceType.DB, walletFile);
|
||||
Assertions.assertThrows(StorageException.class, storage::loadUnencryptedWallet);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void linkedTableRejectedWithoutExecuting() throws Exception {
|
||||
File marker = tempDir.resolve("output.csv").toFile();
|
||||
String target = "jdbc:h2:" + tempDir.resolve("external") + ";INIT=CREATE TABLE IF NOT EXISTS PUB(ID INT)\\;CALL CSVWRITE('" + marker.getAbsolutePath() + "','SELECT 1')";
|
||||
File walletFile = buildWalletFile("create force linked table wallet_master.remote('','" + target.replace("'", "''") + "','sa','','PUB')");
|
||||
marker.delete();
|
||||
|
||||
assertRejected(walletFile);
|
||||
Assertions.assertFalse(marker.exists(), "linked table connected during load");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void triggerRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create table wallet_master.t(id int)",
|
||||
"create force trigger wallet_master.tg before insert on wallet_master.t for each row call \"com.sparrowwallet.Missing\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedColumnRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create table wallet_master.g(id int, computed int generated always as (id + 1))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void columnDefaultFunctionRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create table wallet_master.d(id int, x int default LENGTH(FILE_READ('/etc/hostname')))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constantRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create constant wallet_master.k value 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void javaObjectColumnRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create table wallet_master.j(id int, obj OTHER)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkConstraintRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create table wallet_master.c(id int check (LENGTH(FILE_READ('/etc/hostname')) > 0))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void domainRejected() throws Exception {
|
||||
assertRejected(buildWalletFile("create domain wallet_master.dm as int default 0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Date;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WalletLabelsTest {
|
||||
//BIP84 test vector account key at m/84'/0'/0'
|
||||
private static final String XPUB = "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V";
|
||||
private static final String MASTER_FINGERPRINT = "73c5da0a";
|
||||
private static final String ORIGIN = "wpkh([73c5da0a/84h/0h/0h])";
|
||||
|
||||
@TempDir
|
||||
private static Path tempHome;
|
||||
|
||||
@BeforeAll
|
||||
public static void setup() {
|
||||
//Isolate Config.get() from the developer's config, whose settings can otherwise change entry construction (e.g. hiding empty used addresses)
|
||||
System.setProperty(SparrowWallet.APP_HOME_PROPERTY, tempHome.toString());
|
||||
Network.set(Network.MAINNET);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
System.clearProperty(SparrowWallet.APP_HOME_PROPERTY);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExport() throws Exception {
|
||||
TestWallet testWallet = createTestWallet();
|
||||
applyLabels(testWallet);
|
||||
Assertions.assertEquals("bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", testWallet.receiveNode0.getAddress().toString());
|
||||
|
||||
WalletLabels walletLabels = new WalletLabels(List.of(testWallet.walletForm));
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
walletLabels.exportWallet(testWallet.wallet, outputStream, null);
|
||||
|
||||
Map<String, JsonObject> labels = parseLabels(outputStream);
|
||||
String fundingTxid = testWallet.fundingBlkTx.getHashAsString();
|
||||
String spendingTxid = testWallet.spendingBlkTx.getHashAsString();
|
||||
|
||||
JsonObject xpubLabel = labels.get("xpub:" + testWallet.wallet.getKeystores().get(0).getExtendedPublicKey().toString());
|
||||
Assertions.assertEquals("Test Keystore", xpubLabel.get("label").getAsString());
|
||||
|
||||
JsonObject fundingTxLabel = labels.get("tx:" + fundingTxid);
|
||||
Assertions.assertEquals("Funding transaction", fundingTxLabel.get("label").getAsString());
|
||||
Assertions.assertEquals(ORIGIN, fundingTxLabel.get("origin").getAsString());
|
||||
Assertions.assertEquals(850000, fundingTxLabel.get("height").getAsInt());
|
||||
Assertions.assertEquals("2023-11-14T22:13:20Z", fundingTxLabel.get("time").getAsString());
|
||||
Assertions.assertEquals(300000L, fundingTxLabel.get("value").getAsLong());
|
||||
Assertions.assertFalse(fundingTxLabel.has("fee"));
|
||||
|
||||
JsonObject spendingTxLabel = labels.get("tx:" + spendingTxid);
|
||||
Assertions.assertEquals("Spending transaction", spendingTxLabel.get("label").getAsString());
|
||||
Assertions.assertEquals(850001, spendingTxLabel.get("height").getAsInt());
|
||||
Assertions.assertEquals(10000L, spendingTxLabel.get("fee").getAsLong());
|
||||
Assertions.assertEquals(-100000L, spendingTxLabel.get("value").getAsLong());
|
||||
|
||||
JsonObject addr0Label = labels.get("addr:" + testWallet.receiveNode0.getAddress());
|
||||
Assertions.assertEquals("Primary address", addr0Label.get("label").getAsString());
|
||||
Assertions.assertEquals("/0/0", addr0Label.get("keypath").getAsString());
|
||||
List<Integer> addr0Heights = addr0Label.get("heights").getAsJsonArray().asList().stream().map(JsonElement::getAsInt).toList();
|
||||
Assertions.assertEquals(List.of(850000, 850001), addr0Heights);
|
||||
|
||||
JsonObject addr1Label = labels.get("addr:" + testWallet.receiveNode1.getAddress());
|
||||
Assertions.assertEquals("Savings address", addr1Label.get("label").getAsString());
|
||||
Assertions.assertEquals("/0/1", addr1Label.get("keypath").getAsString());
|
||||
Assertions.assertEquals(List.of(850000), addr1Label.get("heights").getAsJsonArray().asList().stream().map(JsonElement::getAsInt).toList());
|
||||
|
||||
JsonObject spentOutputLabel = labels.get("output:" + fundingTxid + ":0");
|
||||
Assertions.assertEquals("Received coins", spentOutputLabel.get("label").getAsString());
|
||||
Assertions.assertEquals("/0/0", spentOutputLabel.get("keypath").getAsString());
|
||||
Assertions.assertEquals(100000L, spentOutputLabel.get("value").getAsLong());
|
||||
Assertions.assertFalse(spentOutputLabel.has("spendable"), "Spendable should be omitted for a spent output");
|
||||
|
||||
JsonObject frozenOutputLabel = labels.get("output:" + fundingTxid + ":1");
|
||||
Assertions.assertEquals("Frozen coins", frozenOutputLabel.get("label").getAsString());
|
||||
Assertions.assertEquals(200000L, frozenOutputLabel.get("value").getAsLong());
|
||||
Assertions.assertFalse(frozenOutputLabel.get("spendable").getAsBoolean(), "Spendable should be false for a frozen output");
|
||||
|
||||
JsonObject inputLabel = labels.get("input:" + spendingTxid + ":0");
|
||||
Assertions.assertEquals("Spent input", inputLabel.get("label").getAsString());
|
||||
Assertions.assertEquals("/0/0", inputLabel.get("keypath").getAsString());
|
||||
Assertions.assertEquals(100000L, inputLabel.get("value").getAsLong());
|
||||
Assertions.assertEquals(850001, inputLabel.get("height").getAsInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testImport() throws Exception {
|
||||
TestWallet testWallet = createTestWallet();
|
||||
testWallet.fundingTxo1.setStatus(Status.FROZEN);
|
||||
|
||||
String fundingTxid = testWallet.fundingBlkTx.getHashAsString();
|
||||
String spendingTxid = testWallet.spendingBlkTx.getHashAsString();
|
||||
String jsonl = String.join("\n",
|
||||
"{\"type\":\"tx\",\"ref\":\"" + fundingTxid + "\",\"label\":\"Funding transaction\",\"origin\":\"wpkh([73c5da0a/84h/0h/0h])\"}",
|
||||
"{\"type\":\"tx\",\"ref\":\"" + spendingTxid + "\",\"label\":\"Wrong origin\",\"origin\":\"wpkh([00000001/84h/0h/0h])\"}",
|
||||
"{\"type\":\"addr\",\"ref\":\"" + testWallet.receiveNode0.getAddress() + "\",\"label\":\"Primary address\",\"origin\":\"wpkh([73C5DA0A/84'/0'/0'])\"}",
|
||||
"{\"type\":\"output\",\"ref\":\"" + fundingTxid + ":1\",\"spendable\":true}",
|
||||
"{\"type\":\"output\",\"ref\":\"" + fundingTxid + ":0\",\"label\":\"Received coins\"}",
|
||||
"{\"type\":\"input\",\"ref\":\"" + spendingTxid + ":0\",\"label\":\"Spent input\"}",
|
||||
"this line is not valid json or csv",
|
||||
spendingTxid + ",Spending transaction");
|
||||
|
||||
WalletLabels walletLabels = new WalletLabels(List.of(testWallet.walletForm));
|
||||
Wallet imported = walletLabels.importWallet(new ByteArrayInputStream(jsonl.getBytes(StandardCharsets.UTF_8)), null);
|
||||
|
||||
Assertions.assertEquals(testWallet.wallet, imported);
|
||||
Assertions.assertEquals("Funding transaction", testWallet.fundingBlkTx.getLabel());
|
||||
Assertions.assertEquals("Spending transaction", testWallet.spendingBlkTx.getLabel(), "CSV fallback label should be applied, mismatched origin label should not");
|
||||
Assertions.assertEquals("Primary address", testWallet.receiveNode0.getLabel());
|
||||
Assertions.assertEquals("Received coins", testWallet.fundingTxo0.getLabel());
|
||||
Assertions.assertEquals("Spent input", testWallet.spendingTxi.getLabel());
|
||||
Assertions.assertNull(testWallet.fundingTxo1.getStatus(), "Spendable true should thaw a frozen output");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRoundTrip() throws Exception {
|
||||
TestWallet sourceWallet = createTestWallet();
|
||||
applyLabels(sourceWallet);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
new WalletLabels(List.of(sourceWallet.walletForm)).exportWallet(sourceWallet.wallet, outputStream, null);
|
||||
|
||||
//Importing an xpub label requires the AppServices singleton, which is not available outside the UI
|
||||
String exported = outputStream.toString(StandardCharsets.UTF_8).lines().filter(line -> !line.contains("\"type\":\"xpub\"")).collect(Collectors.joining("\n"));
|
||||
|
||||
TestWallet destinationWallet = createTestWallet();
|
||||
Assertions.assertNull(destinationWallet.fundingBlkTx.getLabel());
|
||||
new WalletLabels(List.of(destinationWallet.walletForm)).importWallet(new ByteArrayInputStream(exported.getBytes(StandardCharsets.UTF_8)), null);
|
||||
|
||||
Assertions.assertEquals("Funding transaction", destinationWallet.fundingBlkTx.getLabel());
|
||||
Assertions.assertEquals("Spending transaction", destinationWallet.spendingBlkTx.getLabel());
|
||||
Assertions.assertEquals("Primary address", destinationWallet.receiveNode0.getLabel());
|
||||
Assertions.assertEquals("Savings address", destinationWallet.receiveNode1.getLabel());
|
||||
Assertions.assertEquals("Received coins", destinationWallet.fundingTxo0.getLabel());
|
||||
Assertions.assertEquals("Frozen coins", destinationWallet.fundingTxo1.getLabel());
|
||||
Assertions.assertEquals("Spent input", destinationWallet.spendingTxi.getLabel());
|
||||
Assertions.assertEquals(Status.FROZEN, destinationWallet.fundingTxo1.getStatus(), "Frozen status should be restored on import");
|
||||
Assertions.assertNull(destinationWallet.fundingTxo0.getStatus());
|
||||
}
|
||||
|
||||
private void applyLabels(TestWallet testWallet) {
|
||||
testWallet.fundingBlkTx.setLabel("Funding transaction");
|
||||
testWallet.spendingBlkTx.setLabel("Spending transaction");
|
||||
testWallet.receiveNode0.setLabel("Primary address");
|
||||
testWallet.receiveNode1.setLabel("Savings address");
|
||||
testWallet.fundingTxo0.setLabel("Received coins");
|
||||
testWallet.fundingTxo1.setLabel("Frozen coins");
|
||||
testWallet.spendingTxi.setLabel("Spent input");
|
||||
testWallet.fundingTxo1.setStatus(Status.FROZEN);
|
||||
}
|
||||
|
||||
private Map<String, JsonObject> parseLabels(ByteArrayOutputStream outputStream) {
|
||||
return outputStream.toString(StandardCharsets.UTF_8).lines().map(line -> JsonParser.parseString(line).getAsJsonObject())
|
||||
.collect(Collectors.toMap(json -> json.get("type").getAsString() + ":" + json.get("ref").getAsString(), json -> json));
|
||||
}
|
||||
|
||||
private TestWallet createTestWallet() throws Exception {
|
||||
Wallet wallet = new Wallet("Labels Test");
|
||||
wallet.setPolicyType(PolicyType.SINGLE_HD);
|
||||
wallet.setScriptType(ScriptType.P2WPKH);
|
||||
|
||||
Keystore keystore = new Keystore("Test Keystore");
|
||||
keystore.setSource(KeystoreSource.SW_WATCH);
|
||||
keystore.setWalletModel(WalletModel.SPARROW);
|
||||
//Use hardened notation to ensure origin matching normalizes the wallet-side derivation
|
||||
keystore.setKeyDerivation(new KeyDerivation(MASTER_FINGERPRINT, "m/84h/0h/0h"));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(XPUB));
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), null));
|
||||
wallet.setStoredBlockHeight(850010);
|
||||
Assertions.assertTrue(wallet.isValid());
|
||||
|
||||
Iterator<WalletNode> receiveNodes = wallet.getNode(KeyPurpose.RECEIVE).getChildren().iterator();
|
||||
WalletNode receiveNode0 = receiveNodes.next();
|
||||
WalletNode receiveNode1 = receiveNodes.next();
|
||||
|
||||
Date fundingDate = new Date(1700000000000L);
|
||||
Transaction fundingTx = new Transaction();
|
||||
fundingTx.addInput(Sha256Hash.wrap("0000000000000000000000000000000000000000000000000000000000000001"), 0, new Script(new byte[0]));
|
||||
fundingTx.addOutput(100000L, receiveNode0.getAddress());
|
||||
fundingTx.addOutput(200000L, receiveNode1.getAddress());
|
||||
BlockTransaction fundingBlkTx = new BlockTransaction(fundingTx.getTxId(), 850000, fundingDate, null, fundingTx);
|
||||
|
||||
Date spendingDate = new Date(1700086400000L);
|
||||
Transaction spendingTx = new Transaction();
|
||||
spendingTx.addInput(fundingTx.getTxId(), 0, new Script(new byte[0]));
|
||||
spendingTx.addOutput(90000L, Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"));
|
||||
BlockTransaction spendingBlkTx = new BlockTransaction(spendingTx.getTxId(), 850001, spendingDate, null, spendingTx);
|
||||
|
||||
wallet.updateTransactions(Map.of(fundingTx.getTxId(), fundingBlkTx, spendingTx.getTxId(), spendingBlkTx));
|
||||
|
||||
BlockTransactionHashIndex fundingTxo0 = new BlockTransactionHashIndex(fundingTx.getTxId(), 850000, fundingDate, null, 0, 100000L);
|
||||
BlockTransactionHashIndex spendingTxi = new BlockTransactionHashIndex(spendingTx.getTxId(), 850001, spendingDate, null, 0, 100000L);
|
||||
fundingTxo0.setSpentBy(spendingTxi);
|
||||
receiveNode0.getTransactionOutputs().add(fundingTxo0);
|
||||
|
||||
BlockTransactionHashIndex fundingTxo1 = new BlockTransactionHashIndex(fundingTx.getTxId(), 850000, fundingDate, null, 1, 200000L);
|
||||
receiveNode1.getTransactionOutputs().add(fundingTxo1);
|
||||
|
||||
Storage storage = new Storage(PersistenceType.JSON, new File(tempHome.toFile(), "labels-test.json"));
|
||||
WalletForm walletForm = new WalletForm(storage, wallet);
|
||||
|
||||
return new TestWallet(walletForm, wallet, receiveNode0, receiveNode1, fundingBlkTx, spendingBlkTx, fundingTxo0, fundingTxo1, spendingTxi);
|
||||
}
|
||||
|
||||
private static class TestWallet {
|
||||
final WalletForm walletForm;
|
||||
final Wallet wallet;
|
||||
final WalletNode receiveNode0;
|
||||
final WalletNode receiveNode1;
|
||||
final BlockTransaction fundingBlkTx;
|
||||
final BlockTransaction spendingBlkTx;
|
||||
final BlockTransactionHashIndex fundingTxo0;
|
||||
final BlockTransactionHashIndex fundingTxo1;
|
||||
final BlockTransactionHashIndex spendingTxi;
|
||||
|
||||
TestWallet(WalletForm walletForm, Wallet wallet, WalletNode receiveNode0, WalletNode receiveNode1, BlockTransaction fundingBlkTx, BlockTransaction spendingBlkTx,
|
||||
BlockTransactionHashIndex fundingTxo0, BlockTransactionHashIndex fundingTxo1, BlockTransactionHashIndex spendingTxi) {
|
||||
this.walletForm = walletForm;
|
||||
this.wallet = wallet;
|
||||
this.receiveNode0 = receiveNode0;
|
||||
this.receiveNode1 = receiveNode1;
|
||||
this.fundingBlkTx = fundingBlkTx;
|
||||
this.spendingBlkTx = spendingBlkTx;
|
||||
this.fundingTxo0 = fundingTxo0;
|
||||
this.fundingTxo1 = fundingTxo1;
|
||||
this.spendingTxi = spendingTxi;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
public class Auth47Test {
|
||||
@Test
|
||||
public void acceptsHttpsCallbacksWithResource() throws Exception {
|
||||
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=https://example.com/auth&r=example"));
|
||||
|
||||
assertEquals("https", auth47.getCallback().getProtocol());
|
||||
assertEquals("example.com", auth47.getCallback().getHost());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsSrbnCallbacks() throws Exception {
|
||||
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=srbn://alice@relay.example.com"));
|
||||
|
||||
assertEquals("https", auth47.getCallback().getProtocol());
|
||||
assertEquals("relay.example.com", auth47.getCallback().getHost());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsHttpClearnetCallbacks() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new Auth47(new URI("auth47://nonce?c=http://example.com/auth&r=example")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonHttpCallbacksWithResource() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new Auth47(new URI("auth47://nonce?c=file:///tmp/auth47&r=example")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Bech32;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
public class LnurlAuthTest {
|
||||
private static final String K1 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
|
||||
@Test
|
||||
public void acceptsHttpsCallbacks() throws Exception {
|
||||
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("https://example.com/lnurl-auth?tag=login&k1=" + K1));
|
||||
|
||||
assertEquals("example.com", lnurlAuth.getDomain());
|
||||
assertEquals("login to example.com", lnurlAuth.getLoginMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsHttpOnionCallbacks() throws Exception {
|
||||
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/lnurl-auth?tag=login&k1=" + K1));
|
||||
|
||||
assertEquals("abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion", lnurlAuth.getDomain());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsHttpClearnetCallbacks() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new LnurlAuth(lightningUri("http://example.com/lnurl-auth?tag=login&k1=" + K1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonHttpCallbacks() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new LnurlAuth(lightningUri("ftp://example.com/lnurl-auth?tag=login&k1=" + K1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonLnurlBech32Prefix() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
new LnurlAuth(lightningUri("lnurlx", "https://example.com/lnurl-auth?tag=login&k1=" + K1)));
|
||||
}
|
||||
|
||||
private static URI lightningUri(String url) throws Exception {
|
||||
return lightningUri("lnurl", url);
|
||||
}
|
||||
|
||||
private static URI lightningUri(String prefix, String url) throws Exception {
|
||||
byte[] urlBytes = url.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] lnurlData = Bech32.convertBits(urlBytes, 0, urlBytes.length, 8, 5, true);
|
||||
return new URI("lightning:" + Bech32.encode(prefix, Bech32.Encoding.BECH32, lnurlData));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user