From 84550550e328dc98ca7a48878a1d7469e77c3b61 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Wed, 6 May 2026 15:17:29 -0500 Subject: [PATCH 1/9] Add design spec for configurable payload size limit in tauri-plugin-nostr-sync Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 387 +++++- Cargo.toml | 3 + crates/sage-config/src/config.rs | 23 + crates/sage-config/src/lib.rs | 2 + crates/sage-config/src/old.rs | 2 + crates/sage-config/src/sync.rs | 55 + crates/sage-config/src/wallet.rs | 33 +- .../plans/2026-05-05-wallet-settings-sync.md | 1212 +++++++++++++++++ .../2026-05-05-wallet-settings-sync-design.md | 142 ++ ...05-06-configurable-payload-limit-design.md | 122 ++ package.json | 1 + pnpm-lock.yaml | 13 + src-tauri/Cargo.toml | 4 + src-tauri/capabilities/default.json | 3 +- src-tauri/capabilities/mobile.json | 3 +- src-tauri/gen/apple/sage-tauri_iOS/Info.plist | 2 +- .../sage-tauri_iOS.entitlements | 2 +- src-tauri/src/commands.rs | 352 ++++- src-tauri/src/lib.rs | 15 +- src/bindings.ts | 27 +- src/components/WalletCard.tsx | 14 +- src/contexts/WalletContext.tsx | 8 +- src/pages/Settings.tsx | 151 ++ src/state.ts | 21 + 24 files changed, 2564 insertions(+), 33 deletions(-) create mode 100644 crates/sage-config/src/sync.rs create mode 100644 docs/superpowers/plans/2026-05-05-wallet-settings-sync.md create mode 100644 docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md create mode 100644 docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md diff --git a/Cargo.lock b/Cargo.lock index badb16b27..67c9730d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,37 @@ dependencies = [ "syn 2.0.111", ] +[[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", + "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", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "atk" version = "0.18.2" @@ -462,6 +493,12 @@ dependencies = [ "num-traits", ] +[[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" @@ -663,6 +700,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bigdecimal" version = "0.4.9" @@ -748,13 +791,21 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + [[package]] name = "bitcoin_hashes" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ + "bitcoin-io", "hex-conservative", + "serde", ] [[package]] @@ -811,6 +862,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -981,6 +1041,15 @@ dependencies = [ "toml 0.9.8", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.48" @@ -1041,6 +1110,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chia-bls" version = "0.28.2" @@ -1174,9 +1267,9 @@ dependencies = [ "rustls-pemfile", "thiserror 2.0.17", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", - "tungstenite", + "tungstenite 0.24.0", ] [[package]] @@ -1291,7 +1384,7 @@ dependencies = [ "signature", "thiserror 2.0.17", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", ] @@ -1326,7 +1419,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93cecade7c3fe750845cde404e2c69aaeb75caf88dc343fff2ff4952554eccf9" dependencies = [ - "bech32", + "bech32 0.9.1", "chia-protocol", "hex", "indexmap 2.12.1", @@ -1540,8 +1633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1553,6 +1648,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -2717,6 +2813,20 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -2809,6 +2919,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -3092,6 +3203,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3656,9 +3779,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3920,9 +4056,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -4053,6 +4189,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + [[package]] name = "lru-slab" version = "0.1.2" @@ -4180,9 +4322,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -4250,6 +4392,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 = "new_debug_unreachable" version = "1.0.6" @@ -4300,6 +4448,83 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.16", + "hex", + "instant", + "scrypt", + "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", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "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", + "tracing", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4919,6 +5144,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -5192,6 +5427,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6210,7 +6456,7 @@ name = "sage" version = "0.12.10" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.9.1", "bincode 1.3.3", "bip39", "chia-wallet-sdk", @@ -6391,6 +6637,8 @@ dependencies = [ "aws-lc-rs", "chia-wallet-sdk", "glob", + "hkdf", + "nostr-sdk", "reqwest 0.12.24", "rustls", "sage", @@ -6401,6 +6649,7 @@ dependencies = [ "sage-wallet", "serde", "serde_json", + "sha2", "specta", "specta-typescript", "tauri", @@ -6410,6 +6659,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-nostr-sync", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-safe-area-insets", @@ -6448,6 +6698,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6520,6 +6779,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sec1" version = "0.7.3" @@ -6534,6 +6805,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 = "selectors" version = "0.24.0" @@ -6862,12 +7153,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7591,6 +7882,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-nostr-sync" +version = "0.1.0-alpha.1" +dependencies = [ + "chrono", + "nostr-sdk", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "uuid", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -8005,9 +8311,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -8023,9 +8329,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -8042,6 +8348,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -8065,7 +8383,23 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.24.0", + "webpki-roots 0.26.11", +] + +[[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", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.26.2", "webpki-roots 0.26.11", ] @@ -8376,6 +8710,25 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 8479a1a63..3674ab6f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,9 @@ rand = "0.8.5" rand_chacha = "0.3.1" aes-gcm = "0.10.3" argon2 = "0.5.3" +hkdf = "0.12" +sha2 = "0.10" +nostr-sdk = { version = "0.44.1", features = ["nip44"] } # Async tokio = "1.39.2" diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index 4c7a8a0c1..9b3d2ac34 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -1,3 +1,4 @@ +use crate::SyncConfig; use serde::{Deserialize, Serialize}; use specta::Type; @@ -8,6 +9,7 @@ pub struct Config { pub global: GlobalConfig, pub network: NetworkConfig, pub rpc: RpcConfig, + pub sync: SyncConfig, } impl Default for Config { @@ -17,6 +19,7 @@ impl Default for Config { global: GlobalConfig::default(), network: NetworkConfig::default(), rpc: RpcConfig::default(), + sync: SyncConfig::default(), } } } @@ -70,3 +73,23 @@ impl Default for RpcConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_config_in_config_defaults_correctly() { + let cfg = Config::default(); + assert_eq!(cfg.sync.relays.len(), 3); + } + + #[test] + fn config_toml_roundtrip_preserves_sync() { + let mut cfg = Config::default(); + cfg.sync.relays = vec!["wss://custom.relay".to_string()]; + let toml = toml::to_string_pretty(&cfg).unwrap(); + let parsed: Config = toml::from_str(&toml).unwrap(); + assert_eq!(parsed.sync.relays, vec!["wss://custom.relay"]); + } +} diff --git a/crates/sage-config/src/lib.rs b/crates/sage-config/src/lib.rs index fefc0ae99..cc7aa1cec 100644 --- a/crates/sage-config/src/lib.rs +++ b/crates/sage-config/src/lib.rs @@ -3,9 +3,11 @@ mod config; mod network; mod old; +mod sync; mod wallet; pub use config::*; pub use network::*; pub use old::*; +pub use sync::*; pub use wallet::*; diff --git a/crates/sage-config/src/old.rs b/crates/sage-config/src/old.rs index 98fb7bc8f..adc805849 100644 --- a/crates/sage-config/src/old.rs +++ b/crates/sage-config/src/old.rs @@ -140,6 +140,7 @@ pub fn migrate_config(old: OldConfig) -> Result<(Config, WalletConfig), ParseInt enabled: old.rpc.run_on_startup, port: old.rpc.server_port, }, + sync: crate::SyncConfig::default(), }; let mut wallet_config = WalletConfig { @@ -155,6 +156,7 @@ pub fn migrate_config(old: OldConfig) -> Result<(Config, WalletConfig), ParseInt delta_sync: None, emoji: None, change_address: None, + sync_enabled: false, }); } diff --git a/crates/sage-config/src/sync.rs b/crates/sage-config/src/sync.rs new file mode 100644 index 000000000..33f67408e --- /dev/null +++ b/crates/sage-config/src/sync.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct SyncConfig { + pub relays: Vec, +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + relays: vec![ + "wss://relay.damus.io".to_string(), + "wss://relay.nostr.band".to_string(), + "wss://nos.lol".to_string(), + ], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_relays_are_populated() { + let cfg = SyncConfig::default(); + assert_eq!( + cfg.relays, + vec![ + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + ] + ); + } + + #[test] + fn toml_roundtrip_preserves_relays() { + let original = SyncConfig { + relays: vec!["wss://relay.example.com".to_string()], + }; + let toml = toml::to_string_pretty(&original).unwrap(); + let parsed: SyncConfig = toml::from_str(&toml).unwrap(); + assert_eq!(parsed.relays, original.relays); + } + + #[test] + fn empty_relay_list_deserializes() { + let toml = r#"relays = []"#; + let cfg: SyncConfig = toml::from_str(toml).unwrap(); + assert!(cfg.relays.is_empty()); + } +} diff --git a/crates/sage-config/src/wallet.rs b/crates/sage-config/src/wallet.rs index a59a6b13d..8198f360b 100644 --- a/crates/sage-config/src/wallet.rs +++ b/crates/sage-config/src/wallet.rs @@ -31,6 +31,8 @@ pub struct Wallet { pub emoji: Option, #[serde(skip_serializing_if = "Option::is_none")] pub change_address: Option, + #[serde(default)] + pub sync_enabled: bool, } impl Wallet { @@ -48,6 +50,7 @@ impl Default for Wallet { delta_sync: None, emoji: None, change_address: None, + sync_enabled: false, } } } @@ -68,6 +71,7 @@ mod tests { change_address: Some( "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t".to_string(), ), + sync_enabled: false, } } @@ -95,6 +99,7 @@ mod tests { name = "Main" fingerprint = 1000000 change_address = "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t" + sync_enabled = false "#]], &expect![[r#" { @@ -106,7 +111,8 @@ mod tests { "name": "Main", "fingerprint": 1000000, "delta_sync": null, - "change_address": "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t" + "change_address": "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t", + "sync_enabled": false } ] }"#]], @@ -126,6 +132,7 @@ mod tests { name = "Main" fingerprint = 1000000 change_address = "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t" + sync_enabled = false "#]], &expect![[r#" { @@ -137,10 +144,32 @@ mod tests { "name": "Main", "fingerprint": 1000000, "delta_sync": null, - "change_address": "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t" + "change_address": "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t", + "sync_enabled": false } ] }"#]], ); } + + #[test] + fn sync_enabled_defaults_to_false() { + let wallet = Wallet::default(); + assert!(!wallet.sync_enabled); + } + + #[test] + fn sync_enabled_survives_toml_roundtrip() { + let wallet = Wallet { + sync_enabled: true, + ..Wallet::default() + }; + let config = WalletConfig { + defaults: WalletDefaults::default(), + wallets: vec![wallet], + }; + let toml = toml::to_string_pretty(&config).unwrap(); + let parsed: WalletConfig = toml::from_str(&toml).unwrap(); + assert!(parsed.wallets[0].sync_enabled); + } } diff --git a/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md b/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md new file mode 100644 index 000000000..2dd6d916c --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md @@ -0,0 +1,1212 @@ +# Wallet Settings Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sync wallet name and emoji across app instances using `tauri-plugin-nostr-sync` as transport, with opt-in per wallet and manual fetch from the Advanced settings tab. + +**Architecture:** Each wallet derives a secp256k1 Nostr keypair from its BLS master secret via HKDF-SHA256. The plugin encrypts settings payloads with NIP-44 (self-encryption) and publishes to Nostr relays as NIP-33 replaceable events. Publish is triggered from a new Tauri command called by the frontend after each mutation; fetch is manually triggered from the Advanced settings tab. + +**Tech Stack:** Rust (`tauri-plugin-nostr-sync`, `hkdf`, `sha2`, `nostr-sdk`), TypeScript (`tauri-plugin-nostr-sync-api`), React/Tailwind (existing patterns) + +--- + +## File Map + +| File | Action | Responsibility | +| --- | --- | --- | +| `crates/sage-config/src/sync.rs` | Create | `SyncConfig` struct with relay list | +| `crates/sage-config/src/lib.rs` | Modify | Re-export `SyncConfig` | +| `crates/sage-config/src/wallet.rs` | Modify | Add `sync_enabled: bool` to `Wallet` | +| `crates/sage-config/src/config.rs` | Modify | Add `sync: SyncConfig` to `Config` | +| `Cargo.toml` | Modify | Add `hkdf`, `sha2` workspace deps | +| `src-tauri/Cargo.toml` | Modify | Add plugin + hkdf + sha2 crate deps | +| `src-tauri/src/lib.rs` | Modify | Register plugin; load relays in initialize | +| `src-tauri/src/commands.rs` | Modify | 8 new commands (signer, config, publish, fetch) | +| `src-tauri/capabilities/default.json` | Modify | Add `nostr-sync:default` permission | +| `src/state.ts` | Modify | Call inject/clear signer on login/logout | +| `src/pages/Settings.tsx` | Modify | Add `SyncSettings` to Advanced tab | + +--- + +## Task 1: sage-config — SyncConfig struct + +**Files:** +- Create: `crates/sage-config/src/sync.rs` +- Modify: `crates/sage-config/src/lib.rs` + +- [ ] **Write the test** + +Add to the bottom of `crates/sage-config/src/sync.rs` (write the file first, then add tests): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_relays_are_populated() { + let cfg = SyncConfig::default(); + assert_eq!(cfg.relays.len(), 3); + assert!(cfg.relays.contains(&"wss://relay.damus.io".to_string())); + } + + #[test] + fn toml_roundtrip_preserves_relays() { + let original = SyncConfig { + relays: vec!["wss://relay.example.com".to_string()], + }; + let toml = toml::to_string_pretty(&original).unwrap(); + let parsed: SyncConfig = toml::from_str(&toml).unwrap(); + assert_eq!(parsed.relays, original.relays); + } + + #[test] + fn empty_relay_list_deserializes() { + let toml = r#"relays = []"#; + let cfg: SyncConfig = toml::from_str(toml).unwrap(); + assert!(cfg.relays.is_empty()); + } +} +``` + +- [ ] **Run test to verify it fails** + +```bash +cd /Users/don/src/dkackman/sage +cargo test -p sage-config sync 2>&1 | tail -20 +``` + +Expected: compile error — `SyncConfig` not defined yet. + +- [ ] **Implement `SyncConfig`** + +Create `crates/sage-config/src/sync.rs`: + +```rust +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct SyncConfig { + pub relays: Vec, +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + relays: vec![ + "wss://relay.damus.io".to_string(), + "wss://relay.nostr.band".to_string(), + "wss://nos.lol".to_string(), + ], + } + } +} +``` + +- [ ] **Re-export from lib.rs** + +In `crates/sage-config/src/lib.rs`, add before the existing `pub use` lines: + +```rust +mod sync; +pub use sync::*; +``` + +- [ ] **Run tests to verify they pass** + +```bash +cargo test -p sage-config sync 2>&1 | tail -20 +``` + +Expected: 3 tests pass. + +--- + +## Task 2: sage-config — sync_enabled on Wallet and SyncConfig on Config + +**Files:** +- Modify: `crates/sage-config/src/wallet.rs` +- Modify: `crates/sage-config/src/config.rs` + +- [ ] **Write the tests** + +In `crates/sage-config/src/wallet.rs`, add to the existing `#[cfg(test)]` block: + +```rust +#[test] +fn sync_enabled_defaults_to_false() { + let wallet = Wallet::default(); + assert!(!wallet.sync_enabled); +} + +#[test] +fn sync_enabled_survives_toml_roundtrip() { + let wallet = Wallet { + sync_enabled: true, + ..Wallet::default() + }; + let config = WalletConfig { + defaults: WalletDefaults::default(), + wallets: vec![wallet], + }; + let toml = toml::to_string_pretty(&config).unwrap(); + let parsed: WalletConfig = toml::from_str(&toml).unwrap(); + assert!(parsed.wallets[0].sync_enabled); +} +``` + +In `crates/sage-config/src/config.rs`, add a new test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_config_in_config_defaults_correctly() { + let cfg = Config::default(); + assert_eq!(cfg.sync.relays.len(), 3); + } + + #[test] + fn config_toml_roundtrip_preserves_sync() { + let mut cfg = Config::default(); + cfg.sync.relays = vec!["wss://custom.relay".to_string()]; + let toml = toml::to_string_pretty(&cfg).unwrap(); + let parsed: Config = toml::from_str(&toml).unwrap(); + assert_eq!(parsed.sync.relays, vec!["wss://custom.relay"]); + } +} +``` + +- [ ] **Run tests to verify they fail** + +```bash +cargo test -p sage-config 2>&1 | tail -20 +``` + +Expected: compile errors — `sync_enabled` and `sync` fields don't exist yet. + +- [ ] **Add `sync_enabled` to `Wallet`** + +In `crates/sage-config/src/wallet.rs`, add the field after `change_address`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct Wallet { + pub name: String, + pub fingerprint: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + pub delta_sync: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub emoji: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub change_address: Option, + #[serde(default)] + pub sync_enabled: bool, +} +``` + +Update `Default for Wallet` to include `sync_enabled: false`: + +```rust +impl Default for Wallet { + fn default() -> Self { + Self { + name: "Unnamed Wallet".to_string(), + fingerprint: 0, + network: None, + delta_sync: None, + emoji: None, + change_address: None, + sync_enabled: false, + } + } +} +``` + +- [ ] **Add `sync: SyncConfig` to `Config`** + +In `crates/sage-config/src/config.rs`, add the import and field: + +```rust +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::SyncConfig; + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct Config { + pub version: u32, + pub global: GlobalConfig, + pub network: NetworkConfig, + pub rpc: RpcConfig, + pub sync: SyncConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + version: 2, + global: GlobalConfig::default(), + network: NetworkConfig::default(), + rpc: RpcConfig::default(), + sync: SyncConfig::default(), + } + } +} +``` + +- [ ] **Run tests to verify they pass** + +```bash +cargo test -p sage-config 2>&1 | tail -20 +``` + +Expected: all sage-config tests pass (including the existing wallet config tests). + +--- + +## Task 3: Cargo dependencies + +**Files:** +- Modify: `Cargo.toml` (workspace) +- Modify: `src-tauri/Cargo.toml` + +- [ ] **Add workspace dependencies** + +In `Cargo.toml`, find the `[workspace.dependencies]` section and add: + +```toml +hkdf = "0.12" +sha2 = "0.10" +``` + +- [ ] **Add crate dependencies to src-tauri** + +In `src-tauri/Cargo.toml`, add to `[dependencies]`: + +```toml +tauri-plugin-nostr-sync = { path = "../../tauri-plugin-nostr" } +hkdf = { workspace = true } +sha2 = { workspace = true } +nostr-sdk = { version = "0.44.1", features = ["nip44"] } +``` + +- [ ] **Verify the workspace compiles** + +```bash +cargo check -p sage-tauri 2>&1 | tail -30 +``` + +Expected: no errors (warnings OK). If `nostr-sdk` version conflicts with the plugin's internal use, check `Cargo.lock` and adjust. + +--- + +## Task 4: Register plugin in lib.rs and add permission + +**Files:** +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/capabilities/default.json` + +- [ ] **Add plugin permission to capabilities** + +In `src-tauri/capabilities/default.json`, add `"nostr-sync:default"` to the `permissions` array: + +```json +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": ["main"], + "permissions": [ + "core:path:default", + "core:event:default", + "core:window:default", + "core:webview:default", + "core:app:default", + "core:resources:default", + "core:image:default", + "clipboard-manager:default", + "clipboard-manager:allow-write-text", + "clipboard-manager:allow-read-text", + "opener:default", + "nostr-sync:default" + ] +} +``` + +- [ ] **Register the plugin in `lib.rs`** + +Add the plugin to `src-tauri/src/lib.rs`. Find the `let mut tauri_builder = tauri::Builder::default()` block and add the plugin registration. The plugin is added for both desktop and mobile. Add it after the existing `.plugin(tauri_plugin_os::init())` call: + +```rust +let mut tauri_builder = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_os::init()) + .plugin( + tauri_plugin_nostr_sync::Builder::new() + .app_namespace("sage") + .build(), + ); +``` + +Also add the import at the top of the file (or in the block where it's used — Rust allows `use` anywhere): + +The `tauri_plugin_nostr_sync` crate is used directly via its builder. No explicit `use` is needed since we're using the full path. + +- [ ] **Verify the build compiles** + +```bash +cargo build -p sage-tauri 2>&1 | tail -30 +``` + +Expected: compiles. The plugin registers itself with the Tauri runtime. + +--- + +## Task 5: New Tauri commands — signer lifecycle + +**Files:** +- Modify: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/lib.rs` + +These commands derive the Nostr key from the wallet's BLS master secret and inject it into the plugin. + +- [ ] **Write the failing test for HKDF derivation** + +At the bottom of `src-tauri/src/commands.rs`, add a test module: + +```rust +#[cfg(test)] +mod sync_tests { + use super::*; + + #[test] + fn hkdf_derive_nostr_key_produces_32_bytes() { + let ikm = [42u8; 32]; + let hk = hkdf::Hkdf::::new(None, &ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + assert_eq!(okm.len(), 32); + assert_ne!(okm, [0u8; 32]); + } + + #[test] + fn same_ikm_produces_same_nostr_key() { + let ikm = [99u8; 32]; + let derive = |ikm: &[u8]| { + let hk = hkdf::Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + okm + }; + assert_eq!(derive(&ikm), derive(&ikm)); + } + + #[test] + fn different_ikm_produces_different_nostr_key() { + let derive = |ikm: &[u8]| { + let hk = hkdf::Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + okm + }; + assert_ne!(derive(&[1u8; 32]), derive(&[2u8; 32])); + } +} +``` + +- [ ] **Run test to verify it fails** + +```bash +cargo test -p sage-tauri sync_tests 2>&1 | tail -20 +``` + +Expected: compile error — `hkdf` and `sha2` not imported. + +- [ ] **Add imports and implement `inject_nostr_signer` and `clear_nostr_signer`** + +Add these imports at the top of `src-tauri/src/commands.rs`: + +```rust +use hkdf::Hkdf; +use sha2::Sha256; +use tauri_plugin_nostr_sync::TauriPluginNostrSyncExt; +``` + +Add these two commands after the existing commands in `src-tauri/src/commands.rs`: + +```rust +#[command] +#[specta] +pub async fn inject_nostr_signer( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result<()> { + let sage = state.lock().await; + let Ok((_, Some(master_sk))) = sage.keychain.extract_secrets(fingerprint, b"") else { + return Ok(()); // watch-only wallet — skip silently + }; + drop(sage); + + let ikm = master_sk.to_bytes(); + let hk = Hkdf::::new(None, &ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm) + .expect("32 bytes is a valid HKDF output length"); + + let secret_key = nostr_sdk::secp256k1::SecretKey::from_slice(&okm).map_err(|e| { + crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + } + })?; + let keys = nostr_sdk::Keys::new(secret_key.into()); + + app_handle + .nostr_sync() + .set_signer(keys) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + Ok(()) +} + +#[command] +#[specta] +pub async fn clear_nostr_signer(app_handle: AppHandle) -> Result<()> { + app_handle.nostr_sync().clear_signer().await; + Ok(()) +} +``` + +- [ ] **Register the new commands in `lib.rs`** + +In `src-tauri/src/lib.rs`, add the two commands to `collect_commands!`: + +```rust +commands::inject_nostr_signer, +commands::clear_nostr_signer, +``` + +- [ ] **Run tests to verify** + +```bash +cargo test -p sage-tauri sync_tests 2>&1 | tail -20 +``` + +Expected: 3 tests pass. + +```bash +cargo build -p sage-tauri 2>&1 | tail -20 +``` + +Expected: compiles cleanly. + +--- + +## Task 6: New Tauri commands — sync config and relay management + +**Files:** +- Modify: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Write tests** + +Add to the `sync_tests` module in `src-tauri/src/commands.rs`: + +```rust +#[test] +fn wallet_settings_payload_schema_version_is_1() { + let payload = serde_json::json!({ + "v": 1, + "name": "My Wallet", + "emoji": null, + }); + assert_eq!(payload["v"], 1); + assert_eq!(payload["name"], "My Wallet"); + assert!(payload["emoji"].is_null()); +} +``` + +- [ ] **Implement the four config/relay commands** + +Add to `src-tauri/src/commands.rs`: + +```rust +#[command] +#[specta] +pub async fn get_sync_enabled(state: State<'_, AppState>, fingerprint: u32) -> Result { + let sage = state.lock().await; + let enabled = sage + .wallet_config + .wallets + .iter() + .find(|w| w.fingerprint == fingerprint) + .map(|w| w.sync_enabled) + .unwrap_or(false); + Ok(enabled) +} + +#[command] +#[specta] +pub async fn set_sync_enabled( + state: State<'_, AppState>, + fingerprint: u32, + enabled: bool, +) -> Result<()> { + let mut sage = state.lock().await; + let Some(wallet) = sage + .wallet_config + .wallets + .iter_mut() + .find(|w| w.fingerprint == fingerprint) + else { + return Err(sage::Error::UnknownFingerprint.into()); + }; + wallet.sync_enabled = enabled; + sage.save_config()?; + Ok(()) +} + +#[command] +#[specta] +pub async fn add_sync_relay( + app_handle: AppHandle, + state: State<'_, AppState>, + url: String, +) -> Result<()> { + app_handle + .nostr_sync() + .add_relay(&url) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let mut sage = state.lock().await; + if !sage.config.sync.relays.contains(&url) { + sage.config.sync.relays.push(url); + sage.save_config()?; + } + Ok(()) +} + +#[command] +#[specta] +pub async fn remove_sync_relay( + app_handle: AppHandle, + state: State<'_, AppState>, + url: String, +) -> Result<()> { + app_handle + .nostr_sync() + .remove_relay(&url) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let mut sage = state.lock().await; + sage.config.sync.relays.retain(|r| r != &url); + sage.save_config()?; + Ok(()) +} +``` + +- [ ] **Register commands in `lib.rs`** + +Add to `collect_commands!`: + +```rust +commands::get_sync_enabled, +commands::set_sync_enabled, +commands::add_sync_relay, +commands::remove_sync_relay, +``` + +- [ ] **Load relays in `initialize` command** + +In `src-tauri/src/commands.rs`, find the `initialize` function. After the existing initialization logic and before the final `Ok(())`, add: + +```rust + // Load persisted relays into the nostr-sync plugin + let relays = { + let sage = state.lock().await; + sage.config.sync.relays.clone() + }; + for url in relays { + if let Err(e) = app_handle.nostr_sync().add_relay(&url).await { + tracing::warn!("Failed to connect to relay {url}: {e}"); + } + } +``` + +- [ ] **Verify build** + +```bash +cargo build -p sage-tauri 2>&1 | tail -20 +``` + +Expected: compiles. New commands and bindings generated. + +--- + +## Task 7: New Tauri commands — publish and fetch + +**Files:** +- Modify: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Write tests** + +Add to `sync_tests` module: + +```rust +#[test] +fn fetch_payload_v1_parses_name_and_emoji() { + let payload = serde_json::json!({ + "v": 1, + "name": "Cold Storage", + "emoji": "🧊", + }); + let v = payload["v"].as_u64().unwrap_or(0); + let name = payload["name"].as_str().map(str::to_string); + let emoji = payload.get("emoji").and_then(|e| { + if e.is_null() { None } else { e.as_str().map(str::to_string) } + }); + assert_eq!(v, 1); + assert_eq!(name, Some("Cold Storage".to_string())); + assert_eq!(emoji, Some("🧊".to_string())); +} + +#[test] +fn fetch_payload_unknown_version_is_rejected() { + let payload = serde_json::json!({ "v": 99, "name": "x" }); + let v = payload["v"].as_u64().unwrap_or(0); + assert_ne!(v, 1, "should treat v=99 as unknown"); +} +``` + +- [ ] **Run test to verify it fails** + +```bash +cargo test -p sage-tauri sync_tests 2>&1 | tail -20 +``` + +Expected: compile error (functions referenced by tests don't exist yet) OR tests pass if they're pure logic tests. These are pure logic tests — they should pass immediately once the test module compiles. + +- [ ] **Implement `publish_wallet_settings`** + +Add to `src-tauri/src/commands.rs`: + +```rust +#[command] +#[specta] +pub async fn publish_wallet_settings( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result<()> { + let (sync_enabled, name, emoji) = { + let sage = state.lock().await; + let Some(wallet) = sage.wallet_config.wallets.iter().find(|w| w.fingerprint == fingerprint) else { + return Ok(()); + }; + (wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone()) + }; + + if !sync_enabled { + return Ok(()); + } + + let payload = serde_json::json!({ + "v": 1, + "name": name, + "emoji": emoji, + }); + + if let Err(e) = app_handle.nostr_sync().publish("wallet-settings", &payload).await { + tracing::warn!("Failed to publish wallet settings: {e}"); + } + + Ok(()) +} +``` + +- [ ] **Implement `fetch_wallet_settings`** + +Add to `src-tauri/src/commands.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct FetchSettingsResult { + pub applied: bool, + pub name: Option, + pub emoji: Option, +} + +#[command] +#[specta] +pub async fn fetch_wallet_settings( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result { + let result = app_handle + .nostr_sync() + .fetch("wallet-settings") + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let Some(fetch_result) = result else { + return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); + }; + + let payload = &fetch_result.payload; + + let version = payload["v"].as_u64().unwrap_or(0); + if version != 1 { + tracing::warn!("fetch_wallet_settings: unknown schema version {version}, skipping"); + return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); + } + + let remote_name = payload["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string); + let remote_emoji = payload.get("emoji").and_then(|e| { + if e.is_null() { None } else { e.as_str().map(str::to_string) } + }); + + let mut sage = state.lock().await; + let Some(wallet) = sage.wallet_config.wallets.iter_mut().find(|w| w.fingerprint == fingerprint) else { + return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); + }; + + let mut applied = false; + + if let Some(ref name) = remote_name { + if wallet.name != *name { + wallet.name = name.clone(); + applied = true; + } + } + + if remote_emoji != wallet.emoji { + wallet.emoji = remote_emoji.clone(); + applied = true; + } + + if applied { + sage.save_config()?; + } + + Ok(FetchSettingsResult { + applied, + name: remote_name, + emoji: remote_emoji, + }) +} +``` + +- [ ] **Register commands in `lib.rs`** + +Add to `collect_commands!`: + +```rust +commands::publish_wallet_settings, +commands::fetch_wallet_settings, +``` + +- [ ] **Run all sync tests** + +```bash +cargo test -p sage-tauri sync_tests 2>&1 | tail -20 +``` + +Expected: all tests pass. + +```bash +cargo build -p sage-tauri 2>&1 | tail -20 +``` + +Expected: compiles, TypeScript bindings regenerated at `src/bindings.ts`. + +--- + +## Task 8: state.ts — signer lifecycle on login/logout + +**Files:** +- Modify: `src/state.ts` + +- [ ] **Call `injectNostrSigner` after login** + +In `src/state.ts`, update `loginAndUpdateState`: + +```typescript +export async function loginAndUpdateState( + fingerprint: number, + onError?: (error: CustomError) => void, +): Promise { + try { + await commands.login({ fingerprint }); + await fetchState(); + // Inject Nostr signer for settings sync (no-op for watch-only wallets) + await commands.injectNostrSigner({ fingerprint }).catch((e) => + console.warn('inject_nostr_signer failed:', e), + ); + } catch (error) { + if (onError) { + onError(error as CustomError); + } else { + console.error(error); + } + throw error; + } +} +``` + +- [ ] **Call `clearNostrSigner` on logout** + +Update `logoutAndUpdateState`: + +```typescript +export async function logoutAndUpdateState(): Promise { + clearState(); + if (setWalletState) { + setWalletState(null); + } + await commands.clearNostrSigner({}).catch((e) => + console.warn('clear_nostr_signer failed:', e), + ); + await commands.logout({}); +} +``` + +- [ ] **Verify TypeScript compiles** + +```bash +cd /Users/don/src/dkackman/sage +pnpm tsc --noEmit 2>&1 | tail -20 +``` + +Expected: no type errors. If `injectNostrSigner` or `clearNostrSigner` are not in `bindings.ts`, confirm the Rust build completed in Task 7 (it regenerates bindings on debug build). + +--- + +## Task 9: Settings.tsx — SyncSettings UI + +**Files:** +- Modify: `src/pages/Settings.tsx` + +This task adds a new `SyncSettings` React component and mounts it in the Advanced tab. + +- [ ] **Add plugin imports at the top of Settings.tsx** + +Add to the existing imports in `src/pages/Settings.tsx`: + +```typescript +import { + addRelay, + getRelays, + getStatus, + RelayInfo, + SyncStatus, +} from 'tauri-plugin-nostr-sync-api'; +``` + +Also add to the `commands` import from `../bindings` the new commands: + +The `commands` object in `bindings.ts` will automatically include the new commands once the Rust build runs. No manual addition needed. + +- [ ] **Implement the `SyncSettings` component** + +Add this component before the `export default function Settings()` line in `src/pages/Settings.tsx`: + +```typescript +function SyncSettings({ fingerprint }: { fingerprint: number }) { + const { addError } = useErrors(); + const [syncEnabled, setSyncEnabled] = useState(false); + const [relays, setRelays] = useState([]); + const [status, setStatus] = useState(null); + const [newRelay, setNewRelay] = useState(''); + const [fetchMessage, setFetchMessage] = useState(null); + const [fetching, setFetching] = useState(false); + + useEffect(() => { + commands + .getSyncEnabled({ fingerprint }) + .then(setSyncEnabled) + .catch(addError); + }, [fingerprint, addError]); + + useEffect(() => { + if (!syncEnabled) return; + const load = () => { + getRelays().then(setRelays).catch(console.warn); + getStatus().then(setStatus).catch(console.warn); + }; + load(); + const id = setInterval(load, 5000); + return () => clearInterval(id); + }, [syncEnabled]); + + const toggleSync = async (enabled: boolean) => { + await commands + .setSyncEnabled({ fingerprint, enabled }) + .catch(addError); + setSyncEnabled(enabled); + }; + + const handleAddRelay = async () => { + const url = newRelay.trim(); + if (!url) return; + await commands.addSyncRelay({ url }).catch(addError); + setNewRelay(''); + getRelays().then(setRelays).catch(console.warn); + }; + + const handleRemoveRelay = async (url: string) => { + await commands.removeSyncRelay({ url }).catch(addError); + getRelays().then(setRelays).catch(console.warn); + }; + + const handleFetch = async () => { + setFetching(true); + setFetchMessage(null); + try { + const result = await commands.fetchWalletSettings({ fingerprint }); + if (result.applied) { + setFetchMessage(t`Settings applied`); + } else { + setFetchMessage(t`Already up to date`); + } + } catch (e) { + setFetchMessage(String(e)); + } finally { + setFetching(false); + } + }; + + const connectedCount = status?.connectedRelayCount ?? 0; + const totalCount = status?.relayCount ?? 0; + + return ( + + + } + /> + + {syncEnabled && ( + <> +
+ {status?.ready + ? t`Sync ready (${connectedCount}/${totalCount} relays connected)` + : t`Not connected`} +
+ + {relays.map((relay) => ( +
+
+
+ {relay.url} +
+ +
+ ))} + +
+ setNewRelay(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddRelay()} + /> + +
+ +
+ + {fetchMessage && ( + + {fetchMessage} + + )} +
+ + )} + + ); +} +``` + +- [ ] **Mount SyncSettings in the Advanced tab** + +Find the `` block in `Settings.tsx`. Update it to include `SyncSettings` when a wallet is logged in: + +```typescript + +
+ {wallet && } + {!isMobile && } + +
+
+``` + +- [ ] **Add `publish_wallet_settings` calls to WalletSettings handlers** + +In the `WalletSettings` component in `Settings.tsx`, find the `onBlur` handler for the wallet name input and add the publish call after rename succeeds: + +```typescript +onBlur={() => { + if (localName === wallet?.name) return; + + commands + .renameKey({ + fingerprint, + name: localName, + }) + .then(() => { + if (wallet) { + setWallet({ ...wallet, name: localName }); + } + // Publish if sync is enabled (fire-and-forget) + commands + .publishWalletSettings({ fingerprint }) + .catch(console.warn); + }) + .catch(addError); +}} +``` + +Find where `set_wallet_emoji` is called (search for `setWalletEmoji` in the file — it's called from the wallet card or emoji picker). Add after it resolves: + +```typescript +commands.publishWalletSettings({ fingerprint }).catch(console.warn); +``` + +Note: `setWalletEmoji` may be called from `WalletCard.tsx` rather than `Settings.tsx`. Check both files: + +```bash +grep -rn "setWalletEmoji\|set_wallet_emoji" /Users/don/src/dkackman/sage/src/ +``` + +Add the publish call wherever `setWalletEmoji` is called from, following the same pattern. + +- [ ] **Install the plugin JS package** + +```bash +cd /Users/don/src/dkackman/sage +pnpm add tauri-plugin-nostr-sync-api@file:../../tauri-plugin-nostr/dist-js +``` + +If the dist-js is not built yet, build it first: + +```bash +cd /Users/don/src/dkackman/tauri-plugin-nostr +pnpm install && pnpm build +``` + +Then re-run the pnpm add command. + +- [ ] **Verify TypeScript compiles** + +```bash +cd /Users/don/src/dkackman/sage +pnpm tsc --noEmit 2>&1 | tail -30 +``` + +Expected: no type errors. + +- [ ] **Run the dev server and test the feature** + +```bash +cd /Users/don/src/dkackman/sage +pnpm tauri dev 2>&1 +``` + +Manual test checklist: +- [ ] Open Settings → Advanced tab. "Settings Sync" section is visible when a wallet with secrets is active. +- [ ] Toggle sync on. Three default relays appear. Status line shows connection state. +- [ ] Add a custom relay URL. It appears in the list and persists after restart. +- [ ] Remove a relay. It disappears from the list. +- [ ] Change wallet name in the Wallet tab. No error thrown; publish fires silently. +- [ ] Click "Fetch Settings" in the Advanced tab. Message appears ("Already up to date" or "Settings applied"). +- [ ] Toggle sync off. Relay list and fetch button hide. + +--- + +## Self-Review + +### Spec coverage check + +| Spec requirement | Task | +| --- | --- | +| HKDF-SHA256 key derivation from BLS master key | Task 5 | +| Publish on name change | Task 9 (WalletSettings onBlur) | +| Publish on emoji change | Task 9 (setWalletEmoji call site) | +| Opt-in per wallet (`sync_enabled`) | Tasks 2, 6 | +| Manual fetch from Advanced tab | Tasks 7, 9 | +| Relay management (add/remove/list) | Tasks 6, 9 | +| Default relays loaded from config | Tasks 1, 2, 6 | +| Silent apply on fetch (no prompt) | Task 7 | +| Publish failures don't fail mutation | Task 9 (`.catch(console.warn)`) | +| Unknown schema version → skip | Task 7 | +| Empty name → skip | Task 7 (`filter(|s| !s.is_empty())`) | +| Watch-only wallet → no-op | Task 5 (early return) | + +### Placeholder check + +No TBDs. The `setWalletEmoji` call site check in Task 9 requires a grep before implementing — this is intentional since we don't know at plan-write time exactly where it's called from. + +### Type consistency + +- `FetchSettingsResult` defined in Task 7, used in Task 9 (TypeScript side via generated bindings) +- `publish_wallet_settings` and `fetch_wallet_settings` both take `fingerprint: u32` consistently +- `add_sync_relay` / `remove_sync_relay` take `url: String` consistently with the plugin's own API diff --git a/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md b/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md new file mode 100644 index 000000000..490543e4c --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md @@ -0,0 +1,142 @@ +# Wallet Settings Sync — Design + +**Date:** 2026-05-05 +**Branch:** sync-settings +**Scope:** Phase 1 — wallet name and emoji, opt-in, manual fetch + +--- + +## Overview + +Sync wallet display settings (name, emoji) across app instances using `tauri-plugin-nostr-sync` as the transport. Each wallet derives its own Nostr identity from its master secret key via HKDF, so only instances holding the same wallet secret can read or write its settings. Sync is opt-in per wallet. Publish happens automatically on every mutation; reading is manually triggered from the Advanced settings tab for now. + +--- + +## Architecture + +Four layers: + +1. **Plugin registration** — `tauri-plugin-nostr-sync` is added to sage's Tauri plugin chain in `lib.rs` with namespace `"sage"` and three default relays (`wss://relay.damus.io`, `wss://relay.nostr.band`, `wss://nos.lol`). + +2. **Signer lifecycle** — On wallet login, sage derives a secp256k1 Nostr keypair from the wallet's BLS master secret key using HKDF-SHA256 with domain label `b"sage-nostr-sync"`. The 32-byte output becomes a `nostr_sdk::Keys` and is injected into the plugin via `app.nostr_sync().set_signer(keys)`. On logout, `clear_signer()` is called. Watch-only wallets (no secrets) skip injection — publish/fetch return `SignerNotSet`, treated as a no-op. + +3. **Publish on mutation** — `rename_key` and the emoji setter, after persisting to config, check `sync_enabled` on the wallet. If true, they call `app.nostr_sync().publish("wallet-settings", payload)`. Publish failures are logged but do not fail the mutation. + +4. **Manual fetch** — A new `fetch_wallet_settings` Tauri command calls the plugin's fetch for category `"wallet-settings"`, then writes the returned name and emoji directly to the `Wallet` config — bypassing the mutation commands so that a fetch does not trigger another publish. + +--- + +## Data Model + +### Wallet config (`sage-config`) + +One new field added to the `Wallet` struct: + +```rust +pub sync_enabled: bool, // serde default = false +``` + +### App-level sync config + +A new `SyncConfig` struct stored alongside the existing app config (not per-wallet, since relays are shared): + +```rust +pub struct SyncConfig { + pub relays: Vec, // default: the three relays listed above +} +``` + +Relays are loaded at startup and added to the plugin via `add_relay`. The plugin's runtime relay state is authoritative; `SyncConfig` is just the persisted seed list. + +### Published payload + +Category: `"wallet-settings"` + +```json +{ + "v": 1, + "name": "My Wallet", + "emoji": "🦋" +} +``` + +- `v` is a schema version. A receiver that sees an unknown version logs a warning and skips applying. +- `emoji` may be `null` if no emoji is set. +- The NIP-33 d-tag is `sage/wallet-settings/v1`; relays retain only the latest event per wallet pubkey, so there is no history to reconcile. + +### Conflict resolution + +The plugin uses NIP-33 parameterized replaceable events. The relay retains only the most recent event by timestamp. Latest-write-wins is the conflict model — no merge, no prompt. + +--- + +## New Tauri Commands + +| Command | Signature | Purpose | +| --- | --- | --- | +| `get_sync_config` | `(fingerprint: u32) -> SyncConfigResponse` | Returns `{ enabled: bool }` for the given wallet | +| `set_sync_enabled` | `(fingerprint: u32, enabled: bool) -> ()` | Persists `sync_enabled` for the wallet | +| `fetch_wallet_settings` | `(fingerprint: u32) -> FetchSettingsResponse` | Fetches and applies remote settings; returns `{ applied: bool, name?: string, emoji?: string }` | + +Relay management uses the plugin's existing JS bindings (`addRelay`, `removeRelay`, `getRelays`) directly — no new Tauri commands needed. + +--- + +## Frontend — Advanced Settings Tab + +A new **Settings Sync** section is added to the Advanced tab, rendered only when a wallet is logged in. It sits above the existing RpcSettings and LogViewer sections. + +### Controls + +**Sync toggle** +`SettingItem` with a `Switch`. On enable: calls `set_sync_enabled(true)`. On disable: calls `set_sync_enabled(false)`. Watch-only wallets see the toggle disabled with a note: "Sync requires a wallet with secrets." + +**Status line** +Shown when sync is enabled. Displays plugin readiness: `"Sync ready (2/3 relays connected)"` or `"Not connected"`. Sourced from `getStatus()` polled on mount. + +**Relay list** +Shown when sync is enabled. Lists relays from `getRelays()` with a connected/disconnected indicator dot. Each entry has a trash button (`removeRelay(url)`). Below the list: a text input + "Add" button (`addRelay(url)`). + +**Fetch button** +`"Fetch Settings"` button calls `fetch_wallet_settings`. Shows one of: + +- `"Settings applied — name and emoji updated"` on success with changes +- `"Already up to date"` if fetched values match local +- `"No remote settings found"` if no Nostr event exists yet +- Inline error message on failure + +--- + +## Key Derivation Detail + +```text +ikm = wallet_master_secret_key_bytes // 32-byte BLS private key +salt = [] // empty (key is already high-entropy) +info = b"sage-nostr-sync" +okm = HKDF-SHA256(ikm, salt, info, 32) +nostr_keys = nostr_sdk::Keys::from_secret_key(SecretKey::from_bytes(okm)) +``` + +The derived Nostr pubkey serves as the wallet's sync identity. Two app instances holding the same wallet secret will derive the same pubkey and can therefore read each other's NIP-44-encrypted events. + +--- + +## Error Handling + +| Scenario | Behavior | +| --- | --- | +| Publish fails (no relay connection) | Log warning, mutation still succeeds | +| Publish fails (signer not set) | Silently skip — watch-only wallet | +| Fetch fails (network) | Surface error inline in Advanced tab | +| Fetch returns unknown schema version | Log warning, do not apply, return `applied: false` | +| Applied name is empty string | Skip name update, keep existing | + +--- + +## Out of Scope (Phase 1) + +- Automatic background polling or startup sync +- Syncing any settings other than wallet name and emoji +- App-level (non-wallet) settings sync +- Relay authentication (NIP-42) +- Multiple relay write strategies (currently: all connected) diff --git a/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md b/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md new file mode 100644 index 000000000..40811e673 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md @@ -0,0 +1,122 @@ +# Configurable Payload Size Limit — Design Spec + +**Date:** 2026-05-06 +**Repo:** tauri-plugin-nostr-sync +**Status:** Approved + +--- + +## Problem + +The 64KB payload size limit is currently a hardcoded module-level constant in `src/state.rs`. There is no way for plugin consumers to increase it. Some use cases (e.g. larger wallet state blobs) may need more headroom up to the practical relay limit. + +--- + +## Goal + +Make the max payload size configurable via `PluginBuilder`, defaulting to 64KB and capped at 400KB. Misconfiguration (over-cap value) surfaces as a catchable error through Tauri's standard plugin `setup` closure mechanism — no panics, no silent clamping. + +--- + +## Constants + +Defined as `pub const` in `src/state.rs`: + +```rust +pub const DEFAULT_PAYLOAD_LIMIT: usize = 64 * 1024; // 64KB — default +pub const MAX_PAYLOAD_LIMIT: usize = 400 * 1024; // 400KB — hard cap +``` + +Both are exposed publicly so host apps can reference them (e.g. for UI validation). + +--- + +## Error Variant + +Add to `src/error.rs`: + +```rust +InvalidPayloadLimit { requested: usize, max: usize } +``` + +Display: `"payload limit {requested} exceeds maximum allowed {max}"`. + +--- + +## Architecture + +### `PluginBuilder` (`src/builder.rs`) + +- New field: `max_payload_size: usize` (defaults to `DEFAULT_PAYLOAD_LIMIT`) +- New infallible setter: `.max_payload_size(bytes: usize) -> Self` +- `build()` signature unchanged — threads `max_payload_size` into the setup closure alongside the existing `relays`, `namespace`, `device_id` + +### `desktop::init()` / `mobile::init()` + +- Add `max_payload_size: usize` parameter +- Pass through to `NostrSyncState::new()` + +### `NostrSyncState` (`src/state.rs`) + +- New field: `max_payload_size: usize` +- `new(namespace, device_id, max_payload_size)` — validates `max_payload_size <= MAX_PAYLOAD_LIMIT`, returns `Err(Error::InvalidPayloadLimit { ... })` if not. Namespace validation runs first (existing order preserved). +- Remove module-level `PAYLOAD_LIMIT` const (replaced by the two public constants above) +- `check_payload_size` becomes a method (`&self`) using `self.max_payload_size` instead of the old const + +### Error propagation path + +```text +NostrSyncState::new() → Err(InvalidPayloadLimit) + ↑ called by +desktop::init() → crate::Result + ↑ called by (with ?) +setup closure in PluginBuilder::build() → Result<(), Box> + ↑ propagated through +Tauri's Plugin::initialize() → catchable by host app at startup +``` + +No changes to public IPC commands or TypeScript bindings — this is purely a Rust-side builder and state machine concern. + +`NostrSyncState::new()` is `pub` (re-exported from `lib.rs`), so adding the `max_payload_size` parameter is a breaking change to the public Rust API. Acceptable at `0.1.0-alpha`. + +--- + +## Tests + +### `src/state.rs` + +- Update `payload_at_limit_is_accepted` and `payload_over_limit_is_rejected` to use instance state rather than the removed module const +- Add: `new_rejects_payload_limit_over_max` — verifies `InvalidPayloadLimit` error when `max_payload_size > MAX_PAYLOAD_LIMIT` +- Add: `new_accepts_payload_limit_at_max` — verifies `MAX_PAYLOAD_LIMIT` itself is accepted +- Add: `custom_limit_is_enforced` — creates state with a small custom limit, verifies payloads above it are rejected and below are accepted + +### `src/builder.rs` + +- Add: `max_payload_size_defaults_to_64kb` +- Add: `max_payload_size_setter_stores_value` + +--- + +## Documentation + +### `README.md` + +Add `.max_payload_size(bytes)` to the builder example block and a short note explaining the default (64KB), the cap (400KB), and that exceeding the cap surfaces as an error at startup. + +### `specs/tauri-plugin-nostr.md` + +Update the "Plugin Registration" Rust snippet and add a row to the design constraints table: + +| Constraint | Value | +| --- | --- | +| Default payload limit | 64KB | +| Maximum configurable limit | 400KB | +| Over-cap behavior | `Error::InvalidPayloadLimit` via setup closure | + +--- + +## Out of Scope + +- No changes to IPC commands or TypeScript bindings +- No per-category payload limits +- No runtime reconfiguration after `build()` diff --git a/package.json b/package.json index 7d0f9fc84..8c3e38309 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "spoiled": "^0.4.0", "tailwind-merge": "^2.6.1", "tailwindcss-animate": "^1.0.7", + "tauri-plugin-nostr-sync-api": "file:../tauri-plugin-nostr", "tauri-plugin-safe-area-insets": "^0.1.0", "tauri-plugin-sage": "file:tauri-plugin-sage", "theme-o-rama": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..316cadae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(yaml@2.5.1)) + tauri-plugin-nostr-sync-api: + specifier: file:../tauri-plugin-nostr + version: file:../tauri-plugin-nostr(@tauri-apps/api@2.10.1) tauri-plugin-safe-area-insets: specifier: ^0.1.0 version: 0.1.0 @@ -3522,6 +3525,12 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tauri-plugin-nostr-sync-api@file:../tauri-plugin-nostr: + resolution: {directory: ../tauri-plugin-nostr, type: directory} + engines: {node: '>=18'} + peerDependencies: + '@tauri-apps/api': '>=2.11.0' + tauri-plugin-safe-area-insets@0.1.0: resolution: {integrity: sha512-NhtxSQdU7+laTJiO4WZFlTB1CBzRlNE5EuHflGAGXS55fiwOkkaWztfNishFQO+eZtb9i5jLHX3Yho48lnqcRQ==} @@ -7426,6 +7435,10 @@ snapshots: - tsx - yaml + tauri-plugin-nostr-sync-api@file:../tauri-plugin-nostr(@tauri-apps/api@2.10.1): + dependencies: + '@tauri-apps/api': 2.10.1 + tauri-plugin-safe-area-insets@0.1.0: dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 349d0028b..724f16389 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,10 @@ tracing = { workspace = true } anyhow = { workspace = true } rustls = { workspace = true } reqwest = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +nostr-sdk = { workspace = true } +tauri-plugin-nostr-sync = { path = "../../tauri-plugin-nostr" } # This is to ensure that the bindgen feature is enabled for the aws-lc-rs crate. # https://aws.github.io/aws-lc-rs/platform_support.html#tested-platforms diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 67c5e4fe7..44673a8a4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "clipboard-manager:default", "clipboard-manager:allow-write-text", "clipboard-manager:allow-read-text", - "opener:default" + "opener:default", + "nostr-sync:default" ] } diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 07f0f17b4..ab00c8848 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,6 +8,7 @@ "barcode-scanner:default", "biometric:default", "sage:default", - "sharesheet:allow-share-text" + "sharesheet:allow-share-text", + "nostr-sync:default" ] } diff --git a/src-tauri/gen/apple/sage-tauri_iOS/Info.plist b/src-tauri/gen/apple/sage-tauri_iOS/Info.plist index 72ff7783d..9b9a0d918 100644 --- a/src-tauri/gen/apple/sage-tauri_iOS/Info.plist +++ b/src-tauri/gen/apple/sage-tauri_iOS/Info.plist @@ -49,4 +49,4 @@ NSPhotoLibraryAddUsageDescription This app needs access to save images to your photo library. - + \ No newline at end of file diff --git a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements index b1c4bd3a1..cab3c8853 100644 --- a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements +++ b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements @@ -9,4 +9,4 @@ com.apple.developer.group-session - + \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fa140bf6e..6efd21f50 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,7 @@ use std::{fs, time::Duration}; use chia_wallet_sdk::utils::Address; +use hkdf::Hkdf; use reqwest::StatusCode; use sage::Error; use sage_api::{wallet_connect::*, *}; @@ -8,8 +9,10 @@ use sage_api_macro::impl_endpoints_tauri; use sage_config::{NetworkConfig, Wallet, WalletDefaults}; use sage_rpc::start_rpc; use serde::{Deserialize, Serialize}; +use sha2::Sha256; use specta::{Type, specta}; use tauri::{AppHandle, State, command}; +use tauri_plugin_nostr_sync::TauriPluginNostrSyncExt; use tokio::time::sleep; use tracing::error; @@ -35,7 +38,7 @@ pub async fn initialize( *initialized = true; let mut sage = state.lock().await; - app_state::initialize(app_handle, &mut sage).await?; + app_state::initialize(app_handle.clone(), &mut sage).await?; drop(sage); let app_state = (*state).clone(); @@ -60,6 +63,16 @@ pub async fn initialize( *rpc_task.0.lock().await = Some(tokio::spawn(start_rpc((*state).clone()))); } + // Load persisted relays into the nostr-sync plugin + let relays = app_state.config.sync.relays.clone(); + drop(app_state); + + for url in relays { + if let Err(e) = app_handle.nostr_sync().add_relay(&url).await { + tracing::warn!("Failed to connect to relay {url}: {e}"); + } + } + Ok(()) } @@ -242,3 +255,340 @@ pub async fn get_logs(state: State<'_, AppState>) -> Result> { Ok(log_files) } + +#[command] +#[specta] +pub async fn inject_nostr_signer( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result<()> { + let sage = state.lock().await; + let Ok((_, Some(master_sk))) = sage.keychain.extract_secrets(fingerprint, b"") else { + return Ok(()); // watch-only wallet — skip silently + }; + drop(sage); + + let ikm = master_sk.to_bytes(); + let hk = Hkdf::::new(None, &ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm) + .expect("32 bytes is a valid HKDF output length"); + + let secret_key = nostr_sdk::SecretKey::from_slice(&okm).map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + let keys = nostr_sdk::Keys::new(secret_key); + + app_handle + .nostr_sync() + .set_signer(keys) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + Ok(()) +} + +#[command] +#[specta] +pub async fn clear_nostr_signer(app_handle: AppHandle) -> Result<()> { + app_handle.nostr_sync().clear_signer().await; + Ok(()) +} + +#[command] +#[specta] +pub async fn get_sync_enabled(state: State<'_, AppState>, fingerprint: u32) -> Result { + let sage = state.lock().await; + let enabled = sage + .wallet_config + .wallets + .iter() + .find(|w| w.fingerprint == fingerprint) + .map(|w| w.sync_enabled) + .unwrap_or(false); + Ok(enabled) +} + +#[command] +#[specta] +pub async fn set_sync_enabled( + state: State<'_, AppState>, + fingerprint: u32, + enabled: bool, +) -> Result<()> { + let mut sage = state.lock().await; + let Some(wallet) = sage + .wallet_config + .wallets + .iter_mut() + .find(|w| w.fingerprint == fingerprint) + else { + return Err(Error::UnknownFingerprint.into()); + }; + wallet.sync_enabled = enabled; + sage.save_config()?; + Ok(()) +} + +#[command] +#[specta] +pub async fn add_sync_relay( + app_handle: AppHandle, + state: State<'_, AppState>, + url: String, +) -> Result<()> { + app_handle + .nostr_sync() + .add_relay(&url) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let mut sage = state.lock().await; + if !sage.config.sync.relays.contains(&url) { + sage.config.sync.relays.push(url); + sage.save_config()?; + } + Ok(()) +} + +#[command] +#[specta] +pub async fn remove_sync_relay( + app_handle: AppHandle, + state: State<'_, AppState>, + url: String, +) -> Result<()> { + app_handle + .nostr_sync() + .remove_relay(&url) + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let mut sage = state.lock().await; + sage.config.sync.relays.retain(|r| r != &url); + sage.save_config()?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct FetchSettingsResult { + pub applied: bool, + pub name: Option, + pub emoji: Option, +} + +#[command] +#[specta] +pub async fn publish_wallet_settings( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result<()> { + let (sync_enabled, name, emoji) = { + let sage = state.lock().await; + let Some(wallet) = sage + .wallet_config + .wallets + .iter() + .find(|w| w.fingerprint == fingerprint) + else { + return Ok(()); + }; + (wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone()) + }; + + if !sync_enabled { + return Ok(()); + } + + let payload = serde_json::json!({ + "v": 1, + "name": name, + "emoji": emoji, + }); + + if let Err(e) = app_handle + .nostr_sync() + .publish("wallet-settings", &payload) + .await + { + tracing::warn!("Failed to publish wallet settings: {e}"); + } + + Ok(()) +} + +#[command] +#[specta] +pub async fn fetch_wallet_settings( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result { + let result = app_handle + .nostr_sync() + .fetch("wallet-settings") + .await + .map_err(|e| crate::error::Error { + kind: sage_api::ErrorKind::Internal, + reason: e.to_string(), + })?; + + let Some(fetch_result) = result else { + return Ok(FetchSettingsResult { + applied: false, + name: None, + emoji: None, + }); + }; + + let payload = &fetch_result.payload; + + let version = payload["v"].as_u64().unwrap_or(0); + if version != 1 { + tracing::warn!("fetch_wallet_settings: unknown schema version {version}, skipping"); + return Ok(FetchSettingsResult { + applied: false, + name: None, + emoji: None, + }); + } + + let remote_name = payload["name"] + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_string); + let remote_emoji = payload.get("emoji").and_then(|e| { + if e.is_null() { + None + } else { + e.as_str().map(str::to_string) + } + }); + + let mut sage = state.lock().await; + let Some(wallet) = sage + .wallet_config + .wallets + .iter_mut() + .find(|w| w.fingerprint == fingerprint) + else { + return Ok(FetchSettingsResult { + applied: false, + name: None, + emoji: None, + }); + }; + + let mut applied = false; + + if let Some(ref name) = remote_name { + if wallet.name != *name { + wallet.name = name.clone(); + applied = true; + } + } + + if remote_emoji != wallet.emoji { + wallet.emoji = remote_emoji.clone(); + applied = true; + } + + if applied { + sage.save_config()?; + } + + Ok(FetchSettingsResult { + applied, + name: remote_name, + emoji: remote_emoji, + }) +} + +#[cfg(test)] +mod sync_tests { + use super::*; + + #[test] + fn hkdf_derive_nostr_key_produces_32_bytes() { + let ikm = [42u8; 32]; + let hk = Hkdf::::new(None, &ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + assert_ne!(okm, [0u8; 32]); + } + + #[test] + fn same_ikm_produces_same_nostr_key() { + let ikm = [99u8; 32]; + let derive = |ikm: &[u8]| { + let hk = Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + okm + }; + assert_eq!(derive(&ikm), derive(&ikm)); + } + + #[test] + fn different_ikm_produces_different_nostr_key() { + let derive = |ikm: &[u8]| { + let hk = Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); + okm + }; + assert_ne!(derive(&[1u8; 32]), derive(&[2u8; 32])); + } + + #[test] + fn wallet_settings_payload_schema_version_is_1() { + let payload = serde_json::json!({ + "v": 1, + "name": "My Wallet", + "emoji": serde_json::Value::Null, + }); + assert_eq!(payload["v"], 1); + assert_eq!(payload["name"], "My Wallet"); + assert!(payload["emoji"].is_null()); + } + + #[test] + fn fetch_payload_v1_parses_name_and_emoji() { + let payload = serde_json::json!({ + "v": 1, + "name": "Cold Storage", + "emoji": "🧊", + }); + let v = payload["v"].as_u64().unwrap_or(0); + let name = payload["name"].as_str().map(str::to_string); + let emoji = payload.get("emoji").and_then(|e| { + if e.is_null() { + None + } else { + e.as_str().map(str::to_string) + } + }); + assert_eq!(v, 1); + assert_eq!(name, Some("Cold Storage".to_string())); + assert_eq!(emoji, Some("🧊".to_string())); + } + + #[test] + fn fetch_payload_unknown_version_is_rejected() { + let payload = serde_json::json!({ "v": 99, "name": "x" }); + let v = payload["v"].as_u64().unwrap_or(0); + assert_ne!(v, 1, "should treat v=99 as unknown"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..5630fe555 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,14 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::inject_nostr_signer, + commands::clear_nostr_signer, + commands::get_sync_enabled, + commands::set_sync_enabled, + commands::add_sync_relay, + commands::remove_sync_relay, + commands::publish_wallet_settings, + commands::fetch_wallet_settings, ]) .events(collect_events![SyncEvent]); @@ -156,7 +164,12 @@ pub fn run() { let mut tauri_builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_os::init()); + .plugin(tauri_plugin_os::init()) + .plugin( + tauri_plugin_nostr_sync::Builder::new() + .app_namespace("sage") + .build(), + ); #[cfg(not(mobile))] { diff --git a/src/bindings.ts b/src/bindings.ts index 45ad7bbf5..41f7f38ef 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -358,6 +358,30 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async injectNostrSigner(fingerprint: number) : Promise { + return await TAURI_INVOKE("inject_nostr_signer", { fingerprint }); +}, +async clearNostrSigner() : Promise { + return await TAURI_INVOKE("clear_nostr_signer"); +}, +async getSyncEnabled(fingerprint: number) : Promise { + return await TAURI_INVOKE("get_sync_enabled", { fingerprint }); +}, +async setSyncEnabled(fingerprint: number, enabled: boolean) : Promise { + return await TAURI_INVOKE("set_sync_enabled", { fingerprint, enabled }); +}, +async addSyncRelay(url: string) : Promise { + return await TAURI_INVOKE("add_sync_relay", { url }); +}, +async removeSyncRelay(url: string) : Promise { + return await TAURI_INVOKE("remove_sync_relay", { url }); +}, +async publishWalletSettings(fingerprint: number) : Promise { + return await TAURI_INVOKE("publish_wallet_settings", { fingerprint }); +}, +async fetchWalletSettings(fingerprint: number) : Promise { + return await TAURI_INVOKE("fetch_wallet_settings", { fingerprint }); } } @@ -826,6 +850,7 @@ export type FeeAction = { * The fee amount, in mojos */ amount: Amount } +export type FetchSettingsResult = { applied: boolean; name: string | null; emoji: string | null } /** * Filter unlocked coins from a list */ @@ -2748,7 +2773,7 @@ offer: OfferSummary; * Offer status */ status: OfferRecordStatus } -export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } +export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null; sync_enabled?: boolean } export type WalletDefaults = { delta_sync: boolean } /** tauri-specta globals **/ diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 5cdcb7925..a86fc5ecb 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -96,15 +96,16 @@ export function WalletCard({ commands .renameKey({ fingerprint: info.fingerprint, name: newName }) - .then(() => + .then(() => { setKeys( keys.map((key) => key.fingerprint === info.fingerprint ? { ...key, name: newName } : key, ), - ), - ) + ); + commands.publishWalletSettings(info.fingerprint).catch(console.warn); + }) .catch(addError) .finally(() => setIsRenameOpen(false)); @@ -114,13 +115,14 @@ export function WalletCard({ const updateEmoji = (emoji: string | null) => { commands .setWalletEmoji({ fingerprint: info.fingerprint, emoji }) - .then(() => + .then(() => { setKeys( keys.map((key) => key.fingerprint === info.fingerprint ? { ...key, emoji } : key, ), - ), - ) + ); + commands.publishWalletSettings(info.fingerprint).catch(console.warn); + }) .catch(addError); }; diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 3e0da02ff..c1fa7f77c 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,7 +1,7 @@ import { KeyInfo, commands } from '@/bindings'; import { CustomError } from '@/contexts/ErrorContext'; import { useErrors } from '@/hooks/useErrors'; -import { fetchState, initializeWalletState } from '@/state'; +import { backgroundFetchSettings, fetchState, initializeWalletState } from '@/state'; import { createContext, useContext, useEffect, useState } from 'react'; interface WalletContextType { @@ -26,6 +26,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { initializeWalletState(setWallet); const data = await commands.getKey({}); setWallet(data.key); + if (data.key) { + await commands + .injectNostrSigner(data.key.fingerprint) + .catch((e) => console.warn('inject_nostr_signer failed on resume:', e)); + backgroundFetchSettings(data.key.fingerprint); + } await fetchState(); } catch (error) { const customError = error as CustomError; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8ef69fa88..7b879b032 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -70,6 +70,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { commands, + FetchSettingsResult, GetDatabaseStatsResponse, KeyInfo, LogFile, @@ -79,6 +80,12 @@ import { Wallet, WalletDefaults, } from '../bindings'; +import { + getRelays, + getStatus, + RelayInfo, + SyncStatus, +} from 'tauri-plugin-nostr-sync-api'; import { ThemeSelectorSimple } from '../components/ThemeSelector'; import { isValidU32 } from '../validation'; @@ -198,6 +205,7 @@ export default function Settings() {
+ {wallet && } {!isMobile && }
@@ -1229,6 +1237,7 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { if (wallet) { setWallet({ ...wallet, name: localName }); } + commands.publishWalletSettings(fingerprint).catch(console.warn); }) .catch(addError); }} @@ -1601,3 +1610,145 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) {
); } + +function SyncSettings({ fingerprint }: { fingerprint: number }) { + const { addError } = useErrors(); + const [syncEnabled, setSyncEnabledState] = useState(false); + const [relays, setRelays] = useState([]); + const [status, setStatus] = useState(null); + const [newRelay, setNewRelay] = useState(''); + const [fetchMessage, setFetchMessage] = useState(null); + const [fetching, setFetching] = useState(false); + + useEffect(() => { + commands.getSyncEnabled(fingerprint).then(setSyncEnabledState).catch(addError); + }, [fingerprint, addError]); + + useEffect(() => { + if (!syncEnabled) return; + const load = () => { + getRelays().then(setRelays).catch(console.warn); + getStatus().then(setStatus).catch(console.warn); + }; + load(); + const id = setInterval(load, 5000); + return () => clearInterval(id); + }, [syncEnabled]); + + const toggleSync = async (enabled: boolean) => { + await commands.setSyncEnabled(fingerprint, enabled).catch(addError); + setSyncEnabledState(enabled); + }; + + const handleAddRelay = async () => { + const url = newRelay.trim(); + if (!url) return; + await commands.addSyncRelay(url).catch(addError); + setNewRelay(''); + getRelays().then(setRelays).catch(console.warn); + }; + + const handleRemoveRelay = async (url: string) => { + await commands.removeSyncRelay(url).catch(addError); + getRelays().then(setRelays).catch(console.warn); + }; + + const handleFetch = async () => { + setFetching(true); + setFetchMessage(null); + try { + const result: FetchSettingsResult = + await commands.fetchWalletSettings(fingerprint); + if (result.applied) { + setFetchMessage(t`Settings applied`); + } else { + setFetchMessage(t`Already up to date`); + } + } catch (e) { + setFetchMessage(String(e)); + } finally { + setFetching(false); + } + }; + + const connectedCount = status?.connectedRelayCount ?? 0; + const totalCount = status?.relayCount ?? 0; + + return ( + + + } + /> + + {syncEnabled && ( + <> +
+ {status?.ready + ? t`Sync ready (${connectedCount}/${totalCount} relays connected)` + : t`Not connected`} +
+ + {relays.map((relay) => ( +
+
+
+ {relay.url} +
+ +
+ ))} + +
+ setNewRelay(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddRelay()} + /> + +
+ +
+ + {fetchMessage && ( + + {fetchMessage} + + )} +
+ + )} + + ); +} diff --git a/src/state.ts b/src/state.ts index 2cfc621eb..1bf3735f1 100644 --- a/src/state.ts +++ b/src/state.ts @@ -115,6 +115,19 @@ events.syncEvent.listen((event) => { } }); +export function backgroundFetchSettings(fingerprint: number): void { + commands + .getSyncEnabled(fingerprint) + .then((enabled) => { + if (enabled) { + commands + .fetchWalletSettings(fingerprint) + .catch((e) => console.warn('background settings fetch failed:', e)); + } + }) + .catch(() => {}); +} + export async function loginAndUpdateState( fingerprint: number, onError?: (error: CustomError) => void, @@ -122,6 +135,11 @@ export async function loginAndUpdateState( try { await commands.login({ fingerprint }); await fetchState(); + // Inject Nostr signer for settings sync (no-op for watch-only wallets) + await commands.injectNostrSigner(fingerprint).catch((e) => + console.warn('inject_nostr_signer failed:', e), + ); + backgroundFetchSettings(fingerprint); } catch (error) { if (onError) { onError(error as CustomError); @@ -146,6 +164,9 @@ export async function logoutAndUpdateState(): Promise { if (setWalletState) { setWalletState(null); } + await commands.clearNostrSigner().catch((e) => + console.warn('clear_nostr_signer failed:', e), + ); await commands.logout({}); } From 0043965fdbbee89903c393d6501728fdccd9a3d1 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 7 May 2026 07:06:17 -0500 Subject: [PATCH 2/9] move publish to back end --- Cargo.lock | 2 +- crates/sage-api/endpoints.json | 4 +- src-tauri/src/commands.rs | 75 ++++++++++++++++++++++++---------- src/components/WalletCard.tsx | 2 - src/pages/Settings.tsx | 1 - 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67c9730d0..7d9615585 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7884,7 +7884,7 @@ dependencies = [ [[package]] name = "tauri-plugin-nostr-sync" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.3" dependencies = [ "chrono", "nostr-sdk", diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..b00218148 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -6,8 +6,6 @@ "import_key": true, "delete_key": false, "delete_database": false, - "rename_key": false, - "set_wallet_emoji": false, "get_key": false, "get_secret_key": false, "get_keys": false, @@ -99,4 +97,4 @@ "redownload_nft": true, "increase_derivation_index": true, "is_asset_owned": true -} +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6efd21f50..7512ecf9d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -317,21 +317,25 @@ pub async fn get_sync_enabled(state: State<'_, AppState>, fingerprint: u32) -> R #[command] #[specta] pub async fn set_sync_enabled( + app_handle: AppHandle, state: State<'_, AppState>, fingerprint: u32, enabled: bool, ) -> Result<()> { - let mut sage = state.lock().await; - let Some(wallet) = sage - .wallet_config - .wallets - .iter_mut() - .find(|w| w.fingerprint == fingerprint) - else { - return Err(Error::UnknownFingerprint.into()); - }; - wallet.sync_enabled = enabled; - sage.save_config()?; + { + let mut sage = state.lock().await; + let Some(wallet) = sage + .wallet_config + .wallets + .iter_mut() + .find(|w| w.fingerprint == fingerprint) + else { + return Err(Error::UnknownFingerprint.into()); + }; + wallet.sync_enabled = enabled; + sage.save_config()?; + } + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; Ok(()) } @@ -388,13 +392,7 @@ pub struct FetchSettingsResult { pub emoji: Option, } -#[command] -#[specta] -pub async fn publish_wallet_settings( - app_handle: AppHandle, - state: State<'_, AppState>, - fingerprint: u32, -) -> Result<()> { +async fn do_publish_wallet_settings(app_handle: &AppHandle, state: &AppState, fingerprint: u32) { let (sync_enabled, name, emoji) = { let sage = state.lock().await; let Some(wallet) = sage @@ -403,13 +401,13 @@ pub async fn publish_wallet_settings( .iter() .find(|w| w.fingerprint == fingerprint) else { - return Ok(()); + return; }; (wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone()) }; if !sync_enabled { - return Ok(()); + return; } let payload = serde_json::json!({ @@ -420,12 +418,47 @@ pub async fn publish_wallet_settings( if let Err(e) = app_handle .nostr_sync() - .publish("wallet-settings", &payload) + .publish("wallet-settings", &payload, None) .await { tracing::warn!("Failed to publish wallet settings: {e}"); } +} + +#[command] +#[specta] +pub async fn rename_key( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::RenameKey, +) -> Result { + let fingerprint = req.fingerprint; + let resp = state.lock().await.rename_key(req)?; + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; + Ok(resp) +} +#[command] +#[specta] +pub async fn set_wallet_emoji( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::SetWalletEmoji, +) -> Result { + let fingerprint = req.fingerprint; + let resp = state.lock().await.set_wallet_emoji(req)?; + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; + Ok(resp) +} + +#[command] +#[specta] +pub async fn publish_wallet_settings( + app_handle: AppHandle, + state: State<'_, AppState>, + fingerprint: u32, +) -> Result<()> { + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; Ok(()) } diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index a86fc5ecb..992f07cb2 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -104,7 +104,6 @@ export function WalletCard({ : key, ), ); - commands.publishWalletSettings(info.fingerprint).catch(console.warn); }) .catch(addError) .finally(() => setIsRenameOpen(false)); @@ -121,7 +120,6 @@ export function WalletCard({ key.fingerprint === info.fingerprint ? { ...key, emoji } : key, ), ); - commands.publishWalletSettings(info.fingerprint).catch(console.warn); }) .catch(addError); }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7b879b032..183cf6dfa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1237,7 +1237,6 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { if (wallet) { setWallet({ ...wallet, name: localName }); } - commands.publishWalletSettings(fingerprint).catch(console.warn); }) .catch(addError); }} From 5756a96b13fe0642a6a0a274252f4f4a5778307f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 7 May 2026 07:22:07 -0500 Subject: [PATCH 3/9] remove --- .../plans/2026-05-05-wallet-settings-sync.md | 1212 ----------------- .../2026-05-05-wallet-settings-sync-design.md | 142 -- ...05-06-configurable-payload-limit-design.md | 122 -- 3 files changed, 1476 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-05-wallet-settings-sync.md delete mode 100644 docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md delete mode 100644 docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md diff --git a/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md b/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md deleted file mode 100644 index 2dd6d916c..000000000 --- a/docs/superpowers/plans/2026-05-05-wallet-settings-sync.md +++ /dev/null @@ -1,1212 +0,0 @@ -# Wallet Settings Sync Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Sync wallet name and emoji across app instances using `tauri-plugin-nostr-sync` as transport, with opt-in per wallet and manual fetch from the Advanced settings tab. - -**Architecture:** Each wallet derives a secp256k1 Nostr keypair from its BLS master secret via HKDF-SHA256. The plugin encrypts settings payloads with NIP-44 (self-encryption) and publishes to Nostr relays as NIP-33 replaceable events. Publish is triggered from a new Tauri command called by the frontend after each mutation; fetch is manually triggered from the Advanced settings tab. - -**Tech Stack:** Rust (`tauri-plugin-nostr-sync`, `hkdf`, `sha2`, `nostr-sdk`), TypeScript (`tauri-plugin-nostr-sync-api`), React/Tailwind (existing patterns) - ---- - -## File Map - -| File | Action | Responsibility | -| --- | --- | --- | -| `crates/sage-config/src/sync.rs` | Create | `SyncConfig` struct with relay list | -| `crates/sage-config/src/lib.rs` | Modify | Re-export `SyncConfig` | -| `crates/sage-config/src/wallet.rs` | Modify | Add `sync_enabled: bool` to `Wallet` | -| `crates/sage-config/src/config.rs` | Modify | Add `sync: SyncConfig` to `Config` | -| `Cargo.toml` | Modify | Add `hkdf`, `sha2` workspace deps | -| `src-tauri/Cargo.toml` | Modify | Add plugin + hkdf + sha2 crate deps | -| `src-tauri/src/lib.rs` | Modify | Register plugin; load relays in initialize | -| `src-tauri/src/commands.rs` | Modify | 8 new commands (signer, config, publish, fetch) | -| `src-tauri/capabilities/default.json` | Modify | Add `nostr-sync:default` permission | -| `src/state.ts` | Modify | Call inject/clear signer on login/logout | -| `src/pages/Settings.tsx` | Modify | Add `SyncSettings` to Advanced tab | - ---- - -## Task 1: sage-config — SyncConfig struct - -**Files:** -- Create: `crates/sage-config/src/sync.rs` -- Modify: `crates/sage-config/src/lib.rs` - -- [ ] **Write the test** - -Add to the bottom of `crates/sage-config/src/sync.rs` (write the file first, then add tests): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_relays_are_populated() { - let cfg = SyncConfig::default(); - assert_eq!(cfg.relays.len(), 3); - assert!(cfg.relays.contains(&"wss://relay.damus.io".to_string())); - } - - #[test] - fn toml_roundtrip_preserves_relays() { - let original = SyncConfig { - relays: vec!["wss://relay.example.com".to_string()], - }; - let toml = toml::to_string_pretty(&original).unwrap(); - let parsed: SyncConfig = toml::from_str(&toml).unwrap(); - assert_eq!(parsed.relays, original.relays); - } - - #[test] - fn empty_relay_list_deserializes() { - let toml = r#"relays = []"#; - let cfg: SyncConfig = toml::from_str(toml).unwrap(); - assert!(cfg.relays.is_empty()); - } -} -``` - -- [ ] **Run test to verify it fails** - -```bash -cd /Users/don/src/dkackman/sage -cargo test -p sage-config sync 2>&1 | tail -20 -``` - -Expected: compile error — `SyncConfig` not defined yet. - -- [ ] **Implement `SyncConfig`** - -Create `crates/sage-config/src/sync.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use specta::Type; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(default)] -pub struct SyncConfig { - pub relays: Vec, -} - -impl Default for SyncConfig { - fn default() -> Self { - Self { - relays: vec![ - "wss://relay.damus.io".to_string(), - "wss://relay.nostr.band".to_string(), - "wss://nos.lol".to_string(), - ], - } - } -} -``` - -- [ ] **Re-export from lib.rs** - -In `crates/sage-config/src/lib.rs`, add before the existing `pub use` lines: - -```rust -mod sync; -pub use sync::*; -``` - -- [ ] **Run tests to verify they pass** - -```bash -cargo test -p sage-config sync 2>&1 | tail -20 -``` - -Expected: 3 tests pass. - ---- - -## Task 2: sage-config — sync_enabled on Wallet and SyncConfig on Config - -**Files:** -- Modify: `crates/sage-config/src/wallet.rs` -- Modify: `crates/sage-config/src/config.rs` - -- [ ] **Write the tests** - -In `crates/sage-config/src/wallet.rs`, add to the existing `#[cfg(test)]` block: - -```rust -#[test] -fn sync_enabled_defaults_to_false() { - let wallet = Wallet::default(); - assert!(!wallet.sync_enabled); -} - -#[test] -fn sync_enabled_survives_toml_roundtrip() { - let wallet = Wallet { - sync_enabled: true, - ..Wallet::default() - }; - let config = WalletConfig { - defaults: WalletDefaults::default(), - wallets: vec![wallet], - }; - let toml = toml::to_string_pretty(&config).unwrap(); - let parsed: WalletConfig = toml::from_str(&toml).unwrap(); - assert!(parsed.wallets[0].sync_enabled); -} -``` - -In `crates/sage-config/src/config.rs`, add a new test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sync_config_in_config_defaults_correctly() { - let cfg = Config::default(); - assert_eq!(cfg.sync.relays.len(), 3); - } - - #[test] - fn config_toml_roundtrip_preserves_sync() { - let mut cfg = Config::default(); - cfg.sync.relays = vec!["wss://custom.relay".to_string()]; - let toml = toml::to_string_pretty(&cfg).unwrap(); - let parsed: Config = toml::from_str(&toml).unwrap(); - assert_eq!(parsed.sync.relays, vec!["wss://custom.relay"]); - } -} -``` - -- [ ] **Run tests to verify they fail** - -```bash -cargo test -p sage-config 2>&1 | tail -20 -``` - -Expected: compile errors — `sync_enabled` and `sync` fields don't exist yet. - -- [ ] **Add `sync_enabled` to `Wallet`** - -In `crates/sage-config/src/wallet.rs`, add the field after `change_address`: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(default)] -pub struct Wallet { - pub name: String, - pub fingerprint: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - pub delta_sync: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub emoji: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub change_address: Option, - #[serde(default)] - pub sync_enabled: bool, -} -``` - -Update `Default for Wallet` to include `sync_enabled: false`: - -```rust -impl Default for Wallet { - fn default() -> Self { - Self { - name: "Unnamed Wallet".to_string(), - fingerprint: 0, - network: None, - delta_sync: None, - emoji: None, - change_address: None, - sync_enabled: false, - } - } -} -``` - -- [ ] **Add `sync: SyncConfig` to `Config`** - -In `crates/sage-config/src/config.rs`, add the import and field: - -```rust -use serde::{Deserialize, Serialize}; -use specta::Type; - -use crate::SyncConfig; - -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Type)] -#[serde(default)] -pub struct Config { - pub version: u32, - pub global: GlobalConfig, - pub network: NetworkConfig, - pub rpc: RpcConfig, - pub sync: SyncConfig, -} - -impl Default for Config { - fn default() -> Self { - Self { - version: 2, - global: GlobalConfig::default(), - network: NetworkConfig::default(), - rpc: RpcConfig::default(), - sync: SyncConfig::default(), - } - } -} -``` - -- [ ] **Run tests to verify they pass** - -```bash -cargo test -p sage-config 2>&1 | tail -20 -``` - -Expected: all sage-config tests pass (including the existing wallet config tests). - ---- - -## Task 3: Cargo dependencies - -**Files:** -- Modify: `Cargo.toml` (workspace) -- Modify: `src-tauri/Cargo.toml` - -- [ ] **Add workspace dependencies** - -In `Cargo.toml`, find the `[workspace.dependencies]` section and add: - -```toml -hkdf = "0.12" -sha2 = "0.10" -``` - -- [ ] **Add crate dependencies to src-tauri** - -In `src-tauri/Cargo.toml`, add to `[dependencies]`: - -```toml -tauri-plugin-nostr-sync = { path = "../../tauri-plugin-nostr" } -hkdf = { workspace = true } -sha2 = { workspace = true } -nostr-sdk = { version = "0.44.1", features = ["nip44"] } -``` - -- [ ] **Verify the workspace compiles** - -```bash -cargo check -p sage-tauri 2>&1 | tail -30 -``` - -Expected: no errors (warnings OK). If `nostr-sdk` version conflicts with the plugin's internal use, check `Cargo.lock` and adjust. - ---- - -## Task 4: Register plugin in lib.rs and add permission - -**Files:** -- Modify: `src-tauri/src/lib.rs` -- Modify: `src-tauri/capabilities/default.json` - -- [ ] **Add plugin permission to capabilities** - -In `src-tauri/capabilities/default.json`, add `"nostr-sync:default"` to the `permissions` array: - -```json -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "enables the default permissions", - "windows": ["main"], - "permissions": [ - "core:path:default", - "core:event:default", - "core:window:default", - "core:webview:default", - "core:app:default", - "core:resources:default", - "core:image:default", - "clipboard-manager:default", - "clipboard-manager:allow-write-text", - "clipboard-manager:allow-read-text", - "opener:default", - "nostr-sync:default" - ] -} -``` - -- [ ] **Register the plugin in `lib.rs`** - -Add the plugin to `src-tauri/src/lib.rs`. Find the `let mut tauri_builder = tauri::Builder::default()` block and add the plugin registration. The plugin is added for both desktop and mobile. Add it after the existing `.plugin(tauri_plugin_os::init())` call: - -```rust -let mut tauri_builder = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_os::init()) - .plugin( - tauri_plugin_nostr_sync::Builder::new() - .app_namespace("sage") - .build(), - ); -``` - -Also add the import at the top of the file (or in the block where it's used — Rust allows `use` anywhere): - -The `tauri_plugin_nostr_sync` crate is used directly via its builder. No explicit `use` is needed since we're using the full path. - -- [ ] **Verify the build compiles** - -```bash -cargo build -p sage-tauri 2>&1 | tail -30 -``` - -Expected: compiles. The plugin registers itself with the Tauri runtime. - ---- - -## Task 5: New Tauri commands — signer lifecycle - -**Files:** -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/lib.rs` - -These commands derive the Nostr key from the wallet's BLS master secret and inject it into the plugin. - -- [ ] **Write the failing test for HKDF derivation** - -At the bottom of `src-tauri/src/commands.rs`, add a test module: - -```rust -#[cfg(test)] -mod sync_tests { - use super::*; - - #[test] - fn hkdf_derive_nostr_key_produces_32_bytes() { - let ikm = [42u8; 32]; - let hk = hkdf::Hkdf::::new(None, &ikm); - let mut okm = [0u8; 32]; - hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); - assert_eq!(okm.len(), 32); - assert_ne!(okm, [0u8; 32]); - } - - #[test] - fn same_ikm_produces_same_nostr_key() { - let ikm = [99u8; 32]; - let derive = |ikm: &[u8]| { - let hk = hkdf::Hkdf::::new(None, ikm); - let mut okm = [0u8; 32]; - hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); - okm - }; - assert_eq!(derive(&ikm), derive(&ikm)); - } - - #[test] - fn different_ikm_produces_different_nostr_key() { - let derive = |ikm: &[u8]| { - let hk = hkdf::Hkdf::::new(None, ikm); - let mut okm = [0u8; 32]; - hk.expand(b"sage-nostr-sync", &mut okm).unwrap(); - okm - }; - assert_ne!(derive(&[1u8; 32]), derive(&[2u8; 32])); - } -} -``` - -- [ ] **Run test to verify it fails** - -```bash -cargo test -p sage-tauri sync_tests 2>&1 | tail -20 -``` - -Expected: compile error — `hkdf` and `sha2` not imported. - -- [ ] **Add imports and implement `inject_nostr_signer` and `clear_nostr_signer`** - -Add these imports at the top of `src-tauri/src/commands.rs`: - -```rust -use hkdf::Hkdf; -use sha2::Sha256; -use tauri_plugin_nostr_sync::TauriPluginNostrSyncExt; -``` - -Add these two commands after the existing commands in `src-tauri/src/commands.rs`: - -```rust -#[command] -#[specta] -pub async fn inject_nostr_signer( - app_handle: AppHandle, - state: State<'_, AppState>, - fingerprint: u32, -) -> Result<()> { - let sage = state.lock().await; - let Ok((_, Some(master_sk))) = sage.keychain.extract_secrets(fingerprint, b"") else { - return Ok(()); // watch-only wallet — skip silently - }; - drop(sage); - - let ikm = master_sk.to_bytes(); - let hk = Hkdf::::new(None, &ikm); - let mut okm = [0u8; 32]; - hk.expand(b"sage-nostr-sync", &mut okm) - .expect("32 bytes is a valid HKDF output length"); - - let secret_key = nostr_sdk::secp256k1::SecretKey::from_slice(&okm).map_err(|e| { - crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - } - })?; - let keys = nostr_sdk::Keys::new(secret_key.into()); - - app_handle - .nostr_sync() - .set_signer(keys) - .await - .map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; - - Ok(()) -} - -#[command] -#[specta] -pub async fn clear_nostr_signer(app_handle: AppHandle) -> Result<()> { - app_handle.nostr_sync().clear_signer().await; - Ok(()) -} -``` - -- [ ] **Register the new commands in `lib.rs`** - -In `src-tauri/src/lib.rs`, add the two commands to `collect_commands!`: - -```rust -commands::inject_nostr_signer, -commands::clear_nostr_signer, -``` - -- [ ] **Run tests to verify** - -```bash -cargo test -p sage-tauri sync_tests 2>&1 | tail -20 -``` - -Expected: 3 tests pass. - -```bash -cargo build -p sage-tauri 2>&1 | tail -20 -``` - -Expected: compiles cleanly. - ---- - -## Task 6: New Tauri commands — sync config and relay management - -**Files:** -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/lib.rs` - -- [ ] **Write tests** - -Add to the `sync_tests` module in `src-tauri/src/commands.rs`: - -```rust -#[test] -fn wallet_settings_payload_schema_version_is_1() { - let payload = serde_json::json!({ - "v": 1, - "name": "My Wallet", - "emoji": null, - }); - assert_eq!(payload["v"], 1); - assert_eq!(payload["name"], "My Wallet"); - assert!(payload["emoji"].is_null()); -} -``` - -- [ ] **Implement the four config/relay commands** - -Add to `src-tauri/src/commands.rs`: - -```rust -#[command] -#[specta] -pub async fn get_sync_enabled(state: State<'_, AppState>, fingerprint: u32) -> Result { - let sage = state.lock().await; - let enabled = sage - .wallet_config - .wallets - .iter() - .find(|w| w.fingerprint == fingerprint) - .map(|w| w.sync_enabled) - .unwrap_or(false); - Ok(enabled) -} - -#[command] -#[specta] -pub async fn set_sync_enabled( - state: State<'_, AppState>, - fingerprint: u32, - enabled: bool, -) -> Result<()> { - let mut sage = state.lock().await; - let Some(wallet) = sage - .wallet_config - .wallets - .iter_mut() - .find(|w| w.fingerprint == fingerprint) - else { - return Err(sage::Error::UnknownFingerprint.into()); - }; - wallet.sync_enabled = enabled; - sage.save_config()?; - Ok(()) -} - -#[command] -#[specta] -pub async fn add_sync_relay( - app_handle: AppHandle, - state: State<'_, AppState>, - url: String, -) -> Result<()> { - app_handle - .nostr_sync() - .add_relay(&url) - .await - .map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; - - let mut sage = state.lock().await; - if !sage.config.sync.relays.contains(&url) { - sage.config.sync.relays.push(url); - sage.save_config()?; - } - Ok(()) -} - -#[command] -#[specta] -pub async fn remove_sync_relay( - app_handle: AppHandle, - state: State<'_, AppState>, - url: String, -) -> Result<()> { - app_handle - .nostr_sync() - .remove_relay(&url) - .await - .map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; - - let mut sage = state.lock().await; - sage.config.sync.relays.retain(|r| r != &url); - sage.save_config()?; - Ok(()) -} -``` - -- [ ] **Register commands in `lib.rs`** - -Add to `collect_commands!`: - -```rust -commands::get_sync_enabled, -commands::set_sync_enabled, -commands::add_sync_relay, -commands::remove_sync_relay, -``` - -- [ ] **Load relays in `initialize` command** - -In `src-tauri/src/commands.rs`, find the `initialize` function. After the existing initialization logic and before the final `Ok(())`, add: - -```rust - // Load persisted relays into the nostr-sync plugin - let relays = { - let sage = state.lock().await; - sage.config.sync.relays.clone() - }; - for url in relays { - if let Err(e) = app_handle.nostr_sync().add_relay(&url).await { - tracing::warn!("Failed to connect to relay {url}: {e}"); - } - } -``` - -- [ ] **Verify build** - -```bash -cargo build -p sage-tauri 2>&1 | tail -20 -``` - -Expected: compiles. New commands and bindings generated. - ---- - -## Task 7: New Tauri commands — publish and fetch - -**Files:** -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/lib.rs` - -- [ ] **Write tests** - -Add to `sync_tests` module: - -```rust -#[test] -fn fetch_payload_v1_parses_name_and_emoji() { - let payload = serde_json::json!({ - "v": 1, - "name": "Cold Storage", - "emoji": "🧊", - }); - let v = payload["v"].as_u64().unwrap_or(0); - let name = payload["name"].as_str().map(str::to_string); - let emoji = payload.get("emoji").and_then(|e| { - if e.is_null() { None } else { e.as_str().map(str::to_string) } - }); - assert_eq!(v, 1); - assert_eq!(name, Some("Cold Storage".to_string())); - assert_eq!(emoji, Some("🧊".to_string())); -} - -#[test] -fn fetch_payload_unknown_version_is_rejected() { - let payload = serde_json::json!({ "v": 99, "name": "x" }); - let v = payload["v"].as_u64().unwrap_or(0); - assert_ne!(v, 1, "should treat v=99 as unknown"); -} -``` - -- [ ] **Run test to verify it fails** - -```bash -cargo test -p sage-tauri sync_tests 2>&1 | tail -20 -``` - -Expected: compile error (functions referenced by tests don't exist yet) OR tests pass if they're pure logic tests. These are pure logic tests — they should pass immediately once the test module compiles. - -- [ ] **Implement `publish_wallet_settings`** - -Add to `src-tauri/src/commands.rs`: - -```rust -#[command] -#[specta] -pub async fn publish_wallet_settings( - app_handle: AppHandle, - state: State<'_, AppState>, - fingerprint: u32, -) -> Result<()> { - let (sync_enabled, name, emoji) = { - let sage = state.lock().await; - let Some(wallet) = sage.wallet_config.wallets.iter().find(|w| w.fingerprint == fingerprint) else { - return Ok(()); - }; - (wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone()) - }; - - if !sync_enabled { - return Ok(()); - } - - let payload = serde_json::json!({ - "v": 1, - "name": name, - "emoji": emoji, - }); - - if let Err(e) = app_handle.nostr_sync().publish("wallet-settings", &payload).await { - tracing::warn!("Failed to publish wallet settings: {e}"); - } - - Ok(()) -} -``` - -- [ ] **Implement `fetch_wallet_settings`** - -Add to `src-tauri/src/commands.rs`: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct FetchSettingsResult { - pub applied: bool, - pub name: Option, - pub emoji: Option, -} - -#[command] -#[specta] -pub async fn fetch_wallet_settings( - app_handle: AppHandle, - state: State<'_, AppState>, - fingerprint: u32, -) -> Result { - let result = app_handle - .nostr_sync() - .fetch("wallet-settings") - .await - .map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; - - let Some(fetch_result) = result else { - return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); - }; - - let payload = &fetch_result.payload; - - let version = payload["v"].as_u64().unwrap_or(0); - if version != 1 { - tracing::warn!("fetch_wallet_settings: unknown schema version {version}, skipping"); - return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); - } - - let remote_name = payload["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string); - let remote_emoji = payload.get("emoji").and_then(|e| { - if e.is_null() { None } else { e.as_str().map(str::to_string) } - }); - - let mut sage = state.lock().await; - let Some(wallet) = sage.wallet_config.wallets.iter_mut().find(|w| w.fingerprint == fingerprint) else { - return Ok(FetchSettingsResult { applied: false, name: None, emoji: None }); - }; - - let mut applied = false; - - if let Some(ref name) = remote_name { - if wallet.name != *name { - wallet.name = name.clone(); - applied = true; - } - } - - if remote_emoji != wallet.emoji { - wallet.emoji = remote_emoji.clone(); - applied = true; - } - - if applied { - sage.save_config()?; - } - - Ok(FetchSettingsResult { - applied, - name: remote_name, - emoji: remote_emoji, - }) -} -``` - -- [ ] **Register commands in `lib.rs`** - -Add to `collect_commands!`: - -```rust -commands::publish_wallet_settings, -commands::fetch_wallet_settings, -``` - -- [ ] **Run all sync tests** - -```bash -cargo test -p sage-tauri sync_tests 2>&1 | tail -20 -``` - -Expected: all tests pass. - -```bash -cargo build -p sage-tauri 2>&1 | tail -20 -``` - -Expected: compiles, TypeScript bindings regenerated at `src/bindings.ts`. - ---- - -## Task 8: state.ts — signer lifecycle on login/logout - -**Files:** -- Modify: `src/state.ts` - -- [ ] **Call `injectNostrSigner` after login** - -In `src/state.ts`, update `loginAndUpdateState`: - -```typescript -export async function loginAndUpdateState( - fingerprint: number, - onError?: (error: CustomError) => void, -): Promise { - try { - await commands.login({ fingerprint }); - await fetchState(); - // Inject Nostr signer for settings sync (no-op for watch-only wallets) - await commands.injectNostrSigner({ fingerprint }).catch((e) => - console.warn('inject_nostr_signer failed:', e), - ); - } catch (error) { - if (onError) { - onError(error as CustomError); - } else { - console.error(error); - } - throw error; - } -} -``` - -- [ ] **Call `clearNostrSigner` on logout** - -Update `logoutAndUpdateState`: - -```typescript -export async function logoutAndUpdateState(): Promise { - clearState(); - if (setWalletState) { - setWalletState(null); - } - await commands.clearNostrSigner({}).catch((e) => - console.warn('clear_nostr_signer failed:', e), - ); - await commands.logout({}); -} -``` - -- [ ] **Verify TypeScript compiles** - -```bash -cd /Users/don/src/dkackman/sage -pnpm tsc --noEmit 2>&1 | tail -20 -``` - -Expected: no type errors. If `injectNostrSigner` or `clearNostrSigner` are not in `bindings.ts`, confirm the Rust build completed in Task 7 (it regenerates bindings on debug build). - ---- - -## Task 9: Settings.tsx — SyncSettings UI - -**Files:** -- Modify: `src/pages/Settings.tsx` - -This task adds a new `SyncSettings` React component and mounts it in the Advanced tab. - -- [ ] **Add plugin imports at the top of Settings.tsx** - -Add to the existing imports in `src/pages/Settings.tsx`: - -```typescript -import { - addRelay, - getRelays, - getStatus, - RelayInfo, - SyncStatus, -} from 'tauri-plugin-nostr-sync-api'; -``` - -Also add to the `commands` import from `../bindings` the new commands: - -The `commands` object in `bindings.ts` will automatically include the new commands once the Rust build runs. No manual addition needed. - -- [ ] **Implement the `SyncSettings` component** - -Add this component before the `export default function Settings()` line in `src/pages/Settings.tsx`: - -```typescript -function SyncSettings({ fingerprint }: { fingerprint: number }) { - const { addError } = useErrors(); - const [syncEnabled, setSyncEnabled] = useState(false); - const [relays, setRelays] = useState([]); - const [status, setStatus] = useState(null); - const [newRelay, setNewRelay] = useState(''); - const [fetchMessage, setFetchMessage] = useState(null); - const [fetching, setFetching] = useState(false); - - useEffect(() => { - commands - .getSyncEnabled({ fingerprint }) - .then(setSyncEnabled) - .catch(addError); - }, [fingerprint, addError]); - - useEffect(() => { - if (!syncEnabled) return; - const load = () => { - getRelays().then(setRelays).catch(console.warn); - getStatus().then(setStatus).catch(console.warn); - }; - load(); - const id = setInterval(load, 5000); - return () => clearInterval(id); - }, [syncEnabled]); - - const toggleSync = async (enabled: boolean) => { - await commands - .setSyncEnabled({ fingerprint, enabled }) - .catch(addError); - setSyncEnabled(enabled); - }; - - const handleAddRelay = async () => { - const url = newRelay.trim(); - if (!url) return; - await commands.addSyncRelay({ url }).catch(addError); - setNewRelay(''); - getRelays().then(setRelays).catch(console.warn); - }; - - const handleRemoveRelay = async (url: string) => { - await commands.removeSyncRelay({ url }).catch(addError); - getRelays().then(setRelays).catch(console.warn); - }; - - const handleFetch = async () => { - setFetching(true); - setFetchMessage(null); - try { - const result = await commands.fetchWalletSettings({ fingerprint }); - if (result.applied) { - setFetchMessage(t`Settings applied`); - } else { - setFetchMessage(t`Already up to date`); - } - } catch (e) { - setFetchMessage(String(e)); - } finally { - setFetching(false); - } - }; - - const connectedCount = status?.connectedRelayCount ?? 0; - const totalCount = status?.relayCount ?? 0; - - return ( - - - } - /> - - {syncEnabled && ( - <> -
- {status?.ready - ? t`Sync ready (${connectedCount}/${totalCount} relays connected)` - : t`Not connected`} -
- - {relays.map((relay) => ( -
-
-
- {relay.url} -
- -
- ))} - -
- setNewRelay(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddRelay()} - /> - -
- -
- - {fetchMessage && ( - - {fetchMessage} - - )} -
- - )} - - ); -} -``` - -- [ ] **Mount SyncSettings in the Advanced tab** - -Find the `` block in `Settings.tsx`. Update it to include `SyncSettings` when a wallet is logged in: - -```typescript - -
- {wallet && } - {!isMobile && } - -
-
-``` - -- [ ] **Add `publish_wallet_settings` calls to WalletSettings handlers** - -In the `WalletSettings` component in `Settings.tsx`, find the `onBlur` handler for the wallet name input and add the publish call after rename succeeds: - -```typescript -onBlur={() => { - if (localName === wallet?.name) return; - - commands - .renameKey({ - fingerprint, - name: localName, - }) - .then(() => { - if (wallet) { - setWallet({ ...wallet, name: localName }); - } - // Publish if sync is enabled (fire-and-forget) - commands - .publishWalletSettings({ fingerprint }) - .catch(console.warn); - }) - .catch(addError); -}} -``` - -Find where `set_wallet_emoji` is called (search for `setWalletEmoji` in the file — it's called from the wallet card or emoji picker). Add after it resolves: - -```typescript -commands.publishWalletSettings({ fingerprint }).catch(console.warn); -``` - -Note: `setWalletEmoji` may be called from `WalletCard.tsx` rather than `Settings.tsx`. Check both files: - -```bash -grep -rn "setWalletEmoji\|set_wallet_emoji" /Users/don/src/dkackman/sage/src/ -``` - -Add the publish call wherever `setWalletEmoji` is called from, following the same pattern. - -- [ ] **Install the plugin JS package** - -```bash -cd /Users/don/src/dkackman/sage -pnpm add tauri-plugin-nostr-sync-api@file:../../tauri-plugin-nostr/dist-js -``` - -If the dist-js is not built yet, build it first: - -```bash -cd /Users/don/src/dkackman/tauri-plugin-nostr -pnpm install && pnpm build -``` - -Then re-run the pnpm add command. - -- [ ] **Verify TypeScript compiles** - -```bash -cd /Users/don/src/dkackman/sage -pnpm tsc --noEmit 2>&1 | tail -30 -``` - -Expected: no type errors. - -- [ ] **Run the dev server and test the feature** - -```bash -cd /Users/don/src/dkackman/sage -pnpm tauri dev 2>&1 -``` - -Manual test checklist: -- [ ] Open Settings → Advanced tab. "Settings Sync" section is visible when a wallet with secrets is active. -- [ ] Toggle sync on. Three default relays appear. Status line shows connection state. -- [ ] Add a custom relay URL. It appears in the list and persists after restart. -- [ ] Remove a relay. It disappears from the list. -- [ ] Change wallet name in the Wallet tab. No error thrown; publish fires silently. -- [ ] Click "Fetch Settings" in the Advanced tab. Message appears ("Already up to date" or "Settings applied"). -- [ ] Toggle sync off. Relay list and fetch button hide. - ---- - -## Self-Review - -### Spec coverage check - -| Spec requirement | Task | -| --- | --- | -| HKDF-SHA256 key derivation from BLS master key | Task 5 | -| Publish on name change | Task 9 (WalletSettings onBlur) | -| Publish on emoji change | Task 9 (setWalletEmoji call site) | -| Opt-in per wallet (`sync_enabled`) | Tasks 2, 6 | -| Manual fetch from Advanced tab | Tasks 7, 9 | -| Relay management (add/remove/list) | Tasks 6, 9 | -| Default relays loaded from config | Tasks 1, 2, 6 | -| Silent apply on fetch (no prompt) | Task 7 | -| Publish failures don't fail mutation | Task 9 (`.catch(console.warn)`) | -| Unknown schema version → skip | Task 7 | -| Empty name → skip | Task 7 (`filter(|s| !s.is_empty())`) | -| Watch-only wallet → no-op | Task 5 (early return) | - -### Placeholder check - -No TBDs. The `setWalletEmoji` call site check in Task 9 requires a grep before implementing — this is intentional since we don't know at plan-write time exactly where it's called from. - -### Type consistency - -- `FetchSettingsResult` defined in Task 7, used in Task 9 (TypeScript side via generated bindings) -- `publish_wallet_settings` and `fetch_wallet_settings` both take `fingerprint: u32` consistently -- `add_sync_relay` / `remove_sync_relay` take `url: String` consistently with the plugin's own API diff --git a/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md b/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md deleted file mode 100644 index 490543e4c..000000000 --- a/docs/superpowers/specs/2026-05-05-wallet-settings-sync-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# Wallet Settings Sync — Design - -**Date:** 2026-05-05 -**Branch:** sync-settings -**Scope:** Phase 1 — wallet name and emoji, opt-in, manual fetch - ---- - -## Overview - -Sync wallet display settings (name, emoji) across app instances using `tauri-plugin-nostr-sync` as the transport. Each wallet derives its own Nostr identity from its master secret key via HKDF, so only instances holding the same wallet secret can read or write its settings. Sync is opt-in per wallet. Publish happens automatically on every mutation; reading is manually triggered from the Advanced settings tab for now. - ---- - -## Architecture - -Four layers: - -1. **Plugin registration** — `tauri-plugin-nostr-sync` is added to sage's Tauri plugin chain in `lib.rs` with namespace `"sage"` and three default relays (`wss://relay.damus.io`, `wss://relay.nostr.band`, `wss://nos.lol`). - -2. **Signer lifecycle** — On wallet login, sage derives a secp256k1 Nostr keypair from the wallet's BLS master secret key using HKDF-SHA256 with domain label `b"sage-nostr-sync"`. The 32-byte output becomes a `nostr_sdk::Keys` and is injected into the plugin via `app.nostr_sync().set_signer(keys)`. On logout, `clear_signer()` is called. Watch-only wallets (no secrets) skip injection — publish/fetch return `SignerNotSet`, treated as a no-op. - -3. **Publish on mutation** — `rename_key` and the emoji setter, after persisting to config, check `sync_enabled` on the wallet. If true, they call `app.nostr_sync().publish("wallet-settings", payload)`. Publish failures are logged but do not fail the mutation. - -4. **Manual fetch** — A new `fetch_wallet_settings` Tauri command calls the plugin's fetch for category `"wallet-settings"`, then writes the returned name and emoji directly to the `Wallet` config — bypassing the mutation commands so that a fetch does not trigger another publish. - ---- - -## Data Model - -### Wallet config (`sage-config`) - -One new field added to the `Wallet` struct: - -```rust -pub sync_enabled: bool, // serde default = false -``` - -### App-level sync config - -A new `SyncConfig` struct stored alongside the existing app config (not per-wallet, since relays are shared): - -```rust -pub struct SyncConfig { - pub relays: Vec, // default: the three relays listed above -} -``` - -Relays are loaded at startup and added to the plugin via `add_relay`. The plugin's runtime relay state is authoritative; `SyncConfig` is just the persisted seed list. - -### Published payload - -Category: `"wallet-settings"` - -```json -{ - "v": 1, - "name": "My Wallet", - "emoji": "🦋" -} -``` - -- `v` is a schema version. A receiver that sees an unknown version logs a warning and skips applying. -- `emoji` may be `null` if no emoji is set. -- The NIP-33 d-tag is `sage/wallet-settings/v1`; relays retain only the latest event per wallet pubkey, so there is no history to reconcile. - -### Conflict resolution - -The plugin uses NIP-33 parameterized replaceable events. The relay retains only the most recent event by timestamp. Latest-write-wins is the conflict model — no merge, no prompt. - ---- - -## New Tauri Commands - -| Command | Signature | Purpose | -| --- | --- | --- | -| `get_sync_config` | `(fingerprint: u32) -> SyncConfigResponse` | Returns `{ enabled: bool }` for the given wallet | -| `set_sync_enabled` | `(fingerprint: u32, enabled: bool) -> ()` | Persists `sync_enabled` for the wallet | -| `fetch_wallet_settings` | `(fingerprint: u32) -> FetchSettingsResponse` | Fetches and applies remote settings; returns `{ applied: bool, name?: string, emoji?: string }` | - -Relay management uses the plugin's existing JS bindings (`addRelay`, `removeRelay`, `getRelays`) directly — no new Tauri commands needed. - ---- - -## Frontend — Advanced Settings Tab - -A new **Settings Sync** section is added to the Advanced tab, rendered only when a wallet is logged in. It sits above the existing RpcSettings and LogViewer sections. - -### Controls - -**Sync toggle** -`SettingItem` with a `Switch`. On enable: calls `set_sync_enabled(true)`. On disable: calls `set_sync_enabled(false)`. Watch-only wallets see the toggle disabled with a note: "Sync requires a wallet with secrets." - -**Status line** -Shown when sync is enabled. Displays plugin readiness: `"Sync ready (2/3 relays connected)"` or `"Not connected"`. Sourced from `getStatus()` polled on mount. - -**Relay list** -Shown when sync is enabled. Lists relays from `getRelays()` with a connected/disconnected indicator dot. Each entry has a trash button (`removeRelay(url)`). Below the list: a text input + "Add" button (`addRelay(url)`). - -**Fetch button** -`"Fetch Settings"` button calls `fetch_wallet_settings`. Shows one of: - -- `"Settings applied — name and emoji updated"` on success with changes -- `"Already up to date"` if fetched values match local -- `"No remote settings found"` if no Nostr event exists yet -- Inline error message on failure - ---- - -## Key Derivation Detail - -```text -ikm = wallet_master_secret_key_bytes // 32-byte BLS private key -salt = [] // empty (key is already high-entropy) -info = b"sage-nostr-sync" -okm = HKDF-SHA256(ikm, salt, info, 32) -nostr_keys = nostr_sdk::Keys::from_secret_key(SecretKey::from_bytes(okm)) -``` - -The derived Nostr pubkey serves as the wallet's sync identity. Two app instances holding the same wallet secret will derive the same pubkey and can therefore read each other's NIP-44-encrypted events. - ---- - -## Error Handling - -| Scenario | Behavior | -| --- | --- | -| Publish fails (no relay connection) | Log warning, mutation still succeeds | -| Publish fails (signer not set) | Silently skip — watch-only wallet | -| Fetch fails (network) | Surface error inline in Advanced tab | -| Fetch returns unknown schema version | Log warning, do not apply, return `applied: false` | -| Applied name is empty string | Skip name update, keep existing | - ---- - -## Out of Scope (Phase 1) - -- Automatic background polling or startup sync -- Syncing any settings other than wallet name and emoji -- App-level (non-wallet) settings sync -- Relay authentication (NIP-42) -- Multiple relay write strategies (currently: all connected) diff --git a/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md b/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md deleted file mode 100644 index 40811e673..000000000 --- a/docs/superpowers/specs/2026-05-06-configurable-payload-limit-design.md +++ /dev/null @@ -1,122 +0,0 @@ -# Configurable Payload Size Limit — Design Spec - -**Date:** 2026-05-06 -**Repo:** tauri-plugin-nostr-sync -**Status:** Approved - ---- - -## Problem - -The 64KB payload size limit is currently a hardcoded module-level constant in `src/state.rs`. There is no way for plugin consumers to increase it. Some use cases (e.g. larger wallet state blobs) may need more headroom up to the practical relay limit. - ---- - -## Goal - -Make the max payload size configurable via `PluginBuilder`, defaulting to 64KB and capped at 400KB. Misconfiguration (over-cap value) surfaces as a catchable error through Tauri's standard plugin `setup` closure mechanism — no panics, no silent clamping. - ---- - -## Constants - -Defined as `pub const` in `src/state.rs`: - -```rust -pub const DEFAULT_PAYLOAD_LIMIT: usize = 64 * 1024; // 64KB — default -pub const MAX_PAYLOAD_LIMIT: usize = 400 * 1024; // 400KB — hard cap -``` - -Both are exposed publicly so host apps can reference them (e.g. for UI validation). - ---- - -## Error Variant - -Add to `src/error.rs`: - -```rust -InvalidPayloadLimit { requested: usize, max: usize } -``` - -Display: `"payload limit {requested} exceeds maximum allowed {max}"`. - ---- - -## Architecture - -### `PluginBuilder` (`src/builder.rs`) - -- New field: `max_payload_size: usize` (defaults to `DEFAULT_PAYLOAD_LIMIT`) -- New infallible setter: `.max_payload_size(bytes: usize) -> Self` -- `build()` signature unchanged — threads `max_payload_size` into the setup closure alongside the existing `relays`, `namespace`, `device_id` - -### `desktop::init()` / `mobile::init()` - -- Add `max_payload_size: usize` parameter -- Pass through to `NostrSyncState::new()` - -### `NostrSyncState` (`src/state.rs`) - -- New field: `max_payload_size: usize` -- `new(namespace, device_id, max_payload_size)` — validates `max_payload_size <= MAX_PAYLOAD_LIMIT`, returns `Err(Error::InvalidPayloadLimit { ... })` if not. Namespace validation runs first (existing order preserved). -- Remove module-level `PAYLOAD_LIMIT` const (replaced by the two public constants above) -- `check_payload_size` becomes a method (`&self`) using `self.max_payload_size` instead of the old const - -### Error propagation path - -```text -NostrSyncState::new() → Err(InvalidPayloadLimit) - ↑ called by -desktop::init() → crate::Result - ↑ called by (with ?) -setup closure in PluginBuilder::build() → Result<(), Box> - ↑ propagated through -Tauri's Plugin::initialize() → catchable by host app at startup -``` - -No changes to public IPC commands or TypeScript bindings — this is purely a Rust-side builder and state machine concern. - -`NostrSyncState::new()` is `pub` (re-exported from `lib.rs`), so adding the `max_payload_size` parameter is a breaking change to the public Rust API. Acceptable at `0.1.0-alpha`. - ---- - -## Tests - -### `src/state.rs` - -- Update `payload_at_limit_is_accepted` and `payload_over_limit_is_rejected` to use instance state rather than the removed module const -- Add: `new_rejects_payload_limit_over_max` — verifies `InvalidPayloadLimit` error when `max_payload_size > MAX_PAYLOAD_LIMIT` -- Add: `new_accepts_payload_limit_at_max` — verifies `MAX_PAYLOAD_LIMIT` itself is accepted -- Add: `custom_limit_is_enforced` — creates state with a small custom limit, verifies payloads above it are rejected and below are accepted - -### `src/builder.rs` - -- Add: `max_payload_size_defaults_to_64kb` -- Add: `max_payload_size_setter_stores_value` - ---- - -## Documentation - -### `README.md` - -Add `.max_payload_size(bytes)` to the builder example block and a short note explaining the default (64KB), the cap (400KB), and that exceeding the cap surfaces as an error at startup. - -### `specs/tauri-plugin-nostr.md` - -Update the "Plugin Registration" Rust snippet and add a row to the design constraints table: - -| Constraint | Value | -| --- | --- | -| Default payload limit | 64KB | -| Maximum configurable limit | 400KB | -| Over-cap behavior | `Error::InvalidPayloadLimit` via setup closure | - ---- - -## Out of Scope - -- No changes to IPC commands or TypeScript bindings -- No per-category payload limits -- No runtime reconfiguration after `build()` From 46f1c316676b49ac8eb8e84977c9cc5ac0af8153 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 7 May 2026 07:44:58 -0500 Subject: [PATCH 4/9] move settings sync to backend --- crates/sage-api/endpoints.json | 2 - src-tauri/src/commands.rs | 88 ++++++++++++++++++++++++---------- src-tauri/src/lib.rs | 2 - src/contexts/WalletContext.tsx | 8 +--- src/state.ts | 21 -------- 5 files changed, 63 insertions(+), 58 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index b00218148..03077f761 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -1,6 +1,4 @@ { - "login": true, - "logout": true, "resync": true, "generate_mnemonic": false, "import_key": true, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7512ecf9d..551af006b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -65,6 +65,7 @@ pub async fn initialize( // Load persisted relays into the nostr-sync plugin let relays = app_state.config.sync.relays.clone(); + let resume_fingerprint = app_state.config.global.fingerprint; drop(app_state); for url in relays { @@ -73,6 +74,12 @@ pub async fn initialize( } } + // Resume: inject signer and trigger background settings fetch for the active wallet + if let Some(fingerprint) = resume_fingerprint { + inject_signer_for_fingerprint(&app_handle, &state, fingerprint).await; + spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint); + } + Ok(()) } @@ -256,18 +263,14 @@ pub async fn get_logs(state: State<'_, AppState>) -> Result> { Ok(log_files) } -#[command] -#[specta] -pub async fn inject_nostr_signer( - app_handle: AppHandle, - state: State<'_, AppState>, - fingerprint: u32, -) -> Result<()> { - let sage = state.lock().await; - let Ok((_, Some(master_sk))) = sage.keychain.extract_secrets(fingerprint, b"") else { - return Ok(()); // watch-only wallet — skip silently +async fn inject_signer_for_fingerprint(app_handle: &AppHandle, state: &AppState, fingerprint: u32) { + let master_sk = { + let sage = state.lock().await; + let Ok((_, Some(sk))) = sage.keychain.extract_secrets(fingerprint, b"") else { + return; // watch-only wallet — skip silently + }; + sk }; - drop(sage); let ikm = master_sk.to_bytes(); let hk = Hkdf::::new(None, &ikm); @@ -275,29 +278,62 @@ pub async fn inject_nostr_signer( hk.expand(b"sage-nostr-sync", &mut okm) .expect("32 bytes is a valid HKDF output length"); - let secret_key = nostr_sdk::SecretKey::from_slice(&okm).map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; + let Ok(secret_key) = nostr_sdk::SecretKey::from_slice(&okm) else { + return; + }; let keys = nostr_sdk::Keys::new(secret_key); - app_handle - .nostr_sync() - .set_signer(keys) - .await - .map_err(|e| crate::error::Error { - kind: sage_api::ErrorKind::Internal, - reason: e.to_string(), - })?; + if let Err(e) = app_handle.nostr_sync().set_signer(keys).await { + tracing::warn!("Failed to inject Nostr signer: {e}"); + } +} - Ok(()) +fn spawn_fetch_settings_if_enabled(app_handle: AppHandle, state: AppState, fingerprint: u32) { + tokio::spawn(async move { + let sync_enabled = { + let sage = state.lock().await; + sage.wallet_config + .wallets + .iter() + .find(|w| w.fingerprint == fingerprint) + .map(|w| w.sync_enabled) + .unwrap_or(false) + }; + + if !sync_enabled { + return; + } + + if let Err(e) = app_handle.nostr_sync().fetch("wallet-settings").await { + tracing::warn!("Background settings fetch failed: {e}"); + } + }); +} + +#[command] +#[specta] +pub async fn login( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::Login, +) -> Result { + let fingerprint = req.fingerprint; + let resp = state.lock().await.login(req).await?; + inject_signer_for_fingerprint(&app_handle, &state, fingerprint).await; + spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint); + Ok(resp) } #[command] #[specta] -pub async fn clear_nostr_signer(app_handle: AppHandle) -> Result<()> { +pub async fn logout( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::Logout, +) -> Result { + let resp = state.lock().await.logout(req).await?; app_handle.nostr_sync().clear_signer().await; - Ok(()) + Ok(resp) } #[command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5630fe555..305061161 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,8 +141,6 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, - commands::inject_nostr_signer, - commands::clear_nostr_signer, commands::get_sync_enabled, commands::set_sync_enabled, commands::add_sync_relay, diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index c1fa7f77c..3e0da02ff 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,7 +1,7 @@ import { KeyInfo, commands } from '@/bindings'; import { CustomError } from '@/contexts/ErrorContext'; import { useErrors } from '@/hooks/useErrors'; -import { backgroundFetchSettings, fetchState, initializeWalletState } from '@/state'; +import { fetchState, initializeWalletState } from '@/state'; import { createContext, useContext, useEffect, useState } from 'react'; interface WalletContextType { @@ -26,12 +26,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { initializeWalletState(setWallet); const data = await commands.getKey({}); setWallet(data.key); - if (data.key) { - await commands - .injectNostrSigner(data.key.fingerprint) - .catch((e) => console.warn('inject_nostr_signer failed on resume:', e)); - backgroundFetchSettings(data.key.fingerprint); - } await fetchState(); } catch (error) { const customError = error as CustomError; diff --git a/src/state.ts b/src/state.ts index 1bf3735f1..2cfc621eb 100644 --- a/src/state.ts +++ b/src/state.ts @@ -115,19 +115,6 @@ events.syncEvent.listen((event) => { } }); -export function backgroundFetchSettings(fingerprint: number): void { - commands - .getSyncEnabled(fingerprint) - .then((enabled) => { - if (enabled) { - commands - .fetchWalletSettings(fingerprint) - .catch((e) => console.warn('background settings fetch failed:', e)); - } - }) - .catch(() => {}); -} - export async function loginAndUpdateState( fingerprint: number, onError?: (error: CustomError) => void, @@ -135,11 +122,6 @@ export async function loginAndUpdateState( try { await commands.login({ fingerprint }); await fetchState(); - // Inject Nostr signer for settings sync (no-op for watch-only wallets) - await commands.injectNostrSigner(fingerprint).catch((e) => - console.warn('inject_nostr_signer failed:', e), - ); - backgroundFetchSettings(fingerprint); } catch (error) { if (onError) { onError(error as CustomError); @@ -164,9 +146,6 @@ export async function logoutAndUpdateState(): Promise { if (setWalletState) { setWalletState(null); } - await commands.clearNostrSigner().catch((e) => - console.warn('clear_nostr_signer failed:', e), - ); await commands.logout({}); } From 440f3ea367f10a79dc074387e43464b882f241a2 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 7 May 2026 07:52:48 -0500 Subject: [PATCH 5/9] remove low value tests --- crates/sage-config/src/wallet.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/crates/sage-config/src/wallet.rs b/crates/sage-config/src/wallet.rs index 8198f360b..35b5ad8b1 100644 --- a/crates/sage-config/src/wallet.rs +++ b/crates/sage-config/src/wallet.rs @@ -151,25 +151,4 @@ mod tests { }"#]], ); } - - #[test] - fn sync_enabled_defaults_to_false() { - let wallet = Wallet::default(); - assert!(!wallet.sync_enabled); - } - - #[test] - fn sync_enabled_survives_toml_roundtrip() { - let wallet = Wallet { - sync_enabled: true, - ..Wallet::default() - }; - let config = WalletConfig { - defaults: WalletDefaults::default(), - wallets: vec![wallet], - }; - let toml = toml::to_string_pretty(&config).unwrap(); - let parsed: WalletConfig = toml::from_str(&toml).unwrap(); - assert!(parsed.wallets[0].sync_enabled); - } } From 9e66ad38e21af02a90839a5ce53d6273c4b9bc7a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 7 May 2026 12:12:50 -0500 Subject: [PATCH 6/9] review fixes --- crates/sage-api/endpoints.json | 3 +- crates/sage-api/src/requests/data.rs | 26 ++++++++++++++++++ crates/sage/src/endpoints/data.rs | 30 ++++++++++++++++---- src-tauri/src/commands.rs | 41 ++++++++++++++++++++++++---- src-tauri/src/lib.rs | 1 + src/bindings.ts | 29 ++++++++++++++------ src/components/WalletCard.tsx | 20 ++++---------- src/pages/Settings.tsx | 22 +++++++++------ 8 files changed, 130 insertions(+), 42 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 03077f761..76fa9e68f 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -8,6 +8,7 @@ "get_secret_key": false, "get_keys": false, "get_sync_status": true, + "get_wallet_receive_address": true, "get_version": false, "get_database_stats": true, "perform_database_maintenance": true, @@ -95,4 +96,4 @@ "redownload_nft": true, "increase_derivation_index": true, "is_asset_owned": true -} \ No newline at end of file +} diff --git a/crates/sage-api/src/requests/data.rs b/crates/sage-api/src/requests/data.rs index 34917898f..3b3b05186 100644 --- a/crates/sage-api/src/requests/data.rs +++ b/crates/sage-api/src/requests/data.rs @@ -208,6 +208,32 @@ pub struct GetVersionResponse { pub version: String, } +/// Get the current receive address for any wallet by fingerprint +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "System & Sync", + description = "Get the receive address for a wallet without switching the active session." + ) +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWalletReceiveAddress { + /// Wallet fingerprint + pub fingerprint: u32, +} + +/// Response with the wallet receive address +#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "System & Sync"))] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWalletReceiveAddressResponse { + /// Encoded receive address + pub address: String, +} + /// Check if specific coins are spendable #[cfg_attr( feature = "openapi", diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 26631044c..d7f5dfc46 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -19,11 +19,11 @@ use sage_api::{ GetPendingTransactions, GetPendingTransactionsResponse, GetSpendableCoinCount, GetSpendableCoinCountResponse, GetSyncStatus, GetSyncStatusResponse, GetToken, GetTokenResponse, GetTransaction, GetTransactionResponse, GetTransactions, - GetTransactionsResponse, GetVersion, GetVersionResponse, IsAssetOwned, IsAssetOwnedResponse, - NftCollectionRecord, NftData, NftRecord, NftSortMode as ApiNftSortMode, NftSpecialUseType, - OptionRecord, OptionSortMode as ApiOptionSortMode, PendingTransactionRecord, - PerformDatabaseMaintenance, PerformDatabaseMaintenanceResponse, TokenRecord, - TransactionCoinRecord, TransactionRecord, + GetTransactionsResponse, GetVersion, GetVersionResponse, GetWalletReceiveAddress, + GetWalletReceiveAddressResponse, IsAssetOwned, IsAssetOwnedResponse, NftCollectionRecord, + NftData, NftRecord, NftSortMode as ApiNftSortMode, NftSpecialUseType, OptionRecord, + OptionSortMode as ApiOptionSortMode, PendingTransactionRecord, PerformDatabaseMaintenance, + PerformDatabaseMaintenanceResponse, TokenRecord, TransactionCoinRecord, TransactionRecord, }; use sage_database::{ AssetFilter, CoinFilterMode, CoinSortMode, NftGroupSearch, NftRow, NftSortMode, OptionSortMode, @@ -120,6 +120,26 @@ impl Sage { }) } + pub async fn get_wallet_receive_address( + &self, + req: GetWalletReceiveAddress, + ) -> Result { + let pool = self.connect_to_database(req.fingerprint).await?; + let db = sage_database::Database::new(pool); + + let Some(max_idx) = db.max_derivation_index(false).await? else { + return Err(Error::NotLoggedIn); + }; + + let (derivations, _) = db.derivations(false, 1, max_idx).await?; + let Some(row) = derivations.first() else { + return Err(Error::NotLoggedIn); + }; + + let address = Address::new(row.p2_puzzle_hash, self.network().prefix()).encode()?; + Ok(GetWalletReceiveAddressResponse { address }) + } + pub async fn check_address(&self, req: CheckAddress) -> Result { let wallet = self.wallet()?; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 551af006b..0b1f0cd77 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -77,7 +77,7 @@ pub async fn initialize( // Resume: inject signer and trigger background settings fetch for the active wallet if let Some(fingerprint) = resume_fingerprint { inject_signer_for_fingerprint(&app_handle, &state, fingerprint).await; - spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint); + spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint, true); } Ok(()) @@ -288,8 +288,20 @@ async fn inject_signer_for_fingerprint(app_handle: &AppHandle, state: &AppState, } } -fn spawn_fetch_settings_if_enabled(app_handle: AppHandle, state: AppState, fingerprint: u32) { +fn spawn_fetch_settings_if_enabled( + app_handle: AppHandle, + state: AppState, + fingerprint: u32, + wait_connect: bool, +) { tokio::spawn(async move { + if wait_connect { + app_handle + .nostr_sync() + .wait_for_connection(Duration::from_secs(10)) + .await; + } + let sync_enabled = { let sage = state.lock().await; sage.wallet_config @@ -320,7 +332,7 @@ pub async fn login( let fingerprint = req.fingerprint; let resp = state.lock().await.login(req).await?; inject_signer_for_fingerprint(&app_handle, &state, fingerprint).await; - spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint); + spawn_fetch_settings_if_enabled(app_handle, (*state).clone(), fingerprint, false); Ok(resp) } @@ -439,13 +451,21 @@ async fn do_publish_wallet_settings(app_handle: &AppHandle, state: &AppState, fi else { return; }; - (wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone()) + ( + wallet.sync_enabled, + wallet.name.clone(), + wallet.emoji.clone(), + ) }; if !sync_enabled { return; } + if app_handle.nostr_sync().pubkey().await.is_none() { + return; + } + let payload = serde_json::json!({ "v": 1, "name": name, @@ -503,8 +523,19 @@ pub async fn publish_wallet_settings( pub async fn fetch_wallet_settings( app_handle: AppHandle, state: State<'_, AppState>, - fingerprint: u32, ) -> Result { + let fingerprint = { + let sage = state.lock().await; + let Some(fp) = sage.config.global.fingerprint else { + return Ok(FetchSettingsResult { + applied: false, + name: None, + emoji: None, + }); + }; + fp + }; + let result = app_handle .nostr_sync() .fetch("wallet-settings") diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 305061161..115029b5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -61,6 +61,7 @@ pub fn run() { commands::view_coin_spends, commands::submit_transaction, commands::get_sync_status, + commands::get_wallet_receive_address, commands::get_version, commands::get_database_stats, commands::perform_database_maintenance, diff --git a/src/bindings.ts b/src/bindings.ts index 41f7f38ef..0151637ce 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -119,6 +119,9 @@ async submitTransaction(req: SubmitTransaction) : Promise { return await TAURI_INVOKE("get_sync_status", { req }); }, +async getWalletReceiveAddress(req: GetWalletReceiveAddress) : Promise { + return await TAURI_INVOKE("get_wallet_receive_address", { req }); +}, async getVersion(req: GetVersion) : Promise { return await TAURI_INVOKE("get_version", { req }); }, @@ -359,12 +362,6 @@ async getLogs() : Promise { async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); }, -async injectNostrSigner(fingerprint: number) : Promise { - return await TAURI_INVOKE("inject_nostr_signer", { fingerprint }); -}, -async clearNostrSigner() : Promise { - return await TAURI_INVOKE("clear_nostr_signer"); -}, async getSyncEnabled(fingerprint: number) : Promise { return await TAURI_INVOKE("get_sync_enabled", { fingerprint }); }, @@ -380,8 +377,8 @@ async removeSyncRelay(url: string) : Promise { async publishWalletSettings(fingerprint: number) : Promise { return await TAURI_INVOKE("publish_wallet_settings", { fingerprint }); }, -async fetchWalletSettings(fingerprint: number) : Promise { - return await TAURI_INVOKE("fetch_wallet_settings", { fingerprint }); +async fetchWalletSettings() : Promise { + return await TAURI_INVOKE("fetch_wallet_settings"); } } @@ -1625,6 +1622,22 @@ export type GetVersionResponse = { * Semantic version string */ version: string } +/** + * Get the current receive address for any wallet by fingerprint + */ +export type GetWalletReceiveAddress = { +/** + * Wallet fingerprint + */ +fingerprint: number } +/** + * Response with the wallet receive address + */ +export type GetWalletReceiveAddressResponse = { +/** + * Encoded receive address + */ +address: string } export type Id = /** * The XCH asset diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 992f07cb2..c912af2d5 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -126,23 +126,13 @@ export function WalletCard({ const copyAddress = async () => { try { - await commands.login({ fingerprint: info.fingerprint }); - const sync = await commands.getSyncStatus({}); - - if (sync?.receive_address) { - await writeText(sync.receive_address); - toast.success(t`Address copied to clipboard`); - } else { - toast.error(t`No address found`); - } + const result = await commands.getWalletReceiveAddress({ + fingerprint: info.fingerprint, + }); + await writeText(result.address); + toast.success(t`Address copied to clipboard`); } catch { toast.error(t`Failed to copy address to clipboard`); - } finally { - try { - await commands.logout({}); - } catch (error) { - console.error(error); - } } }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 183cf6dfa..db34d5e9f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1612,7 +1612,7 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { function SyncSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); - const [syncEnabled, setSyncEnabledState] = useState(false); + const [syncEnabledState, setSyncEnabledState] = useState(false); const [relays, setRelays] = useState([]); const [status, setStatus] = useState(null); const [newRelay, setNewRelay] = useState(''); @@ -1620,11 +1620,18 @@ function SyncSettings({ fingerprint }: { fingerprint: number }) { const [fetching, setFetching] = useState(false); useEffect(() => { - commands.getSyncEnabled(fingerprint).then(setSyncEnabledState).catch(addError); + commands + .getSyncEnabled(fingerprint) + .then(setSyncEnabledState) + .catch(addError); }, [fingerprint, addError]); useEffect(() => { - if (!syncEnabled) return; + if (!syncEnabledState) { + setRelays([]); + setStatus(null); + return; + } const load = () => { getRelays().then(setRelays).catch(console.warn); getStatus().then(setStatus).catch(console.warn); @@ -1632,7 +1639,7 @@ function SyncSettings({ fingerprint }: { fingerprint: number }) { load(); const id = setInterval(load, 5000); return () => clearInterval(id); - }, [syncEnabled]); + }, [syncEnabledState]); const toggleSync = async (enabled: boolean) => { await commands.setSyncEnabled(fingerprint, enabled).catch(addError); @@ -1656,8 +1663,7 @@ function SyncSettings({ fingerprint }: { fingerprint: number }) { setFetching(true); setFetchMessage(null); try { - const result: FetchSettingsResult = - await commands.fetchWalletSettings(fingerprint); + const result: FetchSettingsResult = await commands.fetchWalletSettings(); if (result.applied) { setFetchMessage(t`Settings applied`); } else { @@ -1679,11 +1685,11 @@ function SyncSettings({ fingerprint }: { fingerprint: number }) { label={t`Sync Wallet Settings`} description={t`Sync name and icon across devices using Nostr`} control={ - + } /> - {syncEnabled && ( + {syncEnabledState && ( <>
{status?.ready From f97d97a22b4b28acc2f4c53b782f3f9f5e350e26 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 8 May 2026 08:56:02 -0500 Subject: [PATCH 7/9] move sync toggle to wallet tab --- crates/sage-rpc/src/tests.rs | 9 +++++++++ src/pages/Settings.tsx | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index a04617375..a0897d96b 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -172,6 +172,15 @@ impl TestApp { self.consume_until(|event| matches!(event, SyncEvent::PuzzleBatchSynced)) .await; } + + async fn login(&self, body: sage_api::Login) -> Result { + self.call_rpc("/login", body).await + } + + #[allow(unused)] + async fn logout(&self, body: sage_api::Logout) -> Result { + self.call_rpc("/logout", body).await + } } impl_endpoints! { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index db34d5e9f..50624e61a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -205,7 +205,6 @@ export default function Settings() {
- {wallet && } {!isMobile && }
@@ -1606,6 +1605,8 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { + +
); } @@ -1682,8 +1683,8 @@ function SyncSettings({ fingerprint }: { fingerprint: number }) { return ( } From 96c5a2d764bee22b934610be64530e8a8c408c0e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 9 May 2026 08:23:08 -0500 Subject: [PATCH 8/9] add network tag --- crates/sage-api/endpoints.json | 2 -- crates/sage-rpc/src/lib.rs | 19 ++++++++++- src-tauri/src/commands.rs | 61 +++++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 76fa9e68f..15558bf32 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -81,12 +81,10 @@ "set_discover_peers": true, "set_target_peers": true, "set_network": true, - "set_network_override": true, "get_networks": false, "get_network": false, "set_delta_sync": false, "set_delta_sync_override": false, - "set_change_address": true, "resync_cat": true, "update_cat": true, "update_did": true, diff --git a/crates/sage-rpc/src/lib.rs b/crates/sage-rpc/src/lib.rs index 60333f75d..b07f8fd68 100644 --- a/crates/sage-rpc/src/lib.rs +++ b/crates/sage-rpc/src/lib.rs @@ -95,6 +95,23 @@ pub async fn start_rpc(sage: Arc>) -> Result<()> { Ok(()) } +async fn set_network_override( + State(state): State, + Json(req): Json, +) -> Response { + handle(state.sage.lock().await.set_network_override(req).await) +} + +async fn set_change_address( + State(state): State, + Json(req): Json, +) -> Response { + handle(state.sage.lock().await.set_change_address(req).await) +} + pub fn make_router(sage: Arc>) -> Router { - api_router().with_state(AppState { sage }) + api_router() + .route("/set_network_override", post(set_network_override)) + .route("/set_change_address", post(set_change_address)) + .with_state(AppState { sage }) } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b1f0cd77..52e812c5e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -441,7 +441,7 @@ pub struct FetchSettingsResult { } async fn do_publish_wallet_settings(app_handle: &AppHandle, state: &AppState, fingerprint: u32) { - let (sync_enabled, name, emoji) = { + let (sync_enabled, name, emoji, network, change_address) = { let sage = state.lock().await; let Some(wallet) = sage .wallet_config @@ -455,6 +455,8 @@ async fn do_publish_wallet_settings(app_handle: &AppHandle, state: &AppState, fi wallet.sync_enabled, wallet.name.clone(), wallet.emoji.clone(), + wallet.network.clone(), + wallet.change_address.clone(), ) }; @@ -470,6 +472,8 @@ async fn do_publish_wallet_settings(app_handle: &AppHandle, state: &AppState, fi "v": 1, "name": name, "emoji": emoji, + "network": network, + "change_address": change_address, }); if let Err(e) = app_handle @@ -507,6 +511,32 @@ pub async fn set_wallet_emoji( Ok(resp) } +#[command] +#[specta] +pub async fn set_network_override( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::SetNetworkOverride, +) -> Result { + let fingerprint = req.fingerprint; + let resp = state.lock().await.set_network_override(req).await?; + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; + Ok(resp) +} + +#[command] +#[specta] +pub async fn set_change_address( + app_handle: AppHandle, + state: State<'_, AppState>, + req: sage_api::SetChangeAddress, +) -> Result { + let fingerprint = req.fingerprint; + let resp = state.lock().await.set_change_address(req).await?; + do_publish_wallet_settings(&app_handle, &state, fingerprint).await; + Ok(resp) +} + #[command] #[specta] pub async fn publish_wallet_settings( @@ -576,6 +606,21 @@ pub async fn fetch_wallet_settings( e.as_str().map(str::to_string) } }); + // network and change_address: Some(None) = explicitly cleared, None = key absent (skip) + let remote_network: Option> = payload.get("network").map(|v| { + if v.is_null() { + None + } else { + v.as_str().map(str::to_string) + } + }); + let remote_change_address: Option> = payload.get("change_address").map(|v| { + if v.is_null() { + None + } else { + v.as_str().map(str::to_string) + } + }); let mut sage = state.lock().await; let Some(wallet) = sage @@ -605,6 +650,20 @@ pub async fn fetch_wallet_settings( applied = true; } + if let Some(network) = remote_network { + if wallet.network != network { + wallet.network = network; + applied = true; + } + } + + if let Some(change_address) = remote_change_address { + if wallet.change_address != change_address { + wallet.change_address = change_address; + applied = true; + } + } + if applied { sage.save_config()?; } From 5237ea267324b36dc7f936d8659d35220de8e995 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 9 May 2026 18:15:08 -0500 Subject: [PATCH 9/9] add salt --- src-tauri/src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 52e812c5e..fc7c51e2a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -273,7 +273,7 @@ async fn inject_signer_for_fingerprint(app_handle: &AppHandle, state: &AppState, }; let ikm = master_sk.to_bytes(); - let hk = Hkdf::::new(None, &ikm); + let hk = Hkdf::::new(Some(b"sage-nostr-sync-v1".as_ref()), &ikm); let mut okm = [0u8; 32]; hk.expand(b"sage-nostr-sync", &mut okm) .expect("32 bytes is a valid HKDF output length");