diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index 98b11c9..1950378 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index daeae6c..d6e68b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index eadfba9..c867550 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,15 +2,15 @@ name = "grim" version = "0.3.6" authors = ["Ardocrat "] -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"] } diff --git a/android/app/build.gradle b/android/app/build.gradle index 9a22661..fbdde13 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,7 +8,7 @@ android { buildToolsVersion = '36.1.0' defaultConfig { - applicationId "mw.gri.android" + applicationId "st.goblin.wallet" minSdk 24 targetSdk 36 versionCode 5 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa2fb15..bb3f8f2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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" diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 234b260..1148091 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 4dd9bae..1ad2e38 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 79d1acb..1148091 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 77b82c5..e533f41 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index e479107..cc56f3e 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 3bd0a6b..dd17d2b 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 6cf2fd4..4f02e6e 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 7975b84..c22df52 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 7f83ee6..4f02e6e 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 2a72f97..ecf792e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 68027ee..1332a8e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index d61d74e..627fe7d 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 8217cfb..e0a58c9 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index f448f2d..151e261 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 9e5da21..e0a58c9 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml index 8cf0561..3ae02f5 100644 --- a/android/app/src/main/res/values/ic_launcher_background.xml +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #FCEF03 + #FFD60A \ No newline at end of file diff --git a/fonts/GEIST-OFL.txt b/fonts/GEIST-OFL.txt new file mode 100644 index 0000000..04e95fc --- /dev/null +++ b/fonts/GEIST-OFL.txt @@ -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. \ No newline at end of file diff --git a/fonts/Geist-Bold.ttf b/fonts/Geist-Bold.ttf new file mode 100644 index 0000000..863b286 Binary files /dev/null and b/fonts/Geist-Bold.ttf differ diff --git a/fonts/Geist-Medium.ttf b/fonts/Geist-Medium.ttf new file mode 100644 index 0000000..96cb22f Binary files /dev/null and b/fonts/Geist-Medium.ttf differ diff --git a/fonts/Geist-Regular.ttf b/fonts/Geist-Regular.ttf new file mode 100644 index 0000000..4da1b3a Binary files /dev/null and b/fonts/Geist-Regular.ttf differ diff --git a/fonts/Geist-SemiBold.ttf b/fonts/Geist-SemiBold.ttf new file mode 100644 index 0000000..b2b0618 Binary files /dev/null and b/fonts/Geist-SemiBold.ttf differ diff --git a/fonts/GeistMono-Regular.ttf b/fonts/GeistMono-Regular.ttf new file mode 100644 index 0000000..50c9d5a Binary files /dev/null and b/fonts/GeistMono-Regular.ttf differ diff --git a/fonts/GeistMono-SemiBold.ttf b/fonts/GeistMono-SemiBold.ttf new file mode 100644 index 0000000..1b21724 Binary files /dev/null and b/fonts/GeistMono-SemiBold.ttf differ diff --git a/img/goblin-icon-512.png b/img/goblin-icon-512.png new file mode 100644 index 0000000..6c79261 Binary files /dev/null and b/img/goblin-icon-512.png differ diff --git a/img/goblin-icon.png b/img/goblin-icon.png new file mode 100644 index 0000000..52a2c6e Binary files /dev/null and b/img/goblin-icon.png differ diff --git a/img/goblin-mask-128.png b/img/goblin-mask-128.png new file mode 100644 index 0000000..b1b3bbd Binary files /dev/null and b/img/goblin-mask-128.png differ diff --git a/img/goblin-mask-64.png b/img/goblin-mask-64.png new file mode 100644 index 0000000..9328d71 Binary files /dev/null and b/img/goblin-mask-64.png differ diff --git a/img/goblin-mask-white.png b/img/goblin-mask-white.png new file mode 100644 index 0000000..a883a9d Binary files /dev/null and b/img/goblin-mask-white.png differ diff --git a/img/goblin-mask.png b/img/goblin-mask.png new file mode 100644 index 0000000..890e1f3 Binary files /dev/null and b/img/goblin-mask.png differ diff --git a/img/icon.png b/img/icon.png index 87f8a7b..7753cf2 100644 Binary files a/img/icon.png and b/img/icon.png differ diff --git a/linux/Grim.AppDir/.DirIcon b/linux/Goblin.AppDir/.DirIcon similarity index 100% rename from linux/Grim.AppDir/.DirIcon rename to linux/Goblin.AppDir/.DirIcon diff --git a/linux/Grim.AppDir/grim.desktop b/linux/Goblin.AppDir/goblin.desktop similarity index 72% rename from linux/Grim.AppDir/grim.desktop rename to linux/Goblin.AppDir/goblin.desktop index 781f500..66649a2 100644 --- a/linux/Grim.AppDir/grim.desktop +++ b/linux/Goblin.AppDir/goblin.desktop @@ -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; \ No newline at end of file diff --git a/linux/Grim.AppDir/grim.png b/linux/Goblin.AppDir/grim.png similarity index 100% rename from linux/Grim.AppDir/grim.png rename to linux/Goblin.AppDir/grim.png diff --git a/linux/build_release.sh b/linux/build_release.sh index 3a048f4..7654cbe 100755 --- a/linux/build_release.sh +++ b/linux/build_release.sh @@ -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 \ No newline at end of file +appimagetool linux/Goblin.AppDir target/${arch}/release/goblin-v$2-linux-$1.AppImage \ No newline at end of file diff --git a/macos/Grim.app/Contents/Info.plist b/macos/Goblin.app/Contents/Info.plist similarity index 91% rename from macos/Grim.app/Contents/Info.plist rename to macos/Goblin.app/Contents/Info.plist index 775d964..8a6776b 100644 --- a/macos/Grim.app/Contents/Info.plist +++ b/macos/Goblin.app/Contents/Info.plist @@ -5,9 +5,9 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Grim + Goblin CFBundleExecutable - grim + goblin CFBundleIconFile AppIcon CFBundleIconName @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Grim + Goblin CFBundlePackageType APPL CFBundleShortVersionString @@ -29,7 +29,7 @@ CFBundleVersion 1 NSCameraUsageDescription - Grim needs an access to your camera to scan QR code. + Goblin needs an access to your camera to scan QR code. CFBundleDocumentTypes diff --git a/macos/Grim.app/Contents/MacOS/.gitignore b/macos/Goblin.app/Contents/MacOS/.gitignore similarity index 100% rename from macos/Grim.app/Contents/MacOS/.gitignore rename to macos/Goblin.app/Contents/MacOS/.gitignore diff --git a/macos/Grim.app/Contents/Resources/AppIcon.icns b/macos/Goblin.app/Contents/Resources/AppIcon.icns similarity index 100% rename from macos/Grim.app/Contents/Resources/AppIcon.icns rename to macos/Goblin.app/Contents/Resources/AppIcon.icns diff --git a/macos/Grim.app/Contents/_CodeSignature/CodeResources b/macos/Goblin.app/Contents/_CodeSignature/CodeResources similarity index 100% rename from macos/Grim.app/Contents/_CodeSignature/CodeResources rename to macos/Goblin.app/Contents/_CodeSignature/CodeResources diff --git a/macos/build_release.sh b/macos/build_release.sh index 14e70b0..c6e94d7 100755 --- a/macos/build_release.sh +++ b/macos/build_release.sh @@ -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 diff --git a/scripts/gen_icons.sh b/scripts/gen_icons.sh new file mode 100755 index 0000000..528c596 --- /dev/null +++ b/scripts/gen_icons.sh @@ -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" diff --git a/src/gui/colors.rs b/src/gui/colors.rs index 95d6073..15bccb7 100644 --- a/src/gui/colors.rs +++ b/src/gui/colors.rs @@ -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 } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 7ddf7a5..79623d9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -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; diff --git a/src/gui/theme.rs b/src/gui/theme.rs new file mode 100644 index 0000000..178019d --- /dev/null +++ b/src/gui/theme.rs @@ -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 { + 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 { + 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()] +} diff --git a/src/lib.rs b/src/lib.rs index 66a94a7..4f41d40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(); diff --git a/src/nostr/client.rs b/src/nostr/client.rs new file mode 100644 index 0000000..3b41a78 --- /dev/null +++ b/src/nostr/client.rs @@ -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, + /// Per-wallet configuration. + pub config: RwLock, + /// Metadata archive. + pub store: Arc, + /// Directory holding identity.json. + nostr_dir: PathBuf, + + /// SDK client, present while the service loop runs. + client: RwLock>, + /// 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>>, +} + +impl NostrService { + /// Create the service for an unlocked identity. + pub fn new( + keys: Keys, + identity: NostrIdentity, + config: NostrConfig, + store: NostrStore, + nostr_dir: PathBuf, + ) -> Arc { + 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, 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, 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 { + 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 { + 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 { + // 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, 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, client: &Client) { + let relays = svc.relays(); + let dm_tags: Vec = 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, 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, 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); +} diff --git a/src/nostr/config.rs b/src/nostr/config.rs new file mode 100644 index 0000000..1bc8efb --- /dev/null +++ b/src/nostr/config.rs @@ -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, + /// Relay list override. + relays: Option>, + /// Accept policy for incoming payments. + accept_from: Option, + /// NIP-05 identity server base URL. + nip05_server: Option, + /// Days after which a pending outgoing payment is shown as expired. + request_expiry_days: Option, + + /// Path of the config file, not serialized. + #[serde(skip)] + path: Option, +} + +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 { + 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) { + 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) + } +} diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs new file mode 100644 index 0000000..5c544ac --- /dev/null +++ b/src/nostr/identity.rs @@ -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, + /// 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 { + 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::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 { + 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 { + 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() + ); + } +} diff --git a/src/nostr/ingest.rs b/src/nostr/ingest.rs new file mode 100644 index 0000000..257f827 --- /dev/null +++ b/src/nostr/ingest.rs @@ -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(_))); + } + } +} diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs new file mode 100644 index 0000000..a02469e --- /dev/null +++ b/src/nostr/mod.rs @@ -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; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs new file mode 100644 index 0000000..868d20f --- /dev/null +++ b/src/nostr/nip05.rs @@ -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, +} + +/// 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 { + 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 { + 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::(&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 { + 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::(&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()); + } +} diff --git a/src/nostr/protocol.rs b/src/nostr/protocol.rs new file mode 100644 index 0000000..91b6cd3 --- /dev/null +++ b/src/nostr/protocol.rs @@ -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 = 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 { + let cleaned: String = raw + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let collapsed = cleaned.split_whitespace().collect::>().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 { + 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 { + 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 { + 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()); + } +} diff --git a/src/nostr/relays.rs b/src/nostr/relays.rs new file mode 100644 index 0000000..e6ac8d2 --- /dev/null +++ b/src/nostr/relays.rs @@ -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 { + 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); + } +} diff --git a/src/nostr/store.rs b/src/nostr/store.rs new file mode 100644 index 0000000..22e90f5 --- /dev/null +++ b/src/nostr/store.rs @@ -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>>, + /// Tx metadata by slate uuid. + tx_meta: SingleStore, + /// Contacts by pubkey hex. + contacts: SingleStore, + /// Payment requests by rumor id hex. + requests: SingleStore, + /// Processed markers (event/rumor ids and slate states) to timestamps. + processed: SingleStore, + /// Service settings (last connected time etc). + settings: SingleStore, +} + +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::::singleton().write().unwrap(); + let created_arc = manager + .get_or_create(dir.as_path(), Rkv::new::) + .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( + &self, + store: &SingleStore, + key: &str, + ) -> Option { + 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(&self, store: &SingleStore, 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, 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(&self, store: &SingleStore) -> Vec { + 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) { + 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 { + 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 { + 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 { + 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 { + self.all_json(&self.contacts) + } + + // ── payment requests ──────────────────────────────────────────────────── + + pub fn request(&self, rumor_id: &str) -> Option { + 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 { + self.all_json(&self.requests) + } + + pub fn pending_requests(&self) -> Vec { + let mut reqs: Vec = 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 = { + 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 { + 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); + } +} diff --git a/src/nostr/types.rs b/src/nostr/types.rs new file mode 100644 index 0000000..9069752 --- /dev/null +++ b/src/nostr/types.rs @@ -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, + pub status: NostrSendStatus, + /// Gift wrap event id of our outgoing message, hex. + pub sent_event_id: Option, + /// Rumor id of the counterparty message we processed, hex. + pub received_rumor_id: Option, + 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, + /// NIP-05 identifier (user@domain). + pub nip05: Option, + /// Unix time of last successful NIP-05 verification. + pub nip05_verified_at: Option, + /// Known DM relays (kind 10050) of the contact. + pub relays: Vec, + /// 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, +} + +/// 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, + 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) +} diff --git a/src/settings/config.rs b/src/settings/config.rs index 128ab76..bc9caf1 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -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, + /// Color theme identifier: "light", "dark" or "yellow". + theme: Option, + /// Density identifier: "compact", "regular" or "comfy". + density: Option, + /// Identifier of the last opened wallet to boot into. + last_wallet_id: Option, /// Flag to use proxy for network requests. use_proxy: Option, @@ -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 { 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 { + 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) { + let mut w_config = Settings::app_config_to_update(); + w_config.last_wallet_id = id; w_config.save(); } diff --git a/src/settings/settings.rs b/src/settings/settings.rs index a314a9e..421b079 100644 --- a/src/settings/settings.rs +++ b/src/settings/settings.rs @@ -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 { diff --git a/src/tor/mod.rs b/src/tor/mod.rs index 67a3bd8..51eb6ef 100644 --- a/src/tor/mod.rs +++ b/src/tor/mod.rs @@ -21,4 +21,6 @@ pub use tor::Tor; mod types; pub use types::*; +pub mod transport; + mod http; diff --git a/src/tor/tor.rs b/src/tor/tor.rs index b13b995..a828d08 100644 --- a/src/tor/tor.rs +++ b/src/tor/tor.rs @@ -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> { + 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, + headers: Vec<(String, String)>, + ) -> Option { + 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(); diff --git a/src/tor/transport.rs b/src/tor/transport.rs new file mode 100644 index 0000000..1820893 --- /dev/null +++ b/src/tor/transport.rs @@ -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) -> 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 { + 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); + +impl Sink for ArtiSink +where + S: Sink + Send + Unpin, +{ + type Error = TransportError; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + 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> { + 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> { + Pin::new(&mut self.0) + .poll_close_unpin(cx) + .map_err(TransportError::backend) + } +} diff --git a/src/wallet/config.rs b/src/wallet/config.rs index d4398ee..c512b52 100644 --- a/src/wallet/config.rs +++ b/src/wallet/config.rs @@ -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()); diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 4c3ecd3..6623c83 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -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), + /// 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), } diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index a4b9b6c..ba4bf70 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -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>>>, /// Task result with optional transaction identifier. task_result: Arc, WalletTask)>>>, + + /// Nostr payment-messaging service, present while wallet is open. + nostr: Arc>>>, } 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> { + let r_nostr = self.nostr.read(); + r_nostr.clone() + } + /// Get keychain mask [`SecretKey`]. pub fn keychain_mask(&self) -> Option { 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 { + 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); + } + } + } }; } diff --git a/wix/main.wxs b/wix/main.wxs index 37cc618..c25f715 100644 --- a/wix/main.wxs +++ b/wix/main.wxs @@ -7,9 +7,9 @@ - + - + @@ -18,21 +18,21 @@ - + - + @@ -50,12 +50,12 @@ - - + + - + - + @@ -65,18 +65,18 @@ - + - - + + @@ -84,7 +84,7 @@ - + @@ -95,7 +95,7 @@ - +