Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Spoke.UnitTestsClean/Spoke.UnitTestsClean.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
<ItemGroup>
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Spoke\WalletManager.cs" Link="WalletManager.cs" />
<Compile Include="..\Spoke\BridgeConnection.cs" Link="BridgeConnection.cs" />
</ItemGroup>
</Project>
51 changes: 51 additions & 0 deletions Spoke.UnitTestsClean/UnitTests/ExportViewingWalletTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
43 changes: 43 additions & 0 deletions Spoke/Spoke/Meta/Node.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using IXICore.Network;
using IXICore.RegNames;
using Spoke.Network;
using System.IO;

namespace Spoke.Meta;

Expand Down Expand Up @@ -91,6 +92,48 @@ public void init()
Logging.info("Spoke Node initialization complete");
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Checks for existing wallet file
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions Spoke/Spoke/Pages/SettingsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@

<!-- Save Button -->
<Button Text="Save Settings" Clicked="OnSaveClicked" Margin="0,10,0,20"/>

<!-- Wallet Backup -->
<Button Text="Export View-Only Wallet" Clicked="OnExportViewingWalletClicked" Margin="0,0,0,20" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
29 changes: 29 additions & 0 deletions Spoke/Spoke/Pages/SettingsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}


39 changes: 39 additions & 0 deletions Spoke/Spoke/Platforms/SFileOperations.cs
Original file line number Diff line number Diff line change
@@ -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<bool> 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;
}
}
}
16 changes: 16 additions & 0 deletions Spoke/Spoke/Wallet/WalletAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,21 @@ public static class WalletAdapter
}
return null;
}

/// <summary>
/// Export a view-only wallet using the Node helper.
/// Returns the exported file path on success, null on failure.
/// </summary>
public static string? ExportViewingWallet(string? destPath = null)
{
try
{
return Spoke.Meta.Node.ExportViewingWallet(destPath);
}
catch
{
return null;
}
}
}
}
21 changes: 21 additions & 0 deletions specs/main/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading