What changed, and why it matters
This is a large feature update for the Skylight Monero wallet that brings in improvements from another project called Spice. It adds support for connecting directly to a full Monero node (not just a light wallet server), background and continuous syncing, a more secure clipboard for copying sensitive data, QR-code wallet restoration, and a new OpenAlias resolver. The changes are mostly defensive: they improve privacy, reduce clipboard leaks, and make transaction sending more precise. There is no clear security vulnerability introduced by the patch, but because it is a very large change touching many security-sensitive areas (wallet files, network connections, background services, and clipboard handling), it deserves careful review and testing before release.
Treat this as a major feature release requiring full QA. Review the new Rust OpenAlias plugin, foreground/background sync service, and full-node wallet-manager switching for logic bugs. Verify that secure-clipboard behavior works as intended on Android 13+ and iOS, that the `_node` wallet cache does not accidentally reuse or leak keys across modes, and that background sync cannot be abused to keep the wallet unlocked or expose secrets. Run static analysis on the new native code and ensure the `flutter_foreground_task` service is not exported and does not accept untrusted intents.
Security signals we found
New Android foreground service permissions and exported=false service declared for background sync
New platform secure-clipboard channel marks copied secrets as sensitive and avoids cloud/Universal Clipboard sync
OpenAlias resolution moved from monero_c's built-in resolver to a new Rust FFI plugin that resolves over Tor with DNSSEC validation
Transaction broadcast now checks both commit result and status code before reporting success
Amount conversion switched from double to exact decimal string parsing to reduce precision loss
Secret screens (seed, keys, restore) now use a SecureScreenMixin to mitigate screenshot/screen-recording leaks
Full-node mode introduces a second wallet cache file path (`_node` suffix) and manager factory selection logic
Background sync task constrained to unmetered network + charging for full-node mode
Evidence from the diff
The commit is a wholesale merge of ‘Spice’ improvements into Skylight Wallet. Key technical changes visible in the diff include: (1) dual wallet-manager support (LWSF vs standard wallet2) with separate cache files and mode-aware open/rebuild logic; (2) full-node connection path with Wallet_init(lightWallet: false), auto-refresh thread management, and sync-progress polling; (3) background/foreground sync services using WorkManager and flutter_foreground_task; (4) platform secure-clipboard implementations (Android ClipDescription.EXTRA_IS_SENSITIVE, iOS UIPasteboard with localOnly and expirationDate); (5) OpenAlias resolution moved to a Rust/hickory DNSSEC-over-Tor plugin; (6) amount parsing now uses exact decimal string-to-base-units conversion to avoid double-precision loss; (7) transaction commit success is additionally gated on PendingTransaction.status() == 0; (8) seed/secret-key screens use a SecureScreenMixin; (9) build toolchain bumped to Java/Kotlin 17. The patch is defensive in nature and does not, from the supplied diff, introduce an obvious exploit. However, the breadth of the change and the presence of new native/Rust code, background services, and network paths increase the attack surface and the chance of subtle bugs.
Changed components
AndroidManifest.xmlMainActivity.kt / AppDelegate.swiftlib/models/wallet_model.dartlib/periodic_tasks.dartlib/services/foreground_sync_service.dartlib/util/secure_clipboard.dartlib/util/secure_screen.dartlib/util/amount_units.dartlib/screens/send.dartlib/screens/confirm_send.dartlib/screens/restore_wallet.dartlib/screens/secret_keys.dartlib/screens/lws_keys.dartlib/screens/generate_seed.dartplugins/openalias_ffipubspec.yaml / build.gradleInspect captured patch +4618 / −664
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 6caae74..75b2418 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -27,12 +27,12 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = JavaVersion.VERSION_11.toString()
+ jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d6600bd..eca61b6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,7 +5,12 @@
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
-
+ <!-- flutter_foreground_task: background sync foreground service -->
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
<application
android:label="Skylight Wallet"
android:name="${applicationName}"
@@ -50,6 +55,11 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
+ <!-- flutter_foreground_task background-sync service. Do not rename. -->
+ <service
+ android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
+ android:foregroundServiceType="dataSync"
+ android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
diff --git a/android/app/src/main/kotlin/com/example/monero_light_wallet/MainActivity.kt b/android/app/src/main/kotlin/com/example/monero_light_wallet/MainActivity.kt
index d02b51d..4f76235 100644
--- a/android/app/src/main/kotlin/com/example/monero_light_wallet/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/example/monero_light_wallet/MainActivity.kt
@@ -1,5 +1,39 @@
package org.magicgrants.skylight
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.PersistableBundle
import io.flutter.embedding.android.FlutterFragmentActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity : FlutterFragmentActivity()
+class MainActivity : FlutterFragmentActivity() {
+ private val secureClipboardChannel = "org.magicgrants.skylight/secure_clipboard"
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, secureClipboardChannel)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "copySensitive" -> {
+ copySensitive(call.argument<String>("text") ?: "")
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ // Copies [text] flagged as sensitive so keyboards/clipboard UIs don't show a
+ // preview and it isn't synced. The extras key is honored on Android 13+
+ // (ClipDescription.EXTRA_IS_SENSITIVE) and by some earlier OEM keyboards.
+ private fun copySensitive(text: String) {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("", text)
+ clip.description.extras = PersistableBundle().apply {
+ putBoolean("android.content.extra.IS_SENSITIVE", true)
+ }
+ clipboard.setPrimaryClip(clip)
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 89176ef..e5be6ff 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -11,6 +11,22 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
+
+ // Pin every subproject (incl. plugins like screen_protector) to a single
+ // JVM target so Java (17) and Kotlin don't disagree and fail the build.
+ afterEvaluate {
+ (extensions.findByName("android") as? com.android.build.gradle.BaseExtension)?.apply {
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ }
+ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+ }
+ }
}
subprojects {
project.evaluationDependsOn(":app")
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 752d65d..fd135a1 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -3,6 +3,8 @@ import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ private var secureClipboardChannel: FlutterMethodChannel?
+
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -19,5 +21,33 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+
+ let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "SecureClipboard")
+ if let messenger = registrar?.messenger() {
+ let channel = FlutterMethodChannel(
+ name: "org.magicgrants.skylight/secure_clipboard",
+ binaryMessenger: messenger
+ )
+ channel.setMethodCallHandler { call, reply in
+ guard call.method == "copySensitive" else {
+ reply(FlutterMethodNotImplemented)
+ return
+ }
+ let args = call.arguments as? [String: Any]
+ let text = args?["text"] as? String ?? ""
+ let seconds = (args?["clearAfterSeconds"] as? NSNumber)?.doubleValue ?? 60
+ // localOnly keeps it off Universal Clipboard (Handoff); expirationDate
+ // lets iOS clear it even if the app is no longer running.
+ UIPasteboard.general.setItems(
+ [["public.utf8-plain-text": text]],
+ options: [
+ .localOnly: true,
+ .expirationDate: Date().addingTimeInterval(seconds),
+ ]
+ )
+ reply(nil)
+ }
+ secureClipboardChannel = channel
+ }
}
}
diff --git a/lib/consts.dart b/lib/consts.dart
index ff439a9..e7d93bb 100644
--- a/lib/consts.dart
+++ b/lib/consts.dart
@@ -2,6 +2,7 @@ const String walletFileName = 'mywallet';
const String torDataDirName = 'tor';
const int txDirectionIncoming = 0;
const int txDirectionOutgoing = 1;
+const int moneroDecimals = 12;
const supportedFiatCurrencies = [
'USD',
'EUR',
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 64fcf49..1f61037 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -18,21 +18,50 @@
"restoreWarningTitle": "Restore Warning",
"restoreWarningDescription": "Are you sure? The server that you connect to will be able to see your past and future Monero transaction history.",
"restoreWarningContinueButton": "Continue",
- "lwsSetupTitle": "LWS Setup",
- "lwsSetupDescription": "Enter the address of your Monero light-wallet server (LWS).",
+ "lwsSetupTitle": "Connection Setup",
+ "lwsSetupDescription": "Connect to a Monero light-wallet server (LWS) or your own full node.",
"lwsSetupAddressHint": "e.g. 192.168.1.1:18090 or example.com:18090",
- "lwsSetupProxyPortLabel": "HTTP Proxy Port (optional)",
+ "lwsSetupProxyPortLabel": "HTTP Proxy Port (Optional)",
"lwsSetupProxyPortHint": "e.g. 4444 for I2P",
"lwsSetupUseTorLabel": "Use Tor",
- "lwsSetupUseSslLabel": "Use SSL",
"lwsSetupTestConnectionButton": "Test Connection",
"lwsSetupStartingTor": "Starting Tor...",
"lwsSetupContinueButton": "Continue",
+ "connectionTypeLws": "Light Wallet Server",
+ "connectionTypeNode": "Monero Node",
+ "connectionNodeAddressHint": "e.g. node.example.com:18081",
+ "connectionRemoteIpNotAllowed": "Connections to remote IP addresses aren't allowed. Use a domain name or a local IP address.",
+ "connectionProtocolHttps": "Removing protocol. Using HTTPS for domains.",
+ "connectionProtocolHttp": "Removing protocol. Using HTTP for local addresses.",
+ "connectionIndicatorHttps": "HTTPS",
+ "connectionIndicatorLocal": "Local",
+ "connectionIndicatorTorInternal": "Internal Tor",
+ "connectionIndicatorTorExternal": "Using Port {port}",
+ "@connectionIndicatorTorExternal": {
+ "placeholders": {
+ "port": {
+ "type": "String"
+ }
+ }
+ },
+ "settingsConnectionSettingsLabel": "Connection Settings",
+ "settingsBackgroundSyncLabel": "Background Sync",
+ "settingsBackgroundSyncDescription": "Periodically sync Monero in the background so it's up to date when you open the app. Only runs while charging and on WiFi.",
+ "settingsForegroundSyncLabel": "Continuous Sync",
+ "settingsForegroundSyncDescription": "Keep Monero syncing continuously while the app runs in the background, with a persistent notification. Uses more battery.",
+ "homeBlocksRemaining": "{count} blocks left",
+ "@homeBlocksRemaining": {
+ "placeholders": {
+ "count": {
+ "type": "String"
+ }
+ }
+ },
"fiatApiSetupTitle": "Fiat Display Setup",
"fiatApiSetupDescription": "Optional reference price for your XMR balance.",
"fiatApiSettingsModeLabel": "Mode",
"fiatApiSettingsModeTorOnly": "Tor-Only",
- "fiatApiSettingsModeClearnet": "Clearnet-Only (not private)",
+ "fiatApiSettingsModeClearnet": "Clearnet-Only (Not Private)",
"fiatApiSettingsModeDisabled": "Disabled",
"fiatApiSettingsDisplayCurrencyLabel": "Display Currency",
"createWalletTitle": "Create Wallet",
@@ -41,7 +70,7 @@
"createWalletCreateNewButton": "Create New",
"generateSeedTitle": "New Wallet",
"generateSeedDescription": "This is your polyseed. Write it down and keep it in a safe place.",
- "generateSeedContinueButton": "I wrote it down",
+ "generateSeedContinueButton": "I Wrote It Down",
"lwsDetailsTitle": "Wallet Details",
"lwsDetailsDescription": "You can use these details to whitelist this wallet on the light wallet server if needed.",
"lwsDetailsPrimaryAddressLabel": "Primary Address",
@@ -50,13 +79,13 @@
"restoreWalletTitle": "Restore Wallet",
"restoreWalletDescription": "Input your Monero seed below. We will check common formats.",
"restoreWalletSeedLabel": "Seed",
- "restoreWalletRestoreHeightLabel": "Restore Height (optional)",
+ "restoreWalletRestoreHeightLabel": "Restore Height (Optional)",
"restoreWalletRestoreButton": "Restore",
"restoreWalletInvalidMnemonic": "Invalid seed.",
"navigationBarWallet": "Wallet",
"navigationBarSettings": "Settings",
"unlockButton": "Unlock",
- "unlockReason": "Unlock wallet",
+ "unlockReason": "Unlock Wallet",
"unlockUnableToAuthError": "Unable to authenticate.",
"unlockTitle": "Unlock Wallet",
"unlockDescription": "Enter your wallet password to unlock",
@@ -68,12 +97,12 @@
"homeHeight": "Height",
"homeReceive": "Receive",
"homeSend": "Send",
- "homeBalanceLocked": "locked",
+ "homeBalanceLocked": "Locked",
"homeTransactionsTitle": "Transactions",
"homeOutgoingTxSemanticLabel": "Outgoing Transaction",
"homeIncomingTxSemanticLabel": "Incoming Transaction",
"homeTransactionConfirmed": "Confirmed",
- "homeNoTransactions": "No transactions",
+ "homeNoTransactions": "No Transactions",
"homeFiatApiError": "Error connecting to fiat API",
"receiveTitle": "Receive",
"receivePrimaryAddressWarn": "Warning: Unless you know what you're doing, please consider using subaddresses for better privacy.",
@@ -92,7 +121,7 @@
"settingsTitle": "Settings",
"settingsNotifyNewTxsLabel": "Notify New Transactions",
"settingsAppLockLabel": "App Lock",
- "settingsAppLockUnlockReason": "Unlock wallet",
+ "settingsAppLockUnlockReason": "Unlock Wallet",
"settingsAppLockUnableToAuthError": "Unable to authenticate. Make sure you have device unlock set up.",
"settingsVerboseLoggingLabel": "Verbose Logging",
"settingsVerboseLoggingDescription": "Logs wallet operations to a text file in the app's data folder for debugging purposes.",
@@ -150,23 +179,23 @@
},
"addressBookDelete": "Delete",
"addressBookSearchHint": "Search contacts...",
- "addressBookNoContacts": "No contacts yet",
+ "addressBookNoContacts": "No Contacts Yet",
"addressBookNoContactsDescription": "Add your first contact by tapping the + button",
- "addressBookNoSearchResults": "No contacts found",
+ "addressBookNoSearchResults": "No Contacts Found",
"addressBookCopyAddress": "Copy Address",
"addressBookEdit": "Edit",
"addressBookContactName": "Contact Name",
"addressBookUpdate": "Update",
"addressBookSave": "Save",
- "sendSelectedContact": "Selected contact",
- "sendClearSelectedContact": "Clear selected contact",
+ "sendSelectedContact": "Selected Contact",
+ "sendClearSelectedContact": "Clear Selected Contact",
"sendPriorityLow": "Low",
"sendPriorityNormal": "Normal",
"sendPriorityHigh": "High",
- "sendPriorityLabel": "priority",
+ "sendPriorityLabel": "Priority",
"sendTransactionPriority": "Transaction Priority",
"sendFeeLabel": "Fee",
- "sendBalanceLabel": "Balance",
+ "sendContactsButton": "Contacts",
"sendFailedToGetFeesError": "Failed to get fees.",
"torInfoTitle": "Tor Built-in",
"torInfoDescription": "Skylight Wallet automatically uses built-in Tor to protect your internet connections.",
@@ -183,10 +212,9 @@
"torSettingsUseOrbotLabelIos": "Use Orbot",
"torSettingsSaveButton": "Save",
"torSettingsTestConnectionButton": "Test Connection",
- "settingsLwsSettingsLabel": "LWS Settings",
"settingsTorSettingsLabel": "Tor Settings",
- "lwsSetupUsingInternalTor": "Using internal Tor",
- "lwsSetupUsingExternalTor": "Using external Tor proxy at {address}",
+ "lwsSetupUsingInternalTor": "Using Internal Tor",
+ "lwsSetupUsingExternalTor": "Using External Tor Proxy at {address}",
"@lwsSetupUsingExternalTor": {
"placeholders": {
"address": {
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 44c0b68..2bda039 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -204,13 +204,13 @@ abstract class AppLocalizations {
/// No description provided for @lwsSetupTitle.
///
/// In en, this message translates to:
- /// **'LWS Setup'**
+ /// **'Connection Setup'**
String get lwsSetupTitle;
/// No description provided for @lwsSetupDescription.
///
/// In en, this message translates to:
- /// **'Enter the address of your Monero light-wallet server (LWS).'**
+ /// **'Connect to a Monero light-wallet server (LWS) or your own full node.'**
String get lwsSetupDescription;
/// No description provided for @lwsSetupAddressHint.
@@ -222,7 +222,7 @@ abstract class AppLocalizations {
/// No description provided for @lwsSetupProxyPortLabel.
///
/// In en, this message translates to:
- /// **'HTTP Proxy Port (optional)'**
+ /// **'HTTP Proxy Port (Optional)'**
String get lwsSetupProxyPortLabel;
/// No description provided for @lwsSetupProxyPortHint.
@@ -237,12 +237,6 @@ abstract class AppLocalizations {
/// **'Use Tor'**
String get lwsSetupUseTorLabel;
- /// No description provided for @lwsSetupUseSslLabel.
- ///
- /// In en, this message translates to:
- /// **'Use SSL'**
- String get lwsSetupUseSslLabel;
-
/// No description provided for @lwsSetupTestConnectionButton.
///
/// In en, this message translates to:
@@ -261,6 +255,102 @@ abstract class AppLocalizations {
/// **'Continue'**
String get lwsSetupContinueButton;
+ /// No description provided for @connectionTypeLws.
+ ///
+ /// In en, this message translates to:
+ /// **'Light Wallet Server'**
+ String get connectionTypeLws;
+
+ /// No description provided for @connectionTypeNode.
+ ///
+ /// In en, this message translates to:
+ /// **'Monero Node'**
+ String get connectionTypeNode;
+
+ /// No description provided for @connectionNodeAddressHint.
+ ///
+ /// In en, this message translates to:
+ /// **'e.g. node.example.com:18081'**
+ String get connectionNodeAddressHint;
+
+ /// No description provided for @connectionRemoteIpNotAllowed.
+ ///
+ /// In en, this message translates to:
+ /// **'Connections to remote IP addresses aren\'t allowed. Use a domain name or a local IP address.'**
+ String get connectionRemoteIpNotAllowed;
+
+ /// No description provided for @connectionProtocolHttps.
+ ///
+ /// In en, this message translates to:
+ /// **'Removing protocol. Using HTTPS for domains.'**
+ String get connectionProtocolHttps;
+
+ /// No description provided for @connectionProtocolHttp.
+ ///
+ /// In en, this message translates to:
+ /// **'Removing protocol. Using HTTP for local addresses.'**
+ String get connectionProtocolHttp;
+
+ /// No description provided for @connectionIndicatorHttps.
+ ///
+ /// In en, this message translates to:
+ /// **'HTTPS'**
+ String get connectionIndicatorHttps;
+
+ /// No description provided for @connectionIndicatorLocal.
+ ///
+ /// In en, this message translates to:
+ /// **'Local'**
+ String get connectionIndicatorLocal;
+
+ /// No description provided for @connectionIndicatorTorInternal.
+ ///
+ /// In en, this message translates to:
+ /// **'Internal Tor'**
+ String get connectionIndicatorTorInternal;
+
+ /// No description provided for @connectionIndicatorTorExternal.
+ ///
+ /// In en, this message translates to:
+ /// **'Using Port {port}'**
+ String connectionIndicatorTorExternal(String port);
+
+ /// No description provided for @settingsConnectionSettingsLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Connection Settings'**
+ String get settingsConnectionSettingsLabel;
+
+ /// No description provided for @settingsBackgroundSyncLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Background Sync'**
+ String get settingsBackgroundSyncLabel;
+
+ /// No description provided for @settingsBackgroundSyncDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Periodically sync Monero in the background so it\'s up to date when you open the app. Only runs while charging and on WiFi.'**
+ String get settingsBackgroundSyncDescription;
+
+ /// No description provided for @settingsForegroundSyncLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Continuous Sync'**
+ String get settingsForegroundSyncLabel;
+
+ /// No description provided for @settingsForegroundSyncDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Keep Monero syncing continuously while the app runs in the background, with a persistent notification. Uses more battery.'**
+ String get settingsForegroundSyncDescription;
+
+ /// No description provided for @homeBlocksRemaining.
+ ///
+ /// In en, this message translates to:
+ /// **'{count} blocks left'**
+ String homeBlocksRemaining(String count);
+
/// No description provided for @fiatApiSetupTitle.
///
/// In en, this message translates to:
@@ -288,7 +378,7 @@ abstract class AppLocalizations {
/// No description provided for @fiatApiSettingsModeClearnet.
///
/// In en, this message translates to:
- /// **'Clearnet-Only (not private)'**
+ /// **'Clearnet-Only (Not Private)'**
String get fiatApiSettingsModeClearnet;
/// No description provided for @fiatApiSettingsModeDisabled.
@@ -342,7 +432,7 @@ abstract class AppLocalizations {
/// No description provided for @generateSeedContinueButton.
///
/// In en, this message translates to:
- /// **'I wrote it down'**
+ /// **'I Wrote It Down'**
String get generateSeedContinueButton;
/// No description provided for @lwsDetailsTitle.
@@ -396,7 +486,7 @@ abstract class AppLocalizations {
/// No description provided for @restoreWalletRestoreHeightLabel.
///
/// In en, this message translates to:
- /// **'Restore Height (optional)'**
+ /// **'Restore Height (Optional)'**
String get restoreWalletRestoreHeightLabel;
/// No description provided for @restoreWalletRestoreButton.
@@ -432,7 +522,7 @@ abstract class AppLocalizations {
/// No description provided for @unlockReason.
///
/// In en, this message translates to:
- /// **'Unlock wallet'**
+ /// **'Unlock Wallet'**
String get unlockReason;
/// No description provided for @unlockUnableToAuthError.
@@ -504,7 +594,7 @@ abstract class AppLocalizations {
/// No description provided for @homeBalanceLocked.
///
/// In en, this message translates to:
- /// **'locked'**
+ /// **'Locked'**
String get homeBalanceLocked;
/// No description provided for @homeTransactionsTitle.
@@ -534,7 +624,7 @@ abstract class AppLocalizations {
/// No description provided for @homeNoTransactions.
///
/// In en, this message translates to:
- /// **'No transactions'**
+ /// **'No Transactions'**
String get homeNoTransactions;
/// No description provided for @homeFiatApiError.
@@ -648,7 +738,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsAppLockUnlockReason.
///
/// In en, this message translates to:
- /// **'Unlock wallet'**
+ /// **'Unlock Wallet'**
String get settingsAppLockUnlockReason;
/// No description provided for @settingsAppLockUnableToAuthError.
@@ -954,7 +1044,7 @@ abstract class AppLocalizations {
/// No description provided for @addressBookNoContacts.
///
/// In en, this message translates to:
- /// **'No contacts yet'**
+ /// **'No Contacts Yet'**
String get addressBookNoContacts;
/// No description provided for @addressBookNoContactsDescription.
@@ -966,7 +1056,7 @@ abstract class AppLocalizations {
/// No description provided for @addressBookNoSearchResults.
///
/// In en, this message translates to:
- /// **'No contacts found'**
+ /// **'No Contacts Found'**
String get addressBookNoSearchResults;
/// No description provided for @addressBookCopyAddress.
@@ -1002,13 +1092,13 @@ abstract class AppLocalizations {
/// No description provided for @sendSelectedContact.
///
/// In en, this message translates to:
- /// **'Selected contact'**
+ /// **'Selected Contact'**
String get sendSelectedContact;
/// No description provided for @sendClearSelectedContact.
///
/// In en, this message translates to:
- /// **'Clear selected contact'**
+ /// **'Clear Selected Contact'**
String get sendClearSelectedContact;
/// No description provided for @sendPriorityLow.
@@ -1032,7 +1122,7 @@ abstract class AppLocalizations {
/// No description provided for @sendPriorityLabel.
///
/// In en, this message translates to:
- /// **'priority'**
+ /// **'Priority'**
String get sendPriorityLabel;
/// No description provided for @sendTransactionPriority.
@@ -1047,11 +1137,11 @@ abstract class AppLocalizations {
/// **'Fee'**
String get sendFeeLabel;
- /// No description provided for @sendBalanceLabel.
+ /// No description provided for @sendContactsButton.
///
/// In en, this message translates to:
- /// **'Balance'**
- String get sendBalanceLabel;
+ /// **'Contacts'**
+ String get sendContactsButton;
/// No description provided for @sendFailedToGetFeesError.
///
@@ -1149,12 +1239,6 @@ abstract class AppLocalizations {
/// **'Test Connection'**
String get torSettingsTestConnectionButton;
- /// No description provided for @settingsLwsSettingsLabel.
- ///
- /// In en, this message translates to:
- /// **'LWS Settings'**
- String get settingsLwsSettingsLabel;
-
/// No description provided for @settingsTorSettingsLabel.
///
/// In en, this message translates to:
@@ -1164,13 +1248,13 @@ abstract class AppLocalizations {
/// No description provided for @lwsSetupUsingInternalTor.
///
/// In en, this message translates to:
- /// **'Using internal Tor'**
+ /// **'Using Internal Tor'**
String get lwsSetupUsingInternalTor;
/// No description provided for @lwsSetupUsingExternalTor.
///
/// In en, this message translates to:
- /// **'Using external Tor proxy at {address}'**
+ /// **'Using External Tor Proxy at {address}'**
String lwsSetupUsingExternalTor(String address);
/// No description provided for @lwsSetupTorDisabledError.
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 050acda..6c65c4b 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -65,16 +65,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get restoreWarningContinueButton => 'Continue';
@override
- String get lwsSetupTitle => 'LWS Setup';
+ String get lwsSetupTitle => 'Connection Setup';
@override
- String get lwsSetupDescription => 'Enter the address of your Monero light-wallet server (LWS).';
+ String get lwsSetupDescription =>
+ 'Connect to a Monero light-wallet server (LWS) or your own full node.';
@override
String get lwsSetupAddressHint => 'e.g. 192.168.1.1:18090 or example.com:18090';
@override
- String get lwsSetupProxyPortLabel => 'HTTP Proxy Port (optional)';
+ String get lwsSetupProxyPortLabel => 'HTTP Proxy Port (Optional)';
@override
String get lwsSetupProxyPortHint => 'e.g. 4444 for I2P';
@@ -82,9 +83,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get lwsSetupUseTorLabel => 'Use Tor';
- @override
- String get lwsSetupUseSslLabel => 'Use SSL';
-
@override
String get lwsSetupTestConnectionButton => 'Test Connection';
@@ -94,6 +92,61 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get lwsSetupContinueButton => 'Continue';
+ @override
+ String get connectionTypeLws => 'Light Wallet Server';
+
+ @override
+ String get connectionTypeNode => 'Monero Node';
+
+ @override
+ String get connectionNodeAddressHint => 'e.g. node.example.com:18081';
+
+ @override
+ String get connectionRemoteIpNotAllowed =>
+ 'Connections to remote IP addresses aren\'t allowed. Use a domain name or a local IP address.';
+
+ @override
+ String get connectionProtocolHttps => 'Removing protocol. Using HTTPS for domains.';
+
+ @override
+ String get connectionProtocolHttp => 'Removing protocol. Using HTTP for local addresses.';
+
+ @override
+ String get connectionIndicatorHttps => 'HTTPS';
+
+ @override
+ String get connectionIndicatorLocal => 'Local';
+
+ @override
+ String get connectionIndicatorTorInternal => 'Internal Tor';
+
+ @override
+ String connectionIndicatorTorExternal(String port) {
+ return 'Using Port $port';
+ }
+
+ @override
+ String get settingsConnectionSettingsLabel => 'Connection Settings';
+
+ @override
+ String get settingsBackgroundSyncLabel => 'Background Sync';
+
+ @override
+ String get settingsBackgroundSyncDescription =>
+ 'Periodically sync Monero in the background so it\'s up to date when you open the app. Only runs while charging and on WiFi.';
+
+ @override
+ String get settingsForegroundSyncLabel => 'Continuous Sync';
+
+ @override
+ String get settingsForegroundSyncDescription =>
+ 'Keep Monero syncing continuously while the app runs in the background, with a persistent notification. Uses more battery.';
+
+ @override
+ String homeBlocksRemaining(String count) {
+ return '$count blocks left';
+ }
+
@override
String get fiatApiSetupTitle => 'Fiat Display Setup';
@@ -107,7 +160,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get fiatApiSettingsModeTorOnly => 'Tor-Only';
@override
- String get fiatApiSettingsModeClearnet => 'Clearnet-Only (not private)';
+ String get fiatApiSettingsModeClearnet => 'Clearnet-Only (Not Private)';
@override
String get fiatApiSettingsModeDisabled => 'Disabled';
@@ -136,7 +189,7 @@ class AppLocalizationsEn extends AppLocalizations {
'This is your polyseed. Write it down and keep it in a safe place.';
@override
- String get generateSeedContinueButton => 'I wrote it down';
+ String get generateSeedContinueButton => 'I Wrote It Down';
@override
String get lwsDetailsTitle => 'Wallet Details';
@@ -165,7 +218,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get restoreWalletSeedLabel => 'Seed';
@override
- String get restoreWalletRestoreHeightLabel => 'Restore Height (optional)';
+ String get restoreWalletRestoreHeightLabel => 'Restore Height (Optional)';
@override
String get restoreWalletRestoreButton => 'Restore';
@@ -183,7 +236,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get unlockButton => 'Unlock';
@override
- String get unlockReason => 'Unlock wallet';
+ String get unlockReason => 'Unlock Wallet';
@override
String get unlockUnableToAuthError => 'Unable to authenticate.';
@@ -219,7 +272,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeSend => 'Send';
@override
- String get homeBalanceLocked => 'locked';
+ String get homeBalanceLocked => 'Locked';
@override
String get homeTransactionsTitle => 'Transactions';
@@ -234,7 +287,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeTransactionConfirmed => 'Confirmed';
@override
- String get homeNoTransactions => 'No transactions';
+ String get homeNoTransactions => 'No Transactions';
@override
String get homeFiatApiError => 'Error connecting to fiat API';
@@ -295,7 +348,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsAppLockLabel => 'App Lock';
@override
- String get settingsAppLockUnlockReason => 'Unlock wallet';
+ String get settingsAppLockUnlockReason => 'Unlock Wallet';
@override
String get settingsAppLockUnableToAuthError =>
@@ -457,13 +510,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get addressBookSearchHint => 'Search contacts...';
@override
- String get addressBookNoContacts => 'No contacts yet';
+ String get addressBookNoContacts => 'No Contacts Yet';
@override
String get addressBookNoContactsDescription => 'Add your first contact by tapping the + button';
@override
- String get addressBookNoSearchResults => 'No contacts found';
+ String get addressBookNoSearchResults => 'No Contacts Found';
@override
String get addressBookCopyAddress => 'Copy Address';
@@ -481,10 +534,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get addressBookSave => 'Save';
@override
- String get sendSelectedContact => 'Selected contact';
+ String get sendSelectedContact => 'Selected Contact';
@override
- String get sendClearSelectedContact => 'Clear selected contact';
+ String get sendClearSelectedContact => 'Clear Selected Contact';
@override
String get sendPriorityLow => 'Low';
@@ -496,7 +549,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get sendPriorityHigh => 'High';
@override
- String get sendPriorityLabel => 'priority';
+ String get sendPriorityLabel => 'Priority';
@override
String get sendTransactionPriority => 'Transaction Priority';
@@ -505,7 +558,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get sendFeeLabel => 'Fee';
@override
- String get sendBalanceLabel => 'Balance';
+ String get sendContactsButton => 'Contacts';
@override
String get sendFailedToGetFeesError => 'Failed to get fees.';
@@ -556,18 +609,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get torSettingsTestConnectionButton => 'Test Connection';
- @override
- String get settingsLwsSettingsLabel => 'LWS Settings';
-
@override
String get settingsTorSettingsLabel => 'Tor Settings';
@override
- String get lwsSetupUsingInternalTor => 'Using internal Tor';
+ String get lwsSetupUsingInternalTor => 'Using Internal Tor';
@override
String lwsSetupUsingExternalTor(String address) {
- return 'Using external Tor proxy at $address';
+ return 'Using External Tor Proxy at $address';
}
@override
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 2fa6549..635495a 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -65,10 +65,11 @@ class AppLocalizationsPt extends AppLocalizations {
String get restoreWarningContinueButton => 'Continuar';
@override
- String get lwsSetupTitle => 'Configuração do LWS';
+ String get lwsSetupTitle => 'Configuração da conexão';
@override
- String get lwsSetupDescription => 'Informe o endereço do seu servidor light-wallet Monero (LWS).';
+ String get lwsSetupDescription =>
+ 'Conecte-se a um servidor light-wallet Monero (LWS) ou ao seu próprio nó completo.';
@override
String get lwsSetupAddressHint => 'ex: 192.168.1.1:18090 ou exemplo.com:18090';
@@ -82,9 +83,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get lwsSetupUseTorLabel => 'Usar Tor';
- @override
- String get lwsSetupUseSslLabel => 'Usar SSL';
-
@override
String get lwsSetupTestConnectionButton => 'Testar Conexão';
@@ -94,6 +92,61 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get lwsSetupContinueButton => 'Continuar';
+ @override
+ String get connectionTypeLws => 'Servidor Light Wallet';
+
+ @override
+ String get connectionTypeNode => 'Nó Monero';
+
+ @override
+ String get connectionNodeAddressHint => 'ex.: node.example.com:18081';
+
+ @override
+ String get connectionRemoteIpNotAllowed =>
+ 'Conexões com endereços IP remotos não são permitidas. Use um nome de domínio ou um endereço IP local.';
+
+ @override
+ String get connectionProtocolHttps => 'Removendo protocolo. Usando HTTPS para domínios.';
+
+ @override
+ String get connectionProtocolHttp => 'Removendo protocolo. Usando HTTP para endereços locais.';
+
+ @override
+ String get connectionIndicatorHttps => 'HTTPS';
+
+ @override
+ String get connectionIndicatorLocal => 'Local';
+
+ @override
+ String get connectionIndicatorTorInternal => 'Tor Interno';
+
+ @override
+ String connectionIndicatorTorExternal(String port) {
+ return 'Usando Porta $port';
+ }
+
+ @override
+ String get settingsConnectionSettingsLabel => 'Configurações de conexão';
+
+ @override
+ String get settingsBackgroundSyncLabel => 'Sincronização em segundo plano';
+
+ @override
+ String get settingsBackgroundSyncDescription =>
+ 'Sincroniza o Monero periodicamente em segundo plano para que esteja atualizado ao abrir o app. Só roda enquanto carrega e no WiFi.';
+
+ @override
+ String get settingsForegroundSyncLabel => 'Sincronização contínua';
+
+ @override
+ String get settingsForegroundSyncDescription =>
+ 'Mantém o Monero sincronizando continuamente enquanto o app roda em segundo plano, com uma notificação persistente. Usa mais bateria.';
+
+ @override
+ String homeBlocksRemaining(String count) {
+ return '$count blocos restantes';
+ }
+
@override
String get fiatApiSetupTitle => 'Exibição de Saldo em Fiat';
@@ -505,7 +558,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get sendFeeLabel => 'Taxa';
@override
- String get sendBalanceLabel => 'Saldo';
+ String get sendContactsButton => 'Contatos';
@override
String get sendFailedToGetFeesError => 'Não foi possível carregar taxas.';
@@ -556,9 +609,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get torSettingsTestConnectionButton => 'Testar Conexão';
- @override
- String get settingsLwsSettingsLabel => 'Configurações do LWS';
-
@override
String get settingsTorSettingsLabel => 'Configurações do Tor';
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index ec10ff9..54420a6 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -18,16 +18,45 @@
"restoreWarningTitle": "Aviso de Restauração",
"restoreWarningDescription": "Você tem certeza? O servidor ao qual você se conectar poderá ver seu histórico de transações Monero passadas e futuras.",
"restoreWarningContinueButton": "Continuar",
- "lwsSetupTitle": "Configuração do LWS",
- "lwsSetupDescription": "Informe o endereço do seu servidor light-wallet Monero (LWS).",
+ "lwsSetupTitle": "Configuração da conexão",
+ "lwsSetupDescription": "Conecte-se a um servidor light-wallet Monero (LWS) ou ao seu próprio nó completo.",
"lwsSetupAddressHint": "ex: 192.168.1.1:18090 ou exemplo.com:18090",
"lwsSetupProxyPortLabel": "Porta do Proxy HTTP (opcional)",
"lwsSetupProxyPortHint": "ex: 4444 para I2P",
"lwsSetupUseTorLabel": "Usar Tor",
- "lwsSetupUseSslLabel": "Usar SSL",
"lwsSetupTestConnectionButton": "Testar Conexão",
"lwsSetupStartingTor": "Iniciando Tor...",
"lwsSetupContinueButton": "Continuar",
+ "connectionTypeLws": "Servidor Light Wallet",
+ "connectionTypeNode": "Nó Monero",
+ "connectionNodeAddressHint": "ex.: node.example.com:18081",
+ "connectionRemoteIpNotAllowed": "Conexões com endereços IP remotos não são permitidas. Use um nome de domínio ou um endereço IP local.",
+ "connectionProtocolHttps": "Removendo protocolo. Usando HTTPS para domínios.",
+ "connectionProtocolHttp": "Removendo protocolo. Usando HTTP para endereços locais.",
+ "connectionIndicatorHttps": "HTTPS",
+ "connectionIndicatorLocal": "Local",
+ "connectionIndicatorTorInternal": "Tor Interno",
+ "connectionIndicatorTorExternal": "Usando Porta {port}",
+ "@connectionIndicatorTorExternal": {
+ "placeholders": {
+ "port": {
+ "type": "String"
+ }
+ }
+ },
+ "settingsConnectionSettingsLabel": "Configurações de conexão",
+ "settingsBackgroundSyncLabel": "Sincronização em segundo plano",
+ "settingsBackgroundSyncDescription": "Sincroniza o Monero periodicamente em segundo plano para que esteja atualizado ao abrir o app. Só roda enquanto carrega e no WiFi.",
+ "settingsForegroundSyncLabel": "Sincronização contínua",
+ "settingsForegroundSyncDescription": "Mantém o Monero sincronizando continuamente enquanto o app roda em segundo plano, com uma notificação persistente. Usa mais bateria.",
+ "homeBlocksRemaining": "{count} blocos restantes",
+ "@homeBlocksRemaining": {
+ "placeholders": {
+ "count": {
+ "type": "String"
+ }
+ }
+ },
"fiatApiSetupTitle": "Exibição de Saldo em Fiat",
"fiatApiSetupDescription": "Preço de referência opcional para seu saldo em XMR.",
"fiatApiSettingsModeLabel": "Modo",
@@ -166,7 +195,7 @@
"sendPriorityLabel": "prioridade",
"sendTransactionPriority": "Prioridade da Transação",
"sendFeeLabel": "Taxa",
- "sendBalanceLabel": "Saldo",
+ "sendContactsButton": "Contatos",
"sendFailedToGetFeesError": "Não foi possível carregar taxas.",
"torInfoTitle": "Tor Integrado",
"torInfoDescription": "A Carteira Skylight usa automaticamente Tor integrado para proteger suas conexões de internet.",
@@ -183,7 +212,6 @@
"torSettingsUseOrbotLabelIos": "Usar Orbot",
"torSettingsSaveButton": "Salvar",
"torSettingsTestConnectionButton": "Testar Conexão",
- "settingsLwsSettingsLabel": "Configurações do LWS",
"settingsTorSettingsLabel": "Configurações do Tor",
"lwsSetupUsingInternalTor": "Usando Tor interno",
"lwsSetupUsingExternalTor": "Usando proxy Tor externo em {address}",
diff --git a/lib/main.dart b/lib/main.dart
index dfc8748..d9007b1 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -41,6 +41,7 @@ import 'package:skylight_wallet/screens/unlock.dart';
import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/periodic_tasks.dart';
+import 'package:skylight_wallet/services/foreground_sync_service.dart';
import 'package:skylight_wallet/util/dirs.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/util/cacert.dart';
@@ -76,6 +77,7 @@ void main() async {
if (Platform.isAndroid) {
copyCacertToAppDocumentsDir();
registerPeriodicTasks();
+ startForegroundSyncIfEnabled();
NotificationService().init();
}
@@ -100,8 +102,10 @@ void main() async {
Future<bool> loadExistingWalletIfExists(WalletModel wallet) async {
if (await wallet.hasExistingWallet()) {
if (isMobile) {
- await wallet.openExisting();
+ // Load the persisted connection first so the wallet opens the file for
+ // the correct mode (LWS vs node).
await wallet.loadPersistedConnection();
+ await wallet.openExisting();
wallet.load();
}
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index 4bee295..b962ac8 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
import 'package:monero/monero.dart' as monero;
import 'package:monero/src/monero.dart';
import 'package:monero/src/wallet2.dart';
+import 'package:openalias_ffi/openalias_ffi.dart';
import 'package:http/http.dart' as http;
import 'package:polyseed/polyseed.dart';
import 'package:bip39/bip39.dart' as bip39;
@@ -18,6 +19,7 @@ import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/services/tor_service.dart';
import 'package:skylight_wallet/services/tor_settings_service.dart';
+import 'package:skylight_wallet/util/amount_units.dart';
import 'package:skylight_wallet/util/bip39.dart';
import 'package:skylight_wallet/util/cacert.dart';
import 'package:skylight_wallet/util/formatting.dart';
@@ -118,17 +120,25 @@ class LWSConnectionDetails {
final String proxyPort;
final bool useTor;
final bool useSsl;
+ final String connectionType;
LWSConnectionDetails({
required this.address,
required this.proxyPort,
required this.useTor,
required this.useSsl,
+ this.connectionType = 'lws',
});
}
class WalletModel with ChangeNotifier {
- final _w2WalletManager = Monero().walletManagerFactory().getLWSFWalletManager();
+ // Which factory built the cached manager: 'lws' (LWSF) or 'node' (wallet2).
+ Wallet2WalletManager? _w2WalletManager;
+ String? _managerType;
+ // Mode the currently-open _w2Wallet was loaded for ('lws' | 'node').
+ String? _loadedType;
+ // True once Wallet_init has run; daemon-dependent FFI calls abort before it.
+ bool _daemonInitialized = false;
Wallet2Wallet? _w2Wallet;
Wallet2TransactionHistory? _w2TxHistory;
@@ -137,6 +147,10 @@ class WalletModel with ChangeNotifier {
late String _connectionProxyPort;
late bool _connectionUseTor;
late bool _connectionUseSsl;
+ String _connectionType = 'lws';
+
+ int? _daemonTargetHeight;
+ DateTime? _lastDaemonHeightFetch;
final _sessionStartedAt = DateTime.now().secondsSinceEpoch;
var _hasAttemptedConnection = false;
@@ -167,11 +181,60 @@ class WalletModel with ChangeNotifier {
bool? get serverSupportsSubaddresses => _serverSupportsSubaddresses;
int? get unusedSubaddressIndex => _unusedSubaddressIndex;
bool? get unusedSubaddressIndexIsSupported => _unusedSubaddressIndexIsSupported;
+ String get connectionType => _connectionType;
+
+ /// True when configured to talk to a full Monero node rather than an LWS.
+ bool get isNodeMode => _connectionType == 'node';
+
+ /// Manager factory kind the current connection needs ('lws' | 'node').
+ String get _desiredManagerType => isNodeMode ? 'node' : 'lws';
+
+ /// Blocks still to scan in node mode while syncing; null in LWS / when synced.
+ int? get syncBlocksRemaining {
+ if (!isNodeMode || _isSynced) return null;
+ final target = _daemonTargetHeight;
+ final have = _syncedHeight;
+ if (target == null || have == null || target <= 0) return null;
+ final remaining = target - have;
+ return remaining > 0 ? remaining : null;
+ }
WalletModel() {
_startTimers();
}
+ /// Returns the wallet manager built by the factory matching the current
+ /// connection type. LWS uses the LWSF manager; a full node uses the standard
+ /// wallet2 manager. Switching types rebuilds the manager and tears down any
+ /// wallet the previous one opened.
+ Future<Wallet2WalletManager> _walletManager() async {
+ final type = _desiredManagerType;
+ if (_w2WalletManager != null && _managerType == type) return _w2WalletManager!;
+
+ if (_w2WalletManager != null && _w2Wallet != null) {
+ _w2WalletManager!.closeWallet(_w2Wallet!, false);
+ _w2Wallet = null;
+ _w2TxHistory = null;
+ _daemonInitialized = false;
+ _loadedType = null;
+ }
+
+ final managerFactory = Monero().walletManagerFactory();
+ _w2WalletManager = type == 'node'
+ ? managerFactory.getWalletManager()
+ : managerFactory.getLWSFWalletManager();
+ _managerType = type;
+ return _w2WalletManager!;
+ }
+
+ /// Path of the wallet file for the current mode. LWS keeps the original
+ /// `mywallet` path; the node gets a `_node` suffix so toggling modes doesn't
+ /// force a rescan.
+ Future<String> resolveWalletPath() async {
+ final basePath = await getWalletPath();
+ return isNodeMode ? '${basePath}_node' : basePath;
+ }
+
void _startTimers() {
Timer.periodic(Duration(seconds: 1), (timer) {
_runCheckConnectionTimerTask();
@@ -194,6 +257,25 @@ class WalletModel with ChangeNotifier {
_isConnected = isConnected;
notifyListeners();
}
+
+ // Node sync state flips off the native background thread; poll it here so
+ // "blocks remaining" advances between the slower refresh cycles.
+ await pollSyncStatus();
+ }
+
+ /// Node-only: reads sync flag + heights off the background scan thread. When
+ /// it just caught up, pulls fresh balances/tx immediately.
+ Future<void> pollSyncStatus() async {
+ if (!isNodeMode || _w2Wallet == null || !_daemonInitialized) return;
+
+ final wasSynced = _isSynced;
+ await loadIsSynced();
+ await loadSyncedHeight();
+ notifyListeners();
+
+ if (!wasSynced && _isSynced) {
+ await loadAllStats();
+ }
}
Future<void> _runRefreshTimerTask() async {
@@ -201,8 +283,25 @@ class WalletModel with ChangeNotifier {
return;
}
+ // Reconnect if the connection dropped since the last cycle.
+ if (!_isConnected) {
+ try {
+ await connectToDaemon();
+ } catch (e) {
+ log(LogLevel.warn, 'Reconnect attempt failed: $e');
+ }
+ }
+
await refresh();
+ // In node mode, defer the expensive stat reads until the scan has caught
+ // up (avoids contending with the native refresh thread). pollSyncStatus
+ // handles progress + the one-time catch-up load.
+ if (isNodeMode && !_isSynced) {
+ await store();
+ return;
+ }
+
try {
await loadAllStats().timeout(Duration(seconds: 20));
} catch (e) {
@@ -219,9 +318,11 @@ class WalletModel with ChangeNotifier {
await loadPersistedSubaddressSupport();
await loadPersistedUnusedSubaddressIndex();
+ // Connect first so the daemon-dependent calls below have an initialized
+ // wallet (refresh/stats early-return until connect sets _daemonInitialized).
+ await connectToDaemon();
await refresh();
await loadAllStats();
- await connectToDaemon();
await loadSubaddressSupport();
await loadUnusedSubaddressIndex();
}
@@ -295,6 +396,7 @@ class WalletModel with ChangeNotifier {
);
await SharedPreferencesService.set(SharedPreferencesKeys.connectionUseTor, _connectionUseTor);
await SharedPreferencesService.set(SharedPreferencesKeys.connectionUseSsl, _connectionUseSsl);
+ await SharedPreferencesService.set(SharedPreferencesKeys.connectionType, _connectionType);
}
Future<LWSConnectionDetails> getPersistedConnection() async {
@@ -308,6 +410,8 @@ class WalletModel with ChangeNotifier {
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.connectionUseTor) ?? false,
useSsl:
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.connectionUseSsl) ?? false,
+ connectionType:
+ await SharedPreferencesService.get<String>(SharedPreferencesKeys.connectionType) ?? 'lws',
);
}
@@ -318,6 +422,7 @@ class WalletModel with ChangeNotifier {
proxyPort: connectionDetails.proxyPort,
useTor: connectionDetails.useTor,
useSsl: connectionDetails.useSsl,
+ connectionType: connectionDetails.connectionType,
);
}
@@ -336,16 +441,36 @@ class WalletModel with ChangeNotifier {
return await SharedPreferencesService.get<int>(SharedPreferencesKeys.txHistoryCount) ?? 0;
}
+ /// Loads balances + tx history from the just-opened monero_c wallet cache,
+ /// offline (no daemon). Lets the last-known state render instantly on reopen
+ /// while the sync catches up. `TransactionHistory_refresh` here is local — it
+ /// re-reads the wallet's cached transfers, not the network.
+ Future<void> loadCachedStats() async {
+ if (_w2Wallet == null || _w2TxHistory == null) return;
+
+ final historyFfiAddr = _w2TxHistory!.ffiAddress();
+ await Isolate.run(
+ // ignore: deprecated_member_use
+ () => monero.TransactionHistory_refresh(Pointer.fromAddress(historyFfiAddr)),
+ );
+
+ await Future.wait([loadUnlockedBalance(), loadTotalBalance(), loadTxHistory()]);
+
+ notifyListeners();
+ }
+
void setConnection({
required String address,
required String proxyPort,
required bool useTor,
required bool useSsl,
+ String connectionType = 'lws',
}) {
_connectionAddress = address;
_connectionProxyPort = proxyPort;
_connectionUseTor = useTor;
_connectionUseSsl = useSsl;
+ _connectionType = connectionType;
notifyListeners();
}
@@ -356,6 +481,17 @@ class WalletModel with ChangeNotifier {
Future<void> connectToDaemon() async {
if (_w2Wallet == null) throw Exception("w2wallet is null");
+ // The open wallet is bound to the factory of the mode it was opened in
+ // (LWS vs node). Connecting before a rebuild would call Wallet_init with a
+ // mismatched lightWallet flag and abort.
+ if (_loadedType != null && _loadedType != _desiredManagerType) {
+ log(
+ LogLevel.warn,
+ 'Skipping connect: loaded as "$_loadedType" but connection needs "$_desiredManagerType"; awaiting rebuild',
+ );
+ return;
+ }
+
String? torProxyPort;
if (_connectionUseTor) {
@@ -392,7 +528,9 @@ class WalletModel with ChangeNotifier {
final walletFfiAddr = _w2Wallet!.ffiAddress();
final daemonAddress = '${useSsl ? 'https://' : 'http://'}$address';
final proxyAddress = proxyPort != '' ? '127.0.0.1:$proxyPort' : '';
- final lightWallet = true;
+ // A full node scans locally (lightWallet=false); LWS scans server-side.
+ final lightWallet = !isNodeMode;
+ final isNode = isNodeMode;
log(LogLevel.info, 'Calling Wallet_init with parameters:');
log(LogLevel.info, ' daemonAddress: $daemonAddress');
@@ -400,25 +538,50 @@ class WalletModel with ChangeNotifier {
log(LogLevel.info, ' useSsl: $useSsl');
log(LogLevel.info, ' lightWallet: $lightWallet');
- final initResult = await Isolate.run(
+ // Run init + connect (+ node refresh-thread kick) in a single isolate hop
+ // and time each step so a slow daemon handshake is attributable.
+ final r = await Isolate.run(() {
+ final ptr = Pointer<Void>.fromAddress(walletFfiAddr);
+ final sw = Stopwatch()..start();
// ignore: deprecated_member_use
- () => monero.Wallet_init(
- Pointer.fromAddress(walletFfiAddr),
+ final initResult = monero.Wallet_init(
+ ptr,
daemonAddress: daemonAddress,
proxyAddress: proxyAddress,
useSsl: useSsl,
lightWallet: lightWallet,
- ),
- );
-
- log(LogLevel.info, 'Wallet_init result: $initResult');
-
- final connectResult = await Isolate.run(
+ );
+ final initMs = sw.elapsedMilliseconds;
+ sw.reset();
// ignore: deprecated_member_use
- () => monero.Wallet_connectToDaemon(Pointer.fromAddress(walletFfiAddr)),
+ final connectResult = monero.Wallet_connectToDaemon(ptr);
+ final connectMs = sw.elapsedMilliseconds;
+ sw.reset();
+ var refreshMs = 0;
+ if (isNode) {
+ // ignore: deprecated_member_use
+ monero.Wallet_setAutoRefreshInterval(ptr, millis: 10000);
+ // ignore: deprecated_member_use
+ monero.Wallet_startRefresh(ptr);
+ refreshMs = sw.elapsedMilliseconds;
+ }
+ return (
+ initResult: initResult,
+ connectResult: connectResult,
+ initMs: initMs,
+ connectMs: connectMs,
+ refreshMs: refreshMs,
+ );
+ });
+
+ log(
+ LogLevel.info,
+ 'Wallet connect timings: init ${r.initMs}ms (result ${r.initResult}), '
+ 'connectToDaemon ${r.connectMs}ms (result ${r.connectResult}), '
+ 'startRefresh ${r.refreshMs}ms',
);
- log(LogLevel.info, 'Wallet_connectToDaemon result: $connectResult');
+ _daemonInitialized = true;
final connectError = _w2Wallet!.errorString();
@@ -427,6 +590,118 @@ class WalletModel with ChangeNotifier {
}
}
+ /// Probes a connection without opening the wallet. Throws on failure.
+ Future<void> testConnection({
+ required String address,
+ String? proxyPort,
+ required bool useSsl,
+ required bool useTor,
+ String connectionType = '',
+ }) async {
+ if (connectionType == 'node') {
+ await _testNodeConnection(
+ address: address,
+ proxyPort: proxyPort,
+ useSsl: useSsl,
+ useTor: useTor,
+ );
+ return;
+ }
+
+ final url = '${useSsl ? 'https' : 'http'}://$address/get_address_info';
+ log(LogLevel.info, 'Probing LWS server: $url (tor=$useTor, proxyPort=$proxyPort)');
+
+ late int statusCode;
+ if (useTor) {
+ final torSettings = TorSettingsService.sharedInstance;
+ if (torSettings.torMode == TorMode.disabled) {
+ throw Exception('Tor is disabled. Please go back and enable it.');
+ }
+ final proxyInfo = await torSettings.getProxy();
+ if (proxyInfo == null) {
+ throw Exception('Could not resolve a Tor proxy.');
+ }
+ final response = await makeSocksHttpRequest(
+ 'POST',
+ url,
+ proxyInfo,
+ ).timeout(Duration(seconds: 20));
+ statusCode = response.statusCode;
+ } else {
+ var httpClient = HttpClient();
+ if (proxyPort != null && proxyPort.isNotEmpty) {
+ httpClient.findProxy = (_) => 'SOCKS localhost:$proxyPort';
+ }
+ try {
+ final request = await httpClient.postUrl(Uri.parse(url));
+ final response = await request.close().timeout(Duration(seconds: 10));
+ statusCode = response.statusCode;
+ } finally {
+ httpClient.close(force: true);
+ }
+ }
+
+ // LWS responds with 500 to an unauthenticated POST to /get_address_info.
+ // Anything else means we're not talking to a real LWS endpoint.
+ if (statusCode != HttpStatus.internalServerError) {
+ throw Exception('Unexpected status $statusCode from $url');
+ }
+ }
+
+ /// Probes a full Monero node via `GET /get_height`. A real monerod replies
+ /// 200 with a JSON body carrying a `height` (and `status`).
+ Future<void> _testNodeConnection({
+ required String address,
+ String? proxyPort,
+ required bool useSsl,
+ required bool useTor,
+ }) async {
+ final url = '${useSsl ? 'https' : 'http'}://$address/get_height';
+ log(LogLevel.info, 'Probing Monero node: $url (tor=$useTor, proxyPort=$proxyPort)');
+
+ late int statusCode;
+ dynamic jsonBody;
+ if (useTor) {
+ final torSettings = TorSettingsService.sharedInstance;
+ if (torSettings.torMode == TorMode.disabled) {
+ throw Exception('Tor is disabled. Please go back and enable it.');
+ }
+ final proxyInfo = await torSettings.getProxy();
+ if (proxyInfo == null) {
+ throw Exception('Could not resolve a Tor proxy.');
+ }
+ final response = await makeSocksHttpRequest(
+ 'GET',
+ url,
+ proxyInfo,
+ ).timeout(Duration(seconds: 20));
+ statusCode = response.statusCode;
+ jsonBody = response.jsonBody;
+ } else {
+ final httpClient = HttpClient();
+ if (proxyPort != null && proxyPort.isNotEmpty) {
+ httpClient.findProxy = (_) => 'SOCKS localhost:$proxyPort';
+ }
+ try {
+ final request = await httpClient.getUrl(Uri.parse(url));
+ final response = await request.close().timeout(Duration(seconds: 10));
+ statusCode = response.statusCode;
+ final body = await response.transform(utf8.decoder).join();
+ try {
+ jsonBody = json.decode(body);
+ } catch (_) {
+ jsonBody = null;
+ }
+ } finally {
+ httpClient.close(force: true);
+ }
+ }
+
+ if (statusCode != HttpStatus.ok || jsonBody is! Map || jsonBody['height'] == null) {
+ throw Exception('Unexpected response ($statusCode) from $url');
+ }
+ }
+
Future<void> loadPersistedSubaddressSupport() async {
_serverSupportsSubaddresses = await SharedPreferencesService.get<bool>(
SharedPreferencesKeys.serverSupportsSubaddresses,
@@ -434,6 +709,16 @@ class WalletModel with ChangeNotifier {
}
Future<void> loadSubaddressSupport() async {
+ // A full node supports subaddresses natively; skip the LWS HTTP probe.
+ if (isNodeMode) {
+ _serverSupportsSubaddresses = true;
+ await SharedPreferencesService.set<bool>(
+ SharedPreferencesKeys.serverSupportsSubaddresses,
+ true,
+ );
+ return;
+ }
+
try {
final isSupported = await isSubaddressSupported(1);
_serverSupportsSubaddresses = isSupported;
@@ -467,6 +752,22 @@ class WalletModel with ChangeNotifier {
}
if (_unusedSubaddressIndex != nextSubaddrIndex) {
+ // Node supports subaddresses natively; skip the LWS HTTP probe.
+ if (isNodeMode) {
+ _unusedSubaddressIndex = nextSubaddrIndex;
+ _unusedSubaddressIndexIsSupported = true;
+ await SharedPreferencesService.set<int>(
+ SharedPreferencesKeys.unusedSubaddressIndex,
+ _unusedSubaddressIndex!,
+ );
+ await SharedPreferencesService.set<bool>(
+ SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
+ true,
+ );
+ notifyListeners();
+ return;
+ }
+
try {
final isSupported = await isSubaddressSupported(nextSubaddrIndex);
_unusedSubaddressIndex = nextSubaddrIndex;
@@ -571,13 +872,26 @@ class WalletModel with ChangeNotifier {
}
Future<void> refresh() async {
+ if (_w2Wallet == null || _w2TxHistory == null || !_daemonInitialized) return;
final walletFfiAddr = _w2Wallet!.ffiAddress();
final historyFfiAddr = _w2TxHistory!.ffiAddress();
- log(
- LogLevel.info,
- 'Calling Wallet_startRefresh, Wallet_refresh, and TransactionHistory_refresh',
- );
+ if (isNodeMode) {
+ // The background refresh thread does the block scanning; just keep it
+ // running and pull the latest tx-history view from the wallet's cache.
+ log(LogLevel.info, 'Ensuring full-node refresh thread + history refresh');
+ await Isolate.run(
+ // ignore: deprecated_member_use
+ () => monero.Wallet_startRefresh(Pointer.fromAddress(walletFfiAddr)),
+ );
+ await Isolate.run(
+ // ignore: deprecated_member_use
+ () => monero.TransactionHistory_refresh(Pointer.fromAddress(historyFfiAddr)),
+ );
+ return;
+ }
+
+ log(LogLevel.info, 'Calling Wallet_refresh and TransactionHistory_refresh');
await Isolate.run(
// ignore: deprecated_member_use
@@ -624,7 +938,7 @@ class WalletModel with ChangeNotifier {
}
Future<int> getCurrentHeight() async {
- final wmFfiAddr = _w2WalletManager.ffiAddress();
+ final wmFfiAddr = (await _walletManager()).ffiAddress();
log(LogLevel.info, 'Calling WalletManager_blockchainHeight');
@@ -647,8 +961,8 @@ class WalletModel with ChangeNotifier {
throw Exception('Password should not be empty.');
}
- final wmFfiAddr = _w2WalletManager.ffiAddress();
- final walletPath = await getWalletPath();
+ final wmFfiAddr = (await _walletManager()).ffiAddress();
+ final walletPath = await resolveWalletPath();
log(LogLevel.info, 'Calling WalletManager_recoveryWallet with parameters:');
log(LogLevel.info, ' mnemonic: <hidden>');
@@ -684,8 +998,8 @@ class WalletModel with ChangeNotifier {
throw Exception('Password should not be empty.');
}
- final wmFfiAddr = _w2WalletManager.ffiAddress();
- final walletPath = await getWalletPath();
+ final wmFfiAddr = (await _walletManager()).ffiAddress();
+ final walletPath = await resolveWalletPath();
final walletFfiAddr = await Isolate.run(() {
// ignore: deprecated_member_use
@@ -749,6 +1063,7 @@ class WalletModel with ChangeNotifier {
_w2Wallet = wallet;
_w2TxHistory = _w2Wallet!.history();
+ _loadedType = _desiredManagerType;
if (Platform.isAndroid || Platform.isIOS) {
await storeMobileWalletPassword(walletPassword);
@@ -759,7 +1074,12 @@ class WalletModel with ChangeNotifier {
}
Future<void> openExisting({String? desktopWalletPassword}) async {
- final path = await getWalletPath();
+ final wm = await _walletManager();
+ final path = await resolveWalletPath();
+
+ if (desktopWalletPassword != null) {
+ _desktopWalletPassword = desktopWalletPassword;
+ }
final password = desktopWalletPassword ?? await getMobileWalletPassword();
@@ -773,7 +1093,7 @@ class WalletModel with ChangeNotifier {
log(LogLevel.info, ' path: $path');
log(LogLevel.info, ' password: <hidden>');
- final w2Wallet = _w2WalletManager.openWallet(path: path, password: password);
+ final w2Wallet = wm.openWallet(path: path, password: password);
if (w2Wallet.errorString() != '') {
final errorMsg = 'WalletManager_openWallet error: ${w2Wallet.errorString()}';
@@ -785,8 +1105,63 @@ class WalletModel with ChangeNotifier {
_w2Wallet = w2Wallet;
_w2TxHistory = _w2Wallet!.history();
+ _loadedType = _desiredManagerType;
notifyListeners();
+
+ // Show last known balances + tx list (from the wallet cache) immediately
+ // while the sync catches up.
+ await loadCachedStats();
+ }
+
+ /// True when the open wallet was loaded for a different mode than the current
+ /// connection needs (e.g. user switched LWS↔node) and must be re-opened.
+ bool needsRebuildForCurrentConnection() =>
+ _w2Wallet != null && _loadedType != null && _loadedType != _desiredManagerType;
+
+ /// Applies a connection change made via the settings form: rebuilds the open
+ /// wallet for the new mode if the server kind changed, then (re)syncs.
+ Future<void> applyConnectionChange() async {
+ if (needsRebuildForCurrentConnection()) {
+ await _rebuildForConnectionType();
+ }
+ await load();
+ }
+
+ /// Re-opens the wallet for the current (newly-selected) mode. LWS and node
+ /// keep separate cache files sharing the same keys/seed; if the target file
+ /// doesn't exist yet it's recovered from the open wallet's seed.
+ Future<void> _rebuildForConnectionType() async {
+ // Both mode files share the same password; reuse the existing one so a
+ // later switch back can still decrypt the other file.
+ final password = _desktopWalletPassword ?? await getMobileWalletPassword();
+ if (password == null) {
+ throw Exception('Cannot rebuild wallet for new connection: no password.');
+ }
+
+ // Extract the seed + restore height while the old-mode wallet is still open.
+ final polyseed = _w2Wallet!.getPolyseed(passphrase: '');
+ final legacySeed = _w2Wallet!.seed(seedOffset: '');
+ final mnemonic = polyseed.isNotEmpty ? polyseed : legacySeed;
+ final restoreHeight = await getRestoreHeight();
+
+ _daemonTargetHeight = null;
+ _lastDaemonHeightFetch = null;
+
+ final targetPath = await resolveWalletPath();
+ final targetExists = await File(targetPath).exists();
+
+ if (targetExists) {
+ // _walletManager() (via openExisting) closes the old wallet + swaps factory.
+ await openExisting(desktopWalletPassword: password);
+ } else {
+ // Recover the target-mode file from the shared seed. Force the existing
+ // password so restoreFromMnemonic doesn't mint a new random one (which
+ // would desync the two mode files). restoreFromMnemonic resolves the
+ // target path and rebuilds the manager for the new mode.
+ _desktopWalletPassword = password;
+ await restoreFromMnemonic(mnemonic, restoreHeight);
+ }
}
Future<bool> store() async {
@@ -804,8 +1179,10 @@ class WalletModel with ChangeNotifier {
}
Future delete() async {
- _w2WalletManager.closeWallet(_w2Wallet!, false);
+ (await _walletManager()).closeWallet(_w2Wallet!, false);
_w2Wallet = null;
+ _daemonInitialized = false;
+ _loadedType = null;
_hasAttemptedConnection = false;
_isConnected = false;
_isSynced = false;
@@ -813,9 +1190,18 @@ class WalletModel with ChangeNotifier {
_unlockedBalance = null;
_totalBalance = null;
_txHistory = [];
- final path = await getWalletPath();
- await File(path).delete();
+ // Remove both mode files (LWS `mywallet*` and node `mywallet_node*`) plus
+ // the companion `.keys` / `.address.txt` Monero writes alongside each.
+ final base = await getWalletPath();
+ for (final b in {base, '${base}_node'}) {
+ for (final p in [b, '$b.keys', '$b.address.txt']) {
+ final file = File(p);
+ if (await file.exists()) await file.delete();
+ }
+ }
+
+ await SharedPreferencesService.remove(SharedPreferencesKeys.connectionType);
await SharedPreferencesService.remove(SharedPreferencesKeys.txHistoryCount);
await SharedPreferencesService.remove(SharedPreferencesKeys.walletRestoreHeight);
await SharedPreferencesService.remove(SharedPreferencesKeys.appLockEnabled);
@@ -830,11 +1216,12 @@ class WalletModel with ChangeNotifier {
log(LogLevel.info, 'Calling WalletManager_walletExists with parameters:');
log(LogLevel.info, ' path: ${await getWalletPath()}');
- final exists = _w2WalletManager.walletExists(await getWalletPath());
+ final wm = await _walletManager();
+ final exists = wm.walletExists(await getWalletPath());
log(LogLevel.info, 'WalletManager_walletExists result: $exists');
- final errorString = _w2WalletManager.errorString();
+ final errorString = wm.errorString();
if (errorString != '') {
log(LogLevel.error, 'WalletManager_walletExists error: $errorString');
@@ -844,6 +1231,7 @@ class WalletModel with ChangeNotifier {
}
Future<bool> getIsConnected() async {
+ if (_w2Wallet == null || !_daemonInitialized) return false;
final w2WalletFfiAddr = _w2Wallet!.ffiAddress();
final connected = await Isolate.run(
@@ -855,20 +1243,40 @@ class WalletModel with ChangeNotifier {
}
Future<void> loadIsSynced() async {
+ if (_w2Wallet == null || !_daemonInitialized) return;
final walletFfiAddr = _w2Wallet!.ffiAddress();
log(LogLevel.info, 'Calling Wallet_synchronized:');
- _isSynced = await Isolate.run(
+ final synced = await Isolate.run(
() =>
// ignore: deprecated_member_use
monero.Wallet_synchronized(Pointer<Void>.fromAddress(walletFfiAddr)),
);
- log(LogLevel.info, 'Wallet_synchronized result: $_isSynced');
+ log(LogLevel.info, 'Wallet_synchronized result: $synced');
+
+ if (isNodeMode && !synced) {
+ // Wallet height is local/cheap — read every poll. The daemon tip is a
+ // network RPC, so only refresh it every 30s to avoid stealing the
+ // circuit/lock from the scan.
+ final now = DateTime.now();
+ if (_lastDaemonHeightFetch == null ||
+ now.difference(_lastDaemonHeightFetch!) >= const Duration(seconds: 30)) {
+ _lastDaemonHeightFetch = now;
+ _daemonTargetHeight = await Isolate.run(
+ // ignore: deprecated_member_use
+ () => monero.Wallet_daemonBlockChainHeight(Pointer<Void>.fromAddress(walletFfiAddr)),
+ );
+ log(LogLevel.info, 'Daemon target height: $_daemonTargetHeight');
+ }
+ }
+
+ _isSynced = synced;
}
Future<void> loadSyncedHeight() async {
+ if (_w2Wallet == null || !_daemonInitialized) return;
final walletFfiAddr = _w2Wallet!.ffiAddress();
log(LogLevel.info, 'Calling Wallet_blockChainHeight:');
@@ -963,8 +1371,13 @@ class WalletModel with ChangeNotifier {
double amount,
bool isSweepAll, {
int priority = 0,
+ String? amountText,
}) async {
- final amountInt = _w2Wallet!.amountFromDouble(amount);
+ // Convert the exact decimal string when available; going through double
+ // loses precision. Falls back to amountFromDouble when no text is given.
+ final amountInt = amountText != null
+ ? decimalToBaseUnits(amountText, consts.moneroDecimals).toInt()
+ : _w2Wallet!.amountFromDouble(amount);
final w2WalletFfiAddr = _w2Wallet!.ffiAddress();
final dstAddr = [destinationAddress];
@@ -1027,7 +1440,8 @@ class WalletModel with ChangeNotifier {
);
});
- log(LogLevel.info, 'PendingTransaction_commit result: $commitResult');
+ final status = tx.status();
+ log(LogLevel.info, 'PendingTransaction_commit result: $commitResult, status: $status');
final errorMsg = tx.errorString();
@@ -1036,31 +1450,57 @@ class WalletModel with ChangeNotifier {
throw FormatException(errorMsg);
}
+ // The broadcast can fail without setting errorString; gate success on the
+ // commit result and status too so we don't report a send that didn't happen.
+ if (!commitResult || status != 0) {
+ log(LogLevel.error, 'PendingTransaction_commit failed: result=$commitResult status=$status');
+ throw FormatException('Failed to broadcast transaction.');
+ }
+
await refresh();
+ // Persist so the just-sent unconfirmed tx (held in the wallet's cache, not
+ // on-chain yet) survives an app restart before it's mined.
+ await store();
+ // Reload balances so the spent/locked amount is reflected right away.
+ await Future.wait([loadTotalBalance(), loadUnlockedBalance()]);
await loadTxHistory();
+ notifyListeners();
}
+ /// Resolves an OpenAlias domain to a Monero address with end-to-end DNSSEC
+ /// validation, over Tor (via the openalias_ffi Rust/hickory resolver). Returns
+ /// the validated address, or '' on any failure (incl. Tor unavailable) so the
+ /// send flow surfaces a resolve error. OpenAlias never leaves Tor.
Future<String> resolveOpenAlias(String address) async {
- final dnssecValid = true;
+ log(LogLevel.info, 'Resolving OpenAlias over Tor: $address');
- log(LogLevel.info, 'Calling WalletManager_resolveOpenAlias with parameters:');
- log(LogLevel.info, ' address: $address');
- log(LogLevel.info, ' dnssecValid: $dnssecValid');
+ final proxy = await TorSettingsService.sharedInstance.getProxy();
+ if (proxy == null) {
+ log(LogLevel.warn, 'OpenAlias: Tor proxy unavailable; cannot resolve.');
+ return '';
+ }
- final w2WalletManagerFfiAddr = _w2WalletManager.ffiAddress();
+ try {
+ final resolved = await OpenAliasFfi.resolve(
+ domain: address,
+ asset: 'xmr',
+ socksPort: proxy.port,
+ );
- final resolvedAddress = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.WalletManager_resolveOpenAlias(
- Pointer.fromAddress(w2WalletManagerFfiAddr),
- address: address,
- dnssecValid: dnssecValid,
- ),
- );
+ if (resolved == null || resolved.isEmpty) return '';
- log(LogLevel.info, 'WalletManager_resolveOpenAlias result: $resolvedAddress');
+ // Only use it if it's a valid Monero address.
+ if (_w2Wallet != null && !_w2Wallet!.addressValid(resolved, 0)) {
+ log(LogLevel.warn, 'OpenAlias: resolved address failed validation.');
+ return '';
+ }
- return resolvedAddress;
+ log(LogLevel.info, 'OpenAlias resolved successfully.');
+ return resolved;
+ } catch (e) {
+ log(LogLevel.warn, 'OpenAlias resolution failed: $e');
+ return '';
+ }
}
List<TxDetails> _getTxHistory() {
diff --git a/lib/periodic_tasks.dart b/lib/periodic_tasks.dart
index 8026e7a..11a8a39 100644
--- a/lib/periodic_tasks.dart
+++ b/lib/periodic_tasks.dart
@@ -12,6 +12,13 @@ class PeriodicTasks {
static const txNotifier = 'txNotifier';
}
+/// Max wall-clock we let a background run scan before returning, leaving margin
+/// under Android's ~10-minute WorkManager budget to persist + notify.
+const _backgroundSyncBudget = Duration(minutes: 9);
+
+/// WorkManager's minimum periodic interval.
+const _minSyncIntervalMinutes = 15;
+
Future<bool> runTxNotifier() async {
final wallet = WalletModel();
@@ -19,61 +26,81 @@ Future<bool> runTxNotifier() async {
return true;
}
- await wallet.openExisting();
+ // Load the connection first so the correct-mode wallet file is opened.
await wallet.loadPersistedConnection();
+ await wallet.openExisting();
+
+ final backgroundSync =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
+ false;
+
+ // A full-node scan is heavy and only runs when Background Sync is on; an LWS
+ // wallet always syncs (server-side, cheap).
+ if (wallet.connectionType == 'node' && !backgroundSync) {
+ return true;
+ }
if (wallet.usingTor) {
await TorService.sharedInstance.start();
await TorService.sharedInstance.waitUntilConnected().timeout(
Duration(minutes: 2),
- onTimeout: () => log(LogLevel.warn, '[TX Notifier] Tor connection timed out'),
+ onTimeout: () => log(LogLevel.warn, '[Background sync] Tor connection timed out'),
);
}
- await wallet.refresh();
- await wallet.connectToDaemon();
- await wallet.loadTxHistory(persistCount: false);
-
- int itersBeforeSynced = 0;
+ if (wallet.connectionAddress.isEmpty) {
+ return true;
+ }
- while (true) {
- if (wallet.isConnected && wallet.isSynced) {
- break;
- }
+ // Kick the daemon connection (starts the scan thread + the wallet's timers).
+ try {
+ await wallet.connectToDaemon();
+ } catch (e) {
+ log(LogLevel.warn, '[Background sync] connect failed: $e');
+ }
- await Future.delayed(Duration(seconds: 2));
+ // Keep the isolate alive so the on-device scan keeps advancing, up to the OS
+ // budget. The wallet's own timers drive the refresh + checkpoint; we just
+ // wait (and bail early once it's synced).
+ final deadline = DateTime.now().add(_backgroundSyncBudget);
+ while (DateTime.now().isBefore(deadline)) {
+ if (wallet.isConnected && wallet.isSynced) break;
+ await Future.delayed(const Duration(seconds: 5));
+ }
- itersBeforeSynced++;
+ final notify =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
- if (itersBeforeSynced == 20) {
- log(LogLevel.warn, '[TX Notifier] Wallet connection timed out');
- return false;
- }
+ try {
+ await wallet.loadTxHistory(persistCount: false);
+ } catch (e) {
+ log(LogLevel.warn, '[Background sync] loadTxHistory failed: $e');
}
- final persistedTxCount = await wallet.getPersistedTxHistoryCount();
- final currentTxCount = wallet.txHistory.length;
- final countOfNewTxs = currentTxCount - persistedTxCount;
+ await _notifyNewTxs(wallet, notify: notify);
- if (countOfNewTxs > 0 && currentTxCount != 0) {
- log(LogLevel.info, '[TX Notifier] Found new transactions');
+ return true;
+}
- for (int i = 0; i < countOfNewTxs; i++) {
- final tx = wallet.txHistory[i];
- if (tx.direction == consts.txDirectionIncoming) {
- log(LogLevel.info, '[TX Notifier] Notifying transaction $i');
- NotificationService().showIncomingTxNotification(tx.amount);
- } else {
- log(LogLevel.info, '[TX Notifier] Not notifying outgoing transaction');
+Future<void> _notifyNewTxs(WalletModel wallet, {required bool notify}) async {
+ final persistedCount = await wallet.getPersistedTxHistoryCount();
+ final currentCount = wallet.txHistory.length;
+ final countOfNewTxs = currentCount - persistedCount;
+
+ if (countOfNewTxs > 0 && currentCount != 0) {
+ // Only surface a notification when notifications are on; either way advance
+ // the baseline count so we don't re-notify (or flood) next run.
+ if (notify) {
+ for (int i = 0; i < countOfNewTxs; i++) {
+ final tx = wallet.txHistory[i];
+ if (tx.direction == consts.txDirectionIncoming) {
+ NotificationService().showIncomingTxNotification(tx.amount);
+ }
}
}
await wallet.persistTxHistoryCount();
- } else {
- log(LogLevel.info, '[TX Notifier] No new transactions found');
}
-
- return true;
}
@pragma('vm:entry-point')
@@ -88,41 +115,48 @@ void _callbackDispatcher() {
});
}
-Future<void> registerTxNotifierTaskIfAllowed() async {
- final notificationsEnabled =
+/// (Re)registers the background task if background sync or notifications is on,
+/// otherwise cancels it. Notifications need the task to run to detect new txs,
+/// so either flag keeps it scheduled; the interval comes from the background-
+/// sync setting. Android only.
+Future<void> applyBackgroundTaskRegistration() async {
+ if (!Platform.isAndroid) return;
+
+ final backgroundSync =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
+ false;
+ final notifications =
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
- if (!notificationsEnabled) {
- return;
- }
+ await Workmanager().cancelByUniqueName(PeriodicTasks.txNotifier);
+ if (!backgroundSync && !notifications) return;
- final notificationsAreAllowed = await NotificationService().promptPermission();
+ final minutes =
+ await SharedPreferencesService.get<int>(
+ SharedPreferencesKeys.backgroundSyncIntervalMinutes,
+ ) ??
+ _minSyncIntervalMinutes;
- if (!notificationsAreAllowed) {
- await SharedPreferencesService.set<bool>(SharedPreferencesKeys.notificationsEnabled, false);
- return;
- }
+ // A node sync is heavy, so gate it on charging + WiFi; notifications are light.
+ final constraints = backgroundSync
+ ? Constraints(networkType: NetworkType.unmetered, requiresCharging: true)
+ : Constraints(networkType: NetworkType.connected, requiresBatteryNotLow: true);
- // Cancelling will replace an existing task, so we can prevent code from an
- // old release from remaining forever.
- await Workmanager().cancelByUniqueName(PeriodicTasks.txNotifier);
await Workmanager().registerPeriodicTask(
PeriodicTasks.txNotifier,
- "New transactions check",
- frequency: Duration(minutes: 15),
- constraints: Constraints(networkType: NetworkType.connected, requiresBatteryNotLow: true),
+ "Background sync",
+ frequency: Duration(
+ minutes: minutes < _minSyncIntervalMinutes ? _minSyncIntervalMinutes : minutes,
+ ),
+ constraints: constraints,
);
}
-Future<void> unregisterPeriodicTasks() async {
- await Workmanager().cancelByUniqueName(PeriodicTasks.txNotifier);
-}
-
Future<void> registerPeriodicTasks() async {
if (!Platform.isAndroid) {
return;
}
Workmanager().initialize(_callbackDispatcher);
- await registerTxNotifierTaskIfAllowed();
+ await applyBackgroundTaskRegistration();
}
diff --git a/lib/screens/confirm_send.dart b/lib/screens/confirm_send.dart
index 3a42061..f6be2b3 100644
--- a/lib/screens/confirm_send.dart
+++ b/lib/screens/confirm_send.dart
@@ -4,6 +4,7 @@ import 'package:monero/src/monero.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:skylight_wallet/util/formatting.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:provider/provider.dart';
@@ -126,7 +127,6 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
- final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final fiatRate = context.watch<FiatRateModel>();
final fiatSymbol = fiatRate.fiatCode == 'EUR' ? '€' : '\$';
final amountFiat = fiatRate.rate is double ? _amount * fiatRate.rate! : null;
@@ -240,21 +240,11 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
),
],
),
- FilledButton.icon(
+ LoadingButton(
+ isLoading: _isLoading,
onPressed: _confirmSend,
- icon: !_isLoading
- ? Icon(Icons.arrow_outward_rounded)
- : SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: isDarkTheme
- ? Theme.of(context).colorScheme.onPrimary
- : Colors.white,
- ),
- ),
- label: Text(i18n.sendSendButton),
+ icon: Icons.arrow_outward_rounded,
+ label: i18n.sendSendButton,
),
],
),
diff --git a/lib/screens/connection_setup.dart b/lib/screens/connection_setup.dart
index 2b066fa..53b9953 100644
--- a/lib/screens/connection_setup.dart
+++ b/lib/screens/connection_setup.dart
@@ -16,34 +16,42 @@ class ConnectionSetupScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text('Skylight Monero Wallet')),
- body: Center(
- child: Container(
- constraints: BoxConstraints(maxWidth: 500),
- child: Padding(
- padding: EdgeInsets.all(20),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- spacing: 20,
- children: [
- Column(
- spacing: 10,
- children: [
- Text(
- i18n.lwsSetupTitle,
- style: Theme.of(context).textTheme.headlineMedium,
- ),
- Text(
- i18n.lwsSetupDescription,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodyLarge,
- ),
- ],
+ body: LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: Center(
+ child: Container(
+ constraints: BoxConstraints(maxWidth: 500),
+ child: Padding(
+ padding: EdgeInsets.all(20),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ spacing: 20,
+ children: [
+ Column(
+ spacing: 10,
+ children: [
+ Text(
+ i18n.lwsSetupTitle,
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ Text(
+ i18n.lwsSetupDescription,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ConnectionSettingsForm(
+ saveButtonLabel: i18n.lwsSetupContinueButton,
+ onSaved: onSaved,
+ ),
+ ],
+ ),
),
- ConnectionSettingsForm(
- saveButtonLabel: i18n.lwsSetupContinueButton,
- onSaved: onSaved,
- ),
- ],
+ ),
),
),
),
diff --git a/lib/screens/create_wallet_password.dart b/lib/screens/create_wallet_password.dart
index 0651679..93ca9c5 100644
--- a/lib/screens/create_wallet_password.dart
+++ b/lib/screens/create_wallet_password.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:provider/provider.dart';
class CreateWalletPasswordScreen extends StatefulWidget {
@@ -80,7 +81,6 @@ class _CreateWalletPasswordScreenState extends State<CreateWalletPasswordScreen>
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
- final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(title: Text('Skylight Monero Wallet')),
@@ -153,20 +153,10 @@ class _CreateWalletPasswordScreenState extends State<CreateWalletPasswordScreen>
enabled: !_isLoading,
onFieldSubmitted: (_) => _savePassword(),
),
- FilledButton(
- onPressed: _isLoading ? null : _savePassword,
- child: _isLoading
- ? SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: isDarkTheme
- ? Theme.of(context).colorScheme.onPrimary
- : Colors.white,
- ),
- )
- : Text(i18n.continueText),
+ LoadingButton(
+ isLoading: _isLoading,
+ onPressed: _savePassword,
+ label: i18n.continueText,
),
],
),
diff --git a/lib/screens/generate_seed.dart b/lib/screens/generate_seed.dart
index 958bfb2..32c14d7 100644
--- a/lib/screens/generate_seed.dart
+++ b/lib/screens/generate_seed.dart
@@ -6,6 +6,7 @@ import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
import 'package:skylight_wallet/screens/create_wallet.dart';
import 'package:skylight_wallet/util/logging.dart';
+import 'package:skylight_wallet/util/secure_screen.dart';
import 'package:provider/provider.dart';
class GenerateSeedScreen extends StatefulWidget {
@@ -15,7 +16,7 @@ class GenerateSeedScreen extends StatefulWidget {
State<GenerateSeedScreen> createState() => _GenerateSeedScreenState();
}
-class _GenerateSeedScreenState extends State<GenerateSeedScreen> {
+class _GenerateSeedScreenState extends State<GenerateSeedScreen> with SecureScreenMixin {
List<String> _seed = [];
int _restoreHeight = 0;
diff --git a/lib/screens/lws_keys.dart b/lib/screens/lws_keys.dart
index 657611b..9ea45c2 100644
--- a/lib/screens/lws_keys.dart
+++ b/lib/screens/lws_keys.dart
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/util/secure_clipboard.dart';
+import 'package:skylight_wallet/util/secure_screen.dart';
class LwsKeysScreen extends StatefulWidget {
const LwsKeysScreen({super.key});
@@ -12,7 +13,7 @@ class LwsKeysScreen extends StatefulWidget {
State<LwsKeysScreen> createState() => _LwsKeysScreenState();
}
-class _LwsKeysScreenState extends State<LwsKeysScreen> {
+class _LwsKeysScreenState extends State<LwsKeysScreen> with SecureScreenMixin {
var _restoreHeight = 0;
@override
@@ -61,9 +62,7 @@ class _LwsKeysScreenState extends State<LwsKeysScreen> {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () => Clipboard.setData(
- ClipboardData(text: primaryAddress),
- ),
+ onPressed: () => SecureClipboard.copy(primaryAddress),
icon: Icon(Icons.copy),
),
),
@@ -90,9 +89,7 @@ class _LwsKeysScreenState extends State<LwsKeysScreen> {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () => Clipboard.setData(
- ClipboardData(text: secretViewKey),
- ),
+ onPressed: () => SecureClipboard.copy(secretViewKey),
icon: Icon(Icons.copy),
),
),
@@ -119,9 +116,7 @@ class _LwsKeysScreenState extends State<LwsKeysScreen> {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () => Clipboard.setData(
- ClipboardData(text: _restoreHeight.toString()),
- ),
+ onPressed: () => SecureClipboard.copy(_restoreHeight.toString()),
icon: Icon(Icons.copy),
),
),
diff --git a/lib/screens/restore_wallet.dart b/lib/screens/restore_wallet.dart
index 1ee981e..2e3ead5 100644
--- a/lib/screens/restore_wallet.dart
+++ b/lib/screens/restore_wallet.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -6,6 +8,9 @@ import 'package:polyseed/polyseed.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/util/get_height_by_date.dart';
+import 'package:skylight_wallet/util/restore_qr.dart';
+import 'package:skylight_wallet/util/secure_screen.dart';
+import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
@@ -16,7 +21,7 @@ class RestoreWalletScreen extends StatefulWidget {
State<RestoreWalletScreen> createState() => _RestoreWalletScreenState();
}
-class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
+class _RestoreWalletScreenState extends State<RestoreWalletScreen> with SecureScreenMixin {
final _mnemonicController = TextEditingController();
final _restoreHeightController = TextEditingController();
bool _isPolyseed = false;
@@ -31,6 +36,28 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
super.dispose();
}
+ Future<void> _scanQrCode() async {
+ final result = await Navigator.pushNamed(context, '/scan_qr');
+ if (result is! String) return;
+
+ final parsed = parseRestoreQr(result);
+ if (parsed == null) return;
+
+ setState(() {
+ _mnemonicController.text = parsed.seed;
+ _mnemonicError = null;
+
+ if (parsed.restoreHeight != null) {
+ _restoreHeightController.text = parsed.restoreHeight.toString();
+ }
+ });
+
+ // No explicit height in the QR — derive it from a polyseed if possible.
+ if (parsed.restoreHeight == null) {
+ _calculatePolyseedHeight();
+ }
+ }
+
Future<void> _restore() async {
if (_isLoading) return;
@@ -159,7 +186,6 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
- final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(title: Text('Skylight Monero Wallet')),
@@ -191,6 +217,12 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
labelText: i18n.restoreWalletSeedLabel,
errorText: _mnemonicError,
border: OutlineInputBorder(),
+ suffixIcon: (Platform.isAndroid || Platform.isIOS)
+ ? IconButton(
+ icon: Icon(Icons.qr_code),
+ onPressed: _scanQrCode,
+ )
+ : null,
),
),
),
@@ -213,21 +245,10 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(i18n.cancel)),
- FilledButton.icon(
+ LoadingButton(
+ isLoading: _isLoading,
onPressed: _restore,
- label: Text(i18n.restoreWalletRestoreButton),
- icon: _isLoading
- ? SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: isDarkTheme
- ? Theme.of(context).colorScheme.onPrimary
- : Colors.white,
- ),
- )
- : null,
+ label: i18n.restoreWalletRestoreButton,
),
],
),
diff --git a/lib/screens/secret_keys.dart b/lib/screens/secret_keys.dart
index 46e6996..574c7c9 100644
--- a/lib/screens/secret_keys.dart
+++ b/lib/screens/secret_keys.dart
@@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/util/secure_clipboard.dart';
+import 'package:skylight_wallet/util/secure_screen.dart';
-class SecretKeysScreen extends StatelessWidget {
+class SecretKeysScreen extends StatefulWidget {
const SecretKeysScreen({super.key});
+ @override
+ State<SecretKeysScreen> createState() => _SecretKeysScreenState();
+}
+
+class _SecretKeysScreenState extends State<SecretKeysScreen> with SecureScreenMixin {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
@@ -36,8 +42,7 @@ class SecretKeysScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () =>
- Clipboard.setData(ClipboardData(text: legacySeed)),
+ onPressed: () => SecureClipboard.copy(legacySeed),
icon: Icon(Icons.copy),
),
),
@@ -51,8 +56,7 @@ class SecretKeysScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () =>
- Clipboard.setData(ClipboardData(text: polyseed)),
+ onPressed: () => SecureClipboard.copy(polyseed),
icon: Icon(Icons.copy),
),
),
@@ -66,8 +70,7 @@ class SecretKeysScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () =>
- Clipboard.setData(ClipboardData(text: publicSpendKey)),
+ onPressed: () => SecureClipboard.copy(publicSpendKey),
icon: Icon(Icons.copy),
),
),
@@ -81,8 +84,7 @@ class SecretKeysScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () =>
- Clipboard.setData(ClipboardData(text: secretSpendKey)),
+ onPressed: () => SecureClipboard.copy(secretSpendKey),
icon: Icon(Icons.copy),
),
),
@@ -96,8 +98,7 @@ class SecretKeysScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
),
suffixIcon: IconButton(
- onPressed: () =>
- Clipboard.setData(ClipboardData(text: publicViewKey)),
+ onPressed: () => SecureClipboard.copy(publicViewKey),
icon: Icon(Icons.copy),
),
),
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index 52ab2b6..50342f4 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -12,6 +12,7 @@ import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/screens/confirm_send.dart';
import 'package:skylight_wallet/util/formatting.dart';
import 'package:skylight_wallet/widgets/fiat_amount.dart';
+import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:skylight_wallet/widgets/monero_amount.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
import 'package:skylight_wallet/models/contact_model.dart';
@@ -47,6 +48,17 @@ class _SendScreenState extends State<SendScreen> {
String _destinationAddressError = '';
String _amountError = '';
+ bool _formValid = false; // gates the send button
+ int _openAliasResolving = 0; // >0 while OpenAlias resolution is in flight
+ bool _didInit = false; // one-time setup guard for didChangeDependencies
+
+ // Caches the last OpenAlias resolution + dedupes concurrent lookups so
+ // re-validation (amount changes, revalidations) doesn't re-hit Tor.
+ String? _resolveCacheInput;
+ String _resolveCacheOutput = '';
+ Future<String>? _resolveInFlight;
+ String _resolveInFlightInput = '';
+
@override
void dispose() {
_destinationAddressController.removeListener(_onAddressChanged);
@@ -60,9 +72,17 @@ class _SendScreenState extends State<SendScreen> {
void didChangeDependencies() {
super.didChangeDependencies();
+ // Run once. didChangeDependencies fires on every WalletModel notify (via
+ // context.watch), and re-adding listeners / re-resolving OpenAlias here
+ // would loop the (slow, over-Tor) resolver forever.
+ if (_didInit) return;
+ _didInit = true;
+
_loadFormFromArgs();
_destinationAddressController.addListener(_onAddressChanged);
_amountController.addListener(_onAmountChanged);
+ // Validate any prefilled values (contact/QR args) so the button reflects them.
+ _revalidate();
}
void _loadFormFromArgs() {
@@ -150,17 +170,42 @@ class _SendScreenState extends State<SendScreen> {
}
Future<String> _resolveDestinationAddress() async {
- final wallet = Provider.of<WalletModel>(context, listen: false);
final unresolvedDestinationAddress = _destinationAddressController.text;
- String destinationAddress = '';
if (domainRegex.hasMatch(unresolvedDestinationAddress)) {
- destinationAddress = await wallet.resolveOpenAlias(unresolvedDestinationAddress);
- } else {
- destinationAddress = unresolvedDestinationAddress;
+ return _resolveDomain(unresolvedDestinationAddress);
+ }
+ return unresolvedDestinationAddress;
+ }
+
+ /// Resolves an OpenAlias [domain] once, caching the result and joining any
+ /// in-flight lookup of the same domain so amount changes / revalidations
+ /// don't re-hit the (slow, over-Tor) resolver. Drives `_openAliasResolving`.
+ Future<String> _resolveDomain(String domain) async {
+ if (domain == _resolveCacheInput) return _resolveCacheOutput;
+ if (_resolveInFlight != null && domain == _resolveInFlightInput) {
+ return _resolveInFlight!;
}
- return destinationAddress;
+ final wallet = Provider.of<WalletModel>(context, listen: false);
+ if (mounted) setState(() => _openAliasResolving++);
+
+ final future = wallet.resolveOpenAlias(domain);
+ _resolveInFlight = future;
+ _resolveInFlightInput = domain;
+
+ try {
+ final resolved = await future;
+ _resolveCacheInput = domain;
+ _resolveCacheOutput = resolved;
+ return resolved;
+ } finally {
+ if (identical(_resolveInFlight, future)) {
+ _resolveInFlight = null;
+ _resolveInFlightInput = '';
+ }
+ if (mounted) setState(() => _openAliasResolving--);
+ }
}
Future<bool> _validateForm({bool setErrors = true}) async {
@@ -176,8 +221,9 @@ class _SendScreenState extends State<SendScreen> {
final i18n = AppLocalizations.of(context)!;
if (domainRegex.hasMatch(unresolvedDestinationAddress)) {
- // check for openalias
- destinationAddress = await wallet.resolveOpenAlias(unresolvedDestinationAddress);
+ // OpenAlias: cached + deduped; the counter gates the send button while
+ // a lookup is in flight.
+ destinationAddress = await _resolveDomain(unresolvedDestinationAddress);
if (destinationAddress == '') {
if (setErrors) {
@@ -214,8 +260,9 @@ class _SendScreenState extends State<SendScreen> {
Future<MoneroPendingTransaction?> _createTxForPriority(
String destinationAddress,
double amount,
- int priority,
- ) async {
+ int priority, {
+ String? amountText,
+ }) async {
final wallet = Provider.of<WalletModel>(context, listen: false);
const maxRetries = 10;
@@ -226,6 +273,7 @@ class _SendScreenState extends State<SendScreen> {
amount,
_isSweepAll,
priority: priority,
+ amountText: amountText,
);
return tx;
} catch (error) {
@@ -263,13 +311,14 @@ class _SendScreenState extends State<SendScreen> {
});
final destinationAddress = await _resolveDestinationAddress();
- final amount = double.parse(_amountController.text);
+ final amountText = _amountController.text;
+ final amount = double.parse(amountText);
try {
final txs = await Future.wait([
- _createTxForPriority(destinationAddress, amount, 1),
- _createTxForPriority(destinationAddress, amount, 2),
- _createTxForPriority(destinationAddress, amount, 3),
+ _createTxForPriority(destinationAddress, amount, 1, amountText: amountText),
+ _createTxForPriority(destinationAddress, amount, 2, amountText: amountText),
+ _createTxForPriority(destinationAddress, amount, 3, amountText: amountText),
]);
// Only update state if this is still the latest request
@@ -354,6 +403,7 @@ class _SendScreenState extends State<SendScreen> {
amount,
_isSweepAll,
priority: _selectedPriority + 1,
+ amountText: _amountController.text,
);
}
@@ -472,10 +522,16 @@ class _SendScreenState extends State<SendScreen> {
);
}
+ /// Re-runs validation (without surfacing errors) and updates the send-button
+ /// gate. Kicks off fee calculation when the form is valid.
+ Future<void> _revalidate() async {
+ final valid = await _validateForm(setErrors: false);
+ if (mounted) setState(() => _formValid = valid);
+ if (valid) _calculateFees();
+ }
+
Future<void> _onAddressChanged() async {
- if (await _validateForm(setErrors: false)) {
- _calculateFees();
- }
+ await _revalidate();
}
Future<void> _onAmountChanged() async {
@@ -494,16 +550,13 @@ class _SendScreenState extends State<SendScreen> {
});
}
- if (await _validateForm(setErrors: false)) {
- _calculateFees();
- }
+ await _revalidate();
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
final wallet = context.watch<WalletModel>();
- final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(title: Text(i18n.sendTitle)),
@@ -514,204 +567,216 @@ class _SendScreenState extends State<SendScreen> {
constraints: BoxConstraints(maxWidth: 440),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
- spacing: 20,
+ spacing: 28,
children: [
- if (_selectedContact == null)
- TextField(
- controller: _destinationAddressController,
- maxLines: null,
- decoration: InputDecoration(
- labelText: i18n.address,
- border: OutlineInputBorder(),
- errorText: _destinationAddressError != '' ? _destinationAddressError : null,
- suffixIcon: Container(
- margin: EdgeInsets.only(right: 14),
- child: Row(
- spacing: 16,
- mainAxisSize: MainAxisSize.min,
- children: [
- GestureDetector(
- onTap: _pasteAddressFromClipboard,
- child: Icon(Icons.paste),
- ),
- if (Platform.isAndroid || Platform.isIOS)
- GestureDetector(onTap: _scanQrCode, child: Icon(Icons.qr_code)),
- GestureDetector(
- onTap: _showContactPicker,
- child: Icon(Icons.contacts_outlined),
+ Column(
+ spacing: 16,
+ children: [
+ if (_selectedContact == null)
+ TextField(
+ controller: _destinationAddressController,
+ maxLines: null,
+ decoration: InputDecoration(
+ labelText: i18n.address,
+ border: OutlineInputBorder(),
+ errorText: _destinationAddressError != ''
+ ? _destinationAddressError
+ : null,
+ suffixIconColor: Theme.of(context).colorScheme.onSurfaceVariant,
+ suffixIcon: Container(
+ margin: EdgeInsets.only(right: 14),
+ child: Row(
+ spacing: 16,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (_openAliasResolving > 0)
+ SizedBox(
+ width: 12,
+ height: 12,
+ child: CircularProgressIndicator(strokeWidth: 1.8),
+ ),
+ GestureDetector(
+ onTap: _pasteAddressFromClipboard,
+ child: Icon(Icons.paste),
+ ),
+ if (Platform.isAndroid || Platform.isIOS)
+ GestureDetector(onTap: _scanQrCode, child: Icon(Icons.qr_code)),
+ ],
),
- ],
+ ),
),
),
- ),
- ),
- if (_selectedContact != null)
- Container(
- width: double.infinity,
- padding: EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.primaryContainer,
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
- ),
- ),
- child: Column(
- children: [
- Row(
+ if (_selectedContact != null)
+ Container(
+ width: double.infinity,
+ padding: EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
+ ),
+ ),
+ child: Column(
children: [
- CircleAvatar(
- radius: 16,
- backgroundColor: Theme.of(context).colorScheme.primary,
- child: Text(
- _selectedContact!.name.isNotEmpty
- ? _selectedContact!.name[0].toUpperCase()
- : '?',
- style: TextStyle(
- color: Theme.of(context).colorScheme.onPrimary,
- fontWeight: FontWeight.bold,
- fontSize: 14,
- ),
- ),
- ),
- SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- _selectedContact!.name,
- style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16),
- ),
- Text(
- i18n.sendSelectedContact,
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 16,
+ backgroundColor: Theme.of(context).colorScheme.primary,
+ child: Text(
+ _selectedContact!.name.isNotEmpty
+ ? _selectedContact!.name[0].toUpperCase()
+ : '?',
style: TextStyle(
- fontSize: 12,
- color: Theme.of(context).colorScheme.onSurfaceVariant,
+ color: Theme.of(context).colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
),
),
- ],
- ),
- ),
- IconButton(
- onPressed: _clearSelectedContact,
- icon: Icon(Icons.close, size: 20),
- tooltip: i18n.sendClearSelectedContact,
+ ),
+ SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _selectedContact!.name,
+ style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16),
+ ),
+ Text(
+ i18n.sendSelectedContact,
+ style: TextStyle(
+ fontSize: 12,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ onPressed: _clearSelectedContact,
+ icon: Icon(Icons.close, size: 20),
+ tooltip: i18n.sendClearSelectedContact,
+ ),
+ ],
),
],
),
- ],
- ),
- ),
- TextField(
- controller: _amountController,
- keyboardType: TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+(\.\d*)?'))],
- decoration: InputDecoration(
- labelText: i18n.amount,
- border: OutlineInputBorder(),
- errorText: _amountError != '' ? _amountError : null,
- suffixIcon: TextButton(onPressed: _setBalanceAsSendAmount, child: Text('Max')),
- ),
- ),
- GestureDetector(
- onTap: _showPrioritySelector,
- child: Container(
- height: 40,
- padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- children: [
- Icon(Icons.speed, size: 18),
- SizedBox(width: 8),
- Text(
- _selectedPriority == 0
- ? i18n.sendPriorityLow
- : _selectedPriority == 1
- ? i18n.sendPriorityNormal
- : i18n.sendPriorityHigh,
- style: TextStyle(fontSize: 14),
+ ),
+ TextField(
+ controller: _amountController,
+ keyboardType: TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+(\.\d*)?'))],
+ decoration: InputDecoration(
+ labelText: i18n.amount,
+ border: OutlineInputBorder(),
+ errorText: _amountError != '' ? _amountError : null,
+ suffixIcon: TextButton(
+ onPressed: _setBalanceAsSendAmount,
+ child: Text('Max'),
),
- Text(
- ' ${i18n.sendPriorityLabel}',
- style: TextStyle(
- fontSize: 14,
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- ),
+ ),
+ ),
+ GestureDetector(
+ onTap: _showPrioritySelector,
+ child: Container(
+ height: 40,
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
),
- Spacer(),
- if (_isLoadingFees)
- SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- else if (_fees != null && _fees!.length > _selectedPriority)
- () {
- final selectedTx = _fees![_selectedPriority];
- if (selectedTx != null) {
- return Row(
- spacing: 8,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- spacing: 4,
+ child: Row(
+ children: [
+ Icon(Icons.speed, size: 18),
+ SizedBox(width: 8),
+ Text(
+ _selectedPriority == 0
+ ? i18n.sendPriorityLow
+ : _selectedPriority == 1
+ ? i18n.sendPriorityNormal
+ : i18n.sendPriorityHigh,
+ style: TextStyle(fontSize: 14),
+ ),
+ Text(
+ ' ${i18n.sendPriorityLabel}',
+ style: TextStyle(
+ fontSize: 14,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Spacer(),
+ if (_isLoadingFees)
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ else if (_fees != null && _fees!.length > _selectedPriority)
+ () {
+ final selectedTx = _fees![_selectedPriority];
+ if (selectedTx != null) {
+ return Row(
+ spacing: 8,
children: [
- SvgPicture.asset(
- 'assets/icons/monero.svg',
- width: 14,
- height: 14,
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ spacing: 4,
+ children: [
+ SvgPicture.asset(
+ 'assets/icons/monero.svg',
+ width: 14,
+ height: 14,
+ ),
+ MoneroAmount(
+ amount: doubleAmountFromInt(selectedTx.fee()),
+ maxFontSize: 14,
+ ),
+ ],
),
- MoneroAmount(
- amount: doubleAmountFromInt(selectedTx.fee()),
- maxFontSize: 14,
+ Icon(Icons.arrow_drop_down),
+ ],
+ );
+ } else {
+ return Row(
+ spacing: 8,
+ children: [
+ Text(
+ i18n.sendInsufficientBalanceError,
+ style: TextStyle(color: Colors.red, fontSize: 14),
),
+ Icon(Icons.arrow_drop_down),
],
- ),
- Icon(Icons.arrow_drop_down),
- ],
- );
- } else {
- return Row(
- spacing: 8,
- children: [
- Text(
- i18n.sendInsufficientBalanceError,
- style: TextStyle(color: Colors.red, fontSize: 14),
- ),
- Icon(Icons.arrow_drop_down),
- ],
- );
- }
- }()
- else
- Icon(Icons.arrow_drop_down),
- ],
+ );
+ }
+ }()
+ else
+ Icon(Icons.arrow_drop_down),
+ ],
+ ),
+ ),
),
- ),
- ),
- Row(
- children: [
- Spacer(),
- GestureDetector(
- onTap: _setBalanceAsSendAmount,
- child: Row(
- spacing: 6,
- children: [
- Text(
- '${i18n.sendBalanceLabel}:',
- style: TextStyle(
- fontSize: 14,
- color: Theme.of(context).colorScheme.onSurfaceVariant,
- ),
+ Row(
+ children: [
+ if (_selectedContact == null)
+ TextButton.icon(
+ onPressed: _showContactPicker,
+ icon: Icon(Icons.contacts_outlined, size: 18),
+ label: Text(i18n.sendContactsButton),
),
- SvgPicture.asset('assets/icons/monero.svg', width: 18, height: 18),
- MoneroAmount(amount: wallet.unlockedBalance ?? 0, maxFontSize: 18),
- ],
- ),
+ Spacer(),
+ GestureDetector(
+ onTap: _setBalanceAsSendAmount,
+ child: Row(
+ spacing: 6,
+ children: [
+ SvgPicture.asset('assets/icons/monero.svg', width: 18, height: 18),
+ MoneroAmount(amount: wallet.unlockedBalance ?? 0, maxFontSize: 18),
+ ],
+ ),
+ ),
+ ],
),
],
),
@@ -720,21 +785,11 @@ class _SendScreenState extends State<SendScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(i18n.cancel)),
- FilledButton.icon(
- onPressed: _send,
- icon: !_isLoading
- ? Icon(Icons.arrow_outward_rounded)
- : SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: isDarkTheme
- ? Theme.of(context).colorScheme.onPrimary
- : Colors.white,
- ),
- ),
- label: Text(i18n.sendSendButton),
+ LoadingButton(
+ isLoading: _isLoading,
+ onPressed: (_formValid && _openAliasResolving == 0) ? _send : null,
+ icon: Icons.arrow_outward_rounded,
+ label: i18n.sendSendButton,
),
],
),
diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart
index 2f1d228..494e601 100644
--- a/lib/screens/settings.dart
+++ b/lib/screens/settings.dart
@@ -77,11 +77,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (isAllowed) {
await SharedPreferencesService.set<bool>(SharedPreferencesKeys.notificationsEnabled, true);
- await registerTxNotifierTaskIfAllowed();
+ await applyBackgroundTaskRegistration();
}
} else {
await SharedPreferencesService.set<bool>(SharedPreferencesKeys.notificationsEnabled, false);
- await unregisterPeriodicTasks();
+ await applyBackgroundTaskRegistration();
}
}
@@ -391,7 +391,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
void onSaved() {
final wallet = Provider.of<WalletModel>(context, listen: false);
- wallet.load();
+ // Rebuilds the wallet if the server kind (LWS↔node) changed, then resyncs.
+ wallet.applyConnectionChange();
final fiatRate = Provider.of<FiatRateModel>(context, listen: false);
fiatRate.startService();
@@ -404,7 +405,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context) => AlertDialog(
constraints: BoxConstraints.tightFor(width: dialogWidth),
insetPadding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0),
- title: Text(i18n.settingsLwsSettingsLabel),
+ title: Text(i18n.settingsConnectionSettingsLabel),
content: ConnectionSettingsForm(
saveButtonLabel: i18n.torSettingsSaveButton,
onSaved: onSaved,
@@ -513,7 +514,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(i18n.settingsLwsSettingsLabel, style: TextStyle(fontSize: 18)),
+ Text(i18n.settingsConnectionSettingsLabel, style: TextStyle(fontSize: 18)),
TextButton.icon(
onPressed: _showConnectionSettingsDialog,
icon: Icon(Icons.dns),
diff --git a/lib/screens/unlock.dart b/lib/screens/unlock.dart
index 6887132..4627ba0 100644
--- a/lib/screens/unlock.dart
+++ b/lib/screens/unlock.dart
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/widgets/loading_button.dart';
class UnlockScreen extends StatefulWidget {
const UnlockScreen({super.key});
@@ -75,8 +76,8 @@ class _UnlockScreenState extends State<UnlockScreen> {
final enteredPassword = _passwordController.text;
final wallet = Provider.of<WalletModel>(context, listen: false);
- await wallet.openExisting(desktopWalletPassword: enteredPassword);
await wallet.loadPersistedConnection();
+ await wallet.openExisting(desktopWalletPassword: enteredPassword);
wallet.load();
if (mounted) {
@@ -104,7 +105,6 @@ class _UnlockScreenState extends State<UnlockScreen> {
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
- final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final isDesktop = Platform.isLinux || Platform.isWindows || Platform.isMacOS;
return Scaffold(
@@ -163,20 +163,10 @@ class _UnlockScreenState extends State<UnlockScreen> {
),
onFieldSubmitted: (_) => _unlockWithPassword(),
),
- FilledButton(
- onPressed: _isLoading ? null : _unlockWithPassword,
- child: _isLoading
- ? SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: isDarkTheme
- ? Theme.of(context).colorScheme.onPrimary
- : Colors.white,
- ),
- )
- : Text(i18n.unlockButton),
+ LoadingButton(
+ isLoading: _isLoading,
+ onPressed: _unlockWithPassword,
+ label: i18n.unlockButton,
),
],
),
diff --git a/lib/screens/wallet_home.dart b/lib/screens/wallet_home.dart
index 7dad8e2..67c68df 100644
--- a/lib/screens/wallet_home.dart
+++ b/lib/screens/wallet_home.dart
@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/services/tor_service.dart';
@@ -193,19 +194,32 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
).showSnackBar(SnackBar(content: Text(i18n.sendTransactionSuccessfullySent)));
}
- Widget _buildStatusIcons(WalletModel wallet, StatusIconStatus lwsConnectionIconStatus) {
- var message = lwsConnectionIconStatus == StatusIconStatus.complete
- ? 'Connected to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}'
- : lwsConnectionIconStatus == StatusIconStatus.loading
- ? 'Connecting to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}...'
- : 'Failed to connect to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}';
+ Widget _buildStatusIcons(
+ BuildContext context,
+ WalletModel wallet,
+ StatusIconStatus lwsConnectionIconStatus,
+ ) {
+ final i18n = AppLocalizations.of(context)!;
+ String message;
- if (wallet.connectionUseTor) {
- message += ' via Tor';
- }
+ // While syncing a full node, show the sync progress instead of the address.
+ final blocksRemaining = wallet.syncBlocksRemaining;
+ if (lwsConnectionIconStatus != StatusIconStatus.complete && blocksRemaining != null) {
+ message = i18n.homeBlocksRemaining(NumberFormat.decimalPattern().format(blocksRemaining));
+ } else {
+ message = lwsConnectionIconStatus == StatusIconStatus.complete
+ ? 'Connected to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}'
+ : lwsConnectionIconStatus == StatusIconStatus.loading
+ ? 'Connecting to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}...'
+ : 'Failed to connect to ${wallet.connectionUseSsl ? 'https' : 'http'}://${wallet.connectionAddress}';
+
+ if (wallet.connectionUseTor) {
+ message += ' via Tor';
+ }
- if (wallet.connectionProxyPort != '') {
- message += ' via proxy port ${wallet.connectionProxyPort}';
+ if (wallet.connectionProxyPort != '') {
+ message += ' via proxy port ${wallet.connectionProxyPort}';
+ }
}
return Tooltip(
@@ -465,7 +479,7 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
Positioned(
top: 0,
right: 0,
- child: _buildStatusIcons(wallet, lwsConnectionIconStatus),
+ child: _buildStatusIcons(context, wallet, lwsConnectionIconStatus),
),
Center(
child: _buildBalanceDisplay(
@@ -525,7 +539,7 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
Positioned(
top: 0,
left: 0,
- child: _buildStatusIcons(wallet, lwsConnectionIconStatus),
+ child: _buildStatusIcons(context, wallet, lwsConnectionIconStatus),
),
Column(
children: [
diff --git a/lib/screens/welcome.dart b/lib/screens/welcome.dart
index 952a709..9bad85f 100644
--- a/lib/screens/welcome.dart
+++ b/lib/screens/welcome.dart
@@ -23,8 +23,8 @@ class _WalletHomeScreenState extends State<WelcomeScreen> {
final wallet = Provider.of<WalletModel>(context, listen: false);
if (await wallet.hasExistingWallet()) {
- await wallet.openExisting();
await wallet.loadPersistedConnection();
+ await wallet.openExisting();
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, '/wallet_home', (Route<dynamic> route) => false);
diff --git a/lib/services/foreground_sync_service.dart b/lib/services/foreground_sync_service.dart
new file mode 100644
index 0000000..c08dd3f
--- /dev/null
+++ b/lib/services/foreground_sync_service.dart
@@ -0,0 +1,122 @@
+import 'dart:io';
+
+import 'package:flutter_foreground_task/flutter_foreground_task.dart';
+
+import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/services/shared_preferences_service.dart';
+import 'package:skylight_wallet/services/tor_service.dart';
+import 'package:skylight_wallet/util/logging.dart';
+
+/// Android foreground service that keeps the Monero wallet syncing while the
+/// app is backgrounded — a persistent-notification alternative to the
+/// budget-limited WorkManager task. Dies on force-quit (OS limitation).
+
+const _channelId = 'skylight_background_sync';
+const _channelName = 'Background sync';
+
+/// Entry point run inside the foreground-service isolate. Must be top-level.
+@pragma('vm:entry-point')
+void foregroundSyncCallback() {
+ FlutterForegroundTask.setTaskHandler(_SyncTaskHandler());
+}
+
+class _SyncTaskHandler extends TaskHandler {
+ WalletModel? _wallet;
+
+ @override
+ Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
+ final wallet = WalletModel();
+ _wallet = wallet;
+ try {
+ if (!await wallet.hasExistingWallet()) return;
+ // Load the connection first so the correct-mode wallet file is opened.
+ await wallet.loadPersistedConnection();
+ await wallet.openExisting();
+
+ if (wallet.usingTor) {
+ await TorService.sharedInstance.start();
+ await TorService.sharedInstance.waitUntilConnected().timeout(
+ const Duration(minutes: 2),
+ onTimeout: () => log(LogLevel.warn, '[FG sync] Tor connection timed out'),
+ );
+ }
+
+ if (wallet.connectionAddress.isEmpty) return;
+ // Connect; the wallet's own timers then drive the scan + checkpoints for
+ // as long as this service keeps the isolate alive.
+ try {
+ await wallet.connectToDaemon();
+ } catch (e) {
+ log(LogLevel.warn, '[FG sync] connect failed: $e');
+ }
+ } catch (e) {
+ log(LogLevel.warn, '[FG sync] start failed: $e');
+ }
+ }
+
+ @override
+ void onRepeatEvent(DateTime timestamp) {
+ final wallet = _wallet;
+ final syncing =
+ wallet != null &&
+ wallet.connectionAddress.isNotEmpty &&
+ !(wallet.isConnected && wallet.isSynced);
+ FlutterForegroundTask.updateService(
+ notificationTitle: 'Skylight Wallet',
+ notificationText: syncing ? 'Syncing…' : 'Wallet up to date',
+ );
+ }
+
+ @override
+ Future<void> onDestroy(DateTime timestamp) async {
+ _wallet = null;
+ }
+}
+
+/// Configures the service. Safe to call more than once.
+void initForegroundSync() {
+ FlutterForegroundTask.init(
+ androidNotificationOptions: AndroidNotificationOptions(
+ channelId: _channelId,
+ channelName: _channelName,
+ channelImportance: NotificationChannelImportance.LOW,
+ priority: NotificationPriority.LOW,
+ ),
+ iosNotificationOptions: const IOSNotificationOptions(),
+ foregroundTaskOptions: ForegroundTaskOptions(
+ eventAction: ForegroundTaskEventAction.repeat(30000),
+ autoRunOnBoot: false,
+ allowWakeLock: true,
+ allowWifiLock: true,
+ ),
+ );
+}
+
+Future<void> startForegroundSync() async {
+ if (!Platform.isAndroid) return;
+ initForegroundSync();
+ await FlutterForegroundTask.requestNotificationPermission();
+ if (await FlutterForegroundTask.isRunningService) return;
+ await FlutterForegroundTask.startService(
+ notificationTitle: 'Skylight Wallet',
+ notificationText: 'Syncing…',
+ callback: foregroundSyncCallback,
+ );
+}
+
+Future<void> stopForegroundSync() async {
+ if (!Platform.isAndroid) return;
+ if (await FlutterForegroundTask.isRunningService) {
+ await FlutterForegroundTask.stopService();
+ }
+}
+
+/// Starts the service on launch if the user enabled it, so backgrounding keeps
+/// syncing.
+Future<void> startForegroundSyncIfEnabled() async {
+ if (!Platform.isAndroid) return;
+ final enabled =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.foregroundSyncEnabled) ??
+ false;
+ if (enabled) await startForegroundSync();
+}
diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart
index 9794581..214542d 100644
--- a/lib/services/shared_preferences_service.dart
+++ b/lib/services/shared_preferences_service.dart
@@ -7,12 +7,16 @@ class SharedPreferencesKeys {
static const String fiatRate = 'fiatRate';
static const String theme = 'theme';
static const String notificationsEnabled = 'notificationsEnabled';
+ static const String backgroundSyncEnabled = 'backgroundSyncEnabled';
+ static const String foregroundSyncEnabled = 'foregroundSyncEnabled';
+ static const String backgroundSyncIntervalMinutes = 'backgroundSyncIntervalMinutes';
static const String appLockEnabled = 'appLockEnabled';
static const String verboseLoggingEnabled = 'verboseLoggingEnabled';
static const String connectionAddress = 'connectionAddress';
static const String connectionProxyPort = 'connectionProxyPort';
static const String connectionUseTor = 'connectionUseTor';
static const String connectionUseSsl = 'connectionUseSsl';
+ static const String connectionType = 'connectionType';
static const String serverSupportsSubaddresses = 'serverSupportsSubaddresses';
static const String walletRestoreHeight = 'walletRestoreHeight';
static const String txHistoryCount = 'txHistoryCount';
diff --git a/lib/util/amount_units.dart b/lib/util/amount_units.dart
new file mode 100644
index 0000000..15a3215
--- /dev/null
+++ b/lib/util/amount_units.dart
@@ -0,0 +1,31 @@
+/// Converts a decimal amount string to integer base units (e.g. XMR →
+/// piconero) without going through `double`, which loses precision above 2^53.
+/// Extra fractional digits beyond [decimals] are truncated.
+BigInt decimalToBaseUnits(String amount, int decimals) {
+ final s = amount.trim();
+ if (s.isEmpty) return BigInt.zero;
+
+ final parts = s.split('.');
+ if (parts.length > 2) throw FormatException('Invalid amount: $amount');
+
+ final intPart = parts[0].isEmpty ? '0' : parts[0];
+ var fracPart = parts.length == 2 ? parts[1] : '';
+ fracPart = fracPart.length > decimals
+ ? fracPart.substring(0, decimals)
+ : fracPart.padRight(decimals, '0');
+
+ return BigInt.parse('$intPart$fracPart');
+}
+
+/// Inverse of [decimalToBaseUnits]: renders integer base units as a decimal
+/// string, trimming trailing fractional zeros. Used to fill the amount field
+/// from an exact balance without going through `double`.
+String baseUnitsToDecimalString(BigInt units, int decimals) {
+ if (decimals <= 0) return units.toString();
+ final negative = units.isNegative;
+ final digits = units.abs().toString().padLeft(decimals + 1, '0');
+ final intPart = digits.substring(0, digits.length - decimals);
+ final fracPart = digits.substring(digits.length - decimals).replaceAll(RegExp(r'0+$'), '');
+ final result = fracPart.isEmpty ? intPart : '$intPart.$fracPart';
+ return negative ? '-$result' : result;
+}
diff --git a/lib/util/restore_qr.dart b/lib/util/restore_qr.dart
new file mode 100644
index 0000000..0b53ed8
--- /dev/null
+++ b/lib/util/restore_qr.dart
@@ -0,0 +1,60 @@
+/// Parses restore QR payloads. Accepts the URI format
+/// (`monero:`, `monero-wallet:` or `monero_wallet:` with `seed`/`height` query
+/// params) as well as a bare seed phrase.
+library;
+
+const _moneroRestoreSchemes = {'monero', 'monero-wallet', 'monero_wallet'};
+
+class ParsedRestoreQr {
+ final String seed;
+ final int? restoreHeight;
+
+ const ParsedRestoreQr({required this.seed, this.restoreHeight});
+}
+
+ParsedRestoreQr? parseRestoreQr(String raw) {
+ final code = raw.trim();
+ if (code.isEmpty) return null;
+
+ final colon = code.indexOf(':');
+ if (colon > 0) {
+ final scheme = code.substring(0, colon).toLowerCase();
+ if (_moneroRestoreSchemes.contains(scheme)) {
+ final query = code.substring(colon + 1).replaceAll('?', '&');
+ final params = _parseQuery(query);
+ final seed = (params['seed'] ?? '').trim();
+ if (seed.isEmpty) return null;
+ final heightStr = params['height'] ?? params['restoreHeight'];
+ final height = heightStr != null ? int.tryParse(heightStr.trim()) : null;
+ return ParsedRestoreQr(seed: seed, restoreHeight: height);
+ }
+ }
+
+ // Not a recognized URI — treat the whole payload as a seed phrase.
+ return ParsedRestoreQr(seed: code);
+}
+
+/// Builds a restore payload (`monero_wallet:?seed=...&height=...`)
+/// for encoding into a QR code. The seed is URL-encoded so spaces survive.
+String buildRestoreQr({required String seed, int? height}) {
+ final buffer = StringBuffer('monero_wallet:?seed=${Uri.encodeQueryComponent(seed)}');
+ if (height != null) buffer.write('&height=$height');
+ return buffer.toString();
+}
+
+Map<String, String> _parseQuery(String query) {
+ final result = <String, String>{};
+ for (final pair in query.split('&')) {
+ if (pair.isEmpty) continue;
+ final eq = pair.indexOf('=');
+ if (eq < 0) continue;
+ final key = pair.substring(0, eq);
+ final value = pair.substring(eq + 1);
+ try {
+ result[key] = Uri.decodeQueryComponent(value);
+ } catch (_) {
+ result[key] = value;
+ }
+ }
+ return result;
+}
diff --git a/lib/util/secure_clipboard.dart b/lib/util/secure_clipboard.dart
new file mode 100644
index 0000000..b5f7385
--- /dev/null
+++ b/lib/util/secure_clipboard.dart
@@ -0,0 +1,45 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/services.dart';
+
+import 'package:skylight_wallet/util/logging.dart';
+
+/// Clipboard helper for secrets (seed, view key).
+///
+/// - Android: flags the clip as sensitive (`EXTRA_IS_SENSITIVE`, excluded from
+/// previews/sync) and clears it after [clearAfter] via an in-app timer
+/// (Android has no per-clip expiry API).
+/// - iOS: writes with `localOnly` (no Handoff/Universal Clipboard) and an
+/// `expirationDate`, so the OS clears it even if the app is gone.
+class SecureClipboard {
+ static const _channel = MethodChannel('org.magicgrants.skylight/secure_clipboard');
+
+ static Future<void> copy(String text, {Duration clearAfter = const Duration(seconds: 60)}) async {
+ var nativeHandled = false;
+ if (Platform.isAndroid || Platform.isIOS) {
+ try {
+ await _channel.invokeMethod('copySensitive', {
+ 'text': text,
+ 'clearAfterSeconds': clearAfter.inSeconds,
+ });
+ nativeHandled = true;
+ } catch (e) {
+ log(LogLevel.warn, 'secure clipboard channel failed: $e');
+ }
+ }
+ if (!nativeHandled) {
+ await Clipboard.setData(ClipboardData(text: text));
+ }
+
+ // iOS clears via the native expiration date; elsewhere run an in-app timer.
+ if (!Platform.isIOS) {
+ Future.delayed(clearAfter, () async {
+ final current = await Clipboard.getData(Clipboard.kTextPlain);
+ if (current?.text == text) {
+ await Clipboard.setData(const ClipboardData(text: ''));
+ }
+ });
+ }
+ }
+}
diff --git a/lib/util/secure_screen.dart b/lib/util/secure_screen.dart
new file mode 100644
index 0000000..7923dfa
--- /dev/null
+++ b/lib/util/secure_screen.dart
@@ -0,0 +1,21 @@
+import 'package:flutter/widgets.dart';
+import 'package:screen_protector/screen_protector.dart';
+
+/// Blocks screenshots/screen recording (Android FLAG_SECURE + iOS) and covers
+/// the app with a blur when it is backgrounded (iOS resign active), while the
+/// screen is mounted. Use on screens that display secrets (seed, keys).
+mixin SecureScreenMixin<T extends StatefulWidget> on State<T> {
+ @override
+ void initState() {
+ super.initState();
+ ScreenProtector.preventScreenshotOn();
+ ScreenProtector.protectDataLeakageWithBlur();
+ }
+
+ @override
+ void dispose() {
+ ScreenProtector.preventScreenshotOff();
+ ScreenProtector.protectDataLeakageWithBlurOff();
+ super.dispose();
+ }
+}
diff --git a/lib/util/secure_storage.dart b/lib/util/secure_storage.dart
new file mode 100644
index 0000000..a6e8566
--- /dev/null
+++ b/lib/util/secure_storage.dart
@@ -0,0 +1,11 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+const _androidOptions = AndroidOptions(encryptedSharedPreferences: true);
+const _appleOptions = IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device);
+const _macOptions = MacOsOptions(accessibility: KeychainAccessibility.first_unlock_this_device);
+
+const secureStorage = FlutterSecureStorage(
+ aOptions: _androidOptions,
+ iOptions: _appleOptions,
+ mOptions: _macOptions,
+);
diff --git a/lib/util/wallet_password.dart b/lib/util/wallet_password.dart
index 1beaced..03bbc4b 100644
--- a/lib/util/wallet_password.dart
+++ b/lib/util/wallet_password.dart
@@ -1,8 +1,8 @@
import 'dart:math';
import 'dart:typed_data';
-import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:skylight_wallet/consts.dart';
+import 'package:skylight_wallet/util/secure_storage.dart';
String genWalletPassword() {
final byteLength = 16;
@@ -16,11 +16,9 @@ String genWalletPassword() {
}
Future<void> storeMobileWalletPassword(String password) async {
- final storage = FlutterSecureStorage();
- await storage.write(key: walletPasswordStorageKey, value: password);
+ await secureStorage.write(key: walletPasswordStorageKey, value: password);
}
Future<String?> getMobileWalletPassword() async {
- final storage = FlutterSecureStorage();
- return storage.read(key: walletPasswordStorageKey);
+ return secureStorage.read(key: walletPasswordStorageKey);
}
diff --git a/lib/widgets/connection_settings_form.dart b/lib/widgets/connection_settings_form.dart
index 11d0710..1bb1dc4 100644
--- a/lib/widgets/connection_settings_form.dart
+++ b/lib/widgets/connection_settings_form.dart
@@ -2,9 +2,12 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:skylight_wallet/periodic_tasks.dart';
+import 'package:skylight_wallet/services/foreground_sync_service.dart';
+import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/services/tor_settings_service.dart';
import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/util/socks_http.dart';
import 'package:provider/provider.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
@@ -13,6 +16,8 @@ import 'package:skylight_wallet/services/tor_service.dart';
const isDemoMode = String.fromEnvironment('DEMO_MODE') == 'true';
+const connectionTypeOptions = ['lws', 'node'];
+
final ipAddressRegex = RegExp(
r'(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}(?::\d{1,5})?$',
);
@@ -46,13 +51,86 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
bool _useTor = false;
bool _useSsl = false;
+ String _connectionType = 'lws';
bool _hasTested = false;
bool _connectionTestIsLoading = false;
bool _connectionSuccess = false;
String? _errorMessage;
+ bool _backgroundSyncEnabled = false;
+ bool _foregroundSyncEnabled = false;
TorConnectionStatus _torStatus = TorService.sharedInstance.status;
Timer? _torStatusTimer;
+ bool get _isNode => _connectionType == 'node';
+
+ /// Background/continuous sync only helps a full-node connection (the slow
+ /// on-device scan); LWS syncs server-side. Android-only.
+ bool get _showSyncOptions => Platform.isAndroid && _isNode;
+
+ Future<void> _loadSyncPrefs() async {
+ final bg =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
+ false;
+ final fg =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.foregroundSyncEnabled) ??
+ false;
+ if (mounted) {
+ setState(() {
+ _backgroundSyncEnabled = bg;
+ _foregroundSyncEnabled = fg;
+ });
+ }
+ }
+
+ void _setBackgroundSyncEnabled(bool value) async {
+ setState(() => _backgroundSyncEnabled = value);
+ await SharedPreferencesService.set<bool>(SharedPreferencesKeys.backgroundSyncEnabled, value);
+ await applyBackgroundTaskRegistration();
+ }
+
+ void _setForegroundSyncEnabled(bool value) async {
+ setState(() => _foregroundSyncEnabled = value);
+ await SharedPreferencesService.set<bool>(SharedPreferencesKeys.foregroundSyncEnabled, value);
+ if (value) {
+ await startForegroundSync();
+ } else {
+ await stopForegroundSync();
+ }
+ }
+
+ Future<void> _disableSync() async {
+ await SharedPreferencesService.set<bool>(SharedPreferencesKeys.backgroundSyncEnabled, false);
+ await SharedPreferencesService.set<bool>(SharedPreferencesKeys.foregroundSyncEnabled, false);
+ await applyBackgroundTaskRegistration();
+ await stopForegroundSync();
+ if (mounted) {
+ setState(() {
+ _backgroundSyncEnabled = false;
+ _foregroundSyncEnabled = false;
+ });
+ }
+ }
+
+ Widget _syncCheckbox({
+ required String label,
+ required String description,
+ required bool value,
+ required ValueChanged<bool> onChanged,
+ }) {
+ return CheckboxListTile(
+ title: Text(label),
+ value: value,
+ onChanged: (v) => onChanged(v ?? false),
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
+ secondary: Tooltip(
+ message: description,
+ triggerMode: TooltipTriggerMode.tap,
+ child: Icon(Icons.help_outline, size: 20),
+ ),
+ );
+ }
+
@override
void initState() {
super.initState();
@@ -74,13 +152,18 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
setState(() {
_addressController.text = conn.address;
_customProxyPortController.text = conn.proxyPort;
- _useTor = conn.useTor;
+ _useTor = conn.useTor && TorSettingsService.sharedInstance.torMode != TorMode.disabled;
_useSsl = conn.useSsl;
+ _connectionType = connectionTypeOptions.contains(conn.connectionType)
+ ? conn.connectionType
+ : 'lws';
});
if (conn.useTor && TorSettingsService.sharedInstance.torMode == TorMode.builtIn) {
_pollTorStatus();
}
+
+ if (_showSyncOptions) _loadSyncPrefs();
}
String _cleanAddress(String value) {
@@ -92,7 +175,41 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
[ipAddressRegex.pattern, onionAddressRegex.pattern, domainAddressRegex.pattern].join('|'),
);
- return connectionUrlRegex.hasMatch(value);
+ if (!connectionUrlRegex.hasMatch(value)) return false;
+ return !_isNonLocalIp(value);
+ }
+
+ /// Private/loopback IPv4 ranges we consider "local network".
+ bool _isLocalIp(String host) {
+ if (host.startsWith('192.168.') || host.startsWith('10.') || host.startsWith('127.')) {
+ return true;
+ }
+ final match = RegExp(r'^172\.(\d{1,3})\.').firstMatch(host);
+ if (match != null) {
+ final second = int.tryParse(match.group(1)!) ?? 0;
+ return second >= 16 && second <= 31;
+ }
+ return false;
+ }
+
+ /// Public IP literals aren't allowed: use a domain (SSL) or a local IP.
+ bool _isNonLocalIp(String value) {
+ final host = value.split(':').first;
+ return ipAddressRegex.hasMatch(value) && !_isLocalIp(host);
+ }
+
+ bool _sslForAddress(String value) {
+ final host = value.split(':').first;
+ if (onionAddressRegex.hasMatch(value)) return false;
+ if (ipAddressRegex.hasMatch(value)) return false;
+ if (host.endsWith('.local')) return false;
+ return domainAddressRegex.hasMatch(value);
+ }
+
+ bool _isLocalAddress(String value) {
+ final host = value.split(':').first;
+ if (ipAddressRegex.hasMatch(value)) return _isLocalIp(host);
+ return host.endsWith('.local');
}
Future<void> _scanQrCode() async {
@@ -121,36 +238,46 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
}
}
- void _onAddressChange(String value) {
- value = _cleanAddress(value);
-
- var useTor = false;
- var useSsl = false;
+ void _onAddressChange(String rawValue) {
+ final hadProtocol = RegExp(r'https?:\/\/').hasMatch(rawValue);
+ final value = _cleanAddress(rawValue);
+ final i18n = AppLocalizations.of(context)!;
- if (ipAddressRegex.hasMatch(value)) {
- useTor = false;
- useSsl = false;
- } else if (onionAddressRegex.hasMatch(value)) {
- useSsl = false;
- useTor = true;
- } else if (domainAddressRegex.hasMatch(value)) {
- useTor = false;
- useSsl = true;
+ // Strip any typed http(s):// from the field itself so it's ignored.
+ if (_addressController.text != value) {
+ _addressController.value = TextEditingValue(
+ text: value,
+ selection: TextSelection.collapsed(offset: value.length),
+ );
}
- if (value.startsWith('https://')) {
+ final useTor = onionAddressRegex.hasMatch(value);
+ var useSsl = _sslForAddress(value);
+
+ if (rawValue.startsWith('https://')) {
useSsl = true;
- } else if (value.startsWith('http://')) {
+ } else if (rawValue.startsWith('http://')) {
useSsl = false;
}
_setUseSsl(useSsl);
- _setUseTor(useTor);
+ // Never auto-disable Tor if the user already turned it on.
+ _setUseTor(useTor || _useTor);
setState(() {
_hasTested = false;
- _errorMessage = null;
+ _errorMessage = _isNonLocalIp(value) ? i18n.connectionRemoteIpNotAllowed : null;
});
+
+ if (hadProtocol) {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentSnackBar()
+ ..showSnackBar(
+ SnackBar(
+ content: Text(useSsl ? i18n.connectionProtocolHttps : i18n.connectionProtocolHttp),
+ ),
+ );
+ }
}
void _onProxyPortChange(String value) {
@@ -167,7 +294,6 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
setState(() {
_useTor = value ?? false;
- _useSsl = value == true ? false : _useSsl;
_hasTested = false;
});
@@ -201,89 +327,104 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
});
}
+ void _setConnectionType(String value) {
+ setState(() {
+ _connectionType = value;
+ _hasTested = false;
+ _errorMessage = null;
+ });
+ if (value == 'node' && Platform.isAndroid) _loadSyncPrefs();
+ }
+
+ String _connectionTypeLabel(AppLocalizations i18n, String type) {
+ return type == 'node' ? i18n.connectionTypeNode : i18n.connectionTypeLws;
+ }
+
+ /// Resolves the SOCKS proxy port to pass to `wallet.testConnection`. When Tor
+ /// is enabled this comes from the running TorService; otherwise it's the
+ /// optional custom HTTP/SOCKS proxy field.
+ Future<String?> _resolveProxyPort() async {
+ if (_useTor) {
+ final proxyInfo = await TorSettingsService.sharedInstance.getProxy();
+ return proxyInfo?.port.toString();
+ }
+ final custom = _customProxyPortController.text.trim();
+ return custom.isEmpty ? null : custom;
+ }
+
Future _testConnection() async {
final i18n = AppLocalizations.of(context)!;
- final proto = _useSsl ? 'https' : 'http';
+ final wallet = Provider.of<WalletModel>(context, listen: false);
final daemonAddress = _cleanAddress(_addressController.text);
- final customProxyPort = _customProxyPortController.text;
- // Handle demo mode
- if (isDemoMode) {
- if (daemonAddress == 'demo') {
- setState(() {
- _hasTested = true;
- _connectionSuccess = true;
- });
- return;
- }
+ if (isDemoMode && daemonAddress == 'demo') {
+ setState(() {
+ _hasTested = true;
+ _connectionSuccess = true;
+ });
+ return;
+ }
+
+ if (_isNonLocalIp(daemonAddress)) {
+ setState(() {
+ _hasTested = false;
+ _errorMessage = i18n.connectionRemoteIpNotAllowed;
+ });
+ return;
+ }
+
+ if (_useTor && TorSettingsService.sharedInstance.torMode == TorMode.disabled) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(i18n.lwsSetupTorDisabledError)));
+ return;
}
setState(() {
_hasTested = true;
_connectionTestIsLoading = true;
+ _connectionSuccess = false;
+ _errorMessage = null;
});
- final url = '$proto://$daemonAddress/get_address_info';
-
try {
- if (_useTor) {
- if (!mounted) {
- return;
- }
-
- final torSettings = TorSettingsService.sharedInstance;
-
- if (torSettings.torMode == TorMode.disabled) {
- // show error toast
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(SnackBar(content: Text(i18n.lwsSetupTorDisabledError)));
- return;
- }
-
- final proxyInfo = await torSettings.getProxy();
-
- final response = await makeSocksHttpRequest(
- 'POST',
- url,
- proxyInfo!,
- ).timeout(Duration(seconds: 20));
-
- setState(() {
- _connectionSuccess = response.statusCode == HttpStatus.internalServerError;
- });
- } else {
- var httpClient = HttpClient();
-
- if (customProxyPort != '') {
- httpClient = httpClient
- ..findProxy = (uri) {
- return "PROXY localhost:$customProxyPort";
- };
- }
-
- final request = await httpClient.postUrl(Uri.parse(url));
- final response = await request.close().timeout(Duration(seconds: 10));
-
- setState(() {
- _connectionSuccess = response.statusCode == HttpStatus.internalServerError;
- });
- }
+ final proxyPort = await _resolveProxyPort();
+ await wallet.testConnection(
+ address: daemonAddress,
+ proxyPort: proxyPort,
+ useSsl: _useSsl,
+ useTor: _useTor,
+ connectionType: _connectionType,
+ );
+ if (!mounted) return;
+ setState(() {
+ _connectionSuccess = true;
+ });
} catch (error) {
+ log(LogLevel.warn, 'testConnection failed: $error');
+ if (!mounted) return;
setState(() {
_connectionSuccess = false;
});
} finally {
- setState(() {
- _connectionTestIsLoading = false;
- });
+ if (mounted) {
+ setState(() {
+ _connectionTestIsLoading = false;
+ });
+ }
}
}
Future<void> _saveConnection() async {
+ final i18n = AppLocalizations.of(context)!;
final daemonAddress = _cleanAddress(_addressController.text);
final proxyAddress = _customProxyPortController.text;
+ if (_isNonLocalIp(daemonAddress)) {
+ setState(() => _errorMessage = i18n.connectionRemoteIpNotAllowed);
+ return;
+ }
+
final wallet = Provider.of<WalletModel>(context, listen: false);
wallet.setConnection(
@@ -291,30 +432,108 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
proxyPort: proxyAddress,
useTor: _useTor,
useSsl: _useSsl,
+ connectionType: _connectionType,
);
await wallet.persistCurrentConnection();
+
+ // Sync only applies to a node connection. If this is saved as LWS, turn any
+ // enabled sync off so it isn't left running with no visible toggle.
+ if (_connectionType != 'node') {
+ await _disableSync();
+ }
+
await widget.onBeforeSave?.call();
widget.onSaved();
}
+ Widget _statusChip({required Widget icon, required String label, required Color color}) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ icon,
+ const SizedBox(width: 4),
+ Text(
+ label,
+ style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w500),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildConnectionIndicators(AppLocalizations i18n, TorMode torMode) {
+ final chips = <Widget>[];
+
+ if (_useTor) {
+ chips.add(
+ _statusChip(
+ icon: SvgPicture.asset('assets/icons/tor.svg', width: 13, height: 13),
+ label: torMode == TorMode.builtIn
+ ? i18n.connectionIndicatorTorInternal
+ : i18n.connectionIndicatorTorExternal(TorSettingsService.sharedInstance.socksPort),
+ color: Colors.purple,
+ ),
+ );
+ }
+
+ if (_useSsl) {
+ chips.add(
+ _statusChip(
+ icon: Icon(Icons.lock, size: 13, color: Colors.green),
+ label: i18n.connectionIndicatorHttps,
+ color: Colors.green,
+ ),
+ );
+ } else if (_isLocalAddress(_cleanAddress(_addressController.text))) {
+ chips.add(
+ _statusChip(
+ icon: Icon(Icons.lock_open, size: 13, color: Colors.grey),
+ label: i18n.connectionIndicatorLocal,
+ color: Colors.grey,
+ ),
+ );
+ }
+
+ if (chips.isEmpty) return const SizedBox.shrink();
+
+ return Center(
+ child: Wrap(spacing: 16, alignment: WrapAlignment.center, children: chips),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
final torMode = TorSettingsService.sharedInstance.torMode;
+ final addressHint = _isNode ? i18n.connectionNodeAddressHint : i18n.lwsSetupAddressHint;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
+ Center(
+ child: SegmentedButton<String>(
+ segments: connectionTypeOptions
+ .map(
+ (type) => ButtonSegment<String>(
+ value: type,
+ label: Text(_connectionTypeLabel(i18n, type)),
+ ),
+ )
+ .toList(),
+ selected: {_connectionType},
+ showSelectedIcon: false,
+ onSelectionChanged: (selection) => _setConnectionType(selection.first),
+ ),
+ ),
TextFormField(
controller: _addressController,
onChanged: _onAddressChange,
decoration: InputDecoration(
labelText: i18n.address,
- hintText: i18n.lwsSetupAddressHint,
+ hintText: addressHint,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@@ -348,31 +567,33 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly],
),
- CheckboxListTile(
- title: Text(i18n.lwsSetupUseTorLabel),
- value: _useTor,
- onChanged: _useSsl || torMode == TorMode.disabled ? null : _setUseTor,
- controlAffinity: ListTileControlAffinity.leading,
- contentPadding: EdgeInsets.zero,
- ),
- CheckboxListTile(
- title: Text(i18n.lwsSetupUseSslLabel),
- value: _useSsl,
- onChanged: !_useTor ? _setUseSsl : null,
- controlAffinity: ListTileControlAffinity.leading,
- contentPadding: EdgeInsets.zero,
- ),
- if (_useTor)
- Center(
- child: Text(
- torMode == TorMode.builtIn
- ? i18n.lwsSetupUsingInternalTor
- : i18n.lwsSetupUsingExternalTor(
- '127.0.0.1:${TorSettingsService.sharedInstance.socksPort}',
- ),
- style: TextStyle(color: Colors.purple, fontStyle: FontStyle.italic),
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CheckboxListTile(
+ title: Text(i18n.lwsSetupUseTorLabel),
+ value: _useTor,
+ onChanged: torMode == TorMode.disabled ? null : _setUseTor,
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
),
- ),
+ if (_showSyncOptions) ...[
+ _syncCheckbox(
+ label: i18n.settingsBackgroundSyncLabel,
+ description: i18n.settingsBackgroundSyncDescription,
+ value: _backgroundSyncEnabled,
+ onChanged: _setBackgroundSyncEnabled,
+ ),
+ _syncCheckbox(
+ label: i18n.settingsForegroundSyncLabel,
+ description: i18n.settingsForegroundSyncDescription,
+ value: _foregroundSyncEnabled,
+ onChanged: _setForegroundSyncEnabled,
+ ),
+ ],
+ ],
+ ),
+ _buildConnectionIndicators(i18n, torMode),
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
diff --git a/lib/widgets/loading_button.dart b/lib/widgets/loading_button.dart
new file mode 100644
index 0000000..e36bb25
--- /dev/null
+++ b/lib/widgets/loading_button.dart
@@ -0,0 +1,46 @@
+import 'package:flutter/material.dart';
+
+/// Filled action button with a consistent loading state: while [isLoading] a
+/// left-aligned spinner takes the leading-icon slot and the button is
+/// disabled; the [label] stays visible throughout.
+class LoadingButton extends StatelessWidget {
+ const LoadingButton({
+ super.key,
+ required this.isLoading,
+ required this.onPressed,
+ required this.label,
+ this.icon,
+ });
+
+ final bool isLoading;
+ final VoidCallback? onPressed;
+ final String label;
+
+ /// Optional leading icon shown when not loading.
+ final IconData? icon;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final spinnerColor = isDark ? Theme.of(context).colorScheme.onPrimary : Colors.white;
+
+ return FilledButton(
+ onPressed: isLoading ? null : onPressed,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ spacing: 8,
+ children: [
+ if (isLoading)
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2, color: spinnerColor),
+ )
+ else if (icon != null)
+ Icon(icon),
+ Text(label),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/tx_details.dart b/lib/widgets/tx_details.dart
index 8226dd6..cd92940 100644
--- a/lib/widgets/tx_details.dart
+++ b/lib/widgets/tx_details.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/util/secure_clipboard.dart';
class TxDetailsDialog {
static void show(BuildContext context, TxDetails txDetails) {
@@ -13,6 +13,24 @@ class TxDetailsDialog {
}
}
+/// Splits [s] into [lines] newline-separated segments of near-equal length
+/// (sizes differ by at most one char).
+String _chunkIntoLines(String s, int lines) {
+ if (lines <= 1 || s.length <= lines) return s;
+ final baseSize = s.length ~/ lines;
+ final remainder = s.length % lines;
+ final buffer = StringBuffer();
+ var start = 0;
+ for (var i = 0; i < lines; i++) {
+ final size = baseSize + (i < remainder ? 1 : 0);
+ final end = start + size;
+ if (i > 0) buffer.write('\n');
+ buffer.write(s.substring(start, end));
+ start = end;
+ }
+ return buffer.toString();
+}
+
class _TxDetailsDialog extends StatelessWidget {
final TxDetails txDetails;
@@ -54,12 +72,12 @@ class _TxDetailsDialog extends StatelessWidget {
constraints: BoxConstraints(maxWidth: 280),
child: GestureDetector(
child: Text(
- txDetails.hash,
+ _chunkIntoLines(txDetails.hash, 3),
textAlign: TextAlign.end,
style: TextStyle(fontFamily: 'monospace'),
softWrap: true,
),
- onTap: () => Clipboard.setData(ClipboardData(text: txDetails.hash)),
+ onTap: () => SecureClipboard.copy(txDetails.hash),
),
),
),
@@ -151,7 +169,7 @@ class _TxDetailsDialog extends StatelessWidget {
style: TextStyle(fontFamily: 'monospace'),
softWrap: true,
),
- onTap: () => Clipboard.setData(ClipboardData(text: txDetails.key)),
+ onTap: () => SecureClipboard.copy(txDetails.key),
),
),
),
@@ -183,14 +201,13 @@ class _TxDetailsDialog extends StatelessWidget {
child: Container(
constraints: BoxConstraints(maxWidth: 280),
child: Text(
- recipient.address,
+ _chunkIntoLines(recipient.address, 3),
style: TextStyle(fontFamily: 'monospace'),
softWrap: true,
textAlign: TextAlign.end,
),
),
- onTap: () =>
- Clipboard.setData(ClipboardData(text: recipient.address)),
+ onTap: () => SecureClipboard.copy(recipient.address),
),
Text('$amountStr XMR', softWrap: true),
],
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index cd5daf1..d3be569 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_zxing
+ openalias_ffi
tor_ffi_plugin
)
diff --git a/plugins/openalias_ffi/analysis_options.yaml b/plugins/openalias_ffi/analysis_options.yaml
new file mode 100644
index 0000000..a5744c1
--- /dev/null
+++ b/plugins/openalias_ffi/analysis_options.yaml
@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/plugins/openalias_ffi/android/.gitignore b/plugins/openalias_ffi/android/.gitignore
new file mode 100644
index 0000000..161bdcd
--- /dev/null
+++ b/plugins/openalias_ffi/android/.gitignore
@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures
+.cxx
diff --git a/plugins/openalias_ffi/android/build.gradle b/plugins/openalias_ffi/android/build.gradle
new file mode 100644
index 0000000..758942d
--- /dev/null
+++ b/plugins/openalias_ffi/android/build.gradle
@@ -0,0 +1,77 @@
+// The Android Gradle Plugin builds the native code with the Android NDK.
+
+group 'org.magicgrants.openalias_ffi'
+version '1.0'
+
+buildscript {
+ ext.kotlin_version = '1.8.0'
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.1.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ if (project.android.hasProperty("namespace")) {
+ namespace 'org.magicgrants.openalias_ffi'
+ }
+
+ compileSdk = 34
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ test.java.srcDirs += "src/test/kotlin"
+ }
+
+ defaultConfig {
+ minSdk = 21
+ }
+
+ dependencies {
+ testImplementation("org.jetbrains.kotlin:kotlin-test")
+ testImplementation("org.mockito:mockito-core:5.0.0")
+ }
+
+ testOptions {
+ unitTests.all {
+ useJUnitPlatform()
+
+ testLogging {
+ events "passed", "skipped", "failed", "standardOut", "standardError"
+ outputs.upToDateWhen {false}
+ showStandardStreams = true
+ }
+ }
+ }
+}
+
+apply from: "../cargokit/gradle/plugin.gradle"
+
+cargokit {
+ manifestDir = "../rust"
+ libname = "openalias_ffi"
+}
\ No newline at end of file
diff --git a/plugins/openalias_ffi/android/settings.gradle b/plugins/openalias_ffi/android/settings.gradle
new file mode 100644
index 0000000..82e0391
--- /dev/null
+++ b/plugins/openalias_ffi/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'openalias_ffi'
diff --git a/plugins/openalias_ffi/android/src/main/AndroidManifest.xml b/plugins/openalias_ffi/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ab49f99
--- /dev/null
+++ b/plugins/openalias_ffi/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.magicgrants.openalias_ffi">
+</manifest>
diff --git a/plugins/openalias_ffi/ffigen.yaml b/plugins/openalias_ffi/ffigen.yaml
new file mode 100644
index 0000000..1225e07
--- /dev/null
+++ b/plugins/openalias_ffi/ffigen.yaml
@@ -0,0 +1,19 @@
+# Run with `flutter pub run ffigen --config ffigen.yaml`.
+name: OpenaliasFfiBindings
+description: |
+ Bindings for `src/openalias_ffi.h`.
+
+ Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
+output: 'lib/openalias_ffi_bindings_generated.dart'
+headers:
+ entry-points:
+ - 'rust/target/openalias_ffi.h'
+ include-directives:
+ - 'rust/target/openalias_ffi.h'
+preamble: |
+ // ignore_for_file: always_specify_types
+ // ignore_for_file: camel_case_types
+ // ignore_for_file: non_constant_identifier_names
+comments:
+ style: any
+ length: full
diff --git a/plugins/openalias_ffi/ios/Classes/openalias_ffi.c b/plugins/openalias_ffi/ios/Classes/openalias_ffi.c
new file mode 100644
index 0000000..09168e8
--- /dev/null
+++ b/plugins/openalias_ffi/ios/Classes/openalias_ffi.c
@@ -0,0 +1,2 @@
+// Relative import to be able to reuse the C sources.
+// See the comment in ../{projectName}}.podspec for more information.
diff --git a/plugins/openalias_ffi/ios/openalias_ffi.podspec b/plugins/openalias_ffi/ios/openalias_ffi.podspec
new file mode 100644
index 0000000..023c0d9
--- /dev/null
+++ b/plugins/openalias_ffi/ios/openalias_ffi.podspec
@@ -0,0 +1,41 @@
+#
+# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
+# Run `pod lib lint openalias_ffi.podspec` to validate before publishing.
+#
+Pod::Spec.new do |s|
+ s.name = 'openalias_ffi'
+ s.version = '0.0.1'
+ s.summary = 'A new Flutter FFI plugin project.'
+ s.description = <<-DESC
+A new Flutter FFI plugin project.
+ DESC
+ s.homepage = 'http://example.com'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Your Company' => 'email@example.com' }
+
+ s.source = { :path => '.' }
+ s.source_files = 'Classes/**/*'
+ s.dependency 'Flutter'
+ s.platform = :ios, '15.0'
+
+ s.script_phase = {
+ :name => 'Build Rust library',
+ # First argument is relative path to the `rust` folder, second is name of rust library
+ :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust openalias_ffi',
+ :execution_position => :before_compile,
+ :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
+ # Let XCode know that the static library referenced in -force_load below is
+ # created by this build step.
+ :output_files => ["${BUILT_PRODUCTS_DIR}/libopenalias_ffi.a"],
+ }
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ # Flutter.framework does not contain a i386 slice.
+ 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
+ 'EXCLUDED_ARCHS' => 'armv7',
+ 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libopenalias_ffi.a',
+ }
+ s.user_target_xcconfig = {
+ 'EXCLUDED_ARCHS' => 'armv7',
+ }
+end
diff --git a/plugins/openalias_ffi/lib/openalias_ffi.dart b/plugins/openalias_ffi/lib/openalias_ffi.dart
new file mode 100644
index 0000000..5dc45f4
--- /dev/null
+++ b/plugins/openalias_ffi/lib/openalias_ffi.dart
@@ -0,0 +1,79 @@
+import 'dart:ffi';
+import 'dart:io';
+import 'dart:isolate';
+
+import 'package:ffi/ffi.dart';
+
+const _libName = 'openalias_ffi';
+
+DynamicLibrary _load() {
+ if (Platform.isAndroid || Platform.isLinux) {
+ return DynamicLibrary.open('lib$_libName.so');
+ } else if (Platform.isIOS || Platform.isMacOS) {
+ return DynamicLibrary.open('$_libName.framework/$_libName');
+ } else if (Platform.isWindows) {
+ return DynamicLibrary.open('$_libName.dll');
+ }
+ throw UnsupportedError('${Platform.operatingSystem} is not supported');
+}
+
+typedef _ResolveNative = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>, Uint16);
+typedef _ResolveDart = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>, int);
+typedef _FreeNative = Void Function(Pointer<Utf8>);
+typedef _FreeDart = void Function(Pointer<Utf8>);
+typedef _LastErrorNative = Pointer<Utf8> Function();
+typedef _LastErrorDart = Pointer<Utf8> Function();
+
+class OpenAliasException implements Exception {
+ OpenAliasException(this.message);
+ final String message;
+ @override
+ String toString() => message;
+}
+
+/// Resolves OpenAlias domains to addresses with end-to-end DNSSEC validation,
+/// over Tor. Backed by a Rust (hickory) native library.
+class OpenAliasFfi {
+ /// Resolves [domain]'s `oa1:<asset>` record to a recipient address, routing
+ /// DNS through the Tor SOCKS proxy at [socksPort] and requiring the answer to
+ /// be DNSSEC-secure. Returns null if no matching record; throws
+ /// [OpenAliasException] on failure (no DNSSEC, network, parse, ...).
+ ///
+ /// Runs in a background isolate (the native call blocks while it queries Tor).
+ static Future<String?> resolve({
+ required String domain,
+ required String asset,
+ required int socksPort,
+ }) {
+ return Isolate.run(() => _resolveSync(domain, asset, socksPort));
+ }
+}
+
+String? _resolveSync(String domain, String asset, int socksPort) {
+ final lib = _load();
+ final resolve = lib.lookupFunction<_ResolveNative, _ResolveDart>('openalias_resolve');
+ final freeStr = lib.lookupFunction<_FreeNative, _FreeDart>('openalias_string_free');
+ final lastError = lib.lookupFunction<_LastErrorNative, _LastErrorDart>(
+ 'openalias_last_error_message',
+ );
+
+ final domainPtr = domain.toNativeUtf8();
+ final assetPtr = asset.toNativeUtf8();
+ try {
+ final result = resolve(domainPtr, assetPtr, socksPort);
+ if (result == nullptr) {
+ final errPtr = lastError();
+ final message = errPtr == nullptr || errPtr.toDartString().isEmpty
+ ? 'OpenAlias resolution failed'
+ : errPtr.toDartString();
+ if (errPtr != nullptr) freeStr(errPtr);
+ throw OpenAliasException(message);
+ }
+ final address = result.toDartString();
+ freeStr(result);
+ return address.isEmpty ? null : address;
+ } finally {
+ malloc.free(domainPtr);
+ malloc.free(assetPtr);
+ }
+}
diff --git a/plugins/openalias_ffi/linux/CMakeLists.txt b/plugins/openalias_ffi/linux/CMakeLists.txt
new file mode 100644
index 0000000..9726df4
--- /dev/null
+++ b/plugins/openalias_ffi/linux/CMakeLists.txt
@@ -0,0 +1,23 @@
+# The Flutter tooling requires that developers have CMake 3.10 or later
+# installed. You should not increase this version, as doing so will cause
+# the plugin to fail to compile for some customers of the plugin.
+cmake_minimum_required(VERSION 3.10)
+
+# Project-level configuration.
+set(PROJECT_NAME "openalias_ffi")
+project(${PROJECT_NAME} LANGUAGES CXX)
+
+# Invoke the build for native code shared with the other target platforms.
+# This can be changed to accommodate different builds.
+include("../cargokit/cmake/cargokit.cmake")
+apply_cargokit(${PROJECT_NAME} ../rust openalias_ffi "")
+
+# List of absolute paths to libraries that should be bundled with the plugin.
+# This list could contain prebuilt libraries, or libraries created by an
+# external build triggered from this build file.
+set(openalias_ffi_bundled_libraries
+ # Defined in ../src/CMakeLists.txt.
+ # This can be changed to accommodate different builds.
+ "${${PROJECT_NAME}_cargokit_lib}"
+ PARENT_SCOPE
+)
diff --git a/plugins/openalias_ffi/macos/Classes/openalias_ffi.c b/plugins/openalias_ffi/macos/Classes/openalias_ffi.c
new file mode 100644
index 0000000..09168e8
--- /dev/null
+++ b/plugins/openalias_ffi/macos/Classes/openalias_ffi.c
@@ -0,0 +1,2 @@
+// Relative import to be able to reuse the C sources.
+// See the comment in ../{projectName}}.podspec for more information.
diff --git a/plugins/openalias_ffi/macos/openalias_ffi.podspec b/plugins/openalias_ffi/macos/openalias_ffi.podspec
new file mode 100644
index 0000000..39395a3
--- /dev/null
+++ b/plugins/openalias_ffi/macos/openalias_ffi.podspec
@@ -0,0 +1,39 @@
+#
+# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
+# Run `pod lib lint openalias_ffi.podspec` to validate before publishing.
+#
+Pod::Spec.new do |s|
+ s.name = 'openalias_ffi'
+ s.version = '0.0.1'
+ s.summary = 'A new Flutter FFI plugin project.'
+ s.description = <<-DESC
+A new Flutter FFI plugin project.
+ DESC
+ s.homepage = 'http://example.com'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Your Company' => 'email@example.com' }
+
+ s.source = { :path => '.' }
+ s.source_files = 'Classes/**/*'
+ s.platform = :osx, '10.13'
+
+ s.script_phase = {
+ :name => 'Build Rust library',
+ # First argument is relative path to the `rust` folder, second is name of rust library
+ :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust openalias_ffi',
+ :execution_position => :before_compile,
+ :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
+ # Let XCode know that the static library referenced in -force_load below is
+ # created by this build step.
+ :output_files => ["${BUILT_PRODUCTS_DIR}/libopenalias_ffi.a"],
+ }
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ # We use `-force_load` instead of `-l` since Xcode strips out unused symbols from static libraries.
+ 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libopenalias_ffi.a',
+ 'DEAD_CODE_STRIPPING' => 'YES',
+ 'STRIP_INSTALLED_PRODUCT[config=Release][sdk=*][arch=*]' => "YES",
+ 'STRIP_STYLE[config=Release][sdk=*][arch=*]' => "non-global",
+ 'DEPLOYMENT_POSTPROCESSING[config=Release][sdk=*][arch=*]' => "YES",
+ }
+end
diff --git a/plugins/openalias_ffi/pubspec.yaml b/plugins/openalias_ffi/pubspec.yaml
new file mode 100644
index 0000000..e3fb960
--- /dev/null
+++ b/plugins/openalias_ffi/pubspec.yaml
@@ -0,0 +1,34 @@
+name: openalias_ffi
+description: OpenAlias resolver with end-to-end DNSSEC validation over Tor, via Rust (hickory) FFI.
+version: 0.0.1
+publish_to: none
+
+environment:
+ sdk: '>=3.5.3 <4.0.0'
+ flutter: ">=3.24.3"
+
+dependencies:
+ flutter:
+ sdk: flutter
+ ffi: ^2.0.1
+ plugin_platform_interface: ^2.0.2
+
+dev_dependencies:
+ ffigen: ^6.1.2
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^2.0.0
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ ffiPlugin: true
+ ios:
+ ffiPlugin: true
+ linux:
+ ffiPlugin: true
+ macos:
+ ffiPlugin: true
+ windows:
+ ffiPlugin: true
diff --git a/plugins/openalias_ffi/rust/.gitignore b/plugins/openalias_ffi/rust/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/plugins/openalias_ffi/rust/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/plugins/openalias_ffi/rust/Cargo.lock b/plugins/openalias_ffi/rust/Cargo.lock
new file mode 100644
index 0000000..250c095
--- /dev/null
+++ b/plugins/openalias_ffi/rust/Cargo.lock
@@ -0,0 +1,1722 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cbindgen"
+version = "0.24.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b922faaf31122819ec80c4047cc684c6979a087366c069611e33649bf98e18d"
+dependencies = [
+ "clap",
+ "heck",
+ "indexmap 1.9.3",
+ "log",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "syn 1.0.109",
+ "tempfile",
+ "toml",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core",
+]
+
+[[package]]
+name = "clap"
+version = "3.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
+dependencies = [
+ "atty",
+ "bitflags 1.3.2",
+ "clap_lex",
+ "indexmap 1.9.3",
+ "strsim",
+ "termcolor",
+ "textwrap",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
+dependencies = [
+ "os_str_bytes",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "rand_core",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "h2"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hickory-net"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
+dependencies = [
+ "async-trait",
+ "bitflags 2.13.0",
+ "bytes",
+ "cfg-if",
+ "data-encoding",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "h2",
+ "hickory-proto",
+ "http",
+ "idna",
+ "ipnet",
+ "jni",
+ "lru-cache",
+ "parking_lot",
+ "rand",
+ "ring",
+ "rustls",
+ "rustls-pki-types",
+ "thiserror 2.0.18",
+ "time",
+ "tinyvec",
+ "tokio",
+ "tokio-rustls",
+ "tracing",
+ "url",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hickory-proto"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
+dependencies = [
+ "bitflags 2.13.0",
+ "data-encoding",
+ "idna",
+ "ipnet",
+ "jni",
+ "once_cell",
+ "prefix-trie",
+ "rand",
+ "ring",
+ "rustls-pki-types",
+ "thiserror 2.0.18",
+ "time",
+ "tinyvec",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-net",
+ "hickory-proto",
+ "ipnet",
+ "moka",
+ "once_cell",
+ "parking_lot",
+ "rand",
+ "rustls",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-rustls",
+ "tracing",
+ "webpki-roots",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "icu_collections"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_locid_transform_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform_data"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d"
+
+[[package]]
+name = "icu_normalizer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "utf16_iter",
+ "utf8_iter",
+ "write16",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7"
+
+[[package]]
+name = "icu_properties"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locid_transform",
+ "icu_properties_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"
+
+[[package]]
+name = "icu_provider"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_provider_macros",
+ "stable_deref_trait",
+ "tinystr",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_provider_macros"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-cache"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moka"
+version = "0.12.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
+
+[[package]]
+name = "openalias_ffi"
+version = "0.1.0"
+dependencies = [
+ "cbindgen",
+ "glob",
+ "hickory-proto",
+ "hickory-resolver",
+ "lazy_static",
+ "log",
+ "tokio",
+ "tokio-socks",
+]
+
+[[package]]
+name = "os_str_bytes"
+version = "6.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "prefix-trie"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
+dependencies = [
+ "either",
+ "ipnet",
+ "num-traits",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "time"
+version = "0.3.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "tinystr"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-socks"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
+dependencies = [
+ "either",
+ "futures-util",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf16_iter"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "write16"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+
+[[package]]
+name = "writeable"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+
+[[package]]
+name = "yoke"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerovec"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/plugins/openalias_ffi/rust/Cargo.toml b/plugins/openalias_ffi/rust/Cargo.toml
new file mode 100644
index 0000000..babd998
--- /dev/null
+++ b/plugins/openalias_ffi/rust/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "openalias_ffi"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "staticlib"]
+
+[dependencies]
+tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time"] }
+tokio-socks = "0.5"
+# DoH (https-ring) + bundled CA roots (webpki-roots): Tor exits reject plain DNS
+# (53) and many restrict 853, but 443 is almost always allowed — so queries go
+# over DNS-over-HTTPS through the SOCKS proxy. DNSSEC is still validated locally.
+hickory-resolver = { version = "0.26", default-features = false, features = ["dnssec-ring", "tokio", "https-ring", "webpki-roots"] }
+hickory-proto = { version = "0.26", default-features = false, features = ["dnssec-ring"] }
+lazy_static = "1.4"
+log = "0.4"
+
+[build-dependencies]
+cbindgen = "0.24"
+glob = "0.3"
diff --git a/plugins/openalias_ffi/rust/build.rs b/plugins/openalias_ffi/rust/build.rs
new file mode 100644
index 0000000..6117a8d
--- /dev/null
+++ b/plugins/openalias_ffi/rust/build.rs
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2023 Foundation Devices Inc.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+use cbindgen::{Config, Language};
+use std::env;
+use std::path::PathBuf;
+use glob::glob;
+
+fn main() {
+ android_on_linux_check();
+
+ // Create C header files for Dart
+ let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
+ let package_name = env::var("CARGO_PKG_NAME").unwrap();
+
+ let output_file = target_dir()
+ .join(format!("{}.h", package_name))
+ .display()
+ .to_string();
+
+ let config = Config {
+ language: Language::C,
+ ..Default::default()
+ };
+
+ cbindgen::generate_with_config(crate_dir, config)
+ .unwrap()
+ .write_to_file(output_file);
+}
+
+fn target_dir() -> PathBuf {
+ if let Ok(target) = env::var("CARGO_TARGET_DIR") {
+ PathBuf::from(target)
+ } else {
+ PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("target")
+ }
+}
+
+
+fn android_on_linux_check() {
+ let target = env::var("TARGET").unwrap();
+ if target == "x86_64-linux-android" {
+ let os = match env::consts::OS {
+ "macos" => "darwin",
+ "windows" => "windows",
+ _ => "linux",
+ };
+
+ let ndk_home_result = env::var("ANDROID_NDK_HOME");
+ let ndk_home = if let Some(value) = ndk_home_result.ok() {
+ value
+ } else {
+ println!("ANDROID_NDK_HOME not set. Trying _CARGOKIT_NDK_LINK_CLANG");
+ let path_to_parse_with_hack = env::var("_CARGOKIT_NDK_LINK_CLANG")
+ .expect("_CARGOKIT_NDK_LINK_CLANG not set");
+
+ path_to_parse_with_hack
+ .split("/toolchains/")
+ .next()
+ .expect("Failed to parse path to get NDK home")
+ .to_string()
+ };
+
+ let link_search_glob = format!(
+ "{}/toolchains/llvm/prebuilt/{}-x86_64/lib/clang/**/lib/linux",
+ ndk_home, os
+ );
+
+ let link_search_path = glob(&link_search_glob)
+ .expect("failed to read link_search_glob")
+ .next()
+ .expect("failed to find link_search_path")
+ .expect("link_search_path glob result failed");
+ println!("cargo:rustc-link-lib=static=clang_rt.builtins-x86_64-android");
+ println!("cargo:rustc-link-search={}", link_search_path.display());
+ }
+}
\ No newline at end of file
diff --git a/plugins/openalias_ffi/rust/rust-toolchain.toml b/plugins/openalias_ffi/rust/rust-toolchain.toml
new file mode 100644
index 0000000..3662df8
--- /dev/null
+++ b/plugins/openalias_ffi/rust/rust-toolchain.toml
@@ -0,0 +1,5 @@
+# hickory 0.25's dependency tree pulls edition2024 crates, which need cargo >= 1.85.
+# Pin so cargokit (via rustup) builds with a new-enough toolchain regardless of
+# the machine default. rustup auto-installs this channel if missing.
+[toolchain]
+channel = "1.88.0"
diff --git a/plugins/openalias_ffi/rust/src/error.rs b/plugins/openalias_ffi/rust/src/error.rs
new file mode 100644
index 0000000..0195e38
--- /dev/null
+++ b/plugins/openalias_ffi/rust/src/error.rs
@@ -0,0 +1,20 @@
+use std::cell::RefCell;
+use std::ffi::{c_char, CString};
+
+thread_local! {
+ static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
+}
+
+/// Returns the most recent error message (empty string if none), as a freshly
+/// allocated C string the caller must free with `openalias_string_free`.
+#[no_mangle]
+pub unsafe extern "C" fn openalias_last_error_message() -> *mut c_char {
+ let msg = LAST_ERROR.with(|prev| prev.borrow_mut().take()).unwrap_or_default();
+ CString::new(msg).unwrap_or_default().into_raw()
+}
+
+pub fn set_last_error(msg: impl Into<String>) {
+ let msg = msg.into();
+ log::warn!("openalias error: {msg}");
+ LAST_ERROR.with(|prev| *prev.borrow_mut() = Some(msg));
+}
diff --git a/plugins/openalias_ffi/rust/src/lib.rs b/plugins/openalias_ffi/rust/src/lib.rs
new file mode 100644
index 0000000..e846123
--- /dev/null
+++ b/plugins/openalias_ffi/rust/src/lib.rs
@@ -0,0 +1,252 @@
+//! OpenAlias resolver with end-to-end DNSSEC validation, routed over Tor's
+//! SOCKS proxy. DNS is fetched via TCP through the proxy (Tor has no UDP) and
+//! validated locally by hickory (RRSIG → DS → root trust anchor), so no
+//! resolver is trusted and no DNS leaks outside Tor.
+
+mod error;
+
+use std::ffi::{c_char, CStr, CString};
+use std::future::Future;
+use std::io;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::pin::Pin;
+use std::sync::Arc;
+use std::time::Duration;
+
+use hickory_proto::dnssec::Proof;
+use hickory_proto::rr::{RData, RecordType};
+use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts};
+use hickory_resolver::net::runtime::iocompat::AsyncIoTokioAsStd;
+use hickory_resolver::net::runtime::{RuntimeProvider, TokioHandle, TokioTime};
+use hickory_resolver::Resolver;
+use lazy_static::lazy_static;
+use tokio::net::{TcpStream as TokioTcpStream, UdpSocket as TokioUdpSocket};
+use tokio::runtime::{Builder, Runtime};
+use tokio_socks::tcp::Socks5Stream;
+
+use crate::error::set_last_error;
+
+lazy_static! {
+ static ref RUNTIME: io::Result<Runtime> = Builder::new_multi_thread().enable_all().build();
+}
+
+/// Public recursive resolvers queried (over Tor) for the signed records, as
+/// DNS-over-HTTPS (port 443) — Tor exits reject plain DNS (53). They only
+/// transport the signed data — validation happens locally — so a malicious
+/// resolver can withhold an answer but cannot forge one. Each entry is the
+/// resolver IP and the TLS/host name used for its certificate + DoH endpoint.
+const UPSTREAMS: &[(Ipv4Addr, &str)] = &[
+ (Ipv4Addr::new(9, 9, 9, 9), "dns.quad9.net"),
+ (Ipv4Addr::new(1, 1, 1, 1), "cloudflare-dns.com"),
+];
+
+const TCP_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// A hickory runtime provider whose TCP connections are dialed through a
+/// SOCKS5 proxy (Tor). UDP is unused (resolver is configured TCP-only).
+#[derive(Clone)]
+struct SocksRuntimeProvider {
+ proxy: SocketAddr,
+ // A persistent handle whose JoinSet outlives the spawned connection driver.
+ // Returning a fresh TokioHandle per call would drop the JoinSet and abort
+ // the h2 driver task → "receiver was canceled". hickory's own provider keeps
+ // one shared handle for exactly this reason.
+ handle: TokioHandle,
+}
+
+impl RuntimeProvider for SocksRuntimeProvider {
+ type Handle = TokioHandle;
+ type Timer = TokioTime;
+ type Udp = TokioUdpSocket;
+ type Tcp = AsyncIoTokioAsStd<Socks5Stream<TokioTcpStream>>;
+
+ fn create_handle(&self) -> Self::Handle {
+ self.handle.clone()
+ }
+
+ fn connect_tcp(
+ &self,
+ server_addr: SocketAddr,
+ _bind_addr: Option<SocketAddr>,
+ _timeout: Option<Duration>,
+ ) -> Pin<Box<dyn Send + Future<Output = io::Result<Self::Tcp>>>> {
+ let proxy = self.proxy;
+ Box::pin(async move {
+ let stream = Socks5Stream::connect(proxy, server_addr)
+ .await
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+ Ok(AsyncIoTokioAsStd(stream))
+ })
+ }
+
+ fn bind_udp(
+ &self,
+ local_addr: SocketAddr,
+ _server_addr: SocketAddr,
+ ) -> Pin<Box<dyn Send + Future<Output = io::Result<Self::Udp>>>> {
+ // Unused: the resolver is TCP-only (Tor has no UDP). Provided to satisfy
+ // the trait.
+ Box::pin(async move { TokioUdpSocket::bind(local_addr).await })
+ }
+}
+
+/// Resolve an OpenAlias `domain` for `asset` (e.g. "btc") over Tor with DNSSEC
+/// validation. Returns a newly-allocated address string, or NULL on failure
+/// (see `openalias_last_error_message`). Caller frees with `openalias_string_free`.
+///
+/// # Safety
+/// `domain` and `asset` must be valid NUL-terminated C strings.
+#[no_mangle]
+pub unsafe extern "C" fn openalias_resolve(
+ domain: *const c_char,
+ asset: *const c_char,
+ socks_port: u16,
+) -> *mut c_char {
+ let domain = match cstr(domain) {
+ Some(s) => s,
+ None => return ret_err("invalid domain"),
+ };
+ let asset = match cstr(asset) {
+ Some(s) => s,
+ None => return ret_err("invalid asset"),
+ };
+
+ let runtime = match RUNTIME.as_ref() {
+ Ok(rt) => rt,
+ Err(e) => return ret_err(format!("tokio runtime: {e}")),
+ };
+
+ match runtime.block_on(resolve(&domain, &asset, socks_port)) {
+ Ok(addr) => match CString::new(addr) {
+ Ok(c) => c.into_raw(),
+ Err(_) => ret_err("address contained NUL"),
+ },
+ Err(msg) => ret_err(msg),
+ }
+}
+
+/// Frees a string returned by this library.
+///
+/// # Safety
+/// `ptr` must have been returned by this library and not already freed.
+#[no_mangle]
+pub unsafe extern "C" fn openalias_string_free(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(CString::from_raw(ptr));
+ }
+}
+
+async fn resolve(domain: &str, asset: &str, socks_port: u16) -> Result<String, String> {
+ let proxy = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), socks_port);
+
+ // DNS-over-HTTPS (default /dns-query on 443). The TLS cert is verified
+ // against `host`; the answer's DNSSEC chain is validated locally.
+ let name_servers = UPSTREAMS
+ .iter()
+ .map(|(ip, host)| NameServerConfig::https(IpAddr::V4(*ip), Arc::from(*host), None))
+ .collect();
+ let config = ResolverConfig::from_parts(None, vec![], name_servers);
+
+ let mut opts = ResolverOpts::default();
+ opts.validate = true; // local DNSSEC validation against the root trust anchor
+ opts.timeout = TCP_TIMEOUT;
+
+ let provider = SocksRuntimeProvider { proxy, handle: TokioHandle::default() };
+ let resolver = Resolver::builder_with_config(config, provider)
+ .with_options(opts)
+ .build()
+ .map_err(|e| format!("resolver build failed: {e}"))?;
+
+ let fqdn = if domain.ends_with('.') {
+ domain.to_string()
+ } else {
+ format!("{domain}.")
+ };
+
+ let lookup = resolver
+ .lookup(fqdn, RecordType::TXT)
+ .await
+ .map_err(|e| format!("lookup failed: {e}"))?;
+
+ // Require DNSSEC-secure: reject unsigned (insecure) and bogus answers.
+ let proofs: Vec<Proof> = lookup.answers().iter().map(|r| r.proof).collect();
+ if !proofs.iter().all(|p| *p == Proof::Secure) {
+ return Err(format!(
+ "answer is not DNSSEC-secure (records={}, proofs={:?})",
+ proofs.len(),
+ proofs
+ ));
+ }
+
+ let prefix = format!("oa1:{}", asset.to_lowercase());
+ let mut txt_seen = 0usize;
+ for record in lookup.answers() {
+ if let RData::TXT(txt) = &record.data {
+ txt_seen += 1;
+ let joined: String = txt
+ .txt_data
+ .iter()
+ .map(|b| String::from_utf8_lossy(b).into_owned())
+ .collect();
+ if let Some(addr) = parse_oa1(&joined, &prefix) {
+ return Ok(addr);
+ }
+ }
+ }
+ Err(format!("no {prefix} record found ({txt_seen} TXT record(s) present)"))
+}
+
+/// Parses a concatenated TXT string for an OpenAlias entry matching `prefix`,
+/// returning its `recipient_address`.
+fn parse_oa1(txt: &str, prefix: &str) -> Option<String> {
+ // OpenAlias: `oa1:<asset> recipient_address=ADDR; recipient_name=NAME; ...`
+ // The prefix and first key share the first (space-separated) segment, so
+ // strip the prefix before splitting the key=value fields on ';'.
+ let rest = txt.trim_start().strip_prefix(prefix)?;
+ for field in rest.split(';') {
+ if let Some(value) = field.trim().strip_prefix("recipient_address=") {
+ let value = value.trim();
+ if !value.is_empty() {
+ return Some(value.to_string());
+ }
+ }
+ }
+ None
+}
+
+unsafe fn cstr(ptr: *const c_char) -> Option<String> {
+ if ptr.is_null() {
+ return None;
+ }
+ CStr::from_ptr(ptr).to_str().ok().map(|s| s.to_string())
+}
+
+fn ret_err(msg: impl Into<String>) -> *mut c_char {
+ set_last_error(msg);
+ std::ptr::null_mut()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::parse_oa1;
+
+ #[test]
+ fn parses_btc_recipient() {
+ let txt = "oa1:btc recipient_address=1BoatSLRHtKNngkdXEeobR76b53LETtpyT; recipient_name=Donate;";
+ assert_eq!(
+ parsWhy this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.