Angor is a Bitcoin investment platform with two frontends:
- Avalonia desktop/mobile app (primary, .NET 9) in
src/avalonia/ - Blazor WASM web app (legacy, .NET 8) in
src/webapp/ - Shared library (.NET 8) in
src/shared/ - SDK (.NET 9) in
src/sdk/ - App (new Avalonia rewrite) in
src/design/ - CLI / MCP server (.NET 10) in
src/cli/
src/
shared/ Angor.Shared (net8.0), Angor.Shared.Tests (net8.0)
sdk/ Angor.Sdk (net9.0), Angor.Sdk.Tests, Angor.Data.Documents, Angor.Data.Documents.LiteDb
avalonia/ AngorApp, AngorApp.Model, AngorApp.Tests, AngorApp.Desktop, AngorApp.Android, AngorApp.iOS, AngorApp.Browser
design/ App, App.Desktop, App.Test.Integration (new Avalonia rewrite)
cli/ Angor.Cli (net10.0 console app), Angor.Cli.Tests
webapp/ Angor.Client (Blazor WASM, net8.0)
Avalonia.sln Main solution: avalonia + sdk + shared
App.sln New app solution: design + sdk + shared
WebApp.sln Web solution: webapp + shared
Directory.Build.props
Directory.Packages.props Central Package Management (CPM)
.editorconfig
# Avalonia desktop app (primary solution)
dotnet build src/Avalonia.sln
# Blazor web app (legacy solution)
dotnet build src/WebApp.sln
# Future app solution
dotnet build src/App.sln
# Single project build
dotnet build src/avalonia/AngorApp.Desktop/AngorApp.Desktop.csproj
dotnet build src/design/App.Desktop/App.Desktop.csprojThe Angor CLI at src/cli/ exposes the full SDK as both a command-line tool and an MCP server. See src/cli/SKILL-ANGOR-CLI.md for the complete command reference, MCP configuration, and workflow examples.
# Build CLI
dotnet build src/cli/Angor.Cli/Angor.Cli.csproj
# Run a command
dotnet run --project src/cli/Angor.Cli -- wallet list
# Start as MCP server
dotnet run --project src/cli/Angor.Cli -- --mcp
# Run CLI tests
dotnet test src/cli/Angor.Cli.Tests/Angor.Cli.Tests.csprojUse the CLI for testing and validation (e.g., checking wallet balances, browsing projects, verifying transaction flows). Use the SDK directly for code changes (adding new operations, fixing bugs in app services).
The new App.Android project requires JavaSdkDirectory set explicitly on macOS (the SDK can't auto-detect via /usr/libexec/java_home). Use openjdk@17:
# One-shot install + launch on the connected device
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
dotnet build src/design/App.Android/App.Android.csproj \
-t:Install -f net9.0-android -c Debug \
-p:JavaSdkDirectory=$JAVA_HOME \
-p:AndroidAttachDebugger=false
adb shell monkey -p io.angor.app 1 # launch installed app
# Verify device + package
adb devices
adb shell pm list packages | grep -i angorPackage id is io.angor.app. When iterating on UI changes, deploy to BOTH desktop and the USB-connected Android in parallel so both surfaces are validated each cycle.
All test projects use xUnit with FluentAssertions and Moq.
# Run all SDK tests (net9.0)
dotnet test src/sdk/Angor.Sdk.Tests/Angor.Sdk.Tests.csproj
# Run shared tests (net8.0)
dotnet test src/shared/Angor.Shared.Tests/Angor.Shared.Tests.csproj
# Run Avalonia model tests (net9.0)
dotnet test src/avalonia/AngorApp.Tests/AngorApp.Tests.csproj
# Run a single test by fully qualified name
dotnet test --filter "FullyQualifiedName=Angor.Sdk.Tests.Funding.Founder.FounderAppServiceTests.MethodName"
# Run a single test by display name
dotnet test --filter "DisplayName~GetProjectInvestmentsHandler_WhenProjectNotFound"
# Run all tests in a single class
dotnet test --filter "ClassName=Angor.Sdk.Tests.Funding.Founder.FounderAppServiceTests"The App.Test.Uat project contains end-to-end tests that launch real App.Desktop processes with Avalonia windows, driven via an HTTP automation server. The automation server is compiled only in Debug builds (#if DEBUG).
- Each test launches one or more
App.Desktopchild processes (one per user profile) withANGOR_TEST_API=1env var - The automation server (
App/Automation/AutomationServer.cs) starts an HTTP listener on a random port - Tests drive the UI via
TestAutomationClientHTTP calls that click buttons, type text, set controls, and invoke composite flows - Tests run against signet (Bitcoin testnet) — they create real wallets, fund from faucet, and broadcast real transactions
# Build (must be Debug — automation server is excluded from Release)
dotnet build src/design/App.Test.Uat/App.Test.Uat.csproj
# Run all UAT tests (excluding long-running ones)
dotnet test src/design/App.Test.Uat/App.Test.Uat.csproj --filter "FullyQualifiedName~CreateProjectTest|FullyQualifiedName~MultiFund|FullyQualifiedName~MultiInvest"
# Run a single UAT test
dotnet test src/design/App.Test.Uat/App.Test.Uat.csproj --filter "FullyQualifiedName~CreateProjectTest"- BigFundTest, BigInvestTest — too long for routine validation
src/design/App/Automation/AutomationServer.cs— HTTP server + routingsrc/design/App/Automation/AutomationFlows.cs— composite flow handlers (click, type, set controls)src/design/App/Automation/AutomationDtos.cs,AutomationFlowDtos.cs— request/response DTOssrc/design/App.Test.Uat/Helpers/TestProcessHost.cs— child process launcher (auto-detects Debug/Release from bin path)src/design/App.Test.Uat/Helpers/TestAutomationClient.cs— HTTP client wrapper
- UAT tests must be built and run in Debug configuration (automation server is
#if DEBUG) - Tests take 1–3 minutes each due to real Bitcoin transactions on signet
- Always capture full test output to log files for failure investigation (see "Cross-Distro Docker Test Runners" section)
- Transient failures may indicate race conditions — always preserve full assertion messages and stack traces
The purpose of tests is to verify the app is working as intended after code changes. When a test fails:
- Investigate the app first — assume the app is broken, not the test
- Never modify a test just to make it pass — if a test fails after an app change, the failure is telling you something is wrong in the app code
- Only change tests when the intended behavior has changed — if a feature was deliberately redesigned, update the test to match the new expected behavior, but be explicit about why
- Treat test changes with extra scrutiny — modifying tests to mask failures defeats their entire purpose and introduces silent regressions
Never call SDK-layer services directly from ViewModels or UI code. All SDK functionality goes through app-layer service facades:
IProjectAppService- project browsing, fetching, creationIFounderAppService- founder operationsIInvestmentAppService- investing, withdrawing, recovery
ViewModels may inject these app services, UIServices, INavigator, IWalletContext.
ViewModels must never inject IProjectService, IRelayService, or other SDK-internal services.
New SDK operations follow this structure in Angor.Sdk/Funding/{area}/Operations/:
public static class OperationName
{
public record OperationNameRequest(/* params */) : IRequest<Result<OperationNameResponse>>;
public record OperationNameResponse(/* return data */);
public class OperationNameHandler(/* deps via primary constructor */)
: IRequestHandler<OperationNameRequest, Result<OperationNameResponse>>
{
public async Task<Result<OperationNameResponse>> Handle(
OperationNameRequest request, CancellationToken cancellationToken)
{
// Implementation - always return Result<T>
}
}
}App services delegate to MediatR: mediator.Send(request).
Use CSharpFunctionalExtensions.Result<T> for all fallible operations. Never throw exceptions for expected failures.
return Result.Success(new MyResponse(data));
return Result.Failure<MyResponse>("Error description");
return someResult.Map(x => new MyResponse(x));
return someResult.Bind(x => anotherOperation(x));- Avalonia/SDK code: file-scoped namespaces (
namespace Foo.Bar;) - Legacy/test code: block-scoped namespaces allowed (
namespace Foo.Bar { }) - New code should use file-scoped namespaces
- 4-space indentation, CRLF line endings
- Max line length: 120 characters
- Braces on new lines for all constructs (Allman style)
- Expression-bodied members: use for properties, indexers, accessors, lambdas; avoid for methods and constructors
- Interfaces:
Iprefix, PascalCase (IProjectAppService) - Types, properties, methods: PascalCase
- Private fields in ViewModels/Model:
camelCasewithout underscore (private readonly IMediator mediator;) - Private fields in test classes:
_camelCasewith underscore (private readonly Mock<IProjectService> _mockProjectService;) [Reactive]fields:privatecamelCase, generates PascalCase property ([Reactive] private bool isDarkThemeEnabled;)- Strong domain types:
WalletId,ProjectId,Amount,TxId,Address,DomainFeeRate(not raw strings/longs)
- Prefer explicit types over
var(editorconfig setting, but not strictly enforced) - Use
readonlyfor fields that don't change after construction - Primary constructors preferred for Handler classes and simple DI
global usingstatements inGlobalUsings.csfor common namespaces (ReactiveUI, System.Reactive, CSharpFunctionalExtensions)- No specific import ordering enforced; follow existing file conventions
public partial class MyViewModel : ReactiveObject, IDisposable
{
private readonly CompositeDisposable disposables = new();
[Reactive] private string? name; // generates public Name property
public MyViewModel()
{
MyCommand = ReactiveCommand.Create(() => { /* ... */ }).DisposeWith(disposables);
this.WhenAnyValue(x => x.Name)
.Where(n => n != null)
.Subscribe(DoSomething)
.DisposeWith(disposables);
}
public void Dispose() => disposables.Dispose();
}- Always dispose subscriptions via
CompositeDisposableand.DisposeWith(disposables) - Use
RxApp.MainThreadSchedulerwhen pushing values that trigger UI updates from background threads - Use
EnhancedCommand(from Zafiro) for commands with built-inIsExecutingandSuccesses() - AXAML bindings to observables use the
^operator:{Binding Status^}
All modals in src/design/App/ use a global sizing system. Never hardcode MinWidth/MaxWidth on a modal root <Border> — apply one of three classes from UI/Shared/Styles/Modals.axaml:
| Class | Min/Max Width | Use for |
|---|---|---|
ModalCard (default) |
320 / 512 | Password prompts, wallet pickers, confirmations, success screens, deploy/invest flow |
ModalCard Wide |
320 / 672 | Tabular content (recovery, penalties, JSON, UTXO lists) |
ModalCard Full |
320 / 1030 | Reserved for true panel-like modals; not currently used |
Width tokens live in UI/Themes/V2/Resources/Tokens.axaml (ModalMinWidth, ModalMaxWidth, ModalWide*, ModalFull*). Modify tokens to change tier sizing globally; modify Modals.axaml to add tiers.
Mobile gutter: every tier sets HorizontalAlignment=Stretch + Margin=16 so phones get a guaranteed 16px gutter left/right/top/bottom on any viewport. Do NOT add inline Margin="16" to a modal root — it duplicates and produces 32px gutters.
Per-modal overrides are fine for one-offs (e.g. WalletDetail UTXO modal sets MaxWidth="896" inline alongside Classes="ModalCard Wide"). Inline MaxWidth/MinWidth overrides the class. Keep per-modal chrome (Background, BorderBrush, CornerRadius, BoxShadow, Padding, DockPanel.Dock="Bottom" footers) inline — the class governs sizing only.
Reference modal (use as the visual benchmark when sizing feels off): the wallet selector in UI/Sections/MyProjects/Deploy/DeployFlowOverlay.axaml (Screen 1).
Modal host: ShellViewModel.ShowModal(object content) mounts content into ShellView's ModalOverlay Panel. The shell handles backdrop, blur, scale-in transitions and backdrop-click close. Modal content is just a UserControl whose root <Border> carries the ModalCard class. ManageProject modals (UI/Sections/MyProjects/Modals/ManageProjectModalsView.axaml) use their own inline backdrop (ZIndex=200) but still apply the same ModalCard classes.
- Test naming:
MethodName_WhenCondition_ExpectedResult(SDK tests) or descriptive (AddStage_adds_new_stage_to_project) - Arrange/Act/Assert pattern with comments
- Use
FluentAssertions:result.IsFailure.Should().BeTrue() - Use
Mock<T>from Moq for dependencies - Use
IClassFixture<T>for shared test setup (e.g., network configuration)
- Manual
ServiceCollectionbuilt inCompositionRoot.CreateMainViewModel() - Modular registration via extension methods:
AddModelServices(),AddViewModels(),AddUIServices() - MediatR registered with
services.AddMediatR(cfg => { ... }) - Factory delegates for parameterized creation:
Func<IProject, IDetailsViewModel> ActivatorUtilities.CreateInstancefor ViewModels needing both DI services and runtime parameters- Section ViewModels discovered via
[Section]/[SectionGroup]attributes
| Library | Version | Purpose |
|---|---|---|
| Avalonia | 11.3.12 | Desktop/mobile UI framework |
| ReactiveUI | 20.1.63 | MVVM with reactive extensions |
| MediatR | 12.5.0 | CQRS mediator pattern |
| CSharpFunctionalExtensions | 3.6.0 | Result type, Maybe, railway-oriented programming |
| NBitcoin | 7.0.46 | Bitcoin protocol operations |
| Zafiro | 46-51.x | UI toolkit, commands, dialogs |
| LiteDB | 5.0.21 | Local document storage |
| Serilog | 4.3.0 | Structured logging |
| xUnit | 2.9.2 | Test framework |
| FluentAssertions | 8.0.0 | Test assertions |
ci.yml: Build + test on push/PR to main (SDK tests + shared tests + Avalonia model tests)release-avalonia.yml: Triggered byv*tag push; builds Windows/Linux/macOS/Android installers, creates GitHub Releasegh-deploy.yml: Triggered byv*tag push; deploys Blazor WASM to angor.io (gh-pages)gh-deploy-test.yml: Triggered by push to main; deploys to test.angor.iopr-deploy.yml: Manual workflow; deploys a PR to debug.angor.io
Integration tests can be run inside containerized Linux distros (Ubuntu, Fedora, Debian, Mint, Arch, openSUSE, Rocky, Manjaro) to catch distro-specific runtime issues.
Infrastructure lives in src/design/App.Test.Integration/docker/runners/.
# Build a distro image
docker build -t angor-distro-tests-ubuntu -f src/design/App.Test.Integration/docker/runners/Dockerfile.ubuntu src/design/App.Test.Integration/docker/runners
# Create a container
docker run -d --name angor-ubuntu-runner --entrypoint sleep -v "$(pwd):/src" -e ANGOR_NETWORK=testnet -e DISPLAY=:99 angor-distro-tests-ubuntu infinity
docker exec angor-ubuntu-runner bash -c 'Xvfb :99 -screen 0 1024x768x24 &'
# Restore, build, run a single test
docker exec angor-ubuntu-runner dotnet restore /src/src/design/App.Test.Integration/App.Test.Integration.csproj
docker exec angor-ubuntu-runner dotnet build /src/src/design/App.Test.Integration/App.Test.Integration.csproj --no-restore
docker exec angor-ubuntu-runner dotnet test /src/src/design/App.Test.Integration/App.Test.Integration.csproj --filter "FullyQualifiedName~App.Test.Integration.SmokeTest" --no-restore --no-build --logger "console;verbosity=normal"When running tests, always pipe full output to a log file so failures can be investigated after the fact. Do NOT discard output with Select-Object -Last N or similar truncation. Example:
docker exec angor-ubuntu-runner dotnet test ... 2>&1 | Out-File "C:\Users\dan\AppData\Local\Temp\opencode\test-ubuntu-FundAndRecoverTest.log" -Encoding utf8Transient test failures must be investigated — they may indicate race conditions or thread-safety bugs in the app, not just signet/indexer lag. Always preserve the full assertion message and stack trace.
- OneClickInvestLightningTest, OneClickInvestOnChainTest — require ThunderHub/LND infrastructure
- Thread affinity: Pushing to
BehaviorSubjectfrom background threads will crash Avalonia. UseRxApp.MainThreadScheduler.Schedule(() => subject.OnNext(value)). - Indexer lag: After broadcasting Bitcoin transactions, the indexer API may not reflect the change immediately. Use optimistic local updates and guard against stale responses in refresh handlers.
- Two .NET versions: Avalonia/SDK projects target net9.0, Blazor/Shared target net8.0. Don't mix SDK references.
- Central Package Management: All package versions are managed in
src/Directory.Packages.props. UseVersionOverridein individual csproj files only when a project needs a different version than the centrally managed one.