Skip to content

Latest commit

 

History

History
371 lines (268 loc) · 17.5 KB

File metadata and controls

371 lines (268 loc) · 17.5 KB

AGENTS.md - Angor Repository Guide

Project Overview

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/

Repository Structure

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

Build Commands

# 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.csproj

CLI / MCP Server

The 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.csproj

Use 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).

Deploying to USB-connected Android device (src/design rewrite)

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 angor

Package 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.

Test Commands

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"

UAT (End-to-End UI) Tests

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).

How it works

  • Each test launches one or more App.Desktop child processes (one per user profile) with ANGOR_TEST_API=1 env var
  • The automation server (App/Automation/AutomationServer.cs) starts an HTTP listener on a random port
  • Tests drive the UI via TestAutomationClient HTTP 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

Running UAT tests

# 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"

Tests to skip

  • BigFundTest, BigInvestTest — too long for routine validation

Key files

  • src/design/App/Automation/AutomationServer.cs — HTTP server + routing
  • src/design/App/Automation/AutomationFlows.cs — composite flow handlers (click, type, set controls)
  • src/design/App/Automation/AutomationDtos.cs, AutomationFlowDtos.cs — request/response DTOs
  • src/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

Important notes

  • 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

Test integrity (CRITICAL)

The purpose of tests is to verify the app is working as intended after code changes. When a test fails:

  1. Investigate the app first — assume the app is broken, not the test
  2. 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
  3. 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
  4. Treat test changes with extra scrutiny — modifying tests to mask failures defeats their entire purpose and introduces silent regressions

Architecture Rules

SDK Access Pattern (CRITICAL)

Never call SDK-layer services directly from ViewModels or UI code. All SDK functionality goes through app-layer service facades:

  • IProjectAppService - project browsing, fetching, creation
  • IFounderAppService - founder operations
  • IInvestmentAppService - investing, withdrawing, recovery

ViewModels may inject these app services, UIServices, INavigator, IWalletContext. ViewModels must never inject IProjectService, IRelayService, or other SDK-internal services.

MediatR Operation Pattern

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).

Result Type

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));

Code Style

Namespaces

  • 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

Formatting

  • 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

Naming Conventions

  • Interfaces: I prefix, PascalCase (IProjectAppService)
  • Types, properties, methods: PascalCase
  • Private fields in ViewModels/Model: camelCase without underscore (private readonly IMediator mediator;)
  • Private fields in test classes: _camelCase with underscore (private readonly Mock<IProjectService> _mockProjectService;)
  • [Reactive] fields: private camelCase, generates PascalCase property ([Reactive] private bool isDarkThemeEnabled;)
  • Strong domain types: WalletId, ProjectId, Amount, TxId, Address, DomainFeeRate (not raw strings/longs)

Type Preferences

  • Prefer explicit types over var (editorconfig setting, but not strictly enforced)
  • Use readonly for fields that don't change after construction
  • Primary constructors preferred for Handler classes and simple DI

Imports

  • global using statements in GlobalUsings.cs for common namespaces (ReactiveUI, System.Reactive, CSharpFunctionalExtensions)
  • No specific import ordering enforced; follow existing file conventions

ReactiveUI Patterns (Avalonia)

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 CompositeDisposable and .DisposeWith(disposables)
  • Use RxApp.MainThreadScheduler when pushing values that trigger UI updates from background threads
  • Use EnhancedCommand (from Zafiro) for commands with built-in IsExecuting and Successes()
  • AXAML bindings to observables use the ^ operator: {Binding Status^}

Modal Sizing (src/design rewrite)

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 Conventions

  • 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)

Dependency Injection

  • Manual ServiceCollection built in CompositionRoot.CreateMainViewModel()
  • Modular registration via extension methods: AddModelServices(), AddViewModels(), AddUIServices()
  • MediatR registered with services.AddMediatR(cfg => { ... })
  • Factory delegates for parameterized creation: Func<IProject, IDetailsViewModel>
  • ActivatorUtilities.CreateInstance for ViewModels needing both DI services and runtime parameters
  • Section ViewModels discovered via [Section]/[SectionGroup] attributes

Key Libraries

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/CD

  • ci.yml: Build + test on push/PR to main (SDK tests + shared tests + Avalonia model tests)
  • release-avalonia.yml: Triggered by v* tag push; builds Windows/Linux/macOS/Android installers, creates GitHub Release
  • gh-deploy.yml: Triggered by v* tag push; deploys Blazor WASM to angor.io (gh-pages)
  • gh-deploy-test.yml: Triggered by push to main; deploys to test.angor.io
  • pr-deploy.yml: Manual workflow; deploys a PR to debug.angor.io

Cross-Distro Docker Test Runners

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/.

Running tests

# 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"

IMPORTANT: Always capture full failure logs

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 utf8

Transient 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.

Tests to skip

  • OneClickInvestLightningTest, OneClickInvestOnChainTest — require ThunderHub/LND infrastructure

Common Pitfalls

  • Thread affinity: Pushing to BehaviorSubject from background threads will crash Avalonia. Use RxApp.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. Use VersionOverride in individual csproj files only when a project needs a different version than the centrally managed one.