1
0
forked from GRIN/grim

Goblin P0-P3 backend: brand reskin, theme tokens, nostr payment subsystem

Infrastructure (P0): deployed nostr-rs-relay (wss://nrelay.us-ea.st) and the
goblin-nip05d NIP-05 service (goblin.st) on us-ea.st with TLS + DNS.

Brand & theme (P1): Goblin name/icon/data-dir (.goblin); three-theme token
system (light/dark/yellow) in gui/theme.rs with colors.rs remapped as a shim;
Geist + Geist Mono fonts; AppConfig theme/density/last_wallet_id.

Nostr subsystem (P2-P3): src/nostr/ with NIP-06 identity (seed-derived,
NIP-49 encrypted), per-wallet rkv archive, guarded ingest policy (never
auto-pays Invoice1; binds replies to the stored counterparty npub), NIP-17
send/receive pipeline, NIP-05 client. Relay traffic routed over the embedded
arti Tor client via a custom WebSocketTransport. Wired into Wallet lifecycle
and the task handler. 26 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-10 01:35:12 -04:00
parent b51a46b943
commit 1848d0c796
68 changed files with 4042 additions and 288 deletions
+58 -58
View File
@@ -97,7 +97,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-android-cargo-${{ hashFiles('**/Cargo.lock') }}
key: goblin-android-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build libs
run: |
rustup -q update
@@ -119,7 +119,7 @@ jobs:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: grim-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: goblin-android-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Setup build
@@ -140,13 +140,13 @@ jobs:
mv ${jni_path}/x86_64 x86_64
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android.apk
name=goblin-${{ needs.version.outputs.v }}-android.apk
mv ${apk_path} "${name}"
- name: Checksum ARM APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-sha256sum.txt
name=goblin-${{ needs.version.outputs.v }}-android.apk
checksum=goblin-${{ needs.version.outputs.v }}-android-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Release x86_64 APK
working-directory: android
@@ -157,7 +157,7 @@ jobs:
mv x86_64 ${jni_path}
./gradlew assembleCiSignedRelease
apk_path=app/build/outputs/apk/ci/signedRelease/app-ci-signedRelease.apk
name=grim-${{ needs.version.outputs.v }}-android-x86_64.apk
name=goblin-${{ needs.version.outputs.v }}-android-x86_64.apk
mv ${apk_path} "${name}"
- name: Save gradle cache
uses: actions/cache/save@v5
@@ -169,15 +169,15 @@ jobs:
- name: Checksum x86_64 APK
working-directory: android
run: |
name=grim-${{ needs.version.outputs.v }}-android.apk
checksum=grim-${{ needs.version.outputs.v }}-android-x86_64-sha256sum.txt
name=goblin-${{ needs.version.outputs.v }}-android.apk
checksum=goblin-${{ needs.version.outputs.v }}-android-x86_64-sha256sum.txt
sha256sum "${name}" > "${checksum}"
- name: Upload artifacts
run: |
mkdir release
mv android/grim* release
mv android/goblin* release
tar -czf android.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file android.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file android.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/android.tar.gz
linux_arm:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -199,7 +199,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
key: goblin-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
@@ -219,7 +219,7 @@ jobs:
key: ${{ steps.cache-cargo-restore.outputs.cache-primary-key }}
- name: Upload artifacts
run: |
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file target/aarch64-unknown-linux-gnu/release/grim ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file target/aarch64-unknown-linux-gnu/release/goblin ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/goblin-linux-arm
linux_arm_appimage:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -229,22 +229,22 @@ jobs:
- uses: actions/checkout@v6
- name: Download Artifact
run: |
wget ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/grim-linux-arm
wget ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/goblin-linux-arm
- name: AppImage
shell: bash
run: |
mkdir release
chmod +x grim-linux-arm
mv grim-linux-arm linux/Grim.AppDir/AppRun
ARCH=aarch64 appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
mv grim-${{ needs.version.outputs.v }}-linux-arm.AppImage release/
chmod +x goblin-linux-arm
mv goblin-linux-arm linux/Goblin.AppDir/AppRun
ARCH=aarch64 appimagetool linux/Goblin.AppDir goblin-${{ needs.version.outputs.v }}-linux-arm.AppImage
mv goblin-${{ needs.version.outputs.v }}-linux-arm.AppImage release/
- name: Checksum AppImage ARM
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-arm.AppImage > grim-${{ needs.version.outputs.v }}-linux-arm-appimage-sha256sum.txt
run: sha256sum goblin-${{ needs.version.outputs.v }}-linux-arm.AppImage > goblin-${{ needs.version.outputs.v }}-linux-arm-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-arm.appimage.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-arm.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-arm.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
linux_x86:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -266,7 +266,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
key: goblin-linux-arm-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup registry
run: |
echo -e '[registries.nexus]\nindex = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"\n[registry]\ndefault = "nexus"\n[source.crates-io]\nreplace-with = "nexus"\n[source.nexus]\nregistry = "sparse+${{ secrets.MAVEN_LOCAL_HOST }}/repository/cargo/"' > ~/.cargo/config.toml
@@ -287,16 +287,16 @@ jobs:
- name: AppImage x86
run: |
mkdir release
mv target/x86_64-unknown-linux-gnu/release/grim linux/Grim.AppDir/AppRun
appimagetool linux/Grim.AppDir grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
mv grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage release/
mv target/x86_64-unknown-linux-gnu/release/goblin linux/Goblin.AppDir/AppRun
appimagetool linux/Goblin.AppDir goblin-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
mv goblin-${{ needs.version.outputs.v }}-linux-x86_64.AppImage release/
- name: Checksum AppImage x86
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage > grim-${{ needs.version.outputs.v }}-linux-x86_64-appimage-sha256sum.txt
run: sha256sum goblin-${{ needs.version.outputs.v }}-linux-x86_64.AppImage > goblin-${{ needs.version.outputs.v }}-linux-x86_64-appimage-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf linux-x86_64.appimage.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-x86_64.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file linux-x86_64.appimage.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
macos:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -319,15 +319,15 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: grim-macos-cargo-${{ hashFiles('**/Cargo.lock') }}
key: goblin-macos-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Release MacOS Universal
run: |
rustup -q update
export MACOSX_DEPLOYMENT_TARGET=11.0
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output grim "target/x86_64-apple-darwin/release/grim" "target/aarch64-apple-darwin/release/grim"
mv grim macos/Grim.app/Contents/MacOS
lipo -create -output goblin "target/x86_64-apple-darwin/release/goblin" "target/aarch64-apple-darwin/release/goblin"
mv goblin macos/Goblin.app/Contents/MacOS
- name: Save cargo cache
uses: actions/cache/save@v5
with:
@@ -341,15 +341,15 @@ jobs:
- name: Archive Universal
working-directory: macos
run: |
zip -r grim-${{ needs.version.outputs.v }}-macos-universal.zip Grim.app
mv grim-${{ needs.version.outputs.v }}-macos-universal.zip ../release
zip -r goblin-${{ needs.version.outputs.v }}-macos-universal.zip Goblin.app
mv goblin-${{ needs.version.outputs.v }}-macos-universal.zip ../release
- name: Checksum Release Universal
working-directory: release
run: sha256sum grim-${{ needs.version.outputs.v }}-macos-universal.zip > grim-${{ needs.version.outputs.v }}-macos-universal-sha256sum.txt
run: sha256sum goblin-${{ needs.version.outputs.v }}-macos-universal.zip > goblin-${{ needs.version.outputs.v }}-macos-universal-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf macos.tar.gz release
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file macos.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file macos.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/macos.tar.gz
windows:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -373,20 +373,20 @@ jobs:
- name: Release Windows x86
run: |
rustup -q update
cargo wix -p grim -o grim-${{ needs.version.outputs.v }}-win-x86_64.msi --nocapture
mv grim-${{ needs.version.outputs.v }}-win-x86_64.msi release\
Compress-Archive -Path target\release\grim.exe -DestinationPath grim-${{ needs.version.outputs.v }}-win-x86_64.zip
mv grim-${{ needs.version.outputs.v }}-win-x86_64.zip release\
cargo wix -p goblin -o goblin-${{ needs.version.outputs.v }}-win-x86_64.msi --nocapture
mv goblin-${{ needs.version.outputs.v }}-win-x86_64.msi release\
Compress-Archive -Path target\release\goblin.exe -DestinationPath goblin-${{ needs.version.outputs.v }}-win-x86_64.zip
mv goblin-${{ needs.version.outputs.v }}-win-x86_64.zip release\
- name: Checksum Archive x86
working-directory: release
run: |
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.msi SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-msi-sha256sum.txt
certutil -hashfile grim-${{ needs.version.outputs.v }}-win-x86_64.zip SHA256 > grim-${{ needs.version.outputs.v }}-win-x86_64-sha256sum.txt
certutil -hashfile goblin-${{ needs.version.outputs.v }}-win-x86_64.msi SHA256 > goblin-${{ needs.version.outputs.v }}-win-x86_64-msi-sha256sum.txt
certutil -hashfile goblin-${{ needs.version.outputs.v }}-win-x86_64.zip SHA256 > goblin-${{ needs.version.outputs.v }}-win-x86_64-sha256sum.txt
- name: Upload artifacts
run: |
tar -czf windows.tar.gz release
Remove-Item alias:curl
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file windows.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
curl -s -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} --upload-file windows.tar.gz ${{ secrets.MAVEN_LOCAL_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/windows.tar.gz
release:
if: ${{ forgejo.ref_type == 'branch' || needs.version.outputs.exists == 'false' }}
@@ -395,19 +395,19 @@ jobs:
steps:
- name: Download All Artifacts
run: |
curl -s -o android.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/android.tar.gz
curl -s -o android.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/android.tar.gz
tar -xzf android.tar.gz
rm android.tar.gz
curl -s -o linux-x86_64.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
curl -s -o linux-x86_64.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/linux-x86_64.appimage.tar.gz
tar -xzf linux-x86_64.appimage.tar.gz
rm linux-x86_64.appimage.tar.gz
curl -s -o linux-arm.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
curl -s -o linux-arm.appimage.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/linux-arm.appimage.tar.gz
tar -xzf linux-arm.appimage.tar.gz
rm linux-arm.appimage.tar.gz
curl -s -o macos.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/macos.tar.gz
curl -s -o macos.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/macos.tar.gz
tar -xzf macos.tar.gz
rm macos.tar.gz
curl -s -o windows.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/grim-ci/${{ forgejo.repository }}/windows.tar.gz
curl -s -o windows.tar.gz -u ${{ secrets.MAVEN_USER }}:${{ secrets.MAVEN_PASSWORD }} ${{ secrets.MAVEN_HOST }}/repository/goblin-ci/${{ forgejo.repository }}/windows.tar.gz
tar -xzf windows.tar.gz
rm windows.tar.gz
- name: Upload release to Forgejo
@@ -447,13 +447,13 @@ jobs:
run: |
mkdir release
cd release
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-macos-universal.zip
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-android.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-android-x86_64.apk
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-linux-arm.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-macos-universal.zip
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-win-x86_64.msi
wget ${{ secrets.REPO_HOST }}/${{ forgejo.repository }}/releases/download/${{ needs.version.outputs.v }}/goblin-${{ needs.version.outputs.v }}-win-x86_64.zip
- name: Upload files to Telegram
uses: actions/telegram-send-file@main
with:
@@ -465,10 +465,10 @@ jobs:
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
pin: true
files: |
release/grim-${{ needs.version.outputs.v }}-android.apk
release/grim-${{ needs.version.outputs.v }}-android-x86_64.apk
release/grim-${{ needs.version.outputs.v }}-linux-arm.AppImage
release/grim-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
release/grim-${{ needs.version.outputs.v }}-macos-universal.zip
release/grim-${{ needs.version.outputs.v }}-win-x86_64.msi
release/grim-${{ needs.version.outputs.v }}-win-x86_64.zip
release/goblin-${{ needs.version.outputs.v }}-android.apk
release/goblin-${{ needs.version.outputs.v }}-android-x86_64.apk
release/goblin-${{ needs.version.outputs.v }}-linux-arm.AppImage
release/goblin-${{ needs.version.outputs.v }}-linux-x86_64.AppImage
release/goblin-${{ needs.version.outputs.v }}-macos-universal.zip
release/goblin-${{ needs.version.outputs.v }}-win-x86_64.msi
release/goblin-${{ needs.version.outputs.v }}-win-x86_64.zip
Generated
+466 -22
View File
@@ -132,6 +132,16 @@ dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array 0.14.7",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -153,7 +163,7 @@ dependencies = [
"age-core",
"base64 0.13.1",
"bech32 0.8.1",
"chacha20poly1305",
"chacha20poly1305 0.9.1",
"cookie-factory",
"hkdf 0.11.0",
"hmac 0.11.0",
@@ -165,7 +175,7 @@ dependencies = [
"rand 0.7.3",
"rand 0.8.5",
"rust-embed",
"scrypt",
"scrypt 0.8.1",
"sha2 0.9.9",
"subtle",
"x25519-dalek 1.1.1",
@@ -179,7 +189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70afa630ef12a4fc666277713efbe6da2bc87bb3f3af0f1149415b701362c615"
dependencies = [
"base64 0.13.1",
"chacha20poly1305",
"chacha20poly1305 0.9.1",
"cookie-factory",
"hkdf 0.11.0",
"nom 7.1.3",
@@ -824,6 +834,37 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "async-utility"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151"
dependencies = [
"futures-util",
"gloo-timers",
"tokio 1.49.0",
"wasm-bindgen-futures",
]
[[package]]
name = "async-wsocket"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069"
dependencies = [
"async-utility",
"futures 0.3.31",
"futures-util",
"js-sys",
"tokio 1.49.0",
"tokio-rustls 0.26.4",
"tokio-socks 0.5.3",
"tokio-tungstenite",
"url",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "async_executors"
version = "0.7.0"
@@ -867,6 +908,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "atomic-destructor"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -1075,6 +1122,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9ff0bbfd639f15c74af777d81383cf53efb7c93613f6cab67c6c11e05bbf8b"
[[package]]
name = "bech32"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]]
name = "bincode"
version = "1.3.3"
@@ -1117,6 +1170,17 @@ dependencies = [
"which",
]
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes 0.12.0",
"serde",
"unicode-normalization",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -1144,6 +1208,12 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitcoin-io"
version = "0.1.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175"
[[package]]
name = "bitcoin-private"
version = "0.1.0"
@@ -1159,6 +1229,17 @@ dependencies = [
"bitcoin-private",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
dependencies = [
"bitcoin-io",
"hex-conservative",
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -1228,7 +1309,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
dependencies = [
"block-padding",
"block-padding 0.1.5",
"byte-tools",
"byteorder",
"generic-array 0.12.4",
@@ -1261,6 +1342,15 @@ dependencies = [
"byte-tools",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -1447,6 +1537,15 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cc"
version = "1.2.55"
@@ -1513,16 +1612,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if 1.0.4",
"cipher 0.4.4",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18446b09be63d457bbec447509e85f662f32952b035ce892290396bc0b0cff5"
dependencies = [
"aead",
"chacha20",
"aead 0.4.3",
"chacha20 0.8.2",
"cipher 0.3.0",
"poly1305",
"poly1305 0.7.2",
"zeroize",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead 0.5.2",
"chacha20 0.9.1",
"cipher 0.4.4",
"poly1305 0.8.0",
"zeroize",
]
@@ -1962,14 +2085,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto-mac"
version = "0.11.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714"
checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
dependencies = [
"generic-array 0.14.7",
"subtle",
@@ -3908,7 +4032,9 @@ dependencies = [
"arboard",
"arti-client",
"async-std",
"async-wsocket",
"backtrace",
"base64 0.22.1",
"built",
"bytes 1.11.1",
"chrono",
@@ -3936,6 +4062,7 @@ dependencies = [
"grin_wallet_impls",
"grin_wallet_libwallet",
"grin_wallet_util",
"hex",
"http-body-util",
"hyper 0.14.32",
"hyper 1.8.1",
@@ -3951,12 +4078,15 @@ dependencies = [
"log",
"log4rs",
"nokhwa",
"nostr-relay-pool",
"nostr-sdk",
"num-bigint 0.4.6",
"parking_lot 0.12.5",
"pin-project",
"qrcode",
"qrcodegen",
"rand 0.9.2",
"regex",
"rfd",
"ring 0.16.20",
"rkv",
@@ -3973,6 +4103,7 @@ dependencies = [
"tls-api-native-tls",
"tokio 0.2.25",
"tokio 1.49.0",
"tokio-tungstenite",
"tokio-util 0.2.0",
"toml 0.9.11+spec-1.1.0",
"tor-config",
@@ -4349,7 +4480,7 @@ dependencies = [
"blake2-rfc",
"bs58",
"byteorder",
"chacha20",
"chacha20 0.8.2",
"chrono",
"curve25519-dalek 2.1.3",
"ed25519-dalek 1.0.1",
@@ -4614,6 +4745,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec 0.7.6",
]
[[package]]
name = "hexf-parse"
version = "0.2.1"
@@ -5299,9 +5439,22 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array 0.14.7",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if 1.0.4",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -6192,6 +6345,12 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "negentropy"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
[[package]]
name = "neli"
version = "0.7.4"
@@ -6356,6 +6515,83 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nostr"
version = "0.44.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a"
dependencies = [
"base64 0.22.1",
"bech32 0.11.1",
"bip39",
"bitcoin_hashes 0.14.100",
"cbc",
"chacha20 0.9.1",
"chacha20poly1305 0.10.1",
"getrandom 0.2.17",
"hex",
"instant",
"scrypt 0.11.0",
"secp256k1",
"serde",
"serde_json",
"unicode-normalization",
"url",
]
[[package]]
name = "nostr-database"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
"lru",
"nostr",
"tokio 1.49.0",
]
[[package]]
name = "nostr-gossip"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
dependencies = [
"nostr",
]
[[package]]
name = "nostr-relay-pool"
version = "0.44.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37"
dependencies = [
"async-utility",
"async-wsocket",
"atomic-destructor",
"hex",
"lru",
"negentropy",
"nostr",
"nostr-database",
"tokio 1.49.0",
"tracing",
]
[[package]]
name = "nostr-sdk"
version = "0.44.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
dependencies = [
"async-utility",
"nostr",
"nostr-database",
"nostr-gossip",
"nostr-relay-pool",
"tokio 1.49.0",
"tracing",
]
[[package]]
name = "notify"
version = "8.2.0"
@@ -7143,9 +7379,20 @@ dependencies = [
[[package]]
name = "password-hash"
version = "0.2.3"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e0b28ace46c5a396546bcf443bf422b57049617433d8854227352a4a9b24e7"
checksum = "c1a5d4e9c205d2c1ae73b84aab6240e98218c0e72e63b50422cfb2d1ca952282"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
@@ -7173,7 +7420,7 @@ dependencies = [
"base64ct",
"crypto-mac",
"hmac 0.11.0",
"password-hash",
"password-hash 0.2.1",
"sha2 0.9.9",
]
@@ -7186,6 +7433,16 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac 0.12.1",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@@ -7440,7 +7697,18 @@ checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede"
dependencies = [
"cpufeatures",
"opaque-debug 0.3.1",
"universal-hash",
"universal-hash 0.4.0",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug 0.3.1",
"universal-hash 0.5.1",
]
[[package]]
@@ -8227,13 +8495,13 @@ dependencies = [
"serde_urlencoded",
"tokio 0.2.25",
"tokio-rustls 0.14.1",
"tokio-socks",
"tokio-socks 0.3.0",
"tokio-tls",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 0.20.0",
"winreg",
]
@@ -8595,6 +8863,20 @@ dependencies = [
"webpki 0.22.4",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring 0.17.14",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.6.3"
@@ -8616,6 +8898,26 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring 0.17.14",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -8674,6 +8976,15 @@ dependencies = [
"cipher 0.3.0",
]
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -8751,7 +9062,19 @@ checksum = "e73d6d7c6311ebdbd9184ad6c4447b2f36337e327bda107d3ba9e3c374f9d325"
dependencies = [
"hmac 0.12.1",
"pbkdf2 0.10.1",
"salsa20",
"salsa20 0.9.0",
"sha2 0.10.9",
]
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash 0.5.0",
"pbkdf2 0.12.2",
"salsa20 0.10.2",
"sha2 0.10.9",
]
@@ -8802,6 +9125,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"rand 0.8.5",
"secp256k1-sys",
"serde",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "secrecy"
version = "0.6.0"
@@ -9456,9 +9799,9 @@ dependencies = [
[[package]]
name = "subtle"
version = "2.4.1"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
@@ -9913,6 +10256,16 @@ dependencies = [
"webpki 0.22.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.40",
"tokio 1.49.0",
]
[[package]]
name = "tokio-socks"
version = "0.3.0"
@@ -9926,6 +10279,18 @@ dependencies = [
"tokio 0.2.25",
]
[[package]]
name = "tokio-socks"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio 1.49.0",
]
[[package]]
name = "tokio-tls"
version = "0.3.1"
@@ -9936,6 +10301,22 @@ dependencies = [
"tokio 0.2.25",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls 0.23.40",
"rustls-pki-types",
"tokio 1.49.0",
"tokio-rustls 0.26.4",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.2.0"
@@ -11372,6 +11753,25 @@ dependencies = [
"core_maths",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes 1.11.1",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.2",
"rustls 0.23.40",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -11475,6 +11875,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -11525,14 +11934,24 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.4.1"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05"
checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402"
dependencies = [
"generic-array 0.14.7",
"subtle",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-any-ors"
version = "1.0.0"
@@ -11572,7 +11991,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010f24a953db5d22d0010969ca3bbf40b3857b89f47c0f7be0da4c2d7ded0760"
dependencies = [
"bitcoin_hashes",
"bitcoin_hashes 0.12.0",
"crc",
"minicbor",
"phf 0.11.3",
@@ -11589,6 +12008,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@@ -11618,6 +12038,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -12091,6 +12517,24 @@ dependencies = [
"webpki 0.21.4",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
+12 -3
View File
@@ -2,15 +2,15 @@
name = "grim"
version = "0.3.6"
authors = ["Ardocrat <ardocrat@gri.mw>"]
description = "Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and tor handled for you."
license = "Apache-2.0"
repository = "https://code.gri.mw/GUI/grim"
keywords = [ "crypto", "grin", "mimblewimble" ]
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
edition = "2024"
build = "build.rs"
[[bin]]
name = "grim"
name = "goblin"
path = "src/main.rs"
[lib]
@@ -91,6 +91,15 @@ async-std = "1.13.2"
uuid = { version = "0.8.2", features = ["v4"] }
num-bigint = "0.4.6"
## nostr
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
nostr-relay-pool = "0.44"
async-wsocket = "0.13"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
base64 = "0.22"
hex = "0.4"
## tor
arti-client = { version = "0.42.0", features = ["static", "pt-client", "onion-service-service", "onion-service-client"] }
tor-rtcompat = { version = "0.42.0", features = ["static"] }
+1 -1
View File
@@ -8,7 +8,7 @@ android {
buildToolsVersion = '36.1.0'
defaultConfig {
applicationId "mw.gri.android"
applicationId "st.goblin.wallet"
minSdk 24
targetSdk 36
versionCode 5
+1 -1
View File
@@ -18,7 +18,7 @@
android:largeHeap="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Grim"
android:label="Goblin"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Main"
android:enableOnBackInvokedCallback="false"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FCEF03</color>
<color name="ic_launcher_background">#FFD60A</color>
</resources>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

@@ -1,7 +1,7 @@
[Desktop Entry]
Name=Grim
Exec=grim
Icon=grim
Name=Goblin
Exec=goblin
Icon=goblin
Type=Application
Categories=Finance
MimeType=application/x-slatepack;text/plain;

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2 -2
View File
@@ -22,6 +22,6 @@ cargo install cargo-zigbuild
cargo zigbuild --release --target ${arch}
# Create AppImage with https://github.com/AppImage/appimagetool
cp target/${arch}/release/grim linux/Grim.AppDir/AppRun
cp target/${arch}/release/goblin linux/Goblin.AppDir/AppRun
rm target/${arch}/release/*.AppImage
appimagetool linux/Grim.AppDir target/${arch}/release/grim-v$2-linux-$1.AppImage
appimagetool linux/Goblin.AppDir target/${arch}/release/goblin-v$2-linux-$1.AppImage
@@ -5,9 +5,9 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Grim</string>
<string>Goblin</string>
<key>CFBundleExecutable</key>
<string>grim</string>
<string>goblin</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Grim</string>
<string>Goblin</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -29,7 +29,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>NSCameraUsageDescription</key>
<string>Grim needs an access to your camera to scan QR code.</string>
<string>Goblin needs an access to your camera to scan QR code.</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
+4 -4
View File
@@ -35,14 +35,14 @@ cargo zigbuild --release --target ${arch}
rm -f .intentionally-empty-file.o
yes | cp -rf target/${arch}/release/grim macos/Grim.app/Contents/MacOS
yes | cp -rf target/${arch}/release/goblin macos/Goblin.app/Contents/MacOS
# Sign .app resources on change:
#rcodesign generate-self-signed-certificate
#rcodesign sign --pem-file cert.pem macos/Grim.app
#rcodesign sign --pem-file cert.pem macos/Goblin.app
# Create release package
FILE_NAME=grim-v$2-macos-$1.zip
FILE_NAME=goblin-v$2-macos-$1.zip
cd macos
zip -r ${FILE_NAME} Grim.app
zip -r ${FILE_NAME} Goblin.app
mv ${FILE_NAME} ../target/${arch}/release
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Generate all Goblin app icons from img/goblin-icon.png (app icon)
# and img/goblin-mask.png (black mascot art on transparency).
# Requires ImageMagick (magick).
set -euo pipefail
cd "$(dirname "$0")/.."
ICON=img/goblin-icon.png
MASK=img/goblin-mask.png
RES=android/app/src/main/res
# Desktop window icon + in-app embeds.
magick "$ICON" -resize 256x256 img/icon.png
magick "$ICON" -resize 512x512 img/goblin-icon-512.png
magick "$MASK" -channel RGB -fill white -colorize 100 img/goblin-mask-white.png
magick img/goblin-mask-white.png -resize 128x128 img/goblin-mask-128.png
magick img/goblin-mask-white.png -resize 64x64 img/goblin-mask-64.png
# Android launcher icons.
declare -A SIZES=( [mdpi]=48 [hdpi]=72 [xhdpi]=96 [xxhdpi]=144 [xxxhdpi]=192 )
declare -A FG_SIZES=( [mdpi]=108 [hdpi]=162 [xhdpi]=216 [xxhdpi]=324 [xxxhdpi]=432 )
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
s=${SIZES[$d]}; fg=${FG_SIZES[$d]}
# mascot occupies ~52% of the adaptive canvas (safe zone is 66%)
art=$(( fg * 52 / 100 ))
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher.png"
magick "$ICON" -resize ${s}x${s} "$RES/mipmap-$d/ic_launcher_round.png"
magick "$MASK" -resize ${art}x${art} -background none \
-gravity center -extent ${fg}x${fg} "$RES/mipmap-$d/ic_launcher_foreground.png"
done
echo "icons generated"
+64 -134
View File
@@ -1,4 +1,5 @@
// Copyright 2023 The Grim Developers
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,95 +13,39 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Legacy color API mapped onto the Goblin design tokens in [`crate::gui::theme`].
//! Existing call sites keep compiling; everything sources from the active theme.
use egui::Color32;
use crate::AppConfig;
use crate::gui::theme;
/// Provides colors values based on current theme.
/// Provides color values based on the current theme tokens.
pub struct Colors;
const WHITE: Color32 = Color32::from_gray(253);
const BLACK: Color32 = Color32::from_gray(12);
const SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(100);
const DARK_SEMI_TRANSPARENT: Color32 = Color32::from_black_alpha(170);
const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
const GOLD_DARK: Color32 = Color32::from_rgb(240, 203, 1);
const INK: Color32 = Color32::from_rgb(0x0E, 0x0E, 0x0C);
const PAPER: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xF7);
const YELLOW: Color32 = Color32::from_rgb(254, 241, 2);
const YELLOW_DARK: Color32 = Color32::from_rgb(239, 229, 3);
const GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
const GREEN_DARK: Color32 = Color32::from_rgb(0, (0x64 as f32 * 1.3 + 0.5) as u8, 0);
const RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
const RED_DARK: Color32 = Color32::from_rgb((0x8B as f32 * 1.3 + 0.5) as u8, 50, 30);
const BLUE: Color32 = Color32::from_rgb(0, 0x66, 0xE4);
const BLUE_DARK: Color32 = Color32::from_rgb(
0,
(0x66 as f32 * 1.3 + 0.5) as u8,
(0xE4 as f32 * 1.3 + 0.5) as u8,
);
const FILL: Color32 = Color32::from_gray(244);
const FILL_DARK: Color32 = Color32::from_gray(26);
const FILL_DEEP_DARK: Color32 = Color32::from_gray(32);
const FILL_LITE: Color32 = Color32::from_gray(249);
const FILL_LITE_DARK: Color32 = Color32::from_gray(21);
const TEXT: Color32 = Color32::from_gray(80);
const TEXT_DARK: Color32 = Color32::from_gray(185);
const CHECKBOX: Color32 = Color32::from_gray(100);
const CHECKBOX_DARK: Color32 = Color32::from_gray(175);
const TEXT_BUTTON: Color32 = Color32::from_gray(70);
const TEXT_BUTTON_DARK: Color32 = Color32::from_gray(195);
const TITLE: Color32 = Color32::from_gray(60);
const TITLE_DARK: Color32 = Color32::from_gray(205);
const GRAY: Color32 = Color32::from_gray(120);
const GRAY_DARK: Color32 = Color32::from_gray(145);
const STROKE_DARK: Color32 = Color32::from_gray(50);
const INACTIVE_TEXT: Color32 = Color32::from_gray(150);
const INACTIVE_TEXT_DARK: Color32 = Color32::from_gray(115);
const ITEM_BUTTON: Color32 = Color32::from_gray(90);
const ITEM_BUTTON_DARK: Color32 = Color32::from_gray(175);
const ITEM_STROKE: Color32 = Color32::from_gray(220);
const ITEM_STROKE_DARK: Color32 = Color32::from_gray(40);
const ITEM_HOVER: Color32 = Color32::from_gray(205);
const ITEM_HOVER_DARK: Color32 = Color32::from_gray(48);
/// Check if dark theme should be used.
fn use_dark() -> bool {
AppConfig::dark_theme().unwrap_or(false)
fn dark_base() -> bool {
theme::tokens().dark_base
}
impl Colors {
pub const FILL_DEEP: Color32 = Color32::from_gray(238);
pub const FILL_DEEP: Color32 = Color32::from_rgb(0xF2, 0xF1, 0xEC);
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const STROKE: Color32 = Color32::from_gray(200);
pub const STROKE: Color32 = Color32::from_rgba_premultiplied(1, 1, 1, 20);
/// Ink when `true`, paper when `false` (theme aware: maps to text/bg).
pub fn white_or_black(black_in_white: bool) -> Color32 {
if use_dark() {
if black_in_white { WHITE } else { BLACK }
} else {
if black_in_white { BLACK } else { WHITE }
}
let t = theme::tokens();
if black_in_white { t.text } else { t.bg }
}
pub fn semi_transparent() -> Color32 {
if use_dark() {
if dark_base() {
DARK_SEMI_TRANSPARENT
} else {
SEMI_TRANSPARENT
@@ -108,130 +53,115 @@ impl Colors {
}
pub fn gold() -> Color32 {
if use_dark() {
GOLD.gamma_multiply(0.9)
} else {
GOLD
}
theme::tokens().accent
}
pub fn gold_dark() -> Color32 {
if use_dark() {
GOLD_DARK.gamma_multiply(0.9)
} else {
GOLD_DARK
}
theme::tokens().accent_dark
}
pub fn yellow() -> Color32 {
YELLOW
theme::tokens().accent
}
pub fn yellow_dark() -> Color32 {
YELLOW_DARK
theme::tokens().accent_dark
}
/// Ink color to draw on top of accent fills.
pub fn accent_ink() -> Color32 {
theme::tokens().accent_ink
}
pub fn green() -> Color32 {
if use_dark() { GREEN_DARK } else { GREEN }
theme::tokens().pos
}
pub fn red() -> Color32 {
if use_dark() { RED_DARK } else { RED }
theme::tokens().neg
}
pub fn blue() -> Color32 {
if use_dark() { BLUE_DARK } else { BLUE }
if dark_base() {
Color32::from_rgb(0x7B, 0xA7, 0xFF)
} else {
Color32::from_rgb(0x0E, 0x62, 0xD0)
}
}
pub fn fill() -> Color32 {
if use_dark() { FILL_DARK } else { FILL }
theme::tokens().bg
}
pub fn fill_deep() -> Color32 {
if use_dark() {
FILL_DEEP_DARK
} else {
Self::FILL_DEEP
}
theme::tokens().surface2
}
pub fn fill_lite() -> Color32 {
if use_dark() {
FILL_LITE_DARK
} else {
FILL_LITE
}
theme::tokens().surface
}
pub fn checkbox() -> Color32 {
if use_dark() { CHECKBOX_DARK } else { CHECKBOX }
theme::tokens().text_dim
}
pub fn text(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TEXT_DARK
if always_light {
// Forced light-theme ink, used over always-light surfaces like QR cards.
Color32::from_rgb(0x6B, 0x6A, 0x63)
} else {
TEXT
theme::tokens().text_dim
}
}
pub fn text_button() -> Color32 {
if use_dark() {
TEXT_BUTTON_DARK
} else {
TEXT_BUTTON
}
theme::tokens().text
}
pub fn title(always_light: bool) -> Color32 {
if use_dark() && !always_light {
TITLE_DARK
if always_light {
INK
} else {
TITLE
theme::tokens().text
}
}
pub fn gray() -> Color32 {
if use_dark() { GRAY_DARK } else { GRAY }
theme::tokens().text_mute
}
pub fn stroke() -> Color32 {
if use_dark() {
STROKE_DARK
} else {
Self::STROKE
}
theme::tokens().line
}
pub fn inactive_text() -> Color32 {
if use_dark() {
INACTIVE_TEXT_DARK
} else {
INACTIVE_TEXT
}
theme::tokens().text_mute
}
pub fn item_button_text() -> Color32 {
if use_dark() {
ITEM_BUTTON_DARK
} else {
ITEM_BUTTON
}
theme::tokens().text_dim
}
pub fn item_stroke() -> Color32 {
if use_dark() {
ITEM_STROKE_DARK
} else {
ITEM_STROKE
}
theme::tokens().line
}
pub fn item_hover() -> Color32 {
if use_dark() {
ITEM_HOVER_DARK
} else {
ITEM_HOVER
theme::tokens().hover
}
/// Positive amount color.
pub fn pos() -> Color32 {
theme::tokens().pos
}
/// Always-dark ink (brand black).
pub const fn ink() -> Color32 {
INK
}
/// Always-light paper (brand white).
pub const fn paper() -> Color32 {
PAPER
}
}
+2
View File
@@ -18,6 +18,8 @@ pub use app::App;
mod colors;
pub use colors::Colors;
pub mod theme;
pub mod icons;
pub mod platform;
pub mod views;
+268
View File
@@ -0,0 +1,268 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Goblin design tokens: three themes (light/dark/yellow) and density scales,
//! taken verbatim from the Goblin design handoff.
use egui::Color32;
use crate::AppConfig;
/// Available color themes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ThemeKind {
Light,
Dark,
Yellow,
}
impl ThemeKind {
pub fn id(&self) -> &'static str {
match self {
ThemeKind::Light => "light",
ThemeKind::Dark => "dark",
ThemeKind::Yellow => "yellow",
}
}
pub fn from_id(id: &str) -> Option<ThemeKind> {
match id {
"light" => Some(ThemeKind::Light),
"dark" => Some(ThemeKind::Dark),
"yellow" => Some(ThemeKind::Yellow),
_ => None,
}
}
}
/// Color tokens for a theme.
pub struct ThemeTokens {
pub bg: Color32,
pub surface: Color32,
pub surface2: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_mute: Color32,
pub line: Color32,
pub accent: Color32,
pub accent_dark: Color32,
pub accent_ink: Color32,
pub pos: Color32,
pub neg: Color32,
pub chip: Color32,
pub hover: Color32,
/// Avatar background palette (initial ink picked by luminance).
pub avatar_palette: [Color32; 7],
/// Whether egui widgets should use the dark base style.
pub dark_base: bool,
}
/// Avatar palette shared by light/dark themes.
const AVATARS: [Color32; 7] = [
Color32::from_rgb(0xFF, 0xD6, 0x0A), // accent
Color32::from_rgb(0xFF, 0x8E, 0x3C),
Color32::from_rgb(0x5B, 0xD2, 0x7A),
Color32::from_rgb(0x7B, 0xA7, 0xFF),
Color32::from_rgb(0xE1, 0x74, 0xD0),
Color32::from_rgb(0xFF, 0xB8, 0x00),
Color32::from_rgb(0xA0, 0xE6, 0x6E),
];
pub const LIGHT: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
surface2: Color32::from_rgb(0xF2, 0xF1, 0xEC),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
// rgba(14,14,12,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(1, 1, 1, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0xB0, 0x48, 0x1E),
chip: Color32::from_rgb(0xF2, 0xF1, 0xEC),
hover: Color32::from_rgb(0xE9, 0xE7, 0xE0),
avatar_palette: AVATARS,
dark_base: false,
};
pub const DARK: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface: Color32::from_rgb(0x1A, 0x1A, 0x17),
surface2: Color32::from_rgb(0x24, 0x24, 0x20),
text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(255,255,255,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(20, 20, 20, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00),
accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C),
pos: Color32::from_rgb(0x5B, 0xD2, 0x7A),
neg: Color32::from_rgb(0xFF, 0x8B, 0x5E),
chip: Color32::from_rgb(0x24, 0x24, 0x20),
hover: Color32::from_rgb(0x2E, 0x2E, 0x29),
avatar_palette: AVATARS,
dark_base: true,
};
pub const YELLOW: ThemeTokens = ThemeTokens {
bg: Color32::from_rgb(0xFF, 0xD6, 0x0A),
surface: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface2: Color32::from_rgb(0x1A, 0x1A, 0x17),
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36),
text_mute: Color32::from_rgb(0x6B, 0x6A, 0x63),
// rgba(14,14,12,0.18) premultiplied.
line: Color32::from_rgba_premultiplied(2, 2, 2, 46),
accent: Color32::from_rgb(0x0E, 0x0E, 0x0C),
accent_dark: Color32::from_rgb(0x24, 0x24, 0x20),
accent_ink: Color32::from_rgb(0xFF, 0xD6, 0x0A),
pos: Color32::from_rgb(0x0E, 0x7C, 0x3A),
neg: Color32::from_rgb(0x9E, 0x2E, 0x0E),
chip: Color32::from_rgba_premultiplied(2, 2, 2, 20),
hover: Color32::from_rgb(0xEF, 0xC8, 0x00),
avatar_palette: AVATARS,
dark_base: false,
};
/// Current theme kind from app config (dark is the product default).
pub fn kind() -> ThemeKind {
AppConfig::theme()
}
/// Current theme tokens.
pub fn tokens() -> &'static ThemeTokens {
match kind() {
ThemeKind::Light => &LIGHT,
ThemeKind::Dark => &DARK,
ThemeKind::Yellow => &YELLOW,
}
}
/// Density scales from the design handoff.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DensityKind {
Compact,
Regular,
Comfy,
}
impl DensityKind {
pub fn id(&self) -> &'static str {
match self {
DensityKind::Compact => "compact",
DensityKind::Regular => "regular",
DensityKind::Comfy => "comfy",
}
}
pub fn from_id(id: &str) -> Option<DensityKind> {
match id {
"compact" => Some(DensityKind::Compact),
"regular" => Some(DensityKind::Regular),
"comfy" => Some(DensityKind::Comfy),
_ => None,
}
}
}
/// Spacing tokens for a density.
#[derive(Clone, Copy)]
pub struct DensityTokens {
pub pad: f32,
pub gap: f32,
pub radius: f32,
pub row: f32,
}
pub const COMPACT: DensityTokens = DensityTokens {
pad: 12.0,
gap: 10.0,
radius: 10.0,
row: 56.0,
};
pub const REGULAR: DensityTokens = DensityTokens {
pad: 16.0,
gap: 14.0,
radius: 16.0,
row: 64.0,
};
pub const COMFY: DensityTokens = DensityTokens {
pad: 20.0,
gap: 18.0,
radius: 22.0,
row: 72.0,
};
/// Current density tokens from app config (comfy is the product default).
pub fn density() -> DensityTokens {
match AppConfig::density() {
DensityKind::Compact => COMPACT,
DensityKind::Regular => REGULAR,
DensityKind::Comfy => COMFY,
}
}
/// Font family helpers for the Geist weight stack registered in `setup_fonts`.
pub mod fonts {
use egui::{FontFamily, FontId};
pub fn regular() -> FontFamily {
FontFamily::Proportional
}
pub fn medium() -> FontFamily {
FontFamily::Name("geist-medium".into())
}
pub fn semibold() -> FontFamily {
FontFamily::Name("geist-semibold".into())
}
pub fn bold() -> FontFamily {
FontFamily::Name("geist-bold".into())
}
pub fn mono() -> FontFamily {
FontFamily::Monospace
}
pub fn mono_semibold() -> FontFamily {
FontFamily::Name("geist-mono-sb".into())
}
/// Uppercase kicker label size (11px in the design).
pub fn kicker() -> FontId {
FontId::new(11.0, semibold())
}
}
/// Pick a readable ink (black or white) for the given background by luminance.
pub fn ink_for(bg: Color32) -> Color32 {
let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
if lum > 140.0 {
Color32::from_rgb(0x0E, 0x0E, 0x0C)
} else {
Color32::from_rgb(0xFA, 0xFA, 0xF7)
}
}
/// Avatar background color for a hue index.
pub fn avatar_color(hue: usize) -> Color32 {
let palette = &tokens().avatar_palette;
palette[hue % palette.len()]
}
+91 -29
View File
@@ -37,6 +37,7 @@ pub mod gui;
mod http;
pub mod logger;
mod node;
pub mod nostr;
mod settings;
mod tor;
mod wallet;
@@ -109,7 +110,7 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
Node::start();
}
// Launch graphical interface.
eframe::run_native("Grim", options, app_creator)
eframe::run_native("Goblin", options, app_creator)
}
/// Setup application [`egui::Style`] and [`egui::Visuals`].
@@ -144,39 +145,75 @@ pub fn setup_visuals(ctx: &Context) {
// Setup style
ctx.set_style(style);
// Setup visuals based on app color theme.
let mut visuals = if use_dark {
// Setup visuals based on the Goblin theme tokens.
let _ = use_dark;
let t = gui::theme::tokens();
let mut visuals = if t.dark_base {
egui::Visuals::dark()
} else {
egui::Visuals::light()
};
// Base surfaces.
visuals.panel_fill = t.bg;
visuals.window_fill = t.surface;
visuals.extreme_bg_color = t.surface2;
visuals.faint_bg_color = t.surface2;
// Default text inks.
visuals.widgets.noninteractive.fg_stroke.color = t.text_dim;
visuals.widgets.hovered.fg_stroke.color = t.text;
visuals.widgets.active.fg_stroke.color = t.text;
// Setup selection color.
visuals.selection.stroke = Stroke {
width: 1.0,
color: Colors::text(false),
color: t.accent_ink,
};
visuals.selection.bg_fill = Colors::gold();
visuals.selection.bg_fill = t.accent;
// Disable stroke around panels by default.
visuals.widgets.noninteractive.bg_stroke = Stroke::NONE;
// Setup stroke around inactive widgets.
visuals.widgets.inactive.bg_stroke = View::default_stroke();
// Setup background and foreground stroke color for widgets like pull-to-refresher.
visuals.widgets.inactive.bg_fill = if use_dark {
Colors::white_or_black(false)
} else {
Colors::yellow()
};
visuals.widgets.inactive.bg_fill = if t.dark_base { t.bg } else { t.accent };
visuals.widgets.inactive.fg_stroke.color = Colors::item_button_text();
// Hover/active fills.
visuals.widgets.hovered.bg_fill = t.hover;
visuals.widgets.active.bg_fill = t.hover;
// Setup visuals.
ctx.set_visuals(visuals);
}
/// Setup application fonts.
/// Setup application fonts: Geist (+ weight families), Geist Mono,
/// Phosphor icons and Noto SC as CJK/ツ fallback.
pub fn setup_fonts(ctx: &Context) {
use egui::FontFamily::Proportional;
use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default();
let plain = |bytes: &'static [u8]| Arc::new(egui::FontData::from_static(bytes));
fonts.font_data.insert(
"geist".to_owned(),
plain(include_bytes!("../fonts/Geist-Regular.ttf")),
);
fonts.font_data.insert(
"geist-medium".to_owned(),
plain(include_bytes!("../fonts/Geist-Medium.ttf")),
);
fonts.font_data.insert(
"geist-semibold".to_owned(),
plain(include_bytes!("../fonts/Geist-SemiBold.ttf")),
);
fonts.font_data.insert(
"geist-bold".to_owned(),
plain(include_bytes!("../fonts/Geist-Bold.ttf")),
);
fonts.font_data.insert(
"geist-mono".to_owned(),
plain(include_bytes!("../fonts/GeistMono-Regular.ttf")),
);
fonts.font_data.insert(
"geist-mono-sb".to_owned(),
plain(include_bytes!("../fonts/GeistMono-SemiBold.ttf")),
);
fonts.font_data.insert(
"phosphor".to_owned(),
Arc::new(
@@ -189,12 +226,6 @@ pub fn setup_fonts(ctx: &Context) {
),
),
);
fonts
.families
.entry(Proportional)
.or_default()
.insert(0, "phosphor".to_owned());
fonts.font_data.insert(
"noto".to_owned(),
Arc::new(
@@ -207,24 +238,55 @@ pub fn setup_fonts(ctx: &Context) {
),
),
);
fonts
.families
.entry(Proportional)
.or_default()
.insert(0, "noto".to_owned());
// Default proportional stack: Geist first, icons and CJK/ツ as fallback.
{
let prop = fonts.families.entry(Proportional).or_default();
prop.insert(0, "geist".to_owned());
prop.insert(1, "phosphor".to_owned());
prop.insert(2, "noto".to_owned());
}
// Monospace stack for amounts (tabular digits).
{
let mono = fonts.families.entry(Monospace).or_default();
mono.insert(0, "geist-mono".to_owned());
mono.insert(1, "phosphor".to_owned());
mono.insert(2, "noto".to_owned());
}
// Named weight families, each with icon + CJK fallback.
for name in [
"geist-medium",
"geist-semibold",
"geist-bold",
"geist-mono-sb",
] {
fonts.families.insert(
egui::FontFamily::Name(name.into()),
vec![name.to_owned(), "phosphor".to_owned(), "noto".to_owned()],
);
}
ctx.set_fonts(fonts);
use egui::FontId;
use egui::TextStyle::*;
use egui::TextStyle;
let mut style = (*ctx.style()).clone();
style.text_styles = [
(Heading, FontId::new(19.0, Proportional)),
(Body, FontId::new(16.0, Proportional)),
(Button, FontId::new(17.0, Proportional)),
(Small, FontId::new(15.0, Proportional)),
(Monospace, FontId::new(16.0, Proportional)),
(
TextStyle::Heading,
FontId::new(19.0, egui::FontFamily::Name("geist-semibold".into())),
),
(TextStyle::Body, FontId::new(16.0, Proportional)),
(
TextStyle::Button,
FontId::new(17.0, egui::FontFamily::Name("geist-medium".into())),
),
(TextStyle::Small, FontId::new(15.0, Proportional)),
(
TextStyle::Monospace,
FontId::new(16.0, egui::FontFamily::Monospace),
),
]
.into();
+646
View File
@@ -0,0 +1,646 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr service: relay connections over the embedded Tor client,
//! identity event publishing, the guarded ingest loop and the DM send path.
use log::{error, info, warn};
use nostr_sdk::{
Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification,
RelayStatus, Tag, TagKind, Timestamp,
};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use crate::nostr::ingest::{IngestContext, IngestDecision, decide};
use crate::nostr::protocol;
use crate::nostr::relays::MAX_DM_RELAYS;
use crate::nostr::types::*;
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
use crate::tor::transport::ArtiWebSocketTransport;
use crate::wallet::Wallet;
/// Subscription look-back window beyond the last connection time: gift wrap
/// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days.
const LOOKBACK_SECS: i64 = 3 * 86_400;
/// Catch-up fetch timeout.
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
/// Send dispatch timeout.
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
/// Rate limit for incoming messages per known contact (events/hour).
const RATE_CONTACT_PER_HOUR: usize = 30;
/// Rate limit for incoming messages per unknown sender (events/hour).
const RATE_UNKNOWN_PER_HOUR: usize = 10;
/// Auto-resend window for pending outgoing messages (days).
const RESEND_WINDOW_SECS: i64 = 7 * 86_400;
/// Per-wallet nostr service.
pub struct NostrService {
/// Identity keys (decrypted for the session).
keys: Keys,
/// Identity file state.
pub identity: RwLock<NostrIdentity>,
/// Per-wallet configuration.
pub config: RwLock<NostrConfig>,
/// Metadata archive.
pub store: Arc<NostrStore>,
/// Directory holding identity.json.
nostr_dir: PathBuf,
/// SDK client, present while the service loop runs.
client: RwLock<Option<Client>>,
/// Service thread started flag.
started: AtomicBool,
/// Shutdown request flag.
shutdown: AtomicBool,
/// At least one relay is connected.
connected: AtomicBool,
/// New payment requests arrived (UI badge hint).
pub has_new_requests: AtomicBool,
/// Per-sender rate limiting state (unix seconds of accepted events).
rate: Mutex<HashMap<String, Vec<i64>>>,
}
impl NostrService {
/// Create the service for an unlocked identity.
pub fn new(
keys: Keys,
identity: NostrIdentity,
config: NostrConfig,
store: NostrStore,
nostr_dir: PathBuf,
) -> Arc<Self> {
Arc::new(Self {
keys,
identity: RwLock::new(identity),
config: RwLock::new(config),
store: Arc::new(store),
nostr_dir,
client: RwLock::new(None),
started: AtomicBool::new(false),
shutdown: AtomicBool::new(false),
connected: AtomicBool::new(false),
has_new_requests: AtomicBool::new(false),
rate: Mutex::new(HashMap::new()),
})
}
/// Own public key.
pub fn public_key(&self) -> PublicKey {
self.keys.public_key()
}
/// Own npub bech32.
pub fn npub(&self) -> String {
self.identity.read().npub.clone()
}
/// Whether at least one relay is connected.
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
/// Whether the service loop is running.
pub fn is_running(&self) -> bool {
self.started.load(Ordering::Relaxed) && !self.shutdown.load(Ordering::Relaxed)
}
/// Save the identity file after mutation (e.g. NIP-05 registration).
pub fn save_identity(&self) {
let identity = self.identity.read().clone();
if let Err(e) = identity.save(&self.nostr_dir) {
error!("nostr: identity save failed: {e}");
}
}
/// Start the service thread (idempotent).
pub fn start(self: &Arc<Self>, wallet: Wallet) {
if self.started.swap(true, Ordering::SeqCst) {
return;
}
let svc = self.clone();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
let svc_run = svc.clone();
rt.block_on(async move {
run_service(svc_run, wallet).await;
});
svc.started.store(false, Ordering::SeqCst);
svc.connected.store(false, Ordering::Relaxed);
info!("nostr: service stopped");
});
}
/// Request the service loop to stop.
pub fn stop(&self) {
self.shutdown.store(true, Ordering::SeqCst);
}
/// Restart with current config (relay list changes).
pub fn restart(self: &Arc<Self>, wallet: Wallet) {
self.stop();
let svc = self.clone();
thread::spawn(move || {
// Wait for the loop to exit, then start again.
while svc.started.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(300));
}
svc.shutdown.store(false, Ordering::SeqCst);
svc.start(wallet);
});
}
/// Current relay list.
pub fn relays(&self) -> Vec<String> {
self.config.read().relays()
}
/// Sliding-window rate limiter, true when the event is allowed.
fn allow_sender(&self, sender: &str, is_contact: bool) -> bool {
let max = if is_contact {
RATE_CONTACT_PER_HOUR
} else {
RATE_UNKNOWN_PER_HOUR
};
let now = unix_time();
let mut rate = self.rate.lock();
let hits = rate.entry(sender.to_string()).or_default();
hits.retain(|t| now - *t < 3600);
if hits.len() >= max {
return false;
}
hits.push(now);
if rate.len() > 10_000 {
rate.retain(|_, v| v.iter().any(|t| now - *t < 3600));
}
true
}
/// Dispatch a payment DM (slatepack + optional note) to a recipient,
/// publishing to their DM relays plus our own relay set.
pub async fn send_payment_dm(
&self,
receiver_hex: &str,
slatepack: &str,
note: Option<&str>,
) -> Result<String, String> {
let client = {
let r_client = self.client.read();
r_client.clone().ok_or("nostr client is not running")?
};
let receiver =
PublicKey::from_hex(receiver_hex).map_err(|e| format!("invalid receiver: {e}"))?;
let content = protocol::build_payment_content(slatepack);
let tags = protocol::build_rumor_tags(note);
// Resolve receiver DM relays (kind 10050) with our relays as fallback.
let mut urls = self.fetch_dm_relays(&client, &receiver).await;
for r in self.relays() {
if !urls.contains(&r) {
urls.push(r);
}
}
let res = tokio::time::timeout(
SEND_TIMEOUT,
client.send_private_msg_to(urls, receiver, content, tags),
)
.await
.map_err(|_| "send timeout".to_string())?
.map_err(|e| format!("send failed: {e}"))?;
Ok(res.val.to_hex())
}
/// Fetch a contact's kind 10050 DM relay list from our relays.
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec<String> {
// Use cached relays first.
if let Some(contact) = self.store.contact(&pk.to_hex()) {
if !contact.relays.is_empty() {
return contact.relays.into_iter().take(MAX_DM_RELAYS).collect();
}
}
let filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1);
let mut out = vec![];
if let Ok(events) = client.fetch_events(filter, FETCH_TIMEOUT).await {
if let Some(event) = events.first() {
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) == Some("relay") {
if let Some(url) = parts.get(1) {
if out.len() < MAX_DM_RELAYS {
out.push(url.trim_end_matches('/').to_string());
}
}
}
}
}
}
// Cache discovered relays on the contact when present.
if !out.is_empty() {
if let Some(mut contact) = self.store.contact(&pk.to_hex()) {
contact.relays = out.clone();
self.store.save_contact(&contact);
}
}
out
}
/// Ensure a contact entry exists for a sender (auto-added as unknown).
fn ensure_contact(&self, sender_hex: &str) {
if self.store.contact(sender_hex).is_none() {
let hue = u8::from_str_radix(&sender_hex[..2], 16).unwrap_or(0) % 7;
self.store.save_contact(&Contact {
ver: 1,
npub: sender_hex.to_string(),
petname: None,
nip05: None,
nip05_verified_at: None,
relays: vec![],
hue,
unknown: true,
added_at: unix_time(),
last_paid_at: None,
});
}
}
}
/// Main service loop: connect, publish identity, catch up, listen.
async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
let relays = svc.relays();
info!(
"nostr: starting service for {} with relays {:?}",
svc.npub(),
relays
);
let client = Client::builder()
.signer(svc.keys.clone())
.websocket_transport(ArtiWebSocketTransport)
.build();
for relay in &relays {
if let Err(e) = client.add_relay(relay.clone()).await {
warn!("nostr: add relay {relay} failed: {e}");
}
}
client.connect().await;
{
let mut w_client = svc.client.write();
*w_client = Some(client.clone());
}
// Publish identity events (kind 10050 DM relays; kind 0 only when named).
publish_identity(&svc, &client).await;
// Catch-up + live subscription for our gift wraps.
let since = svc
.store
.last_connected_at()
.map(|t| t - LOOKBACK_SECS)
.unwrap_or_else(|| unix_time() - LOOKBACK_SECS)
.max(0) as u64;
let filter = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(svc.public_key())
.since(Timestamp::from_secs(since));
if let Ok(events) = client.fetch_events(filter.clone(), FETCH_TIMEOUT).await {
info!("nostr: catch-up fetched {} wraps", events.len());
for event in events.into_iter() {
handle_wrap(&svc, &wallet, &client, event).await;
}
}
if let Err(e) = client.subscribe(filter, None).await {
error!("nostr: subscribe failed: {e}");
}
// Re-dispatch pending outgoing messages after restart.
reconcile(&svc, &wallet).await;
svc.store.set_last_connected_at(unix_time());
svc.store.prune_processed();
let mut notifications = client.notifications();
loop {
if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() {
break;
}
tokio::select! {
notification = notifications.recv() => {
match notification {
Ok(RelayPoolNotification::Event { event, .. }) => {
handle_wrap(&svc, &wallet, &client, *event).await;
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("nostr: notifications lagged by {n}");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
_ = tokio::time::sleep(Duration::from_secs(30)) => {
// Heartbeat: persist last seen time, update connection state.
svc.store.set_last_connected_at(unix_time());
let connected = client.relays().await.values()
.any(|r| r.status() == RelayStatus::Connected);
svc.connected.store(connected, Ordering::Relaxed);
}
}
}
{
let mut w_client = svc.client.write();
*w_client = None;
}
client.disconnect().await;
}
/// Publish kind 10050 DM relay list and, for named identities, kind 0 metadata.
async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
let relays = svc.relays();
let dm_tags: Vec<Tag> = relays
.iter()
.take(MAX_DM_RELAYS)
.map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags);
if let Err(e) = client.send_event_builder(builder).await {
warn!("nostr: publish 10050 failed: {e}");
}
let (anonymous, nip05) = {
let identity = svc.identity.read();
(identity.anonymous, identity.nip05.clone())
};
if !anonymous {
if let Some(nip05) = nip05 {
let name = nip05.split('@').next().unwrap_or_default().to_string();
let metadata = Metadata::new().name(name).nip05(nip05);
let builder = EventBuilder::metadata(&metadata);
if let Err(e) = client.send_event_builder(builder).await {
warn!("nostr: publish kind 0 failed: {e}");
}
}
}
}
/// Re-dispatch our pending outgoing messages (crash/offline recovery).
async fn reconcile(svc: &Arc<NostrService>, wallet: &Wallet) {
let now = unix_time();
for meta in svc.store.all_tx_meta() {
if now - meta.created_at > RESEND_WINDOW_SECS {
continue;
}
let resend_state = match (meta.direction, meta.status) {
// S1 never dispatched or failed.
(NostrTxDirection::Sent, NostrSendStatus::Created)
| (NostrTxDirection::Sent, NostrSendStatus::SendFailed) => {
Some(grin_wallet_libwallet::SlateState::Standard1)
}
// I1 request never dispatched or failed.
(NostrTxDirection::RequestedByUs, NostrSendStatus::Created)
| (NostrTxDirection::RequestedByUs, NostrSendStatus::SendFailed) => {
Some(grin_wallet_libwallet::SlateState::Invoice1)
}
// We received and processed S1 but the S2 reply may not have left.
(NostrTxDirection::Received, NostrSendStatus::ReceivedNoReply) => {
Some(grin_wallet_libwallet::SlateState::Standard2)
}
// We paid a request (I2) but the reply may not have left.
(NostrTxDirection::RequestedOfUs, NostrSendStatus::ReceivedNoReply) => {
Some(grin_wallet_libwallet::SlateState::Invoice2)
}
_ => None,
};
let Some(state) = resend_state else { continue };
let Ok(slate_id) = uuid::Uuid::parse_str(&meta.slate_id) else {
continue;
};
let Some(text) = wallet.read_slatepack_text(slate_id, &state) else {
continue;
};
info!(
"nostr: reconcile re-dispatch {} ({:?})",
meta.slate_id, state
);
match svc
.send_payment_dm(&meta.npub, &text, meta.note.as_deref())
.await
{
Ok(event_id) => {
let mut updated = meta.clone();
updated.sent_event_id = Some(event_id);
updated.status = match state {
grin_wallet_libwallet::SlateState::Standard1 => NostrSendStatus::AwaitingS2,
grin_wallet_libwallet::SlateState::Invoice1 => NostrSendStatus::AwaitingI2,
grin_wallet_libwallet::SlateState::Standard2 => NostrSendStatus::RepliedS2,
_ => NostrSendStatus::PaidAwaitingFinalize,
};
updated.updated_at = unix_time();
svc.store.save_tx_meta(&updated);
}
Err(e) => warn!(
"nostr: reconcile dispatch failed for {}: {e}",
meta.slate_id
),
}
}
}
/// Full guarded pipeline for one incoming gift wrap event.
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client, event: Event) {
// 0. Only gift wraps.
if event.kind != Kind::GiftWrap {
return;
}
let wrap_id = event.id.to_hex();
// 1. Cheap size cap before any crypto.
if event.content.len() > protocol::MAX_WRAP_CONTENT {
svc.store.mark_processed(&wrap_id);
return;
}
// 2. Wrap-level dedupe.
if svc.store.is_processed(&wrap_id) {
return;
}
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed).
let unwrapped = match client.unwrap_gift_wrap(&event).await {
Ok(u) => u,
Err(_) => {
svc.store.mark_processed(&wrap_id);
return;
}
};
let sender = unwrapped.sender;
let mut rumor = unwrapped.rumor;
// 4. The rumor author must be the seal signer (NIP-17 requirement).
if rumor.pubkey != sender {
warn!("nostr: rumor author differs from seal signer, dropping");
svc.store.mark_processed(&wrap_id);
return;
}
// Ignore our own messages (e.g. wrap-to-self copies).
if sender == svc.public_key() {
svc.store.mark_processed(&wrap_id);
return;
}
// 5. Only kind 14 with bounded content.
if rumor.kind != Kind::PrivateDirectMessage || rumor.content.len() > protocol::MAX_RUMOR_CONTENT
{
svc.store.mark_processed(&wrap_id);
return;
}
let sender_hex = sender.to_hex();
let is_contact = svc
.store
.contact(&sender_hex)
.map(|c| !c.unknown)
.unwrap_or(false);
// 6. Rate limit per sender.
if !svc.allow_sender(&sender_hex, is_contact) {
// Deliberately NOT marked processed: legitimate bursts can retry later.
return;
}
// 7. Rumor-level dedupe (the same rumor can arrive in different wraps).
let rumor_id = rumor.id().to_hex();
if svc.store.is_processed(&rumor_id) {
svc.store.mark_processed(&wrap_id);
return;
}
// 8. Extract the slatepack; non-payment DMs are ignored entirely.
let Some(armor) = protocol::extract_slatepack(&rumor.content) else {
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
return;
};
let note = protocol::extract_subject(&rumor.tags);
// 9. Parse and validate the slate itself.
let Ok((slate, _)) = wallet.parse_slatepack(&armor) else {
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
return;
};
// 10. Slate-level dedupe.
let slate_marker = format!("slate:{}:{}", slate.id, slate.state);
if svc.store.is_processed(&slate_marker) {
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
return;
}
// 11. Policy decision.
let meta = svc.store.tx_meta(&slate.id.to_string());
let tx_exists = wallet.has_tx_for_slate(&slate.id);
let accept = svc.config.read().accept_from();
let decision = decide(&IngestContext {
state: slate.state.clone(),
amount: slate.amount,
sender: &sender_hex,
meta: meta.as_ref(),
tx_exists,
is_contact,
accept,
});
info!(
"nostr: wrap {} slate {} state {} from {}…: {:?}",
&wrap_id[..8],
slate.id,
slate.state,
&sender_hex[..8],
decision
);
match decision {
IngestDecision::AutoReceive => {
svc.ensure_contact(&sender_hex);
match wallet.nostr_receive(&slate) {
Ok((_, reply_text)) => {
// Record BEFORE dispatching the reply: crash here is
// recovered by reconcile() re-sending the S2 from disk.
let now = unix_time();
svc.store.save_tx_meta(&TxNostrMeta {
ver: 1,
slate_id: slate.id.to_string(),
npub: sender_hex.clone(),
direction: NostrTxDirection::Received,
note: note.clone(),
status: NostrSendStatus::ReceivedNoReply,
sent_event_id: None,
received_rumor_id: Some(rumor_id.clone()),
created_at: now,
updated_at: now,
});
match svc.send_payment_dm(&sender_hex, &reply_text, None).await {
Ok(event_id) => {
let mut meta = svc.store.tx_meta(&slate.id.to_string()).unwrap();
meta.status = NostrSendStatus::RepliedS2;
meta.sent_event_id = Some(event_id);
meta.updated_at = unix_time();
svc.store.save_tx_meta(&meta);
}
Err(e) => warn!("nostr: S2 reply dispatch failed: {e}"),
}
wallet.sync();
}
Err(e) => {
error!("nostr: receive failed for slate {}: {:?}", slate.id, e);
}
}
}
IngestDecision::SurfaceIncoming | IngestDecision::SurfaceRequest => {
svc.ensure_contact(&sender_hex);
svc.store.save_request(&PaymentRequest {
ver: 1,
rumor_id: rumor_id.clone(),
slate_id: slate.id.to_string(),
slatepack: armor.clone(),
npub: sender_hex.clone(),
amount: slate.amount,
note: note.clone(),
received_at: unix_time(),
status: RequestStatus::Pending,
});
svc.has_new_requests.store(true, Ordering::Relaxed);
}
IngestDecision::FinalizePost => match wallet.nostr_finalize_post(&slate) {
Ok(()) => {
svc.store
.update_tx_status(&slate.id.to_string(), NostrSendStatus::Finalized);
if let Some(mut contact) = svc.store.contact(&sender_hex) {
contact.last_paid_at = Some(unix_time());
svc.store.save_contact(&contact);
}
wallet.sync();
}
Err(e) => {
error!("nostr: finalize failed for slate {}: {:?}", slate.id, e);
}
},
IngestDecision::Drop(reason) => {
info!("nostr: dropped slate {}: {}", slate.id, reason);
}
}
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
svc.store.mark_processed(&slate_marker);
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr configuration, stored as `nostr.toml` in the wallet dir.
use serde_derive::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::Settings;
use crate::nostr::relays::{DEFAULT_NIP05_SERVER, DEFAULT_RELAYS};
/// Policy for accepting incoming payments (Standard1 slates).
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum AcceptPolicy {
/// Accept payments from anyone automatically (default, Cash App feel).
Everyone,
/// Auto-accept contacts, surface unknown senders for approval.
Contacts,
/// Surface every incoming payment for approval.
Ask,
}
/// Per-wallet nostr configuration.
#[derive(Serialize, Deserialize, Clone)]
pub struct NostrConfig {
/// Whether the nostr subsystem runs for this wallet.
enabled: Option<bool>,
/// Relay list override.
relays: Option<Vec<String>>,
/// Accept policy for incoming payments.
accept_from: Option<AcceptPolicy>,
/// NIP-05 identity server base URL.
nip05_server: Option<String>,
/// Days after which a pending outgoing payment is shown as expired.
request_expiry_days: Option<u64>,
/// Path of the config file, not serialized.
#[serde(skip)]
path: Option<PathBuf>,
}
impl Default for NostrConfig {
fn default() -> Self {
Self {
enabled: None,
relays: None,
accept_from: None,
nip05_server: None,
request_expiry_days: None,
path: None,
}
}
}
impl NostrConfig {
/// Nostr configuration file name inside the wallet directory.
pub const FILE_NAME: &'static str = "nostr.toml";
/// Load the config from the wallet directory, falling back to defaults.
pub fn load(wallet_dir: PathBuf) -> Self {
let mut path = wallet_dir;
path.push(Self::FILE_NAME);
let mut config: Self = Settings::read_from_file(path.clone()).unwrap_or_default();
config.path = Some(path);
config
}
/// Save the config to disk.
pub fn save(&self) {
if let Some(path) = &self.path {
Settings::write_to_file(self, path.clone());
}
}
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
self.save();
}
pub fn relays(&self) -> Vec<String> {
self.relays
.clone()
.filter(|r| !r.is_empty())
.unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect())
}
pub fn set_relays(&mut self, relays: Vec<String>) {
self.relays = Some(relays);
self.save();
}
pub fn accept_from(&self) -> AcceptPolicy {
self.accept_from.unwrap_or(AcceptPolicy::Everyone)
}
pub fn set_accept_from(&mut self, policy: AcceptPolicy) {
self.accept_from = Some(policy);
self.save();
}
pub fn nip05_server(&self) -> String {
self.nip05_server
.clone()
.unwrap_or_else(|| DEFAULT_NIP05_SERVER.to_string())
}
pub fn request_expiry_days(&self) -> u64 {
self.request_expiry_days.unwrap_or(7)
}
}
+227
View File
@@ -0,0 +1,227 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by
//! default (one seed restores money AND identity) or imported from an nsec.
//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password.
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr_sdk::prelude::FromMnemonic;
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
use serde_derive::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Where the keys came from.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum IdentitySource {
/// NIP-06 derivation from the wallet BIP-39 mnemonic.
Derived,
/// Imported nsec.
Imported,
}
/// Identity file stored at `wallet_data/nostr/identity.json`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NostrIdentity {
pub ver: u8,
pub source: IdentitySource,
/// NIP-06 account index used for derivation.
pub derivation_account: u32,
/// NIP-49 encrypted secret key (bech32 ncryptsec).
pub ncryptsec: String,
/// Public key, bech32 npub (plaintext so the UI can render pre-unlock).
pub npub: String,
/// Registered NIP-05 identifier (user@goblin.st).
pub nip05: Option<String>,
/// User chose to stay anonymous (no NIP-05, no kind-0 metadata).
pub anonymous: bool,
}
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
const NCRYPTSEC_LOG_N: u8 = 16;
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("identity io error: {0}")]
Io(#[from] std::io::Error),
#[error("identity parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("key error: {0}")]
Key(String),
#[error("wrong password")]
WrongPassword,
}
impl NostrIdentity {
pub const FILE_NAME: &'static str = "identity.json";
/// Identity file path inside the wallet nostr directory.
pub fn path(nostr_dir: &PathBuf) -> PathBuf {
let mut path = nostr_dir.clone();
path.push(Self::FILE_NAME);
path
}
/// Load the identity file if it exists.
pub fn load(nostr_dir: &PathBuf) -> Option<NostrIdentity> {
let path = Self::path(nostr_dir);
let raw = fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
/// Persist the identity file.
pub fn save(&self, nostr_dir: &PathBuf) -> Result<(), IdentityError> {
fs::create_dir_all(nostr_dir)?;
let raw = serde_json::to_string_pretty(self)?;
fs::write(Self::path(nostr_dir), raw)?;
Ok(())
}
/// Delete the identity file (used when the user discards the identity).
pub fn delete(nostr_dir: &PathBuf) {
let _ = fs::remove_file(Self::path(nostr_dir));
}
/// Derive keys from a BIP-39 mnemonic phrase via NIP-06.
pub fn derive_keys(mnemonic: &str, account: u32) -> Result<Keys, IdentityError> {
Keys::from_mnemonic_with_account(mnemonic, None, Some(account))
.map_err(|e| IdentityError::Key(format!("{e}")))
}
/// Create a derived identity from the wallet mnemonic, encrypting the
/// secret key with the wallet password.
pub fn create_derived(
mnemonic: &str,
password: &str,
account: u32,
) -> Result<(NostrIdentity, Keys), IdentityError> {
let keys = Self::derive_keys(mnemonic, account)?;
let identity = Self::from_keys(&keys, password, IdentitySource::Derived, account)?;
Ok((identity, keys))
}
/// Create an imported identity from an nsec string.
pub fn create_imported(
nsec: &str,
password: &str,
) -> Result<(NostrIdentity, Keys), IdentityError> {
let secret = SecretKey::parse(nsec.trim())
.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
let keys = Keys::new(secret);
let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?;
Ok((identity, keys))
}
fn from_keys(
keys: &Keys,
password: &str,
source: IdentitySource,
account: u32,
) -> Result<NostrIdentity, IdentityError> {
let encrypted = EncryptedSecretKey::new(
keys.secret_key(),
password,
NCRYPTSEC_LOG_N,
KeySecurity::Medium,
)
.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
let ncryptsec = encrypted
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
let npub = keys
.public_key()
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
Ok(NostrIdentity {
ver: 1,
source,
derivation_account: account,
ncryptsec,
npub,
nip05: None,
anonymous: true,
})
}
/// Decrypt the stored key with the wallet password.
pub fn unlock(&self, password: &str) -> Result<Keys, IdentityError> {
let encrypted = EncryptedSecretKey::from_bech32(&self.ncryptsec)
.map_err(|e| IdentityError::Key(format!("invalid ncryptsec: {e}")))?;
let secret = encrypted
.decrypt(password)
.map_err(|_| IdentityError::WrongPassword)?;
Ok(Keys::new(secret))
}
/// Re-encrypt the stored key under a new password.
pub fn reencrypt(&mut self, old: &str, new: &str) -> Result<(), IdentityError> {
let keys = self.unlock(old)?;
let encrypted =
EncryptedSecretKey::new(keys.secret_key(), new, NCRYPTSEC_LOG_N, KeySecurity::Medium)
.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
self.ncryptsec = encrypted
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// NIP-06 test vector: this mnemonic must derive this npub (account 0).
const NIP06_MNEMONIC: &str =
"leader monkey parrot ring guide accident before fence cannon height naive bean";
const NIP06_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu";
#[test]
fn nip06_derivation_vector() {
let keys = NostrIdentity::derive_keys(NIP06_MNEMONIC, 0).unwrap();
assert_eq!(keys.public_key().to_bech32().unwrap(), NIP06_NPUB);
}
#[test]
fn encrypt_unlock_roundtrip() {
let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap();
assert_eq!(identity.source, IdentitySource::Derived);
assert!(identity.anonymous);
let unlocked = identity.unlock("hunter2").unwrap();
assert_eq!(unlocked.public_key(), keys.public_key());
assert!(identity.unlock("wrong").is_err());
}
#[test]
fn import_nsec_roundtrip() {
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let (identity, imported) = NostrIdentity::create_imported(&nsec, "pw").unwrap();
assert_eq!(identity.source, IdentitySource::Imported);
assert_eq!(imported.public_key(), keys.public_key());
let unlocked = identity.unlock("pw").unwrap();
assert_eq!(unlocked.public_key(), keys.public_key());
}
#[test]
fn reencrypt_changes_password() {
let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap();
identity.reencrypt("old", "new").unwrap();
assert!(identity.unlock("old").is_err());
assert_eq!(
identity.unlock("new").unwrap().public_key(),
keys.public_key()
);
}
}
+287
View File
@@ -0,0 +1,287 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! The guarded ingest policy: what to do with a validated incoming slate.
//!
//! Security invariants (do not weaken):
//! - Invoice1 (a request for US to PAY) is NEVER paid automatically.
//! - Standard2/Invoice2 replies only finalize when they match a pending
//! transaction we initiated AND the sender matches the stored counterparty.
//! - Everything else is dropped.
use grin_wallet_libwallet::SlateState;
use crate::nostr::config::AcceptPolicy;
use crate::nostr::types::{NostrSendStatus, NostrTxDirection, TxNostrMeta};
/// What the ingest pipeline should do with a validated slate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IngestDecision {
/// Standard1: receive the payment and reply S2 automatically.
AutoReceive,
/// Standard1 under a stricter accept policy: surface for approval.
SurfaceIncoming,
/// Standard2/Invoice2 reply matching our pending tx: finalize and post.
FinalizePost,
/// Invoice1: surface a payment request for explicit user approval.
SurfaceRequest,
/// Drop silently (reason for logging only).
Drop(&'static str),
}
/// Inputs for the policy decision.
pub struct IngestContext<'a> {
/// Parsed slate state.
pub state: SlateState,
/// Parsed slate amount in atomic units.
pub amount: u64,
/// Seal-verified sender public key, hex.
pub sender: &'a str,
/// Stored nostr metadata for this slate id, when present.
pub meta: Option<&'a TxNostrMeta>,
/// Whether the wallet has a transaction with this slate id.
pub tx_exists: bool,
/// Whether the sender is a known (non-unknown) contact.
pub is_contact: bool,
/// Accept policy from wallet config.
pub accept: AcceptPolicy,
}
/// Pure policy function — unit tested, no side effects.
pub fn decide(ctx: &IngestContext) -> IngestDecision {
match ctx.state {
SlateState::Standard1 => {
if ctx.amount == 0 {
return IngestDecision::Drop("zero amount");
}
if ctx.tx_exists || ctx.meta.is_some() {
return IngestDecision::Drop("slate already known");
}
match ctx.accept {
AcceptPolicy::Everyone => IngestDecision::AutoReceive,
AcceptPolicy::Contacts => {
if ctx.is_contact {
IngestDecision::AutoReceive
} else {
IngestDecision::SurfaceIncoming
}
}
AcceptPolicy::Ask => IngestDecision::SurfaceIncoming,
}
}
SlateState::Standard2 => match ctx.meta {
Some(meta)
if meta.direction == NostrTxDirection::Sent
&& matches!(
meta.status,
NostrSendStatus::AwaitingS2
| NostrSendStatus::Created
| NostrSendStatus::SendFailed
) && meta.npub == ctx.sender
&& ctx.tx_exists =>
{
IngestDecision::FinalizePost
}
Some(meta) if meta.npub != ctx.sender => {
IngestDecision::Drop("S2 sender does not match stored counterparty")
}
_ => IngestDecision::Drop("S2 without matching pending send"),
},
SlateState::Invoice1 => {
if ctx.amount == 0 {
return IngestDecision::Drop("zero amount");
}
if ctx.tx_exists || ctx.meta.is_some() {
return IngestDecision::Drop("slate already known");
}
// NEVER pay automatically.
IngestDecision::SurfaceRequest
}
SlateState::Invoice2 => match ctx.meta {
Some(meta)
if meta.direction == NostrTxDirection::RequestedByUs
&& matches!(
meta.status,
NostrSendStatus::AwaitingI2
| NostrSendStatus::Created
| NostrSendStatus::SendFailed
) && meta.npub == ctx.sender
&& ctx.tx_exists =>
{
IngestDecision::FinalizePost
}
Some(meta) if meta.npub != ctx.sender => {
IngestDecision::Drop("I2 sender does not match stored counterparty")
}
_ => IngestDecision::Drop("I2 without matching pending request"),
},
_ => IngestDecision::Drop("unsupported slate state"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nostr::types::unix_time;
const ALICE: &str = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05";
const MALLORY: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
fn meta(direction: NostrTxDirection, status: NostrSendStatus, npub: &str) -> TxNostrMeta {
TxNostrMeta {
ver: 1,
slate_id: "s".into(),
npub: npub.into(),
direction,
note: None,
status,
sent_event_id: None,
received_rumor_id: None,
created_at: unix_time(),
updated_at: unix_time(),
}
}
fn ctx<'a>(
state: SlateState,
amount: u64,
sender: &'a str,
meta: Option<&'a TxNostrMeta>,
tx_exists: bool,
) -> IngestContext<'a> {
IngestContext {
state,
amount,
sender,
meta,
tx_exists,
is_contact: false,
accept: AcceptPolicy::Everyone,
}
}
#[test]
fn s1_auto_receives_from_anyone_by_default() {
let c = ctx(SlateState::Standard1, 100, ALICE, None, false);
assert_eq!(decide(&c), IngestDecision::AutoReceive);
}
#[test]
fn s1_zero_amount_drops() {
let c = ctx(SlateState::Standard1, 0, ALICE, None, false);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s1_duplicate_drops() {
let m = meta(
NostrTxDirection::Received,
NostrSendStatus::RepliedS2,
ALICE,
);
let c = ctx(SlateState::Standard1, 100, ALICE, Some(&m), false);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
let c2 = ctx(SlateState::Standard1, 100, ALICE, None, true);
assert!(matches!(decide(&c2), IngestDecision::Drop(_)));
}
#[test]
fn s1_contacts_policy_surfaces_unknown() {
let mut c = ctx(SlateState::Standard1, 100, ALICE, None, false);
c.accept = AcceptPolicy::Contacts;
assert_eq!(decide(&c), IngestDecision::SurfaceIncoming);
c.is_contact = true;
assert_eq!(decide(&c), IngestDecision::AutoReceive);
}
#[test]
fn s1_ask_policy_always_surfaces() {
let mut c = ctx(SlateState::Standard1, 100, ALICE, None, false);
c.accept = AcceptPolicy::Ask;
c.is_contact = true;
assert_eq!(decide(&c), IngestDecision::SurfaceIncoming);
}
#[test]
fn s2_finalizes_only_matching_pending_send() {
let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE);
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
assert_eq!(decide(&c), IngestDecision::FinalizePost);
}
#[test]
fn s2_from_wrong_sender_drops() {
let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE);
let c = ctx(SlateState::Standard2, 100, MALLORY, Some(&m), true);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_without_meta_drops() {
let c = ctx(SlateState::Standard2, 100, ALICE, None, true);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_without_wallet_tx_drops() {
let m = meta(NostrTxDirection::Sent, NostrSendStatus::AwaitingS2, ALICE);
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), false);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn s2_wrong_direction_drops() {
let m = meta(
NostrTxDirection::Received,
NostrSendStatus::RepliedS2,
ALICE,
);
let c = ctx(SlateState::Standard2, 100, ALICE, Some(&m), true);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
#[test]
fn i1_never_pays_automatically() {
// Even from a contact under the most permissive policy.
let mut c = ctx(SlateState::Invoice1, 100, ALICE, None, false);
c.is_contact = true;
c.accept = AcceptPolicy::Everyone;
assert_eq!(decide(&c), IngestDecision::SurfaceRequest);
}
#[test]
fn i2_finalizes_only_matching_request() {
let m = meta(
NostrTxDirection::RequestedByUs,
NostrSendStatus::AwaitingI2,
ALICE,
);
let c = ctx(SlateState::Invoice2, 100, ALICE, Some(&m), true);
assert_eq!(decide(&c), IngestDecision::FinalizePost);
let c2 = ctx(SlateState::Invoice2, 100, MALLORY, Some(&m), true);
assert!(matches!(decide(&c2), IngestDecision::Drop(_)));
}
#[test]
fn terminal_states_drop() {
for state in [
SlateState::Standard3,
SlateState::Invoice3,
SlateState::Unknown,
] {
let c = ctx(state, 100, ALICE, None, false);
assert!(matches!(decide(&c), IngestDecision::Drop(_)));
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Nostr payment-messaging subsystem: contacts are nostr users, slatepacks
//! travel as NIP-17 private DMs (NIP-44 encrypted, NIP-59 gift-wrapped) over
//! relays reached through the embedded Tor client.
mod types;
pub use types::*;
mod config;
pub use config::NostrConfig;
pub mod relays;
mod store;
pub use store::NostrStore;
mod identity;
pub use identity::{IdentitySource, NostrIdentity};
mod protocol;
pub use protocol::*;
mod ingest;
pub use ingest::*;
mod client;
pub use client::NostrService;
pub mod nip05;
+250
View File
@@ -0,0 +1,250 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! NIP-05 username resolution/verification and goblin.st registration,
//! all HTTP routed through the embedded Tor client.
use base64::Engine;
use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::nostr::relays::HOME_NIP05_DOMAIN;
use crate::tor::Tor;
/// Result of resolving a NIP-05 identifier.
#[derive(Debug, Clone)]
pub struct Nip05Resolution {
pub pubkey: PublicKey,
pub relays: Vec<String>,
}
/// Parse `user@domain` into (name, domain). A bare `@user` or `user`
/// resolves against the home domain (goblin.st).
pub fn split_identifier(input: &str) -> Option<(String, String)> {
let trimmed = input.trim().trim_start_matches('@');
if trimmed.is_empty() {
return None;
}
match trimmed.split_once('@') {
Some((name, domain)) if !name.is_empty() && domain.contains('.') => {
Some((name.to_lowercase(), domain.to_lowercase()))
}
Some(_) => None,
None => Some((trimmed.to_lowercase(), HOME_NIP05_DOMAIN.to_string())),
}
}
/// Resolve a NIP-05 identifier (user@domain) to a pubkey + relay hints.
pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
let url = format!(
"https://{}/.well-known/nostr.json?name={}",
domain,
urlencode(name)
);
let body = Tor::http_request("GET", url, None, vec![]).await?;
parse_well_known(&body, name)
}
/// Verify that a pubkey matches its claimed NIP-05 identifier.
pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool {
match resolve(name, domain).await {
Some(res) => res.pubkey == *pubkey,
None => false,
}
}
/// Parse a .well-known/nostr.json document for a specific name.
pub fn parse_well_known(body: &str, name: &str) -> Option<Nip05Resolution> {
let doc: Value = serde_json::from_str(body).ok()?;
let pk_hex = doc.get("names")?.get(name)?.as_str()?;
let pubkey = PublicKey::from_hex(pk_hex).ok()?;
let relays = doc
.get("relays")
.and_then(|r| r.get(pk_hex))
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Some(Nip05Resolution { pubkey, relays })
}
/// Availability result from the registration server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Availability {
Available,
Taken,
Reserved,
Invalid,
Quarantined,
Unknown,
}
/// Check name availability against the identity server.
pub async fn check_availability(server: &str, name: &str) -> Availability {
let url = format!(
"{}/api/v1/name/{}",
server.trim_end_matches('/'),
urlencode(name)
);
let Some(body) = Tor::http_request("GET", url, None, vec![]).await else {
return Availability::Unknown;
};
let Ok(doc) = serde_json::from_str::<Value>(&body) else {
return Availability::Unknown;
};
if doc.get("available").and_then(|v| v.as_bool()) == Some(true) {
return Availability::Available;
}
match doc.get("reason").and_then(|v| v.as_str()) {
Some("taken") => Availability::Taken,
Some("reserved") => Availability::Reserved,
Some("invalid") => Availability::Invalid,
Some("quarantined") => Availability::Quarantined,
_ => Availability::Unknown,
}
}
/// Build a NIP-98 Authorization header value for a request.
fn nip98_auth(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> Option<String> {
let mut tags = vec![
Tag::custom(TagKind::custom("u"), [url.to_string()]),
Tag::custom(TagKind::custom("method"), [method.to_string()]),
];
if let Some(body) = body {
let hash = hex::encode(Sha256::digest(body));
tags.push(Tag::custom(TagKind::custom("payload"), [hash]));
}
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.ok()?;
let encoded = base64::engine::general_purpose::STANDARD.encode(event.as_json());
Some(format!("Nostr {}", encoded))
}
/// Registration outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegisterResult {
/// Registered (or already owned): full nip05 identifier.
Ok(String),
/// Name conflict (taken/quarantined/pubkey already has a name).
Conflict(String),
/// Request rejected (invalid/reserved/unauthorized).
Rejected(String),
/// Network failure.
Network,
}
/// Register `name` for our keys at the identity server (NIP-98 authed).
pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/register", server);
let body = serde_json::json!({
"name": name.to_lowercase(),
"pubkey": keys.public_key().to_hex(),
})
.to_string();
let Some(auth) = nip98_auth(keys, &url, "POST", Some(body.as_bytes())) else {
return RegisterResult::Rejected("auth event build failed".into());
};
let headers = vec![
("Authorization".to_string(), auth),
("Content-Type".to_string(), "application/json".to_string()),
];
let Some(resp) = Tor::http_request("POST", url, Some(body), headers).await else {
return RegisterResult::Network;
};
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
return RegisterResult::Rejected(format!("bad response: {}", resp));
};
if let Some(nip05) = doc.get("nip05").and_then(|v| v.as_str()) {
return RegisterResult::Ok(nip05.to_string());
}
let err = doc
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error")
.to_string();
if err.contains("taken") || err.contains("quarantined") || err.contains("already has") {
RegisterResult::Conflict(err)
} else {
RegisterResult::Rejected(err)
}
}
/// Release a registered name (NIP-98 authed by the owner).
pub async fn unregister(server: &str, name: &str, keys: &Keys) -> bool {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/register/{}", server, urlencode(name));
let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else {
return false;
};
let headers = vec![("Authorization".to_string(), auth)];
match Tor::http_request("DELETE", url, None, headers).await {
Some(resp) => resp.contains("\"released\":true"),
None => false,
}
}
/// Minimal percent-encoding for name path/query segments.
fn urlencode(s: &str) -> String {
s.chars()
.flat_map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
vec![c]
} else {
format!("%{:02X}", c as u32).chars().collect()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_identifiers() {
assert_eq!(
split_identifier("@ada"),
Some(("ada".to_string(), "goblin.st".to_string()))
);
assert_eq!(
split_identifier("ada"),
Some(("ada".to_string(), "goblin.st".to_string()))
);
assert_eq!(
split_identifier("Ada@Example.COM"),
Some(("ada".to_string(), "example.com".to_string()))
);
assert_eq!(split_identifier("ada@"), None);
assert_eq!(split_identifier(""), None);
}
#[test]
fn parses_well_known() {
let body = r#"{
"names": {"ada": "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"},
"relays": {"91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05": ["wss://nrelay.us-ea.st"]}
}"#;
let res = parse_well_known(body, "ada").unwrap();
assert_eq!(res.relays, vec!["wss://nrelay.us-ea.st".to_string()]);
assert!(parse_well_known(body, "bob").is_none());
assert!(parse_well_known("not json", "ada").is_none());
}
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Goblin payment message protocol over NIP-17 (kind 14 rumors).
//!
//! Content layout: a one-line human readable preamble, a blank line and the
//! raw slatepack armor. The per-payment note travels in the standard
//! `subject` tag; a `goblin` tag marks the protocol version. Classification
//! NEVER trusts tags — only the parsed slate.
use nostr_sdk::{Tag, TagKind, Tags};
use regex::Regex;
use std::sync::LazyLock;
/// Maximum gift wrap content size accepted before unwrapping.
pub const MAX_WRAP_CONTENT: usize = 64 * 1024;
/// Maximum rumor content size accepted after unwrapping.
pub const MAX_RUMOR_CONTENT: usize = 32 * 1024;
/// Maximum slatepack armor size accepted.
pub const MAX_SLATEPACK: usize = 30 * 1024;
/// Maximum note length in characters after sanitization.
pub const MAX_NOTE_CHARS: usize = 256;
/// Protocol marker tag name.
pub const GOBLIN_TAG: &str = "goblin";
/// Protocol version value.
pub const PROTOCOL_VERSION: &str = "1";
/// Human readable preamble other NIP-17 clients render.
pub const PREAMBLE: &str =
"[Goblin] GRIN payment message — open in Goblin (https://goblin.st) to process.";
static SLATEPACK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"BEGINSLATEPACK\.[\s\S]*?ENDSLATEPACK\.").expect("slatepack regex")
});
/// Sanitize a user note: strip control characters, collapse whitespace,
/// trim and cap the length. Returns `None` when nothing readable remains.
pub fn sanitize_note(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = collapsed.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(MAX_NOTE_CHARS).collect())
}
/// Build the kind-14 rumor content for a slatepack payment message.
pub fn build_payment_content(slatepack: &str) -> String {
format!("{}\n\n{}", PREAMBLE, slatepack.trim())
}
/// Build rumor tags: protocol marker plus optional subject note.
pub fn build_rumor_tags(note: Option<&str>) -> Vec<Tag> {
let mut tags = vec![Tag::custom(
TagKind::custom(GOBLIN_TAG),
[PROTOCOL_VERSION.to_string()],
)];
if let Some(note) = note.and_then(sanitize_note) {
tags.push(Tag::custom(TagKind::custom("subject"), [note]));
}
tags
}
/// Extract exactly one slatepack armor block from rumor content.
/// More than one block, none at all, or an oversized block returns `None`.
pub fn extract_slatepack(content: &str) -> Option<String> {
if content.len() > MAX_RUMOR_CONTENT {
return None;
}
let mut matches = SLATEPACK_RE.find_iter(content);
let first = matches.next()?;
if matches.next().is_some() {
// Multiple blocks: ambiguous, refuse.
return None;
}
let armor = first.as_str().trim().to_string();
if armor.len() > MAX_SLATEPACK {
return None;
}
Some(armor)
}
/// Read the sanitized subject (note) from rumor tags.
pub fn extract_subject(tags: &Tags) -> Option<String> {
for tag in tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) == Some("subject") {
if let Some(value) = parts.get(1) {
return sanitize_note(value);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const PACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K \
bRGBoQbtTNCb12u DBMTNGsCT7iqGd3 7Sya3iCMu9PdcKW QzL3Wh4qsuTRMyL \
R3Atup1Bf3wgEbi ENMmTon9zFMD3fE 2muWLSZJYnSbN16 89zvvW45w3sQekX \
7d6FGCdJqDXfsmt Gh3CSNNRz7emxZw uHEDFmYqgUkSCk2 ZXAeFCSWZ3nogyB \
o9LL75ZAYTbAQ3d e1bQAGmiKWWQAJ8 oCWk5NHnf6QJhLB ZAtNYUiBu6dgNRM \
ZqxYBhWHtcSkpFn PmJh1nLDfyTbAmM 1AQpoxFBRMUyDmf nNZ75bL5xX9KQVB \
C1q4HEgqgRtAvNo 1deUSPYsCfRZ1Wd k2Lqo6w8oCe2cyU rMcLnRYrFgL27dT \
gZBYLgAfqqHRWaR cnNnnXMNpdNuQbe ojMNMTBuFFHJSus PCBVcvHGEKnYHWS \
W3PCH1MFowyfDxX 4D3DcsGnSAEAxFt 9rEzNuKbcKEfL9z gKVQoCKqzUXVNCZ \
jaG7M8B7etApvXr i1qzezfk7rTQz1k 6XJDjFb1JoTL5wo bSdkzfXJDBfWtAB \
gVMVkSdSXgcZqWS XL4MwBR8VfPv78s g7eRJVuRrBaQTKn xGRT7keqLBPMRRA \
LXkPDgQpHWpFei4 fnUVcuV4EWXarmm 3a1tBZpAvgTKuvF mvVAyeJTagrEXrS \
J2scK99rjQuLpAZ 1135LqkGfMQRmkN 4cWEoYzM3U6BS2y mD3sCctEMNHJKKa \
amGfXo16VLEjvw1 LvAVGFqyo64UQHV V63ufGc3qZkZcSU 1bSaCSDsKs8jzkz \
6jztk3DqqUiZBV3 reNzHKAEhMCfWtD W9STzaTwiakwwGq mcsHcUVJ9SVi7Hd \
1cKB9PNJ6FRJUjh AHWoaXBHRRGCNcm fpPMA9Hxn3BNXgs 8gDosk8mTpnDFRA \
uYbA8eX4d2BG2Hd YsApEnjGBkXuXdg eEdyDvfqQEUDRRG iAjp6X5ZQ6JCNYP \
LFNAFwkjqQ8XqRs aXmDgYTV4hpVtuc 5w69tnULM7vEnXm 14tHK9GktqgNBVy \
LJiVf8feoFc1Lao MEXVJSdpu7sUSn2 8Mz9zPS7XJWyAyT 36WuJSx7DjMpnB2 \
2vqXAjMwYAXmL2V Vmm2Y8wmhomBd1A YwPmTKAm5gFBL5W RkAGUJxq46DCWbz \
mzaBhLqswMGcRUf qmiPiQGqGEMnyQy yMa2HSc9wbXc78d 8GCkRgYepCFK7tC \
Ynw5HuANFLBJgXM zYbR6XLkP8cSC7. ENDSLATEPACK.";
#[test]
fn extracts_single_slatepack() {
let content = format!("{}\n\n{}", PREAMBLE, PACK);
let got = extract_slatepack(&content).unwrap();
assert!(got.starts_with("BEGINSLATEPACK."));
assert!(got.ends_with("ENDSLATEPACK."));
}
#[test]
fn rejects_no_slatepack() {
assert!(extract_slatepack("hi there, no payment here").is_none());
assert!(extract_slatepack("").is_none());
assert!(extract_slatepack("BEGINSLATEPACK. truncated junk").is_none());
}
#[test]
fn rejects_two_slatepacks() {
let content = format!("{} {}", PACK, PACK);
assert!(extract_slatepack(&content).is_none());
}
#[test]
fn rejects_oversize() {
let huge = format!(
"BEGINSLATEPACK. {} ENDSLATEPACK.",
"A".repeat(MAX_SLATEPACK + 1)
);
assert!(extract_slatepack(&huge).is_none());
let oversize_content = "x".repeat(MAX_RUMOR_CONTENT + 1);
assert!(extract_slatepack(&oversize_content).is_none());
}
#[test]
fn sanitizes_notes() {
assert_eq!(sanitize_note(" lunch :) "), Some("lunch :)".to_string()));
assert_eq!(
sanitize_note("a\u{0000}b\u{001b}[31mc"),
Some("a b [31mc".to_string())
);
assert_eq!(
sanitize_note("multi space\n\nnewline"),
Some("multi space newline".to_string())
);
assert_eq!(sanitize_note("\u{0007}\u{0008}"), None);
assert_eq!(sanitize_note(""), None);
let long = "y".repeat(MAX_NOTE_CHARS + 50);
assert_eq!(
sanitize_note(&long).unwrap().chars().count(),
MAX_NOTE_CHARS
);
}
#[test]
fn builds_content_with_preamble() {
let c = build_payment_content(PACK);
assert!(c.starts_with(PREAMBLE));
assert!(extract_slatepack(&c).is_some());
}
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Default relay set and relay list helpers.
/// Default DM relays: the Goblin relay plus large public relays for redundancy.
pub const DEFAULT_RELAYS: &[&str] = &[
"wss://nrelay.us-ea.st",
"wss://relay.damus.io",
"wss://nos.lol",
];
/// Default NIP-05 identity server.
pub const DEFAULT_NIP05_SERVER: &str = "https://goblin.st";
/// Domain whose NIP-05 names display as plain @user.
pub const HOME_NIP05_DOMAIN: &str = "goblin.st";
/// Maximum relays published in the kind 10050 DM relay list (NIP-17 guidance).
pub const MAX_DM_RELAYS: usize = 3;
/// Normalize a user-entered relay url (adds wss:// when missing).
pub fn normalize_relay_url(input: &str) -> Option<String> {
let trimmed = input.trim().trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
let url = if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") {
trimmed.to_string()
} else {
format!("wss://{}", trimmed)
};
// Basic shape validation.
match nostr_sdk::Url::parse(&url) {
Ok(u) if u.host_str().is_some() => Some(url),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_relay_urls() {
assert_eq!(
normalize_relay_url("nrelay.us-ea.st"),
Some("wss://nrelay.us-ea.st".to_string())
);
assert_eq!(
normalize_relay_url("wss://relay.damus.io/"),
Some("wss://relay.damus.io".to_string())
);
assert_eq!(
normalize_relay_url("ws://127.0.0.1:8088"),
Some("ws://127.0.0.1:8088".to_string())
);
assert_eq!(normalize_relay_url(""), None);
assert_eq!(normalize_relay_url(" "), None);
}
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr metadata archive: tx metadata, contacts, payment requests
//! and processed-event markers. rkv (SafeMode) storage under the wallet data
//! directory — the user-controlled local archive.
use rkv::backend::{SafeMode, SafeModeDatabase, SafeModeEnvironment};
use rkv::{Manager, Rkv, SingleStore, StoreOptions, Value};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use crate::nostr::types::*;
/// Keys are processed-event markers older than this get pruned (30 days).
const PROCESSED_TTL_SECS: i64 = 30 * 86_400;
/// Nostr metadata archive for a wallet.
pub struct NostrStore {
env: Arc<RwLock<Rkv<SafeModeEnvironment>>>,
/// Tx metadata by slate uuid.
tx_meta: SingleStore<SafeModeDatabase>,
/// Contacts by pubkey hex.
contacts: SingleStore<SafeModeDatabase>,
/// Payment requests by rumor id hex.
requests: SingleStore<SafeModeDatabase>,
/// Processed markers (event/rumor ids and slate states) to timestamps.
processed: SingleStore<SafeModeDatabase>,
/// Service settings (last connected time etc).
settings: SingleStore<SafeModeDatabase>,
}
impl NostrStore {
/// Open or create the archive in the provided directory.
pub fn new(dir: PathBuf) -> Self {
let _ = fs::create_dir_all(&dir);
let mut manager = Manager::<SafeModeEnvironment>::singleton().write().unwrap();
let created_arc = manager
.get_or_create(dir.as_path(), Rkv::new::<SafeMode>)
.unwrap();
let env = created_arc.clone();
let k = created_arc.read().unwrap();
let tx_meta = k
.open_single("nostr_tx_meta", StoreOptions::create())
.unwrap();
let contacts = k
.open_single("nostr_contacts", StoreOptions::create())
.unwrap();
let requests = k
.open_single("nostr_requests", StoreOptions::create())
.unwrap();
let processed = k
.open_single("nostr_processed", StoreOptions::create())
.unwrap();
let settings = k
.open_single("nostr_settings", StoreOptions::create())
.unwrap();
Self {
env,
tx_meta,
contacts,
requests,
processed,
settings,
}
}
fn get_json<T: DeserializeOwned>(
&self,
store: &SingleStore<SafeModeDatabase>,
key: &str,
) -> Option<T> {
let env = self.env.read().unwrap();
let reader = env.read().unwrap();
if let Ok(Some(Value::Json(raw))) = store.get(&reader, key) {
return serde_json::from_str(raw).ok();
}
None
}
fn put_json<T: Serialize>(&self, store: &SingleStore<SafeModeDatabase>, key: &str, value: &T) {
if let Ok(raw) = serde_json::to_string(value) {
let env = self.env.read().unwrap();
let mut writer = env.write().unwrap();
let _ = store.put(&mut writer, key, &Value::Json(&raw));
let _ = writer.commit();
}
}
fn delete(&self, store: &SingleStore<SafeModeDatabase>, key: &str) {
let env = self.env.read().unwrap();
let mut writer = env.write().unwrap();
let _ = store.delete(&mut writer, key);
let _ = writer.commit();
}
fn all_json<T: DeserializeOwned>(&self, store: &SingleStore<SafeModeDatabase>) -> Vec<T> {
let env = self.env.read().unwrap();
let reader = env.read().unwrap();
let mut out = vec![];
if let Ok(iter) = store.iter_start(&reader) {
for item in iter.flatten() {
if let (_, Value::Json(raw)) = item {
if let Ok(v) = serde_json::from_str(raw) {
out.push(v);
}
}
}
}
out
}
fn clear(&self, store: &SingleStore<SafeModeDatabase>) {
let env = self.env.read().unwrap();
let mut writer = env.write().unwrap();
let _ = store.clear(&mut writer);
let _ = writer.commit();
}
// ── tx metadata ─────────────────────────────────────────────────────────
pub fn tx_meta(&self, slate_id: &str) -> Option<TxNostrMeta> {
self.get_json(&self.tx_meta, slate_id)
}
pub fn save_tx_meta(&self, meta: &TxNostrMeta) {
self.put_json(&self.tx_meta, &meta.slate_id, meta);
}
pub fn all_tx_meta(&self) -> Vec<TxNostrMeta> {
self.all_json(&self.tx_meta)
}
/// Update status of existing tx metadata.
pub fn update_tx_status(&self, slate_id: &str, status: NostrSendStatus) {
if let Some(mut meta) = self.tx_meta(slate_id) {
meta.status = status;
meta.updated_at = unix_time();
self.save_tx_meta(&meta);
}
}
// ── contacts ────────────────────────────────────────────────────────────
pub fn contact(&self, npub_hex: &str) -> Option<Contact> {
self.get_json(&self.contacts, npub_hex)
}
pub fn save_contact(&self, contact: &Contact) {
self.put_json(&self.contacts, &contact.npub, contact);
}
pub fn delete_contact(&self, npub_hex: &str) {
self.delete(&self.contacts, npub_hex);
}
pub fn all_contacts(&self) -> Vec<Contact> {
self.all_json(&self.contacts)
}
// ── payment requests ────────────────────────────────────────────────────
pub fn request(&self, rumor_id: &str) -> Option<PaymentRequest> {
self.get_json(&self.requests, rumor_id)
}
pub fn save_request(&self, request: &PaymentRequest) {
self.put_json(&self.requests, &request.rumor_id, request);
}
pub fn all_requests(&self) -> Vec<PaymentRequest> {
self.all_json(&self.requests)
}
pub fn pending_requests(&self) -> Vec<PaymentRequest> {
let mut reqs: Vec<PaymentRequest> = self
.all_requests()
.into_iter()
.filter(|r| r.status == RequestStatus::Pending)
.collect();
reqs.sort_by_key(|r| std::cmp::Reverse(r.received_at));
reqs
}
// ── processed markers ───────────────────────────────────────────────────
pub fn is_processed(&self, key: &str) -> bool {
let env = self.env.read().unwrap();
let reader = env.read().unwrap();
matches!(self.processed.get(&reader, key), Ok(Some(_)))
}
pub fn mark_processed(&self, key: &str) {
let env = self.env.read().unwrap();
let mut writer = env.write().unwrap();
let _ = self
.processed
.put(&mut writer, key, &Value::I64(unix_time()));
let _ = writer.commit();
}
/// Remove processed markers older than the TTL.
pub fn prune_processed(&self) {
let cutoff = unix_time() - PROCESSED_TTL_SECS;
let stale: Vec<String> = {
let env = self.env.read().unwrap();
let reader = env.read().unwrap();
let mut stale = vec![];
if let Ok(iter) = self.processed.iter_start(&reader) {
for item in iter.flatten() {
if let (key, Value::I64(ts)) = item {
if ts < cutoff {
if let Ok(k) = std::str::from_utf8(key) {
stale.push(k.to_string());
}
}
}
}
}
stale
};
for key in stale {
self.delete(&self.processed, &key);
}
}
// ── settings ────────────────────────────────────────────────────────────
pub fn last_connected_at(&self) -> Option<i64> {
let env = self.env.read().unwrap();
let reader = env.read().unwrap();
if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, "last_connected_at") {
return Some(v);
}
None
}
pub fn set_last_connected_at(&self, ts: i64) {
let env = self.env.read().unwrap();
let mut writer = env.write().unwrap();
let _ = self
.settings
.put(&mut writer, "last_connected_at", &Value::I64(ts));
let _ = writer.commit();
}
// ── archive control (user-facing) ───────────────────────────────────────
/// Export the whole archive as a JSON document.
pub fn export_json(&self, npub: &str) -> String {
let doc = serde_json::json!({
"exported_at": unix_time(),
"npub": npub,
"contacts": self.all_contacts(),
"tx_meta": self.all_tx_meta(),
"requests": self.all_requests(),
});
serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string())
}
/// Wipe payment history metadata (keeps contacts).
pub fn wipe_archive(&self) {
self.clear(&self.tx_meta);
self.clear(&self.requests);
self.clear(&self.processed);
}
/// Wipe everything including contacts.
pub fn wipe_all(&self) {
self.wipe_archive();
self.clear(&self.contacts);
self.clear(&self.settings);
}
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Shared types of the nostr payment-messaging subsystem.
use serde_derive::{Deserialize, Serialize};
/// Direction of a nostr-transported transaction relative to this wallet.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum NostrTxDirection {
/// We sent funds (Standard flow, we created S1).
Sent,
/// We received funds (Standard flow, we replied S2).
Received,
/// We issued an invoice / requested funds (Invoice flow, we created I1).
RequestedByUs,
/// Someone requested funds of us (Invoice flow, we may pay I1).
RequestedOfUs,
}
/// Lifecycle status of a nostr-transported transaction.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum NostrSendStatus {
/// Slate created locally, DM not dispatched yet.
Created,
/// S1 DM dispatched, waiting for the S2 reply.
AwaitingS2,
/// Incoming S1 processed, S2 reply not yet dispatched (crash recovery).
ReceivedNoReply,
/// S2 reply dispatched for a received payment.
RepliedS2,
/// I1 request dispatched, waiting for the I2 reply.
AwaitingI2,
/// We paid an invoice (I2 reply sent), their side finalizes.
PaidAwaitingFinalize,
/// Transaction finalized and posted.
Finalized,
/// DM dispatch failed, retry possible.
SendFailed,
/// Cancelled locally.
Cancelled,
}
/// Per-transaction nostr metadata, joined to wallet txs by slate id.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TxNostrMeta {
pub ver: u8,
/// Slate UUID string.
pub slate_id: String,
/// Counterparty public key, hex.
pub npub: String,
pub direction: NostrTxDirection,
/// Sanitized user note (subject line).
pub note: Option<String>,
pub status: NostrSendStatus,
/// Gift wrap event id of our outgoing message, hex.
pub sent_event_id: Option<String>,
/// Rumor id of the counterparty message we processed, hex.
pub received_rumor_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
/// A contact: another nostr user we can pay.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Contact {
pub ver: u8,
/// Public key, hex.
pub npub: String,
/// Local petname, overrides any resolved name.
pub petname: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05: Option<String>,
/// Unix time of last successful NIP-05 verification.
pub nip05_verified_at: Option<i64>,
/// Known DM relays (kind 10050) of the contact.
pub relays: Vec<String>,
/// Avatar palette index.
pub hue: u8,
/// Auto-added from an incoming payment, not yet confirmed by the user.
pub unknown: bool,
pub added_at: i64,
pub last_paid_at: Option<i64>,
}
/// Status of an incoming payment request (Invoice1).
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum RequestStatus {
Pending,
Approved,
Declined,
Expired,
}
/// An incoming Invoice1 payment request awaiting explicit user approval.
/// NEVER paid automatically.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PaymentRequest {
pub ver: u8,
/// Rumor event id, hex (storage key).
pub rumor_id: String,
/// Slate UUID string.
pub slate_id: String,
/// Raw slatepack armor to pay on approval.
pub slatepack: String,
/// Requester public key, hex.
pub npub: String,
/// Requested amount in atomic units.
pub amount: u64,
/// Sanitized note.
pub note: Option<String>,
pub received_at: i64,
pub status: RequestStatus,
}
/// Current unix time in seconds.
pub fn unix_time() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
+77 -3
View File
@@ -69,6 +69,12 @@ pub struct AppConfig {
/// Flag to check if dark theme should be used, use system settings if not set.
use_dark_theme: Option<bool>,
/// Color theme identifier: "light", "dark" or "yellow".
theme: Option<String>,
/// Density identifier: "compact", "regular" or "comfy".
density: Option<String>,
/// Identifier of the last opened wallet to boot into.
last_wallet_id: Option<i64>,
/// Flag to use proxy for network requests.
use_proxy: Option<bool>,
@@ -100,6 +106,9 @@ impl Default for AppConfig {
lang: None,
english_keyboard: None,
use_dark_theme: None,
theme: None,
density: None,
last_wallet_id: None,
use_proxy: None,
use_socks_proxy: None,
http_proxy_url: None,
@@ -286,16 +295,81 @@ impl AppConfig {
w_config.save();
}
/// Check if dark theme should be used.
/// Check if dark theme should be used (derived from the theme tokens).
pub fn dark_theme() -> Option<bool> {
let r_config = Settings::app_config_to_read();
if let Some(theme) = r_config.theme.clone() {
if let Some(kind) = crate::gui::theme::ThemeKind::from_id(&theme) {
return Some(match kind {
crate::gui::theme::ThemeKind::Light => false,
crate::gui::theme::ThemeKind::Dark => true,
// Yellow paints dark ink on a light background.
crate::gui::theme::ThemeKind::Yellow => false,
});
}
}
r_config.use_dark_theme.clone()
}
/// Setup flag to use dark theme.
/// Setup flag to use dark theme (legacy path, maps to theme identifier).
pub fn set_dark_theme(use_dark: bool) {
Self::set_theme(if use_dark {
crate::gui::theme::ThemeKind::Dark
} else {
crate::gui::theme::ThemeKind::Light
});
}
/// Get current color theme, migrating the legacy dark flag when present.
pub fn theme() -> crate::gui::theme::ThemeKind {
let r_config = Settings::app_config_to_read();
if let Some(theme) = r_config.theme.clone() {
if let Some(kind) = crate::gui::theme::ThemeKind::from_id(&theme) {
return kind;
}
}
match r_config.use_dark_theme {
Some(false) => crate::gui::theme::ThemeKind::Light,
// Goblin defaults to the dark theme.
_ => crate::gui::theme::ThemeKind::Dark,
}
}
/// Save color theme.
pub fn set_theme(kind: crate::gui::theme::ThemeKind) {
let mut w_config = Settings::app_config_to_update();
w_config.use_dark_theme = Some(use_dark);
w_config.theme = Some(kind.id().to_string());
w_config.use_dark_theme = Some(kind == crate::gui::theme::ThemeKind::Dark);
w_config.save();
}
/// Get current density.
pub fn density() -> crate::gui::theme::DensityKind {
let r_config = Settings::app_config_to_read();
r_config
.density
.clone()
.and_then(|d| crate::gui::theme::DensityKind::from_id(&d))
.unwrap_or(crate::gui::theme::DensityKind::Comfy)
}
/// Save density.
pub fn set_density(kind: crate::gui::theme::DensityKind) {
let mut w_config = Settings::app_config_to_update();
w_config.density = Some(kind.id().to_string());
w_config.save();
}
/// Get identifier of the last opened wallet.
pub fn last_wallet_id() -> Option<i64> {
let r_config = Settings::app_config_to_read();
r_config.last_wallet_id
}
/// Save identifier of the last opened wallet.
pub fn set_last_wallet_id(id: Option<i64>) {
let mut w_config = Settings::app_config_to_update();
w_config.last_wallet_id = id;
w_config.save();
}
+3 -3
View File
@@ -47,11 +47,11 @@ pub struct Settings {
impl Settings {
/// Main application directory name.
pub const MAIN_DIR_NAME: &'static str = ".grim";
pub const MAIN_DIR_NAME: &'static str = ".goblin";
/// Application socket name.
pub const SOCKET_NAME: &'static str = "grim.sock";
pub const SOCKET_NAME: &'static str = "goblin.sock";
/// Log file name.
pub const LOG_FILE_NAME: &'static str = "grim.log";
pub const LOG_FILE_NAME: &'static str = "goblin.log";
/// Initialize settings with app and node configs.
fn init() -> Self {
+2
View File
@@ -21,4 +21,6 @@ pub use tor::Tor;
mod types;
pub use types::*;
pub mod transport;
mod http;
+58
View File
@@ -348,6 +348,64 @@ impl Tor {
r_client_config.clone()
}
/// Get an isolated Tor client for outgoing connections (e.g. Nostr relays),
/// launching the embedded client first when needed. Blocking: bootstrap can
/// take up to a minute, call from a blocking-friendly context.
pub fn isolated_client_blocking() -> Option<TorClient<TokioNativeTlsRuntime>> {
if Self::client_config().is_none() {
Self::launch();
}
Self::client_config().map(|(c, _)| c.isolated_client())
}
/// Perform an HTTP request over the embedded Tor client with optional
/// headers, returning the response body. Used for NIP-05 and price APIs.
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
if Self::client_config().is_none() {
error!("Tor: client not launched");
return None;
}
let client = Self::client_config().unwrap().0.isolated_client();
let tls_conn = TlsConnector::builder().unwrap().build().unwrap();
let conn = ArtiHttpConnector::new(client, tls_conn);
let http = hyper_tor::Client::builder().build::<_, hyper_tor::Body>(conn);
let mut builder = hyper_tor::Request::builder()
.method(match method {
"POST" => hyper_tor::Method::POST,
"DELETE" => hyper_tor::Method::DELETE,
_ => hyper_tor::Method::GET,
})
.uri(url);
for (k, v) in headers {
builder = builder.header(k.as_str(), v.as_str());
}
let req = match builder.body(hyper_tor::Body::from(body.unwrap_or_default())) {
Ok(r) => r,
Err(e) => {
error!("Tor: http request build error: {}", e);
return None;
}
};
match http.request(req).await {
Ok(r) => match hyper_tor::body::to_bytes(r).await {
Ok(raw) => Some(String::from_utf8_lossy(&raw).to_string()),
Err(e) => {
error!("Tor: http response parse error: {}", e);
None
}
},
Err(e) => {
error!("Tor: http request failed: {}", e);
None
}
}
}
/// Check if Onion service is starting.
pub fn is_service_starting(id: &String) -> bool {
let r_services = TOR_STATE.start.read();
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! WebSocket transport for the Nostr relay pool routed through the embedded
//! Tor (arti) client that Goblin already runs for slatepack exchange.
//! Every connection uses a fresh isolated circuit.
use std::fmt;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
use async_wsocket::{ConnectionMode, Message};
use nostr_relay_pool::transport::error::TransportError;
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
use nostr_sdk::Url;
use nostr_sdk::util::BoxedFuture;
use tokio_tungstenite::tungstenite::Message as TgMessage;
use crate::tor::Tor;
/// Error type for transport failures outside the websocket layer.
#[derive(Debug)]
struct ArtiTransportError(String);
impl fmt::Display for ArtiTransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ArtiTransportError {}
fn terr(msg: impl Into<String>) -> TransportError {
TransportError::backend(ArtiTransportError(msg.into()))
}
/// Nostr websocket transport over the embedded arti Tor client.
#[derive(Debug, Clone, Copy, Default)]
pub struct ArtiWebSocketTransport;
impl WebSocketTransport for ArtiWebSocketTransport {
fn support_ping(&self) -> bool {
true
}
fn connect<'a>(
&'a self,
url: &'a Url,
_mode: &'a ConnectionMode,
timeout: Duration,
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
Box::pin(async move {
let host = url
.host_str()
.ok_or_else(|| terr("relay url has no host"))?
.to_string();
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
// Get an isolated Tor client, launching the embedded client if needed.
let client = tokio::task::spawn_blocking(Tor::isolated_client_blocking)
.await
.map_err(|e| terr(format!("tor client task failed: {e}")))?
.ok_or_else(|| terr("tor client is not available"))?;
// Open a Tor data stream to the relay host.
let stream = tokio::time::timeout(timeout, client.connect((host.as_str(), port)))
.await
.map_err(|_| terr("tor connect timeout"))?
.map_err(|e| terr(format!("tor connect failed: {e}")))?;
// Perform TLS (for wss) + websocket handshake over the Tor stream.
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
let (tx, rx) = ws.split();
let sink: WebSocketSink = Box::new(ArtiSink(tx)) as WebSocketSink;
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
match msg {
Ok(tg) => tg_to_message(tg).map(Ok),
Err(e) => Some(Err(TransportError::backend(e))),
}
})) as WebSocketStream;
Ok((sink, stream))
})
}
}
/// Convert a tungstenite message into an async-wsocket pool message.
/// Returns `None` for raw frames (never surfaced while reading).
fn tg_to_message(msg: TgMessage) -> Option<Message> {
match msg {
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
TgMessage::Close(_) => Some(Message::Close(None)),
TgMessage::Frame(_) => None,
}
}
/// Sink adapter converting pool messages into tungstenite messages.
struct ArtiSink<S>(S);
impl<S> Sink<Message> for ArtiSink<S>
where
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
{
type Error = TransportError;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_ready_unpin(cx)
.map_err(TransportError::backend)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.0)
.start_send_unpin(TgMessage::from(item))
.map_err(TransportError::backend)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_flush_unpin(cx)
.map_err(TransportError::backend)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_close_unpin(cx)
.map_err(TransportError::backend)
}
}
+20
View File
@@ -227,6 +227,26 @@ impl WalletConfig {
path.to_str().unwrap().to_string()
}
/// Get nostr identity directory path (holds identity.json).
pub fn get_nostr_path(&self) -> PathBuf {
let mut path = PathBuf::from(self.get_base_data_path());
path.push("nostr");
if !path.exists() {
let _ = fs::create_dir_all(path.clone());
}
path
}
/// Get nostr metadata archive database path.
pub fn get_nostr_db_path(&self) -> PathBuf {
let mut path = self.get_nostr_path();
path.push("db");
if !path.exists() {
let _ = fs::create_dir_all(path.clone());
}
path
}
/// Get Slatepack file path for Slate.
pub fn get_slate_path(&self, id: Uuid, state: &SlateState) -> PathBuf {
let mut path = PathBuf::from(self.get_base_data_path());
+11
View File
@@ -433,4 +433,15 @@ pub enum WalletTask {
/// Delete transaction.
/// * tx id
Delete(u32),
/// Send amount to a nostr contact as NIP-17 DM.
/// * amount
/// * receiver public key (hex)
/// * optional note (subject line)
NostrSend(u64, String, Option<String>),
/// Re-dispatch the pending nostr message for transaction.
/// * tx id
NostrResend(u32),
/// Pay an APPROVED incoming payment request (explicit user action).
/// * request id (rumor event id hex)
NostrPayRequest(String),
}
+325 -1
View File
@@ -14,6 +14,7 @@
use crate::AppConfig;
use crate::node::{Node, NodeConfig};
use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore};
use crate::tor::Tor;
use crate::wallet::seed::WalletSeed;
use crate::wallet::store::TxHeightStore;
@@ -45,7 +46,7 @@ use grin_wallet_libwallet::{
VersionedSlate, WalletBackend, WalletInitStatus, WalletInst, WalletLCProvider, address,
};
use grin_wallet_util::OnionV3Address;
use log::error;
use log::{error, info};
use num_bigint::BigInt;
use parking_lot::RwLock;
use rand::Rng;
@@ -142,6 +143,9 @@ pub struct Wallet {
tasks_sender: Arc<RwLock<Option<Sender<WalletTask>>>>,
/// Task result with optional transaction identifier.
task_result: Arc<RwLock<Option<(Option<u32>, WalletTask)>>>,
/// Nostr payment-messaging service, present while wallet is open.
nostr: Arc<RwLock<Option<Arc<NostrService>>>>,
}
impl Wallet {
@@ -180,6 +184,7 @@ impl Wallet {
proof_verifying: Arc::new(AtomicBool::new(false)),
tasks_sender: Arc::new(RwLock::new(None)),
task_result: Arc::new(RwLock::new(None)),
nostr: Arc::new(RwLock::new(None)),
}
}
@@ -328,6 +333,8 @@ impl Wallet {
if self.is_open() {
return Err(Error::GenericError("Already opened".to_string()));
}
// Keep the password for nostr identity setup after opening.
let nostr_password = password.to_string();
// Create new wallet instance if sync thread was stopped or instance was not created.
let has_instance = {
@@ -394,9 +401,74 @@ impl Wallet {
// Update Slatepack address and secret key.
self.update_secret_key_addr()?;
// Initialize nostr identity and service (non-fatal on failure).
self.init_nostr(&nostr_password);
Ok(())
}
/// Initialize the nostr identity and service for this wallet.
/// Failures are logged and disable nostr for the session only.
fn init_nostr(&self, password: &str) {
{
let r_nostr = self.nostr.read();
if r_nostr.is_some() {
return;
}
}
let config = self.get_config();
let wallet_dir = PathBuf::from(config.get_data_path());
let nostr_config = NostrConfig::load(wallet_dir);
if !nostr_config.enabled() {
return;
}
let nostr_dir = config.get_nostr_path();
// Load existing identity or derive one from the wallet seed (NIP-06).
let identity = match NostrIdentity::load(&nostr_dir) {
Some(identity) => identity,
None => {
let phrase = match self.get_recovery(password.to_string()) {
Ok(p) => p,
Err(e) => {
error!("nostr: recovery phrase unavailable: {:?}", e);
return;
}
};
match NostrIdentity::create_derived(&phrase, password, 0) {
Ok((identity, _)) => {
if let Err(e) = identity.save(&nostr_dir) {
error!("nostr: identity save failed: {e}");
return;
}
identity
}
Err(e) => {
error!("nostr: identity derivation failed: {e}");
return;
}
}
}
};
let keys = match identity.unlock(password) {
Ok(keys) => keys,
Err(e) => {
error!("nostr: identity unlock failed: {e}");
return;
}
};
info!("nostr: identity ready: {}", identity.npub);
let store = NostrStore::new(config.get_nostr_db_path());
let service = NostrService::new(keys, identity, nostr_config, store, nostr_dir);
let mut w_nostr = self.nostr.write();
*w_nostr = Some(service);
}
/// Get the nostr service when available.
pub fn nostr_service(&self) -> Option<Arc<NostrService>> {
let r_nostr = self.nostr.read();
r_nostr.clone()
}
/// Get keychain mask [`SecretKey`].
pub fn keychain_mask(&self) -> Option<SecretKey> {
let r_key = self.keychain_mask.read();
@@ -572,6 +644,13 @@ impl Wallet {
}
// Stop running Tor service.
Tor::stop_service(&service_id);
// Stop nostr service.
{
let mut w_nostr = wallet_close.nostr.write();
if let Some(service) = w_nostr.take() {
service.stop();
}
}
// Close the wallet.
let r_inst = wallet_close.instance.as_ref().read();
let instance = r_inst.clone().unwrap();
@@ -976,6 +1055,49 @@ impl Wallet {
fs::exists(slatepack_path).unwrap_or(false)
}
/// Read a stored Slatepack message text for the given slate id and state.
pub fn read_slatepack_text(&self, id: Uuid, state: &SlateState) -> Option<String> {
let path = self.get_config().get_slate_path(id, state);
fs::read_to_string(path).ok()
}
/// Check if the wallet has a transaction for the given slate id.
pub fn has_tx_for_slate(&self, slate_id: &Uuid) -> bool {
self.retrieve_tx_by_id(None, Some(*slate_id)).is_some()
}
/// Guarded nostr ingest: receive an incoming Standard1 payment and return
/// the S2 reply slate with its slatepack text. Receiving only creates an
/// output and signs — it never spends funds.
pub fn nostr_receive(&self, slate: &Slate) -> Result<(Slate, String), Error> {
let reply = self.receive(slate, None)?;
let text = self
.read_slatepack_text(reply.id, &reply.state)
.ok_or_else(|| Error::GenericError("response slatepack missing".to_string()))?;
Ok((reply, text))
}
/// Guarded nostr ingest: finalize and post a matching S2/I2 reply.
/// Caller (ingest policy) has already verified the counterparty.
pub fn nostr_finalize_post(&self, slate: &Slate) -> Result<(), Error> {
let tx = self
.retrieve_tx_by_id(None, Some(slate.id))
.ok_or_else(|| Error::GenericError("transaction not found".to_string()))?;
let finalized = self.finalize(slate, tx.id)?;
self.post(&finalized, Some(tx.id))?;
Ok(())
}
/// Pay an APPROVED payment request (Invoice1). Only ever called from the
/// explicit user approval task — never from the ingest pipeline.
pub fn nostr_pay(&self, slate: &Slate) -> Result<(Slate, String), Error> {
let reply = self.pay(slate)?;
let text = self
.read_slatepack_text(reply.id, &reply.state)
.ok_or_else(|| Error::GenericError("response slatepack missing".to_string()))?;
Ok((reply, text))
}
/// Get possible state from tx type.
pub fn get_slate_state(&self, slate_id: Uuid, tx_type: &TxLogEntryType) -> SlateState {
let mut slate = Slate::blank(1, false);
@@ -1716,6 +1838,11 @@ fn start_sync(wallet: Wallet) -> Thread {
Tor::start_service(api.1, key, &wallet.identifier());
}
}
// Start nostr payment-messaging service (idempotent).
if let Some(service) = wallet.nostr_service() {
service.start(wallet.clone());
}
}
// Sync wallet from node.
@@ -1987,6 +2114,203 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
w.on_tx_error(*id, Some(e));
}
},
WalletTask::NostrSend(a, receiver, note) => {
let Some(service) = w.nostr_service() else {
error!("nostr send: service not available");
return;
};
w.send_creating.store(true, Ordering::Relaxed);
match w.send(*a, None) {
Ok(s) => {
sync_wallet_data(&w, false);
let now = crate::nostr::unix_time();
// Record intent BEFORE the network dispatch so a crash
// is recovered by the service reconcile pass.
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
ver: 1,
slate_id: s.id.to_string(),
npub: receiver.clone(),
direction: crate::nostr::NostrTxDirection::Sent,
note: note.clone().and_then(|n| crate::nostr::sanitize_note(&n)),
status: crate::nostr::NostrSendStatus::Created,
sent_event_id: None,
received_rumor_id: None,
created_at: now,
updated_at: now,
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.send_creating.store(false, Ordering::Relaxed);
if let Some(text) = w.read_slatepack_text(s.id, &s.state) {
match service
.send_payment_dm(receiver, &text, note.as_deref())
.await
{
Ok(event_id) => {
let mut meta = service.store.tx_meta(&s.id.to_string()).unwrap();
meta.status = crate::nostr::NostrSendStatus::AwaitingS2;
meta.sent_event_id = Some(event_id);
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
// Update contact last paid time.
if let Some(mut contact) = service.store.contact(receiver) {
contact.last_paid_at = Some(crate::nostr::unix_time());
contact.unknown = false;
service.store.save_contact(&contact);
}
}
Err(e) => {
error!("nostr send dispatch failed: {e}");
service.store.update_tx_status(
&s.id.to_string(),
crate::nostr::NostrSendStatus::SendFailed,
);
if let Some(tx) = &tx {
w.on_tx_error(
tx.id,
Some(Error::GenericError(format!(
"nostr dispatch failed: {e}"
))),
);
}
}
}
}
w.on_task_result(tx, &t);
}
Err(e) => {
error!("nostr send error: {:?}", e);
w.send_creating.store(false, Ordering::Relaxed);
}
}
}
WalletTask::NostrResend(id) => {
let Some(service) = w.nostr_service() else {
return;
};
if let Some(s) = w.get_tx_slate(*id) {
let slate_id = s.id.to_string();
if let Some(meta) = service.store.tx_meta(&slate_id) {
if let Some(text) = w.read_slatepack_text(s.id, &s.state) {
match service
.send_payment_dm(&meta.npub, &text, meta.note.as_deref())
.await
{
Ok(event_id) => {
let mut meta = meta.clone();
meta.sent_event_id = Some(event_id);
if meta.status == crate::nostr::NostrSendStatus::SendFailed
|| meta.status == crate::nostr::NostrSendStatus::Created
{
meta.status = match meta.direction {
crate::nostr::NostrTxDirection::RequestedByUs => {
crate::nostr::NostrSendStatus::AwaitingI2
}
_ => crate::nostr::NostrSendStatus::AwaitingS2,
};
}
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
}
Err(e) => error!("nostr resend failed: {e}"),
}
}
}
}
}
WalletTask::NostrPayRequest(request_id) => {
let Some(service) = w.nostr_service() else {
return;
};
let Some(mut request) = service.store.request(request_id) else {
error!("nostr pay: request not found");
return;
};
if request.status != crate::nostr::RequestStatus::Pending {
error!("nostr pay: request is not pending");
return;
}
// Re-parse and re-validate the stored slatepack: it must still be
// an Invoice1 (or a Standard1 surfaced under a strict policy).
match w.parse_slatepack(&request.slatepack) {
Ok((s, _)) if s.state == SlateState::Invoice1 => match w.nostr_pay(&s) {
Ok((reply, text)) => {
let now = crate::nostr::unix_time();
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
ver: 1,
slate_id: reply.id.to_string(),
npub: request.npub.clone(),
direction: crate::nostr::NostrTxDirection::RequestedOfUs,
note: request.note.clone(),
status: crate::nostr::NostrSendStatus::ReceivedNoReply,
sent_event_id: None,
received_rumor_id: Some(request.rumor_id.clone()),
created_at: now,
updated_at: now,
});
match service.send_payment_dm(&request.npub, &text, None).await {
Ok(event_id) => {
let mut meta =
service.store.tx_meta(&reply.id.to_string()).unwrap();
meta.status = crate::nostr::NostrSendStatus::PaidAwaitingFinalize;
meta.sent_event_id = Some(event_id);
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
}
Err(e) => error!("nostr pay reply dispatch failed: {e}"),
}
request.status = crate::nostr::RequestStatus::Approved;
service.store.save_request(&request);
sync_wallet_data(&w, false);
}
Err(e) => {
error!("nostr pay failed: {:?}", e);
}
},
Ok((s, _)) if s.state == SlateState::Standard1 => {
// Incoming payment surfaced under Contacts/Ask policy:
// receiving is safe, process like an auto-receive.
match w.nostr_receive(&s) {
Ok((reply, text)) => {
let now = crate::nostr::unix_time();
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
ver: 1,
slate_id: reply.id.to_string(),
npub: request.npub.clone(),
direction: crate::nostr::NostrTxDirection::Received,
note: request.note.clone(),
status: crate::nostr::NostrSendStatus::ReceivedNoReply,
sent_event_id: None,
received_rumor_id: Some(request.rumor_id.clone()),
created_at: now,
updated_at: now,
});
match service.send_payment_dm(&request.npub, &text, None).await {
Ok(event_id) => {
let mut meta =
service.store.tx_meta(&reply.id.to_string()).unwrap();
meta.status = crate::nostr::NostrSendStatus::RepliedS2;
meta.sent_event_id = Some(event_id);
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
}
Err(e) => error!("nostr accept reply dispatch failed: {e}"),
}
request.status = crate::nostr::RequestStatus::Approved;
service.store.save_request(&request);
sync_wallet_data(&w, false);
}
Err(e) => {
error!("nostr accept failed: {:?}", e);
}
}
}
_ => {
error!("nostr pay: stored slatepack is not payable");
request.status = crate::nostr::RequestStatus::Expired;
service.store.save_request(&request);
}
}
}
};
}
+18 -18
View File
@@ -7,9 +7,9 @@
<?endif ?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Version="0.3.6" UpgradeCode="C19F9B41-CD13-4F0E-B27D-E0EF8CF1CE91" Language="1033" Name="Grim" Manufacturer="Ardocrat">
<Product Id="*" Version="0.3.6" UpgradeCode="C19F9B41-CD13-4F0E-B27D-E0EF8CF1CE91" Language="1033" Name="Goblin" Manufacturer="Ardocrat">
<Package Id="F4D20D15-2788-4199-9220-3905799817F6" InstallerVersion="300" Compressed="yes"/>
<Media Id="1" Cabinet="grim.cab" EmbedCab="yes" />
<Media Id="1" Cabinet="goblin.cab" EmbedCab="yes" />
<MajorUpgrade AllowDowngrades = "yes"/>
@@ -18,21 +18,21 @@
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id='$(var.PlatformProgramFilesFolder)'>
<Directory Id="APPLICATIONROOTDIRECTORY" Name="Grim"/>
<Directory Id="APPLICATIONROOTDIRECTORY" Name="Goblin"/>
</Directory>
<Directory Id="DesktopFolder" Name="Desktop">
<Component Id="ApplicationShortcutDesktop" Guid="14efa019-7ed7-4765-8263-fa5460f92495">
<Shortcut Id="ApplicationDesktopShortcut"
Name="Grim"
Name="Goblin"
Icon="Product.ico"
Description="GUI for Grin"
Target="[APPLICATIONROOTDIRECTORY]grim.exe"
Target="[APPLICATIONROOTDIRECTORY]goblin.exe"
WorkingDirectory="APPLICATIONROOTDIRECTORY"/>
<RemoveFolder Id="DesktopFolder" On="uninstall"/>
<RegistryValue
Root="HKCU"
Key="Software\Ardocrat\Grim"
Key="Software\Ardocrat\Goblin"
Name="installed"
Type="integer"
Value="1"
@@ -42,7 +42,7 @@
<!-- Step 1: Define the directory structure -->
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="Grim"/>
<Directory Id="ApplicationProgramsFolder" Name="Goblin"/>
</Directory>
</Directory>
@@ -50,12 +50,12 @@
<Component Id="License" Guid="4b1d11d3-5d76-430e-b5ef-87f1a62cf21b">
<File Id="LicenseFile" DiskId="1" Source="wix\License.rtf" KeyPath="yes"/>
</Component>
<Component Id="grim.exe" Guid="95444223-45BF-427A-85CA-61B035044305">
<File Id="grim.exe" Source="$(var.CargoTargetBinDir)\grim.exe" KeyPath="yes" Checksum="yes"/>
<Component Id="goblin.exe" Guid="95444223-45BF-427A-85CA-61B035044305">
<File Id="goblin.exe" Source="$(var.CargoTargetBinDir)\goblin.exe" KeyPath="yes" Checksum="yes"/>
<File Id="slatepack.ico" Source="wix\Product.ico" />
<ProgId Id='grim.slatepack' Description='Grin Slatepack message' Icon='slatepack.ico'>
<ProgId Id='goblin.slatepack' Description='Grin Slatepack message' Icon='slatepack.ico'>
<Extension Id='slatepack' ContentType='text/plain'>
<Verb Id='open' Command='Open' Target='[APPLICATIONROOTDIRECTORY]grim.exe' Argument='%1' />
<Verb Id='open' Command='Open' Target='[APPLICATIONROOTDIRECTORY]goblin.exe' Argument='%1' />
</Extension>
</ProgId>
</Component>
@@ -65,18 +65,18 @@
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ApplicationShortcut" Guid="07f7fc68-bc3e-4715-9c10-0231a92b5ccb">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="Grim"
Name="Goblin"
Description="Cross-platform GUI for Grin with focus on usability and availability to be used by anyone, anywhere."
Icon="Product.ico"
Target="[#grim.exe]"
Target="[#goblin.exe]"
WorkingDirectory="APPLICATIONROOTDIRECTORY"/>
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
<RegistryValue Root="HKCU" Key="Software\Ardocrat\Grim" Name="installed" Type="integer" Value="1" KeyPath="yes"/>
<RegistryValue Root="HKCU" Key="Software\Ardocrat\Goblin" Name="installed" Type="integer" Value="1" KeyPath="yes"/>
</Component>
</DirectoryRef>
<Feature Id="MainApplication" Title="Grim" Level="1">
<ComponentRef Id="grim.exe" />
<Feature Id="MainApplication" Title="Goblin" Level="1">
<ComponentRef Id="goblin.exe" />
<ComponentRef Id="License" />
<!-- Step 3: Tell WiX to install the shortcut -->
<ComponentRef Id="ApplicationShortcutDesktop" />
@@ -84,7 +84,7 @@
</Feature>
<Property Id='ARPHELPLINK' Value='https://github.com/ardocrat/grim'/>
<Property Id='ARPHELPLINK' Value='https://github.com/ardocrat/goblin'/>
<UI>
<UIRef Id="WixUI_Minimal" />
@@ -95,7 +95,7 @@
</UI>
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch Application" />
<Property Id="WixShellExecTarget" Value="[#grim.exe]" />
<Property Id="WixShellExecTarget" Value="[#goblin.exe]" />
<CustomAction Id="LaunchApplication"
BinaryKey="WixCA"
DllEntry="WixShellExec"