diff --git a/Spoke.UnitTestsClean/Spoke.UnitTestsClean.csproj b/Spoke.UnitTestsClean/Spoke.UnitTestsClean.csproj
index 1d66adf..b5c42b0 100644
--- a/Spoke.UnitTestsClean/Spoke.UnitTestsClean.csproj
+++ b/Spoke.UnitTestsClean/Spoke.UnitTestsClean.csproj
@@ -7,10 +7,8 @@
-
+
-
-
\ No newline at end of file
diff --git a/Spoke.UnitTestsClean/UnitTests/ExportViewingWalletTests.cs b/Spoke.UnitTestsClean/UnitTests/ExportViewingWalletTests.cs
new file mode 100644
index 0000000..2a1f777
--- /dev/null
+++ b/Spoke.UnitTestsClean/UnitTests/ExportViewingWalletTests.cs
@@ -0,0 +1,51 @@
+using System;
+using System.IO;
+using IXICore;
+using IXICore.Meta;
+using Spoke.Meta;
+using Xunit;
+
+namespace Spoke.UnitTestsClean.UnitTests
+{
+ public class ExportViewingWalletTests : IDisposable
+ {
+ private readonly string _tempDir;
+
+ public ExportViewingWalletTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "SpokeExportTest", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(_tempDir);
+ // Ensure Spoke uses our temp folder for wallet storage
+ Config.spokeUserFolder = _tempDir;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
+ }
+ catch { }
+ }
+
+ [Fact]
+ public void ExportViewingWallet_CreatesViewFile()
+ {
+ string walletPath = Path.Combine(_tempDir, Node.walletFile);
+
+ // Create a real WalletStorage and add to IxianHandler
+ WalletStorage ws = new WalletStorage(walletPath);
+ bool gen = ws.generateWallet("test-password-123");
+ Assert.True(gen, "Failed to generate wallet for test");
+
+ bool added = IXICore.Meta.IxianHandler.addWallet(ws);
+ Assert.True(added, "Failed to add wallet to IxianHandler");
+
+ string? exported = Node.ExportViewingWallet();
+ Assert.False(string.IsNullOrEmpty(exported));
+ Assert.True(File.Exists(exported));
+ var bytes = File.ReadAllBytes(exported);
+ Assert.True(bytes.Length > 0, "Exported view wallet should contain bytes");
+ }
+ }
+}
diff --git a/Spoke/Spoke/Meta/Node.cs b/Spoke/Spoke/Meta/Node.cs
index c34c068..1761b67 100644
--- a/Spoke/Spoke/Meta/Node.cs
+++ b/Spoke/Spoke/Meta/Node.cs
@@ -3,6 +3,7 @@
using IXICore.Network;
using IXICore.RegNames;
using Spoke.Network;
+using System.IO;
namespace Spoke.Meta;
@@ -91,6 +92,48 @@ public void init()
Logging.info("Spoke Node initialization complete");
}
+ ///
+ /// Export a view-only (viewing) wallet file for the currently loaded wallet
+ /// If destPath is null, writes to `Config.spokeUserFolder` as `wallet.view.ixi` and returns the written path.
+ /// Returns null on failure.
+ ///
+ public static string? ExportViewingWallet(string? destPath = null)
+ {
+ try
+ {
+ var ws = IxianHandler.getWalletStorage();
+ if (ws == null)
+ {
+ Logging.error("No wallet storage available to export viewing wallet.");
+ return null;
+ }
+
+ byte[] viewingBytes = ws.getRawViewingWallet();
+ if (viewingBytes == null)
+ {
+ Logging.error("Failed to get viewing wallet bytes.");
+ return null;
+ }
+
+ string outPath = destPath ?? Path.Combine(Config.spokeUserFolder, Path.GetFileNameWithoutExtension(walletFile) + ".view.ixi");
+ string? directory = Path.GetDirectoryName(outPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllBytes(outPath, viewingBytes);
+
+ Logging.info("Exported viewing wallet to {0}", outPath);
+ return outPath;
+ }
+ catch (Exception ex)
+ {
+ Logging.error("Exception exporting viewing wallet: {0}", ex.ToString());
+ return null;
+ }
+ }
+
///
/// Checks for existing wallet file
///
diff --git a/Spoke/Spoke/Pages/SettingsPage.xaml b/Spoke/Spoke/Pages/SettingsPage.xaml
index 2027bd4..c05121b 100644
--- a/Spoke/Spoke/Pages/SettingsPage.xaml
+++ b/Spoke/Spoke/Pages/SettingsPage.xaml
@@ -67,6 +67,9 @@
+
+
+
diff --git a/Spoke/Spoke/Pages/SettingsPage.xaml.cs b/Spoke/Spoke/Pages/SettingsPage.xaml.cs
index b5c0d5c..7928746 100644
--- a/Spoke/Spoke/Pages/SettingsPage.xaml.cs
+++ b/Spoke/Spoke/Pages/SettingsPage.xaml.cs
@@ -135,6 +135,35 @@ private async void OnSaveClicked(object sender, EventArgs e)
await DisplayAlert("Settings Saved", "Your settings have been saved successfully.", "OK");
}
+
+ private async void OnExportViewingWalletClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ // Export view-only wallet via adapter
+ string? exportedPath = Spoke.Wallet.WalletAdapter.ExportViewingWallet();
+ if (string.IsNullOrEmpty(exportedPath))
+ {
+ await DisplayAlert("Export Failed", "Unable to export viewing wallet.", "OK");
+ return;
+ }
+
+ // Attempt to share/save the exported file
+ bool shared = await Spoke.Platforms.SFileOperations.share(exportedPath, "Export Viewing Wallet");
+ if (shared)
+ {
+ await DisplayAlert("Exported", $"Viewing wallet exported to:\n{exportedPath}", "OK");
+ }
+ else
+ {
+ await DisplayAlert("Export Saved", $"Viewing wallet saved to:\n{exportedPath}", "OK");
+ }
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlert("Error", $"Exception exporting viewing wallet: {ex.Message}", "OK");
+ }
+ }
}
diff --git a/Spoke/Spoke/Platforms/SFileOperations.cs b/Spoke/Spoke/Platforms/SFileOperations.cs
new file mode 100644
index 0000000..e1e2deb
--- /dev/null
+++ b/Spoke/Spoke/Platforms/SFileOperations.cs
@@ -0,0 +1,39 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Maui.Storage;
+using CommunityToolkit.Maui.Alerts;
+
+namespace Spoke.Platforms
+{
+ public static class SFileOperations
+ {
+ public static async Task share(string filepath, string title)
+ {
+ if (!File.Exists(filepath))
+ {
+ return false;
+ }
+
+ CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
+ try
+ {
+ string fileName = Path.GetFileName(filepath);
+ using FileStream fileStream = File.OpenRead(filepath);
+ var fileSaverResult = await FileSaver.Default.SaveAsync(fileName, fileStream, cancellationTokenSource.Token);
+ if (!fileSaverResult.IsSuccessful)
+ {
+ await Toast.Make($"The file was not saved. Error: {fileSaverResult.Exception?.Message}").Show(cancellationTokenSource.Token);
+ return false;
+ }
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/Spoke/Spoke/Wallet/WalletAdapter.cs b/Spoke/Spoke/Wallet/WalletAdapter.cs
index 040e721..db8900b 100644
--- a/Spoke/Spoke/Wallet/WalletAdapter.cs
+++ b/Spoke/Spoke/Wallet/WalletAdapter.cs
@@ -42,5 +42,21 @@ public static class WalletAdapter
}
return null;
}
+
+ ///
+ /// Export a view-only wallet using the Node helper.
+ /// Returns the exported file path on success, null on failure.
+ ///
+ public static string? ExportViewingWallet(string? destPath = null)
+ {
+ try
+ {
+ return Spoke.Meta.Node.ExportViewingWallet(destPath);
+ }
+ catch
+ {
+ return null;
+ }
+ }
}
}
diff --git a/specs/main/quickstart.md b/specs/main/quickstart.md
index 81b21df..63536d1 100644
--- a/specs/main/quickstart.md
+++ b/specs/main/quickstart.md
@@ -34,3 +34,24 @@ Notes
- CI: the repository workflow `/.github/workflows/ci-dotnet-maui.yml` runs workload restore, restore, build and tests on push and PR to `main`.
If you need help installing platform-specific toolchains, see `specs/main/research.md` for recommendations and verification commands.
+
+Exporting a View-Only Wallet
+
+To create a view-only (viewing) wallet file that can be shared safely (does not contain private key material), Spoke provides an export helper that writes a view-only wallet file to disk.
+
+Default location:
+- `~\Spoke\wallet.view.ixi` (on Windows this is under `%USERPROFILE%\Spoke` — controlled by `Config.spokeUserFolder`).
+
+Commands (developer)
+
+```powershell
+# Export to default location (programmatic call via Node.ExportViewingWallet())
+# In the running app, invoke the export via the settings UI or programmatically:
+# Spoke.Wallet.WalletAdapter.ExportViewingWallet()
+
+# If you need to export to a specific path, call the helper with a destination path.
+```
+
+Notes
+- The exported viewing wallet is intended for read-only use and should not include private keys. Treat exported files as sensitive in transport and storage.
+- User-facing export is available in the Settings → Backup screen when the export action is triggered.