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>
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -8,7 +8,7 @@ android {
|
||||
buildToolsVersion = '36.1.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "mw.gri.android"
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 8.4 KiB |
|
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>
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
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 |
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,4 +21,6 @@ pub use tor::Tor;
|
||||
mod types;
|
||||
pub use types::*;
|
||||
|
||||
pub mod transport;
|
||||
|
||||
mod http;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||