Compare commits

..
55 Commits
Author SHA1 Message Date
Craig Raw 0dce4783ef treat whitespace-only labels as blank on label import and export 2026-07-29 13:04:58 +02:00
nroktibandGitHub cf797ea0f4 add option to ignore dust on private key sweep 2026-07-29 12:31:28 +02:00
Craig Raw 969dd72d6d align no usb sp support error message with related exceptions 2026-07-28 10:15:08 +02:00
Craig Raw 2d667d118a add opt-in xdg application directory support 2026-07-28 09:23:58 +02:00
Craig Raw 1b8e9b5229 fix potential date formatting concurrency issue on backup 2026-07-24 09:55:36 +02:00
Craig Raw aeb0eeb11a persistence hardening followup 2026-07-24 09:48:45 +02:00
Craig Raw febece9e9b add support for singlesig era hardware wallet 2026-07-23 12:12:25 +02:00
Craig Raw 432758d22a update scanned keystores to importer type and model on wallet import 2026-07-23 12:11:37 +02:00
Craig Raw 8b410ad005 follow up 2026-07-23 12:08:02 +02:00
Craig Raw abc0a4f2b7 fix typo 2026-07-20 17:04:33 +02:00
Craig Raw 6b30e6a3ec fix ide agent support 2026-07-20 16:40:33 +02:00
Craig Raw a5f705f185 tighten schema validation 2026-07-20 16:39:47 +02:00
Craig Raw d7c726782c use owner only temp directory for wallet import and export 2026-07-20 11:01:40 +02:00
Craig Raw b3d51bce34 update external tor socks proxy control port authentication 2026-07-20 10:12:51 +02:00
Craig Raw d8e7b54cae fix npe comparing unresolved silent payment output 2026-07-16 13:02:57 +02:00
Craig Raw 078af174af check all open tabs when verifying a scanned or loaded transaction matches the originating psbt 2026-07-16 12:32:29 +02:00
Craig Raw 4b8a4594b0 verify scanned or loaded transactions match the originating psbt 2026-07-16 11:55:07 +02:00
Craig Raw 9a870ba522 update wix installer template to remove custom directory chooser 2026-07-15 13:00:55 +02:00
Craig Raw b0591d7afe update wix installer template with capabilities entries 2026-07-15 12:27:05 +02:00
Craig Raw e3db43679f update wix installer template to match stock jdk25 plus registry entries 2026-07-15 11:24:58 +02:00
Craig Raw fb10ac3aa2 reduce unnecessary module lint warnings in workflows 2026-07-13 19:46:19 +02:00
Craig Raw 08b36abb6a add missing key for .module verification issue and remove debug step 2026-07-13 19:25:32 +02:00
Craig Raw fa9194a032 fix debug step for windows 2026-07-13 19:05:52 +02:00
Craig Raw 73897fc53a add debug step to determine cause of failing verification 2026-07-13 19:00:44 +02:00
Craig Raw 72c1d822a4 sha pin all github actions for package workflow 2026-07-13 18:38:05 +02:00
craigrawandGitHub a13016609d initial generation of gradle dependency verification metadata 2026-07-13 18:09:26 +02:00
nzb-tuxxxandGitHub c7798d920e create gradle dependency verification update workflow 2026-07-13 17:27:43 +02:00
doblon8andGitHub 5ae36a549f replace openpnp-capture-java dependency with ffm-based implementation 2026-07-08 09:50:52 +02:00
Craig Raw f7f6b59d6d update beryx jlink plugin to v4.0.2 2026-07-04 12:51:13 +02:00
Craig Raw 771f2dc9bc followup for external classes 2026-07-04 11:56:27 +02:00
Craig Raw a9e144bb0c fix regresssion of json wallet serialization with jdk25 2026-07-04 11:44:03 +02:00
Craig Raw 0b8199948d add wallet labels import and export unit test 2026-07-04 10:12:36 +02:00
Craig Raw f697aa610b fix regression from jdk25 update causing silent skips when importing jsonl wallet label lines 2026-07-03 08:17:28 +02:00
Craig Raw 37bab9f321 change bip329 wallet labels export to only assert spendable false for frozen coins and omit otherwise 2026-07-02 07:49:13 +02:00
Craig Raw 3dc50682a3 upgrade lanterna to v3.1.5 2026-06-23 09:32:44 +02:00
Craig Raw d5b119e338 bump to v2.5.3 2026-05-31 14:24:55 +02:00
Craig Raw b457caa5d2 revise wording for non-default sighash warnings 2026-05-31 12:27:16 +02:00
Craig Raw 61ed816c87 improve verification of psbt sighash types 2026-05-31 11:55:38 +02:00
Craig Raw 9a603d7547 make date axis formatter tests locale-stable 2026-05-30 17:57:05 +02:00
PeterXMRandGitHub ac666545be show full year on balance chart x-axis 2026-05-30 16:54:27 +02:00
Craig Raw c4c77d84e0 use configured unit format in send to many dialog instead of jvm default 2026-05-30 14:04:09 +02:00
Craig Raw 8d126869a6 fix potential off by 1 sat rounding error on imported send to many dialog amounts 2026-05-30 12:32:05 +02:00
Craig Raw 464fade68f improve url validation for auth47 and lnurl-auth 2026-05-30 11:34:53 +02:00
Craig Raw 79517da131 fix potential npes resulting from get transactions 2026-05-28 14:51:24 +02:00
Craig Raw 086297436e followup for precomputed sp outputs 2026-05-26 15:40:31 +02:00
Craig Raw d69e274b18 warn on loading transactions with non-zero outputs of unknown script type 2026-05-26 12:37:27 +02:00
Craig Raw d1e67ad4a0 update lark for hid4java 2026-05-24 19:01:46 +02:00
Craig Raw 37ca98c2b0 implement dust detection for sp wallets on received utxos at a higher default limit 2026-05-24 11:23:33 +02:00
Craig Raw 287c943b44 add custom context menu to signature text area in message sign dialog 2026-05-23 15:17:08 +02:00
Craig Raw cb92f76546 improve loaded psbt verification 2026-05-23 14:52:35 +02:00
Craig Raw 24e9c39cb8 bump to v2.5.2 2026-05-22 18:35:16 +02:00
Craig Raw 754ebf7bbf fix incorrect script type selected in settings on p2tr wallet load 2026-05-22 17:32:41 +02:00
Craig Raw bc7a0be87e update bip322 implementation to match completed spec 2026-05-22 15:00:53 +02:00
Craig Raw 87af1ed9f5 fix potential npe on transaction entry tooltip 2026-05-22 10:28:17 +02:00
Craig Raw da476c9d77 bump to v2.5.1 2026-05-21 13:38:15 +02:00
58 changed files with 6772 additions and 407 deletions
@@ -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
+8 -5
View File
@@ -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: |
+13 -1
View File
@@ -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
View File
@@ -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.3'
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')
}
+1 -1
View File
@@ -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.2"
```
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
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.0</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
+104 -85
View File
@@ -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="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
@@ -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="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
@@ -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="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</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.3";
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()));
}
}
}
+1 -2
View File
@@ -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">
<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">
<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">
<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.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">
<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.5 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));
}
}