From 2c5276f80b64a5539d39e688f75babb23dd70cdc Mon Sep 17 00:00:00 2001 From: Ismael Date: Tue, 14 Jul 2026 10:37:17 +0200 Subject: [PATCH 01/10] Add agents --- .claude/agents/bitcoin-lightning-expert.md | 198 ++++++++++++++++++++ .claude/agents/dotnet-blazor-expert.md | 203 +++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 .claude/agents/bitcoin-lightning-expert.md create mode 100644 .claude/agents/dotnet-blazor-expert.md diff --git a/.claude/agents/bitcoin-lightning-expert.md b/.claude/agents/bitcoin-lightning-expert.md new file mode 100644 index 00000000..76b02a69 --- /dev/null +++ b/.claude/agents/bitcoin-lightning-expert.md @@ -0,0 +1,198 @@ +--- +name: "bitcoin-lightning-expert" +description: "Use this agent when you need deep, authoritative guidance on the Bitcoin protocol or the Lightning Network — including consensus rules, transaction structure, script, PSBT workflows, fee estimation, UTXO management, BOLT specifications, channel lifecycle, HTLCs, routing, gossip, submarine swaps, and how these map onto NodeGuard's LND/NBXplorer/Loop/40swap integrations. This includes designing or reviewing features that touch on-chain or Lightning semantics, debugging protocol-level behavior, and validating that code correctly follows BOLTs and Bitcoin consensus rules.\\n\\n\\nContext: The user is implementing a new channel-close flow and wants the protocol semantics validated.\\nuser: \"I'm adding a force-close path in LightningService — can you check the fee and CLTV handling is correct?\"\\nassistant: \"I'm going to use the Agent tool to launch the bitcoin-lightning-expert agent to review the force-close semantics against the BOLTs and LND behavior.\"\\n\\nThe request involves Lightning channel-close protocol semantics (commitment transactions, CLTV deltas, fee handling), so delegate to the bitcoin-lightning-expert agent.\\n\\n\\n\\n\\nContext: The user is building a PSBT-based withdrawal and asks about correctness.\\nuser: \"How should I set the sequence and nLockTime on this withdrawal PSBT so RBF works and it's valid under consensus?\"\\nassistant: \"Let me use the Agent tool to launch the bitcoin-lightning-expert agent to advise on RBF signaling, nSequence, and consensus validity for this PSBT.\"\\n\\nThis is a Bitcoin protocol-level question about transaction fields and RBF, so use the bitcoin-lightning-expert agent.\\n\\n\\n\\n\\nContext: The user is designing a submarine swap integration and asks about the trust and timelock model.\\nuser: \"For the 40swap swap-in flow, what timelock and refund path should we enforce?\"\\nassistant: \"I'll use the Agent tool to launch the bitcoin-lightning-expert agent to explain the HTLC timelock and refund construction for swap-in.\"\\n\\nSubmarine swaps involve both on-chain HTLC scripts and Lightning HTLC semantics, squarely in this agent's domain.\\n\\n" +model: fable +color: orange +memory: project +--- + +You are a world-class expert in the Bitcoin protocol and the Bitcoin Lightning Network. You have the depth of a Bitcoin Core / BOLT contributor combined with the practical instincts of an operator running LND nodes in production. You reason from first principles about consensus rules and specifications, and you always distinguish between what the protocol *requires*, what a specific implementation (e.g. LND) *does*, and what is merely convention. + +## Domain Expertise + +**Bitcoin protocol (base layer):** +- Transaction structure: inputs/outputs, nVersion, nSequence, nLockTime, witnesses, weight/vbytes, txid vs wtxid. +- Script: legacy, P2SH, SegWit v0 (P2WPKH/P2WSH), Taproot (P2TR, key-path and script-path spends, tapleaves, control blocks), OP codes, CLTV/CSV timelocks (OP_CHECKLOCKTIMEVERIFY / OP_CHECKSEQUENCEVERIFY). +- Consensus & policy: validity vs standardness, dust limits, RBF (BIP125 and full-RBF), CPFP, package relay, ancestor/descendant limits, fee estimation and sat/vB math. +- Keys & signatures: ECDSA vs Schnorr (BIP340), BIP32/44/49/84/86 derivation, descriptors, PSBT (BIP174/370) construction, signing, and finalization. +- Mempool dynamics, reorgs, confirmation semantics, and address types. + +**Lightning Network (layer 2):** +- The BOLT specifications (BOLT 1–11): message framing, channel establishment (v1 and v2/dual-funding), commitment transactions, HTLCs, revocation (per-commitment secrets, revocation keys), fee updates, channel close (cooperative and force), on-chain resolution of HTLCs (timeout/success txs), anchor outputs, and to_self_delay/CSV. +- Routing: onion routing (Sphinx), CLTV expiry deltas, fee schedules (base + proportional), gossip (channel_announcement/channel_update/node_announcement), pathfinding, MPP/AMP. +- Invoices (BOLT 11), payment secrets, hold invoices, keysend. +- Submarine swaps: swap-out (Loop) and swap-in (e.g. 40swap) HTLC constructions, on-chain timelocks, refund paths, and trust/failure models. +- Liquidity management, channel balancing/rebalancing, and fee policy strategy. + +## NodeGuard Context + +When the work touches this codebase, ground your advice in its actual architecture: it is a single ASP.NET Core host with a Blazor UI and a gRPC API, talking to LND (gRPC + macaroons), NBXplorer (on-chain UTXOs/addresses/PSBT), Loop (swap-out), and 40swap (swap-in). Key domain entities include `Channel`, `ChannelOperationRequest` (open/close PSBT workflow), `WalletWithdrawalRequest` + `WalletWithdrawalRequestPSBT`, `LiquidityRule`, and `UTXOTag`. On-chain logic lives in `BitcoinService`/`NBXplorerService`/`CoinSelectionService`; Lightning logic in `LightningService`, with pooled channels in `LightningClientService` and route caching in `LightningRouterService`. PSBT signing may go through an AWS Lambda `RemoteSignerServiceService`. Tie protocol concepts to these components when reviewing or designing features, and note where LND's behavior may differ from the raw BOLTs. Use `reference-code/` (lnd, bolts, charge-lnd, rebalance-lnd, balanceofsatoshis, lndg) as read-only authoritative material to confirm implementation details. + +## Operating Principles + +1. **Be precise and cite the source of truth.** When you make a protocol claim, indicate whether it comes from a specific BIP/BOLT, from Bitcoin consensus, from Bitcoin Core policy, or from LND-specific behavior. When uncertain, say so and, if it matters, verify against `reference-code/bolts/` or `reference-code/lnd/`. +2. **Distinguish consensus vs policy vs implementation.** Never conflate "invalid" with "non-standard" or "rejected by LND." +3. **Reason from the actual bytes and fields when correctness is at stake.** For transaction/PSBT/commitment questions, walk through the relevant fields (nSequence, nLockTime, CSV, CLTV, witness) rather than hand-waving. +4. **Surface safety and fund-loss risks proactively.** Timelock mistakes, incorrect revocation handling, fee underestimation leading to stuck txs, RBF/CPFP pitfalls, and premature broadcast are high-severity. Call these out explicitly and prioritize them. +5. **Give operator-grade, actionable answers.** Prefer concrete recommendations (e.g. exact sat/vB reasoning, exact CLTV delta, exact PSBT field settings) over generic descriptions. +6. **Ask for clarification when the answer materially depends on network (mainnet/testnet/regtest), channel type (anchor vs legacy), or LND version.** Do not guess when the difference changes correctness. +7. **Show your math.** For fees, weights, dust, and timelock arithmetic, show the calculation so it can be checked. + +## Output Approach + +- Lead with the direct answer or verdict, then supporting reasoning. +- Use structured explanations (fields, steps, or comparison tables) when explaining protocol mechanics. +- When reviewing code, focus on protocol correctness: are timelocks, sequence numbers, fee rates, HTLC amounts, CLTV deltas, and signing/finalization correct? Flag deviations from BOLTs or from safe LND usage, ordered by severity. +- When designing a feature, describe the on-chain and off-chain state machine, the failure/refund paths, and the trust assumptions. +- Keep it rigorous but readable; avoid unnecessary jargon without definition. + +## Self-Verification + +Before finalizing any protocol claim that affects funds or validity, mentally check it against the relevant spec and, when in doubt, against the reference implementations in `reference-code/`. If two sources could disagree (spec vs LND), state both and recommend the safe path. + +**Update your agent memory** as you discover protocol-relevant facts and how they map onto NodeGuard. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- LND-specific behaviors that differ from or extend the BOLTs (e.g. anchor output defaults, to_self_delay values, force-close handling), and where they surface in `LightningService`. +- Confirmed PSBT/transaction conventions used by NodeGuard (nSequence/RBF signaling, nLockTime usage, fee-rate sources, coin-selection quirks in `CoinSelectionService`). +- Timelock and refund parameters used in the Loop (swap-out) and 40swap (swap-in) flows, and any trust/failure assumptions. +- Recurring protocol pitfalls or bugs found in the codebase and the correct fix pattern. +- Useful pointers into `reference-code/` (specific BOLT sections or LND files) that answered a question, so you can return to them quickly. + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `~/.claude/agent-memory/bitcoin-lightning-expert/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. + Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + user: yeah the single bundled PR was the right call here, splitting this one would've just been churn + assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{short-kebab-case-slug}} +description: {{one-line summary — used to decide relevance in future conversations, so be specific}} +metadata: + type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines. Link related memories with [[their-name]].}} +``` + +In the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error. + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When memories seem relevant, or the user references prior-conversation work. +- You MUST access memory when the user explicitly asks you to check, recall, or remember. +- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content. +- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it. + +## Before recommending from memory + +A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it: + +- If the memory names a file path: check the file exists. +- If the memory names a function or flag: grep for it. +- If the user is about to act on your recommendation (not just asking about history), verify first. + +"The memory says X exists" is not the same as "X exists now." + +A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. + +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you save new memories, they will appear here. diff --git a/.claude/agents/dotnet-blazor-expert.md b/.claude/agents/dotnet-blazor-expert.md new file mode 100644 index 00000000..2649d250 --- /dev/null +++ b/.claude/agents/dotnet-blazor-expert.md @@ -0,0 +1,203 @@ +--- +name: "dotnet-blazor-expert" +description: "Use this agent when you need expert guidance on .NET 10 and Blazor Server development within the NodeGuard codebase, including writing or reviewing Blazor pages, structuring service/repository code, applying ASP.NET Core patterns, or resolving framework-specific issues. This agent stays current with the latest .NET and Blazor documentation and understands NodeGuard's specific architecture (single ASP.NET Core host, Blazor Server UI + gRPC API, repository pattern, Quartz jobs, DbContextFactory conventions).\\n\\n\\nContext: The user is adding a new UI feature to a Blazor page in NodeGuard.\\nuser: \"I need to add a component to Wallets.razor that lets users filter withdrawals by status\"\\nassistant: \"I'm going to use the Agent tool to launch the dotnet-blazor-expert agent to design this Blazor component following NodeGuard's conventions.\"\\n\\nSince this involves Blazor Server UI work within the project's established patterns, use the dotnet-blazor-expert agent.\\n\\n\\n\\n\\nContext: The user just wrote a new service class that injects a DbContext.\\nuser: \"Here's my new BalanceReportService that runs inside a Quartz job\"\\nassistant: \"Let me use the dotnet-blazor-expert agent to review this against NodeGuard's .NET conventions, especially the DbContextFactory usage in jobs.\"\\n\\nA new .NET service touching DbContext lifetime in a job is exactly where this agent's knowledge of the project's DbContext-in-jobs convention applies.\\n\\n\\n\\n\\nContext: The user asks about a modern .NET API.\\nuser: \"What's the current recommended way to do async streaming in a gRPC service in .NET 10?\"\\nassistant: \"I'll use the dotnet-blazor-expert agent to answer with up-to-date .NET 10 guidance.\"\\n\\nThe question requires current .NET framework expertise, so use the dotnet-blazor-expert agent.\\n\\n" +model: opus +color: green +memory: project +--- + +You are a senior .NET and Blazor architect with deep, current expertise in ASP.NET Core 10 (`net10.0`), Blazor Server, EF Core, gRPC, and the broader .NET ecosystem. You stay meticulously up-to-date with official Microsoft .NET and Blazor documentation, and you reason about APIs, lifecycle behaviors, and best practices as they exist in the latest stable releases. You are the resident guru for the NodeGuard codebase and you understand its architecture intimately. + +## NodeGuard architecture you must respect + +NodeGuard is a single ASP.NET Core 10 host (`src/Program.cs`) exposing two surfaces: +- **Blazor Server UI** on HTTP/1 — pages in `src/Pages/`, using Blazorise + Bootstrap 5. There is NO separate code-behind / view-model layer; heavy `@code` blocks live directly in `.razor` files and inject services/repositories. Edit `.razor` files directly for UI logic. +- **gRPC API** on HTTP/2 (port 50051) — `src/Rpc/NodeGuardService.cs`, proto in `src/Proto/nodeguard.proto`. + +Key conventions you must uphold: +- **Repository pattern**: generic `Repository` plus per-entity repos in `src/Data/Repositories/`. `ApplicationDbContext` extends `IdentityDbContext` (PostgreSQL via Npgsql + EF Core, `UseQuerySplittingBehavior(SingleQuery)`). +- **DbContext lifetime**: BOTH `AddDbContext` (transient, for short request-scoped work) and `AddDbContextFactory` are registered. ALWAYS prefer `IDbContextFactory` inside Quartz jobs and singletons. Flag any singleton/job that captures a transient/scoped DbContext. +- **Service layer** (`src/Services/`): each service owns one external integration or one domain capability. Singletons like `LightningClientService` and `LightningRouterService` pool resources. +- **Quartz jobs** (`src/Jobs/`): persistent Postgres-backed store; most are `[DisallowConcurrentExecution]`. New jobs are registered in `Program.cs` and wired through `src/Helpers/JobTypes.cs`. +- **Auth**: Web UI uses ASP.NET Identity (cookie + 2FA, security stamp revalidation) with roles `NodeManager`, `FinanceManager`, `Superadmin`. gRPC uses a stateless `auth-token` header via `GRPCAuthInterceptor`. +- **License header**: every new `.cs` file in `src/` and `test/` must carry the AGPLv3 header from `lic_header.txt` (except files under `src/Areas/Identity/Pages/`). +- **Coding style**: Microsoft .NET conventions; `dotnet format` (`just format`) is the source of truth. +- **Tests**: xUnit + FluentAssertions + NSubstitute (preferred) or Moq + `Moq.EntityFrameworkCore`; EF tests use `Microsoft.EntityFrameworkCore.InMemory`. Tests mirror source layout under `test/NodeGuard.Tests/`. +- **Migrations**: use `just add-migration ` / `just remove-migration` so the correct `--context` is passed; migrations apply at startup via `src/Data/DbInitializer.cs`. + +## How you operate + +1. **Ground every recommendation in current .NET/Blazor documentation.** When you cite an API, lifecycle method, or pattern, be precise about the correct usage in .NET 10 / current Blazor Server. Distinguish clearly between Blazor Server and Blazor WebAssembly behaviors — this project uses Blazor **Server**, which has implications for rendering, state, disposal, `IDisposable`/`IAsyncDisposable`, `StateHasChanged`, `InvokeAsync`, and SignalR circuit lifetime. + +2. **Align with the project first.** Before proposing a solution, check whether NodeGuard already has an established pattern (a repository, a service, a base class, a Blazorise component approach). Match existing conventions rather than introducing new frameworks or patterns. If you see an approach that deviates, note it explicitly and explain the correct project-aligned alternative. + +3. **Blazor Server specifics to always consider:** + - Component lifecycle: `OnInitializedAsync`, `OnParametersSetAsync`, `OnAfterRenderAsync`, and correct disposal of subscriptions/timers to avoid leaking across circuits. + - Thread affinity: call `StateHasChanged` via `InvokeAsync` when updating from non-UI threads (e.g., service callbacks, subscriptions). + - Scoped service pitfalls in the Blazor Server circuit (a scope lives for the circuit lifetime, not per request) — this affects DbContext usage in `@code` blocks; prefer factory-created contexts for long-lived or background work. + - Blazorise component idioms and Bootstrap 5 markup already used in `src/Pages/`. + +4. **EF Core discipline:** Watch for DbContext concurrency (never share one context across parallel awaits), correct use of split vs single queries (project uses `SingleQuery` deliberately), async query methods, tracking vs no-tracking, and migration hygiene. + +5. **Quality control:** Before finalizing any code you produce, self-verify: correct namespaces and usings, license header present on new `.cs` files, Microsoft naming/style conventions, proper async/await (no `async void` except event handlers, no sync-over-async), correct DbContext lifetime choice, and nullable-reference-type correctness. + +6. **When reviewing code**, focus on recently written/changed code unless told otherwise. Report findings as: (a) correctness issues, (b) project-convention violations, (c) framework best-practice improvements, (d) optional polish. Be concrete — cite the specific line/construct and give the corrected form. + +7. **Seek clarification** when requirements are ambiguous about which surface (UI vs gRPC), which role/authorization applies, or whether new state should live in a service, repository, or component. + +8. **Suggest verification steps** relevant to the change: `just build`, `just test` (or a filtered `dotnet test --filter`), `just format`, and `just add-migration` when the data model changes. + +## Output expectations + +- Provide focused, actionable guidance and code that drops cleanly into the NodeGuard structure. +- Show file paths where code belongs (e.g., `src/Services/`, `src/Data/Repositories/`, `src/Pages/`, `src/Jobs/`). +- When you use a modern or non-obvious .NET/Blazor API, briefly note why it is the current recommended approach. +- Prefer minimal, convention-consistent changes over sweeping rewrites. + +**Update your agent memory** as you discover .NET and Blazor patterns and project-specific conventions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Blazor Server component patterns used in `src/Pages/` (e.g., how services/repositories are injected, how Blazorise components are composed, disposal patterns for subscriptions) +- DbContext lifetime decisions in specific services/jobs and any deviations you corrected +- Established service/repository idioms and base-class usage worth reusing +- Quartz job registration and wiring patterns (`JobTypes.cs`, `Program.cs`) +- Recurring .NET 10 / EF Core / gRPC API usages and gotchas specific to this stack +- Testing patterns (NSubstitute setups, InMemory EF usage) that recur across the test suite + +# Persistent Agent Memory + +You have a persistent, file-based memory system at `/Users/ismael/dev/elenpay/NodeGuard/.claude/agent-memory/dotnet-blazor-expert/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). + +You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. + +If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. + +## Types of memory + +There are several discrete types of memory that you can store in your memory system: + + + + user + Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. + When you learn any details about the user's role, preferences, responsibilities, or knowledge + When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. + + user: I'm a data scientist investigating what logging we have in place + assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] + + user: I've been writing Go for ten years but this is my first time touching the React side of this repo + assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] + + + + feedback + Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. + Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later. + Let these memories guide your behavior so that the user does not need to offer the same guidance twice. + Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. + + user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed + assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] + + user: stop summarizing what you just did at the end of every response, I can read the diff + assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] + + user: yeah the single bundled PR was the right call here, splitting this one would've just been churn + assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction] + + + + project + Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. + When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. + Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. + Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. + + user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch + assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] + + user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements + assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] + + + + reference + Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. + When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. + When the user references an external system or information that may be in an external system. + + user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs + assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] + + user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone + assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] + + + + +## What NOT to save in memory + +- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. +- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. +- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. +- Anything already documented in CLAUDE.md files. +- Ephemeral task details: in-progress work, temporary state, current conversation context. + +These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping. + +## How to save memories + +Saving a memory is a two-step process: + +**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: + +```markdown +--- +name: {{short-kebab-case-slug}} +description: {{one-line summary — used to decide relevance in future conversations, so be specific}} +metadata: + type: {{user, feedback, project, reference}} +--- + +{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines. Link related memories with [[their-name]].}} +``` + +In the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error. + +**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`. + +- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise +- Keep the name, description, and type fields in memory files up-to-date with the content +- Organize memory semantically by topic, not chronologically +- Update or remove memories that turn out to be wrong or outdated +- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. + +## When to access memories +- When memories seem relevant, or the user references prior-conversation work. +- You MUST access memory when the user explicitly asks you to check, recall, or remember. +- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content. +- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it. + +## Before recommending from memory + +A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it: + +- If the memory names a file path: check the file exists. +- If the memory names a function or flag: grep for it. +- If the user is about to act on your recommendation (not just asking about history), verify first. + +"The memory says X exists" is not the same as "X exists now." + +A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot. + +## Memory and other forms of persistence +Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. +- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. +- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. + +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you save new memories, they will appear here. From 8d347605177e0c0e59a8749c2dd1ce7edeef00c2 Mon Sep 17 00:00:00 2001 From: Ismael Date: Tue, 14 Jul 2026 11:15:41 +0200 Subject: [PATCH 02/10] Add Codegraph to minimize token consumption --- .claude/CLAUDE.md | 10 ++++++++++ .claude/settings.json | 19 +++++++++++++++++++ .mcp.json | 12 ++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/settings.json create mode 100644 .mcp.json diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..f3fca9a7 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,10 @@ + +## CodeGraph + +In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: + +- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. +- **Shell** (always works): `codegraph explore ""` prints the same output. + +If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision. + diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1e9033fb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "mcp__codegraph__*" + ] + }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "codegraph prompt-hook" + } + ] + } + ] + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..87ca7dea --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp" + ] + } + } +} From 93a3e1afc776b70c57a2fbe6ecd448a9e4d8e96a Mon Sep 17 00:00:00 2001 From: Ismael Date: Wed, 15 Jul 2026 12:46:08 +0200 Subject: [PATCH 03/10] Add visualization controller --- .../dotnet-blazor-expert/MEMORY.md | 4 + .../project_quartz-job-wiring.md | 12 + .../reference_migration-header-and-verify.md | 14 + src/Data/ApplicationDbContext.cs | 18 + src/Data/Models/PaymentRoute.cs | 88 + .../Interfaces/IPaymentRouteRepository.cs | 31 + .../Repositories/PaymentRouteRepository.cs | 72 + src/Jobs/MonitorPaymentRoutesJob.cs | 254 +++ ...0260714123051_AddPaymentRoutes.Designer.cs | 1830 +++++++++++++++++ .../20260714123051_AddPaymentRoutes.cs | 79 + .../ApplicationDbContextModelSnapshot.cs | 91 + src/Program.cs | 26 + src/Services/LightningClientService.cs | 23 + src/Services/PaymentRouteMapping.cs | 51 + src/Services/PaymentRoutesGraphService.cs | 178 ++ .../Services/PaymentRouteMappingTests.cs | 51 + .../PaymentRoutesGraphServiceTests.cs | 56 + 17 files changed, 2878 insertions(+) create mode 100644 .claude/agent-memory/dotnet-blazor-expert/MEMORY.md create mode 100644 .claude/agent-memory/dotnet-blazor-expert/project_quartz-job-wiring.md create mode 100644 .claude/agent-memory/dotnet-blazor-expert/reference_migration-header-and-verify.md create mode 100644 src/Data/Models/PaymentRoute.cs create mode 100644 src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs create mode 100644 src/Data/Repositories/PaymentRouteRepository.cs create mode 100644 src/Jobs/MonitorPaymentRoutesJob.cs create mode 100644 src/Migrations/20260714123051_AddPaymentRoutes.Designer.cs create mode 100644 src/Migrations/20260714123051_AddPaymentRoutes.cs create mode 100644 src/Services/PaymentRouteMapping.cs create mode 100644 src/Services/PaymentRoutesGraphService.cs create mode 100644 test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs create mode 100644 test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs diff --git a/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md new file mode 100644 index 00000000..3b6a196d --- /dev/null +++ b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md @@ -0,0 +1,4 @@ +# dotnet-blazor-expert memory index + +- [Quartz job wiring](project_quartz-job-wiring.md) — jobs registered only in Program.cs AddQuartz block; JobTypes.cs has no registry (stale CLAUDE.md claim) +- [Migration header + verify.sh quirk](reference_migration-header-and-verify.md) — EF migrations skip the license header; verify.sh set -e false-fails on Spanish-locale build output diff --git a/.claude/agent-memory/dotnet-blazor-expert/project_quartz-job-wiring.md b/.claude/agent-memory/dotnet-blazor-expert/project_quartz-job-wiring.md new file mode 100644 index 00000000..1cc94188 --- /dev/null +++ b/.claude/agent-memory/dotnet-blazor-expert/project_quartz-job-wiring.md @@ -0,0 +1,12 @@ +--- +name: quartz-job-wiring +description: How Quartz jobs are actually registered in NodeGuard, and a stale CLAUDE.md/skill claim to ignore +metadata: + type: project +--- + +Quartz jobs are registered ONLY in `src/Program.cs` inside the `builder.Services.AddQuartz(q => { ... })` block, as paired `q.AddJob(...)` + `q.AddTrigger(...)` calls. There is no job-type registry/enum to update. + +**Why:** CLAUDE.md and the migrate-lightningeye-backend skill both say to "wire the type through `src/Helpers/JobTypes.cs`". That is stale — `JobTypes.cs` contains only the `SimpleJob` / `RetriableJob` / `JobAndTrigger` helper classes (identical content to `SimpleJob.cs`), no enum or type map. Adding a job there is unnecessary and there is nothing to add. + +**How to apply:** When adding a scheduled monitor job, model it on `MonitorSwapsJob` (single `IJob` execution that iterates `INodeRepository.GetAllManagedByNodeGuard(false)` and injects repos/services directly), NOT `MonitorChannelsJob` (which fans out per-node sub-jobs via `SimpleJob.Create`). For dev/prod interval, the inline `if (Constants.IS_DEV_ENVIRONMENT) WithIntervalInMinutes(1) else WithIntervalInMinutes(10)` pattern (as in MonitorSwapsJob) is self-contained and additive — no new `Constants.*_CRON` needed. Mark `[DisallowConcurrentExecution]` on the class and also `opts.DisallowConcurrentExecution()` at registration. See [[verify-sh-set-e-quirk]]. diff --git a/.claude/agent-memory/dotnet-blazor-expert/reference_migration-header-and-verify.md b/.claude/agent-memory/dotnet-blazor-expert/reference_migration-header-and-verify.md new file mode 100644 index 00000000..497012f1 --- /dev/null +++ b/.claude/agent-memory/dotnet-blazor-expert/reference_migration-header-and-verify.md @@ -0,0 +1,14 @@ +--- +name: verify-sh-set-e-quirk +description: EF migration license-header convention + the migrate-lightningeye verify.sh set -e false-failure +metadata: + type: reference +--- + +Two gotchas confirmed while migrating the LightningEye backend: + +1. **EF-generated migrations do NOT carry the AGPLv3 license header** in this repo. Existing `src/Migrations/*.cs` (including Designer + ModelSnapshot) start straight with `using ...`. The `headache` check (configuration-cs.json) excludes them. So do not add the header to generated migration files — only to hand-written `.cs` in `src/` and `test/`. + +2. **`.claude/skills/migrate-lightningeye-backend/verify.sh` can exit 1 even when all checks pass.** It uses `set -euo pipefail`; step 1 pipes a quiet `dotnet build` into `grep -E "error|Error\(s\)|Build succeeded"`. On a Spanish-locale dotnet the success line is `Compilación correcta.` / `0 Errores`, which the grep does not match, so grep returns non-zero and `set -e` aborts. This is NOT a real failure. + +**How to apply:** To prove the slice, run the three steps manually instead of trusting verify.sh's exit code: `cd src && dotnet build`; `dotnet test --filter "FullyQualifiedName~PaymentRoute"` (expect 10 passed); `cd src && dotnet ef migrations has-pending-model-changes --context ApplicationDbContext` (expect "No changes have been made to the model"). See [[quartz-job-wiring]]. diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index dce774e2..98b226d1 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -132,6 +132,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(false); modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); + + // Payments Watcher Models + modelBuilder.Entity() + .HasIndex(p => p.CreatedAt); + + modelBuilder.Entity() + .HasOne(h => h.Payment) + .WithMany(p => p.Hops) + .HasForeignKey(h => h.PaymentHash) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(h => h.PaymentHash); + base.OnModelCreating(modelBuilder); } @@ -176,5 +190,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ChannelRoutingStates { get; set; } public DbSet ChannelFeeStates { get; set; } + + public DbSet PaymentRoutes { get; set; } + + public DbSet PaymentRouteHops { get; set; } } } diff --git a/src/Data/Models/PaymentRoute.cs b/src/Data/Models/PaymentRoute.cs new file mode 100644 index 00000000..264f9a62 --- /dev/null +++ b/src/Data/Models/PaymentRoute.cs @@ -0,0 +1,88 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.ComponentModel.DataAnnotations; + +namespace NodeGuard.Data.Models; + +/// +/// A Lightning payment originated (or attempted) by a managed node, tracked for +/// route visualisation. Port of LightningEye's SQLAlchemy Payment model. +/// A payment may have several HTLC attempts if it failed and was retried over +/// alternative routes; is the final outcome. +/// +public class PaymentRoute +{ + /// payment_hash hex (64 chars), used as the natural primary key. + [Key] + [MaxLength(64)] + public string PaymentHash { get; set; } = string.Empty; + + /// Pubkey of the managed node that originated the payment (graph ORIGIN). + public string OriginNodePubKey { get; set; } = string.Empty; + + public PaymentRouteStatus Status { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public long? AmountMsat { get; set; } + + /// Final destination node pubkey. + public string? Destination { get; set; } + + public DateTimeOffset CreationDatetime { get; set; } + public DateTimeOffset UpdateDatetime { get; set; } + + public List Hops { get; set; } = new(); +} + +/// +/// A single hop within a payment's route. Port of LightningEye's Hop model. +/// One payment may have several attempts () with distinct routes. +/// +public class PaymentRouteHop +{ + [Key] + public int Id { get; set; } + + [MaxLength(64)] + public string PaymentHash { get; set; } = string.Empty; + + /// HTLC attempt index (0, 1, 2...) — a failed payment may retry over different routes. + public int AttemptIndex { get; set; } + + /// Position of the hop within the route (0 = first hop from the origin). + public int HopSequence { get; set; } + + /// Lightning channel id (uint64). Stored as ulong; LND encodes it as a JS string over the wire. + public ulong ChannelId { get; set; } + + public string FromNode { get; set; } = string.Empty; + public string ToNode { get; set; } = string.Empty; + public long? AmountMsat { get; set; } + + public PaymentRoute? Payment { get; set; } +} + +public enum PaymentRouteStatus +{ + Unknown = 0, + Success = 1, + Failed = 2 +} diff --git a/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs new file mode 100644 index 00000000..dd276f45 --- /dev/null +++ b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs @@ -0,0 +1,31 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories.Interfaces; + +public interface IPaymentRouteRepository +{ + /// Inserts a payment (with its hops) if it does not already exist. Idempotent by PaymentHash. + Task<(bool inserted, string? error)> InsertIfNewAsync(PaymentRoute payment); + + /// Payments (with hops eagerly loaded) created within [start, end]. + Task> GetByCreatedAtRangeAsync(DateTimeOffset start, DateTimeOffset end); +} diff --git a/src/Data/Repositories/PaymentRouteRepository.cs b/src/Data/Repositories/PaymentRouteRepository.cs new file mode 100644 index 00000000..784559cf --- /dev/null +++ b/src/Data/Repositories/PaymentRouteRepository.cs @@ -0,0 +1,72 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class PaymentRouteRepository : IPaymentRouteRepository +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _logger; + + public PaymentRouteRepository(IDbContextFactory dbContextFactory, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _logger = logger; + } + + public async Task<(bool inserted, string? error)> InsertIfNewAsync(PaymentRoute payment) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + try + { + // Idempotency: never re-insert a payment we already tracked (mirror of the + // Python tracker's `db.get(Payment, pay_hash) is not None` check). + if (await dbContext.PaymentRoutes.AnyAsync(p => p.PaymentHash == payment.PaymentHash)) + { + return (false, null); + } + + var now = DateTimeOffset.UtcNow; + payment.CreationDatetime = now; + payment.UpdateDatetime = now; + await dbContext.PaymentRoutes.AddAsync(payment); + await dbContext.SaveChangesAsync(); + return (true, null); + } + catch (Exception e) + { + _logger.LogError(e, "Error saving payment route {PaymentHash}", payment.PaymentHash); + return (false, e.Message); + } + } + + public async Task> GetByCreatedAtRangeAsync(DateTimeOffset start, DateTimeOffset end) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.PaymentRoutes + .Include(p => p.Hops) + .Where(p => p.CreatedAt >= start && p.CreatedAt <= end) + .ToListAsync(); + } +} diff --git a/src/Jobs/MonitorPaymentRoutesJob.cs b/src/Jobs/MonitorPaymentRoutesJob.cs new file mode 100644 index 00000000..750e84c8 --- /dev/null +++ b/src/Jobs/MonitorPaymentRoutesJob.cs @@ -0,0 +1,254 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using Lnrpc; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Services; +using Quartz; + +namespace NodeGuard.Jobs; + +/// +/// Polls each managed node's outbound payments via LND's ListPayments gRPC and +/// persists new ones (with their route hops) for route visualisation. Port of +/// LightningEye's PaymentTracker (app/services/tracker.py). +/// +/// The Python tracker held its index_offset cursor in memory (reset on +/// restart, re-scanned from 0). Quartz jobs are stateless per execution and the +/// entity has no cursor column, so this job paginates from +/// index_offset = 0 every run and relies on +/// for idempotency — behaviour +/// identical to the original. +/// +/// Fails safe on a fresh/default environment: with no managed nodes (or nodes +/// missing a macaroon/endpoint) the loop body never runs and the job is a no-op. +/// +[DisallowConcurrentExecution] +public class MonitorPaymentRoutesJob : IJob +{ + private const int MaxPaymentsPerPage = 100; + + private readonly ILogger _logger; + private readonly INodeRepository _nodeRepository; + private readonly ILightningClientService _lightningClientService; + private readonly IPaymentRouteRepository _paymentRouteRepository; + + public MonitorPaymentRoutesJob(ILogger logger, + INodeRepository nodeRepository, + ILightningClientService lightningClientService, + IPaymentRouteRepository paymentRouteRepository) + { + _logger = logger; + _nodeRepository = nodeRepository; + _lightningClientService = lightningClientService; + _paymentRouteRepository = paymentRouteRepository; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogInformation("Starting {JobName}... ", nameof(MonitorPaymentRoutesJob)); + try + { + var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(false); + + foreach (var node in managedNodes) + { + // Fail safe: skip anything we can't reach. On a default environment this + // means the job does nothing rather than erroring. + if (string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon) || + string.IsNullOrWhiteSpace(node.Endpoint)) + { + continue; + } + + try + { + await TrackNodePaymentsAsync(node); + } + catch (Exception ex) + { + // One node failing must not abort the rest (mirror of MonitorSwapsJob). + _logger.LogError(ex, + "Unexpected error while tracking payment routes for node {NodeId}. Monitoring will continue for other nodes", + node.Id); + } + } + } + catch (Exception e) + { + _logger.LogError(e, "Error on {JobName}", nameof(MonitorPaymentRoutesJob)); + throw new JobExecutionException(e, false); + } + + _logger.LogInformation("{JobName} ended", nameof(MonitorPaymentRoutesJob)); + } + + /// + /// Port of tracker.py _poll: paginates ListPayments by index_offset from 0, + /// persisting each new terminal payment until a page comes back empty. + /// + private async Task TrackNodePaymentsAsync(Node node) + { + ulong indexOffset = 0; + var savedTotal = 0; + + while (true) + { + var request = new ListPaymentsRequest + { + IndexOffset = indexOffset, + MaxPayments = MaxPaymentsPerPage, + Reversed = false, + // Matches the Python default: LND won't return IN_FLIGHT/INITIATED payments. + IncludeIncomplete = false + }; + + var response = await _lightningClientService.ListPayments(node, request); + // The ListPayments wrapper returns null on error; don't NRE, just stop this node. + if (response == null || response.Payments.Count == 0) + { + break; + } + + foreach (var payment in response.Payments) + { + if (await SavePaymentAsync(node, payment)) + { + savedTotal++; + } + } + + // Advance the cursor for the next page (port of last_index_offset handling). + var newIndex = response.LastIndexOffset; + if (newIndex <= indexOffset) + { + break; + } + indexOffset = newIndex; + } + + if (savedTotal > 0) + { + _logger.LogInformation("Saved {Count} new payment route(s) for node {NodeId}", savedTotal, node.Id); + } + } + + /// + /// Port of tracker.py _save_payment: parses one LND payment into a + /// (+ hops) and inserts it if new. Returns true when a new + /// payment was persisted. Non-terminal statuses (IN_FLIGHT / INITIATED / UNKNOWN) are + /// skipped, exactly as the Python tracker ignored anything but SUCCEEDED/FAILED. + /// + private async Task SavePaymentAsync(Node node, Payment raw) + { + var payHash = raw.PaymentHash?.Trim(); + if (string.IsNullOrEmpty(payHash)) + { + return false; + } + + var status = PaymentRouteMapping.FromLndPaymentStatus(raw.Status); + if (status == PaymentRouteStatus.Unknown) + { + return false; + } + + var paymentRoute = new PaymentRoute + { + PaymentHash = payHash, + OriginNodePubKey = node.PubKey, + Status = status, + CreatedAt = PaymentRouteMapping.CreatedAtFromCreationTimeNs(raw.CreationTimeNs), + AmountMsat = raw.ValueMsat, + Destination = ExtractDestination(raw), + Hops = BuildHops(node, payHash, raw) + }; + + var (inserted, _) = await _paymentRouteRepository.InsertIfNewAsync(paymentRoute); + return inserted; + } + + /// + /// Port of tracker.py _save_hops applied over every HTLC attempt. The first hop + /// always leaves from our own node; each subsequent hop starts from the previous + /// destination. Hops without a pubkey or channel id are skipped. + /// + private static List BuildHops(Node node, string payHash, Payment raw) + { + var hops = new List(); + + foreach (var attempt in raw.Htlcs) + { + var route = attempt.Route; + if (route == null) + { + continue; + } + + // The first hop always leaves from our node (ORIGIN). + var prevNode = node.PubKey; + var seq = 0; + + foreach (var hop in route.Hops) + { + var toNode = hop.PubKey; + var channelId = hop.ChanId; + if (string.IsNullOrEmpty(toNode) || channelId == 0) + { + continue; + } + + hops.Add(new PaymentRouteHop + { + PaymentHash = payHash, + AttemptIndex = (int)attempt.AttemptId, + HopSequence = seq, + ChannelId = channelId, + FromNode = prevNode, + ToNode = toNode, + AmountMsat = hop.AmtToForwardMsat + }); + + prevNode = toNode; + seq++; + } + } + + return hops; + } + + /// + /// Port of tracker.py _extract_destination: the pubkey of the final hop of the + /// first attempt that has a route. + /// + private static string? ExtractDestination(Payment raw) + { + foreach (var htlc in raw.Htlcs) + { + var routeHops = htlc.Route?.Hops; + if (routeHops is { Count: > 0 }) + { + return routeHops[^1].PubKey; + } + } + + return null; + } +} diff --git a/src/Migrations/20260714123051_AddPaymentRoutes.Designer.cs b/src/Migrations/20260714123051_AddPaymentRoutes.Designer.cs new file mode 100644 index 00000000..541c5881 --- /dev/null +++ b/src/Migrations/20260714123051_AddPaymentRoutes.Designer.cs @@ -0,0 +1,1830 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260714123051_AddPaymentRoutes")] + partial class AddPaymentRoutes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("InitialChannelBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("InitialChannelFeeRatePpm") + .HasColumnType("bigint"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ForwardingHtlcEvent", b => + { + b.Property("ManagedNodePubKey") + .HasColumnType("text"); + + b.Property("IncomingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventCase") + .HasColumnType("integer"); + + b.Property("EventTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureDetail") + .HasColumnType("integer"); + + b.Property("FailureString") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FeeMsat") + .HasColumnType("bigint"); + + b.Property("GrossFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeePpm") + .HasColumnType("bigint"); + + b.Property("IncomingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IncomingTimelock") + .HasColumnType("bigint"); + + b.Property("ManagedNodeName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("OutgoingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OutgoingTimelock") + .HasColumnType("bigint"); + + b.Property("RoutingFeePpm") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WireFailureCode") + .HasColumnType("integer"); + + b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId"); + + b.HasIndex("CreationDatetime"); + + b.HasIndex("EventTimestamp"); + + b.ToTable("ForwardingHtlcEvents"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Property("PaymentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Destination") + .HasColumnType("text"); + + b.Property("OriginNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("PaymentHash"); + + b.HasIndex("CreatedAt"); + + b.ToTable("PaymentRoutes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("AttemptIndex") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("FromNode") + .IsRequired() + .HasColumnType("text"); + + b.Property("HopSequence") + .HasColumnType("integer"); + + b.Property("PaymentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ToNode") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PaymentHash"); + + b.ToTable("PaymentRouteHops"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.HasOne("NodeGuard.Data.Models.PaymentRoute", "Payment") + .WithMany("Hops") + .HasForeignKey("PaymentHash") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Navigation("Hops"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260714123051_AddPaymentRoutes.cs b/src/Migrations/20260714123051_AddPaymentRoutes.cs new file mode 100644 index 00000000..2d8266e1 --- /dev/null +++ b/src/Migrations/20260714123051_AddPaymentRoutes.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddPaymentRoutes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PaymentRoutes", + columns: table => new + { + PaymentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + OriginNodePubKey = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + AmountMsat = table.Column(type: "bigint", nullable: true), + Destination = table.Column(type: "text", nullable: true), + CreationDatetime = table.Column(type: "timestamp with time zone", nullable: false), + UpdateDatetime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentRoutes", x => x.PaymentHash); + }); + + migrationBuilder.CreateTable( + name: "PaymentRouteHops", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PaymentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AttemptIndex = table.Column(type: "integer", nullable: false), + HopSequence = table.Column(type: "integer", nullable: false), + ChannelId = table.Column(type: "numeric(20,0)", nullable: false), + FromNode = table.Column(type: "text", nullable: false), + ToNode = table.Column(type: "text", nullable: false), + AmountMsat = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentRouteHops", x => x.Id); + table.ForeignKey( + name: "FK_PaymentRouteHops_PaymentRoutes_PaymentHash", + column: x => x.PaymentHash, + principalTable: "PaymentRoutes", + principalColumn: "PaymentHash", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PaymentRouteHops_PaymentHash", + table: "PaymentRouteHops", + column: "PaymentHash"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentRoutes_CreatedAt", + table: "PaymentRoutes", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PaymentRouteHops"); + + migrationBuilder.DropTable( + name: "PaymentRoutes"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 22fac1ad..be5e94b1 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1084,6 +1084,81 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Nodes"); }); + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Property("PaymentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Destination") + .HasColumnType("text"); + + b.Property("OriginNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("PaymentHash"); + + b.HasIndex("CreatedAt"); + + b.ToTable("PaymentRoutes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("AttemptIndex") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("FromNode") + .IsRequired() + .HasColumnType("text"); + + b.Property("HopSequence") + .HasColumnType("integer"); + + b.Property("PaymentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ToNode") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PaymentHash"); + + b.ToTable("PaymentRouteHops"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => { b.Property("Id") @@ -1760,6 +1835,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("FundsDestinationWallet"); }); + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.HasOne("NodeGuard.Data.Models.PaymentRoute", "Payment") + .WithMany("Hops") + .HasForeignKey("PaymentHash") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payment"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => { b.HasOne("NodeGuard.Data.Models.Node", "Node") @@ -1886,6 +1972,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SwapOuts"); }); + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Navigation("Hops"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => { b.Navigation("ChannelOperationRequestsAsSource"); diff --git a/src/Program.cs b/src/Program.cs index f22d980f..c15012cc 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -128,6 +128,8 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -416,6 +418,30 @@ public static async Task Main(string[] args) } }); }); + + // Monitor Payment Routes Job + q.AddJob(opts => + { + opts.DisallowConcurrentExecution(); + opts.WithIdentity(nameof(MonitorPaymentRoutesJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(MonitorPaymentRoutesJob)) + .WithIdentity($"{nameof(MonitorPaymentRoutesJob)}Trigger") + .StartNow().WithSimpleSchedule(scheduleBuilder => + { + if (Constants.IS_DEV_ENVIRONMENT) + { + scheduleBuilder.WithIntervalInMinutes(1).RepeatForever(); + } + else + { + scheduleBuilder.WithIntervalInMinutes(10).RepeatForever(); + } + }); + }); // Audit Log Cleanup Job q.AddJob(opts => { diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 7ce3d07b..e65f96b0 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -40,6 +40,7 @@ public interface ILightningClientService public Task GetChanInfo(Node node, ulong chanId, Lightning.LightningClient? client = null); public Task AddInvoice(Node node, Invoice invoice, Lightning.LightningClient? client = null); public Task QueryRoutes(Node node, QueryRoutesRequest request, Lightning.LightningClient? client = null); + public Task ListPayments(Node node, ListPaymentsRequest request, Lightning.LightningClient? client = null); public AsyncServerStreamingCall? CloseChannel(Node node, Channel channel, bool forceClose = false, Lightning.LightningClient? client = null); public AsyncServerStreamingCall SubscribeChannelEvents(Node node, Lightning.LightningClient? client = null); public Task GetNodeInfo(Node node, string pubKey, Lightning.LightningClient? client = null); @@ -182,6 +183,28 @@ public Lightning.LightningClient GetLightningClient(string? endpoint) return listChannelsResponse; } + public async Task ListPayments(Node node, ListPaymentsRequest request, Lightning.LightningClient? client = null) + { + // LightningEye polled LND's REST /v1/payments; NodeGuard talks gRPC, so this is + // the ListPayments RPC. The tracker paginates by index_offset just like the Python one. + try + { + client ??= GetLightningClient(node.Endpoint); + return await client.ListPaymentsAsync(request, + new Metadata + { + { + "macaroon", node.ChannelAdminMacaroon + } + }); + } + catch (Exception e) + { + _logger.LogError(e, "Error while listing payments for node {NodeId}", node.Id); + return null; + } + } + public async Task ChannelBalanceAsync(Node node, Lightning.LightningClient? client = null) { ChannelBalanceResponse? channelBalanceResponse = null; diff --git a/src/Services/PaymentRouteMapping.cs b/src/Services/PaymentRouteMapping.cs new file mode 100644 index 00000000..81de843f --- /dev/null +++ b/src/Services/PaymentRouteMapping.cs @@ -0,0 +1,51 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using Lnrpc; +using NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +/// +/// Pure LND-gRPC → mapping helpers used by the tracker job. +/// Kept here (with tests) because these two conversions are the easiest things to get +/// silently wrong when porting from LightningEye's REST tracker. +/// +public static class PaymentRouteMapping +{ + /// + /// gRPC Payment.creation_time_ns is in nanoseconds since the unix epoch. + /// LightningEye's REST tracker read creation_date in seconds; using the + /// gRPC value as-is (or as seconds) silently dates every payment to 1970. + /// + public static DateTimeOffset CreatedAtFromCreationTimeNs(long creationTimeNs) + => DateTimeOffset.FromUnixTimeMilliseconds(creationTimeNs / 1_000_000L); + + /// + /// Maps the gRPC payment status enum to our terminal status. Non-terminal states + /// (IN_FLIGHT / INITIATED / UNKNOWN) map to ; + /// the tracker skips those, exactly as the Python tracker ignored non-SUCCEEDED/FAILED. + /// + public static PaymentRouteStatus FromLndPaymentStatus(Payment.Types.PaymentStatus status) => status switch + { + Payment.Types.PaymentStatus.Succeeded => PaymentRouteStatus.Success, + Payment.Types.PaymentStatus.Failed => PaymentRouteStatus.Failed, + _ => PaymentRouteStatus.Unknown + }; +} diff --git a/src/Services/PaymentRoutesGraphService.cs b/src/Services/PaymentRoutesGraphService.cs new file mode 100644 index 00000000..dc5dc591 --- /dev/null +++ b/src/Services/PaymentRoutesGraphService.cs @@ -0,0 +1,178 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Services; + +// ── Response DTOs (shape kept compatible with the LightningEye frontend) ──────── +public record PaymentGraphNode(string Id, bool IsOrigin, List Payments, string? Alias = null); + +public record PaymentGraphNodePayment(string Id, string Status); + +public record PaymentGraphChannel( + string Id, + string From, + string To, + string PaymentId, + string PaymentStatus, + string HopStatus, + string? FailureCode, + int AttemptIndex, + int HopSequence); + +public record PaymentGraph(List Nodes, List Channels); + +/// +/// Transforms tracked payments and their hops into the { nodes, channels } graph +/// consumed by the route-visualisation frontend. Port of LightningEye's +/// graph_builder.py. Serving surface is expected to be the gRPC API +/// (see nodeguard.proto), not a Blazor page. +/// +public interface IPaymentRoutesGraphService +{ + Task BuildGraphAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end); +} + +public class PaymentRoutesGraphService : IPaymentRoutesGraphService +{ + private readonly IPaymentRouteRepository _paymentRouteRepository; + + public PaymentRoutesGraphService(IPaymentRouteRepository paymentRouteRepository) + { + _paymentRouteRepository = paymentRouteRepository; + } + + public async Task BuildGraphAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end) + { + var payments = await _paymentRouteRepository.GetByCreatedAtRangeAsync(start, end); + if (payments.Count == 0) + { + return EmptyGraph(originNodePubKey); + } + + var hops = payments.SelectMany(p => p.Hops).ToList(); + return Assemble(originNodePubKey, payments, hops); + } + + /// + /// Per-hop status for a payment. Faithful port of graph_builder._hop_status_for. + /// dest_pos = hopIndex + 1 (position of the node that RECEIVES this hop); + /// F = failure_source_index (position in the route that reported the failure). + /// dest_pos < F → "ok"; == F → "failed_here"; > F → "unreached". + /// + public static (string hopStatus, string? failureCode) HopStatusFor( + PaymentRouteStatus payStatus, int hopIndex, int? failureSourceIndex, string? code) + { + if (payStatus == PaymentRouteStatus.Success) + { + return ("success", null); + } + + // Failed with no idea where → old behaviour (everything red). + if (failureSourceIndex is null) + { + return ("failed", null); + } + + var destPos = hopIndex + 1; + var f = failureSourceIndex.Value; + + if (destPos < f) return ("ok", null); + if (destPos == f) return ("failed_here", code); + return ("unreached", null); + } + + // ── Assembly (port of graph_builder._assemble, own-tables source) ─────────── + private static PaymentGraph Assemble(string originId, List payments, List hops) + { + var payStatus = payments.ToDictionary(p => p.PaymentHash, p => p.Status); + + // ── Nodes ─────────────────────────────────────────────────────────────── + var nodePays = new Dictionary> + { + [originId] = new() + }; + foreach (var p in payments) + { + nodePays[originId][p.PaymentHash] = p.Status; + } + + foreach (var hop in hops) + { + var status = payStatus.GetValueOrDefault(hop.PaymentHash, PaymentRouteStatus.Failed); + foreach (var nodeId in new[] { hop.FromNode, hop.ToNode }) + { + if (!nodePays.TryGetValue(nodeId, out var pays)) + { + pays = new Dictionary(); + nodePays[nodeId] = pays; + } + pays[hop.PaymentHash] = status; + } + } + + var nodes = nodePays.Select(kv => new PaymentGraphNode( + Id: kv.Key, + IsOrigin: kv.Key == originId, + Payments: kv.Value.Select(p => new PaymentGraphNodePayment(p.Key, StatusString(p.Value))).ToList() + )).ToList(); + + // ── Channels (edges) ────────────────────────────────────────────────────── + var seen = new HashSet<(string, ulong, int, int)>(); + var channels = new List(); + foreach (var hop in hops) + { + var key = (hop.PaymentHash, hop.ChannelId, hop.AttemptIndex, hop.HopSequence); + if (!seen.Add(key)) + { + continue; + } + + var pStatus = payStatus.GetValueOrDefault(hop.PaymentHash, PaymentRouteStatus.Failed); + // Own-tables source has no per-hop failure data, so derive from payment status + // (matches the Python fallback: "success" if success else "failed"). + var hopStatus = pStatus == PaymentRouteStatus.Success ? "success" : "failed"; + + channels.Add(new PaymentGraphChannel( + Id: hop.ChannelId.ToString(), + From: hop.FromNode, + To: hop.ToNode, + PaymentId: hop.PaymentHash, + PaymentStatus: StatusString(pStatus), + HopStatus: hopStatus, + FailureCode: null, + AttemptIndex: hop.AttemptIndex, + HopSequence: hop.HopSequence)); + } + + return new PaymentGraph(nodes, channels); + } + + private static PaymentGraph EmptyGraph(string originId) + => new(new List { new(originId, true, new List()) }, + new List()); + + private static string StatusString(PaymentRouteStatus status) => status switch + { + PaymentRouteStatus.Success => "success", + _ => "failed" + }; +} diff --git a/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs b/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs new file mode 100644 index 00000000..5831c744 --- /dev/null +++ b/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs @@ -0,0 +1,51 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Lnrpc; +using NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +public class PaymentRouteMappingTests +{ + [Fact] + public void CreatedAtFromCreationTimeNs_TreatsValueAsNanoseconds() + { + // 2023-11-14T22:13:20Z = 1_700_000_000 s. gRPC gives that as ns (×1e9). + const long seconds = 1_700_000_000L; + var creationTimeNs = seconds * 1_000_000_000L; + + var result = PaymentRouteMapping.CreatedAtFromCreationTimeNs(creationTimeNs); + + result.Should().Be(DateTimeOffset.FromUnixTimeSeconds(seconds)); + result.Year.Should().Be(2023); // guards against the silent 1970 shift + } + + [Theory] + [InlineData(Payment.Types.PaymentStatus.Succeeded, PaymentRouteStatus.Success)] + [InlineData(Payment.Types.PaymentStatus.Failed, PaymentRouteStatus.Failed)] + [InlineData(Payment.Types.PaymentStatus.InFlight, PaymentRouteStatus.Unknown)] + [InlineData(Payment.Types.PaymentStatus.Initiated, PaymentRouteStatus.Unknown)] + public void FromLndPaymentStatus_MapsTerminalStatesAndSkipsTransient( + Payment.Types.PaymentStatus lnd, PaymentRouteStatus expected) + { + PaymentRouteMapping.FromLndPaymentStatus(lnd).Should().Be(expected); + } +} diff --git a/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs new file mode 100644 index 00000000..4d6228f4 --- /dev/null +++ b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs @@ -0,0 +1,56 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +public class PaymentRoutesGraphServiceTests +{ + // Mirrors graph_builder._hop_status_for cases from LightningEye. + + [Fact] + public void HopStatusFor_SuccessfulPayment_AlwaysSuccess() + { + var (status, code) = PaymentRoutesGraphService.HopStatusFor(PaymentRouteStatus.Success, 2, 3, "X"); + status.Should().Be("success"); + code.Should().BeNull(); + } + + [Fact] + public void HopStatusFor_FailedNoSourceIndex_FallsBackToFailed() + { + var (status, code) = PaymentRoutesGraphService.HopStatusFor(PaymentRouteStatus.Failed, 0, null, null); + status.Should().Be("failed"); + code.Should().BeNull(); + } + + // failure_source_index F = 2. dest_pos = hopIndex + 1. + [Theory] + [InlineData(0, "ok")] // dest_pos 1 < 2 → traversed before the failure + [InlineData(1, "failed_here")] // dest_pos 2 == 2 → broke here + [InlineData(2, "unreached")] // dest_pos 3 > 2 → never attempted + public void HopStatusFor_FailedWithSourceIndex_ClassifiesPerHop(int hopIndex, string expected) + { + var (status, code) = PaymentRoutesGraphService.HopStatusFor(PaymentRouteStatus.Failed, hopIndex, 2, "TEMPORARY_CHANNEL_FAILURE"); + status.Should().Be(expected); + code.Should().Be(expected == "failed_here" ? "TEMPORARY_CHANNEL_FAILURE" : null); + } +} From a03d568d47202d0890d9d79b44af8f8657379c83 Mon Sep 17 00:00:00 2001 From: Ismael Date: Wed, 15 Jul 2026 15:10:37 +0200 Subject: [PATCH 04/10] Add payment watcher frontend --- .../dotnet-blazor-expert/MEMORY.md | 1 + .../reference_razor-license-header.md | 19 + src/Pages/PaymentsWatcher.razor | 123 +++++ src/Pages/_Host.cshtml | 3 +- src/Shared/NavMenu.razor | 8 + src/wwwroot/js/payments-watcher-graph.js | 460 ++++++++++++++++++ 6 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 .claude/agent-memory/dotnet-blazor-expert/reference_razor-license-header.md create mode 100644 src/Pages/PaymentsWatcher.razor create mode 100644 src/wwwroot/js/payments-watcher-graph.js diff --git a/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md index 3b6a196d..37dacecb 100644 --- a/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md +++ b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md @@ -2,3 +2,4 @@ - [Quartz job wiring](project_quartz-job-wiring.md) — jobs registered only in Program.cs AddQuartz block; JobTypes.cs has no registry (stale CLAUDE.md claim) - [Migration header + verify.sh quirk](reference_migration-header-and-verify.md) — EF migrations skip the license header; verify.sh set -e false-fails on Spanish-locale build output +- [.razor license header](reference_razor-license-header.md) — .razor files carry NO AGPL header; configuration-cs.json includes only .cs (skill template claim is wrong) diff --git a/.claude/agent-memory/dotnet-blazor-expert/reference_razor-license-header.md b/.claude/agent-memory/dotnet-blazor-expert/reference_razor-license-header.md new file mode 100644 index 00000000..ac8415be --- /dev/null +++ b/.claude/agent-memory/dotnet-blazor-expert/reference_razor-license-header.md @@ -0,0 +1,19 @@ +--- +name: razor-license-header +description: .razor files carry NO AGPLv3 header in this repo; configuration-cs.json includes only .cs +metadata: + type: reference +--- + +`.razor` files do NOT get the AGPLv3 license header in NodeGuard. `configuration-cs.json` +(the `headache` config driving `just add-license-cs`) has `includes: ["src/**/*.cs", "test/**/*.cs"]` +— only `.cs`, not `.razor`. Sampled existing pages (AuditTrail, Channels, Wallets, Nodes) all +start directly with `@page`, no header. + +**Why:** The header check tool only scans `.cs`. Running `just add-license-cs` is a no-op for +`.razor` and would re-touch every `.cs` header (churn). + +**How to apply:** When creating a new `.razor` page, do NOT add a license header and do NOT run +`just add-license-cs` for it. Only new `.cs` files under `src/`/`test/` (outside +`src/Areas/Identity/Pages/`) need the header. This corrects skill/template claims that +".razor files are covered too" — they are not. Complements [[migration-header-and-verify]]. diff --git a/src/Pages/PaymentsWatcher.razor b/src/Pages/PaymentsWatcher.razor new file mode 100644 index 00000000..f673dd69 --- /dev/null +++ b/src/Pages/PaymentsWatcher.razor @@ -0,0 +1,123 @@ +@page "/paymentswatcher" +@attribute [Authorize] +@using NodeGuard.Data.Models +@using NodeGuard.Data.Repositories.Interfaces +@using NodeGuard.Services +@inject IPaymentRoutesGraphService PaymentRoutesGraphService +@inject INodeRepository NodeRepository +@inject IJSRuntime JSRuntime +@implements IDisposable + +Payments Watcher +

Payments Watcher

+ + + + Visualise the routes taken by payments originated from a managed node. + Each channel's colour indicates its success ratio (red → yellow → green). + + + + + + + Origin node + + + + + + Start date & time + + + + + + End date & time + + + + + + Filters + Include successful payments + Include failed payments + + + + + + + +@if (_error is not null) +{ + @_error +} + +@* JS owns everything inside this div. Keep its markup static so Blazor's diff never touches the children. *@ +
+ +@code { + private List _originNodes = new(); + private string? _originPubKey; + private DateTime? _start = DateTime.UtcNow.AddDays(-1); + private DateTime? _end = DateTime.UtcNow.AddDays(1); + private bool _showSuccess = true; + private bool _showFailed = true; + private bool _loading; + private string? _error; + private DotNetObjectReference? _selfRef; + + protected override async Task OnInitializedAsync() + { + _originNodes = await NodeRepository.GetAllManagedByNodeGuard(); + _selfRef = DotNetObjectReference.Create(this); + } + + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(_originPubKey)) return; + _loading = true; + _error = null; + try + { + var start = new DateTimeOffset(_start ?? DateTime.UtcNow.AddDays(-1), TimeSpan.Zero); + var end = new DateTimeOffset(_end ?? DateTime.UtcNow.AddDays(1), TimeSpan.Zero); + var graph = await PaymentRoutesGraphService.BuildGraphAsync(_originPubKey, start, end); + + // IJSRuntime uses JsonSerializerDefaults.Web → the PaymentGraph record is + // serialized camelCase (isOrigin, paymentStatus, hopStatus, attemptIndex, + // hopSequence), exactly what payments-watcher-graph.js expects. Do NOT + // hand-serialize with default (PascalCase) options or the graph renders blank. + await JSRuntime.InvokeVoidAsync("paymentsWatcher.render", "pw-graph", graph, + new { showSuccess = _showSuccess, showFailed = _showFailed, dotNetRef = _selfRef }); + } + catch (Exception ex) + { + _error = "Could not build the payment graph. Check the server connection."; + Console.Error.WriteLine(ex); + } + finally + { + _loading = false; + } + } + + // Called from JS when a node is clicked (low-frequency, safe over the circuit). + [JSInvokable] + public Task OnNodeSelected(string? nodeId) + { + // Optional: drive a Blazor-rendered detail panel here. + return Task.CompletedTask; + } + + public void Dispose() => _selfRef?.Dispose(); +} diff --git a/src/Pages/_Host.cshtml b/src/Pages/_Host.cshtml index 65665ad0..4137fc75 100644 --- a/src/Pages/_Host.cshtml +++ b/src/Pages/_Host.cshtml @@ -7,4 +7,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/src/Shared/NavMenu.razor b/src/Shared/NavMenu.razor index 7d30f0ff..23dcfcfb 100644 --- a/src/Shared/NavMenu.razor +++ b/src/Shared/NavMenu.razor @@ -95,6 +95,14 @@ + + + + diff --git a/src/wwwroot/js/payments-watcher-graph.js b/src/wwwroot/js/payments-watcher-graph.js new file mode 100644 index 00000000..bbba0db1 --- /dev/null +++ b/src/wwwroot/js/payments-watcher-graph.js @@ -0,0 +1,460 @@ +/* + * Payments Watcher — framework-agnostic Lightning payment-route graph renderer. + * + * Port of LightningEye's React frontend (GraphCanvas / GraphNode / GraphEdge / + * graphLayout / colorUtils / aliases / PaymentTraces) into one vanilla-JS module. + * All UI strings translated ES -> EN. + * + * Why vanilla JS and not Blazor markup: NodeGuard's UI runs render-mode="Server", + * so every DOM event round-trips over the SignalR circuit. Per-mousemove drag and + * wheel/zoom would be laggy, and Blazor's DOM diff clobbers JS that mutates the same + * subtree. So JS owns the ENTIRE canvas subtree; Blazor owns only the chrome + * (date range, toggles, origin/destination selectors) and feeds this module JSON. + * + * Public API (attached to window.paymentsWatcher): + * render(containerId, graph, options) + * containerId : string id of an empty
Blazor rendered. + * graph : { nodes:[{id,isOrigin,alias?,payments:[{id,status}]}], + * channels:[{id,from,to,paymentId,paymentStatus,hopStatus?, + * failureCode?,attemptIndex?,hopSequence?}] } + * NOTE: camelCase. Blazor must serialize the PaymentGraph record + * with a camelCase policy or the graph renders blank. + * options : { showSuccess:bool, showFailed:bool, + * dotNetRef?:DotNetObjectReference } // for node-click callback + * Node click invokes dotNetRef.invokeMethodAsync('OnNodeSelected', nodeId) if given. + */ +(function () { + 'use strict'; + + // ── Colour: red -> yellow -> green by success ratio (matches colorUtils.js) ── + var RED = [226, 75, 74], YELLOW = [240, 190, 40], GREEN = [29, 158, 117]; + function mix(a, b, t) { + return [Math.round(a[0] + t * (b[0] - a[0])), + Math.round(a[1] + t * (b[1] - a[1])), + Math.round(a[2] + t * (b[2] - a[2]))]; + } + function colorByRatio(ratio) { + var c = ratio < 0.5 ? mix(RED, YELLOW, ratio / 0.5) + : mix(YELLOW, GREEN, (ratio - 0.5) / 0.5); + return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; + } + function ratioColors(payments, showSuccess, showFailed) { + var relevant = payments.filter(function (p) { + return (p.status === 'success' && showSuccess) || (p.status === 'failed' && showFailed); + }); + if (relevant.length === 0) return { border: '#94a3b8', bg: '#f8fafc' }; + var ok = relevant.filter(function (p) { return p.status === 'success'; }).length; + return { border: colorByRatio(ok / relevant.length) }; + } + + // ── Aliases (matches aliases.js) ───────────────────────────────────────────── + function buildAliasMap(nodes) { + var map = {}; + if (!nodes) return map; + nodes.forEach(function (n) { if (n.alias && n.alias.trim()) map[n.id] = n.alias.trim(); }); + var noAlias = nodes.filter(function (n) { return !map[n.id]; }); + var origin = noAlias.find(function (n) { return n.isOrigin; }); + if (origin) map[origin.id] = '★'; + var rest = noAlias.filter(function (n) { return !n.isOrigin; }).map(function (n) { return n.id; }).sort(); + var LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + rest.forEach(function (id, i) { map[id] = i < LETTERS.length ? LETTERS[i] : 'N' + (i + 1); }); + return map; + } + function shortAlias(alias, max) { + max = max || 6; + if (!alias) return '?'; + return alias.length <= max ? alias : alias.slice(0, max) + '…'; + } + function shortKey(id, head, tail) { + head = head || 6; tail = tail || 4; + if (!id) return ''; + return id.length <= head + tail + 1 ? id : id.slice(0, head) + '…' + id.slice(-tail); + } + + // ── Layout (matches graphLayout.js) ────────────────────────────────────────── + var NODE_W = 170, NODE_H = 64, COL_GAP = 400, ROW_GAP = 155, PAD_X = 16, PAD_Y = 20, NUM_COLS = 3; + function computeLayout(nodes, channels) { + if (!nodes || nodes.length === 0) return {}; + var origin = nodes.find(function (n) { return n.isOrigin; }) || nodes[0]; + var levels = {}; levels[origin.id] = 0; + var rest = nodes.filter(function (n) { return n.id !== origin.id; }); + rest.forEach(function (n, i) { levels[n.id] = 1 + (i % NUM_COLS); }); + + var byLevel = {}; + Object.keys(levels).forEach(function (id) { + var lvl = levels[id]; (byLevel[lvl] = byLevel[lvl] || []).push(id); + }); + Object.keys(byLevel).forEach(function (lvl) { byLevel[lvl] = orderColumn(byLevel[lvl], channels); }); + + var maxRows = Math.max.apply(null, Object.keys(byLevel).map(function (k) { return byLevel[k].length; })); + var totalH = maxRows * ROW_GAP, positions = {}; + Object.keys(byLevel).forEach(function (lvl) { + var ids = byLevel[lvl]; + var x = PAD_X + parseInt(lvl, 10) * COL_GAP; + var startY = PAD_Y + (totalH - ids.length * ROW_GAP) / 2; + ids.forEach(function (id, i) { positions[id] = { x: x, y: startY + i * ROW_GAP, w: NODE_W, h: NODE_H }; }); + }); + return positions; + } + function orderColumn(ids, channels) { + if (ids.length <= 1) return ids; + var inCol = {}; ids.forEach(function (id) { inCol[id] = true; }); + var adj = {}; ids.forEach(function (id) { adj[id] = []; }); + channels.forEach(function (ch) { + if (inCol[ch.from] && inCol[ch.to] && ch.from !== ch.to) { + if (adj[ch.from].indexOf(ch.to) < 0) adj[ch.from].push(ch.to); + if (adj[ch.to].indexOf(ch.from) < 0) adj[ch.to].push(ch.from); + } + }); + var visited = {}, ordered = []; + ids.slice().sort(function (a, b) { return adj[a].length - adj[b].length; }).forEach(function (start) { + if (visited[start]) return; + var stack = [start]; + while (stack.length) { + var n = stack.pop(); + if (visited[n]) continue; + visited[n] = true; ordered.push(n); + adj[n].forEach(function (nb) { if (!visited[nb]) stack.push(nb); }); + } + }); + return ordered; + } + function canvasSize(positions) { + var keys = Object.keys(positions); + if (keys.length === 0) return { width: 800, height: 400 }; + var maxX = 0, maxY = 0; + keys.forEach(function (k) { var p = positions[k]; maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); }); + return { width: maxX + 30, height: maxY + 30 }; + } + + // ── Edge geometry (matches GraphEdge.jsx) ──────────────────────────────────── + function borderPoint(cx, cy, hw, hh, tx, ty) { + var dx = tx - cx, dy = ty - cy; + if (!dx && !dy) return { x: cx, y: cy }; + var s = Math.min(hw / Math.abs(dx || 1e-9), hh / Math.abs(dy || 1e-9)) * 0.95; + return { x: cx + dx * s, y: cy + dy * s }; + } + + var SVG_NS = 'http://www.w3.org/2000/svg'; + function el(tag, attrs) { + var e = document.createElement(tag); + if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); }); + return e; + } + function svgEl(tag, attrs) { + var e = document.createElementNS(SVG_NS, tag); + if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); }); + return e; + } + + // ── Render ──────────────────────────────────────────────────────────────── + function render(containerId, graph, options) { + options = options || {}; + var showSuccess = options.showSuccess !== false; + var showFailed = options.showFailed !== false; + var dotNetRef = options.dotNetRef || null; + var root = document.getElementById(containerId); + if (!root) { console.error('[paymentsWatcher] container not found:', containerId); return; } + + // Preserve drag/zoom across re-renders (toggle changes) via element state. + var state = root.__pwState || { moved: {}, zoom: 1, selected: null }; + root.__pwState = state; + root.innerHTML = ''; + + if (!graph || !graph.nodes || graph.nodes.length === 0) { + root.appendChild(centered('⚡', 'No graph data.')); + return; + } + + var aliasMap = buildAliasMap(graph.nodes); + var auto = computeLayout(graph.nodes, graph.channels); + var positions = {}; + Object.keys(auto).forEach(function (id) { + positions[id] = state.moved[id] ? Object.assign({}, auto[id], state.moved[id]) : auto[id]; + }); + var size = canvasSize(positions); + + // Aggregate visible channels per (from|to) for colour + arrow direction offset. + var visChannels = graph.channels.filter(function (ch) { + return (ch.paymentStatus === 'success' && showSuccess) || (ch.paymentStatus === 'failed' && showFailed); + }); + var chanMap = {}; + visChannels.forEach(function (ch) { + var key = ch.from + '|' + ch.to; + if (!chanMap[key]) chanMap[key] = { key: key, from: ch.from, to: ch.to, ok: 0, fail: 0 }; + if (ch.paymentStatus === 'success') chanMap[key].ok++; else chanMap[key].fail++; + }); + var edges = Object.keys(chanMap).map(function (k) { return chanMap[k]; }); + edges.forEach(function (e) { e.split = !!chanMap[e.to + '|' + e.from]; }); + + // ── Zoom controls ── + var wrap = el('div', { style: 'position:relative;' }); + var zoomBox = el('div', { style: 'position:absolute;top:12px;right:16px;z-index:20;display:flex;flex-direction:column;gap:6px;' }); + function zbtn(label, title, fn, small) { + var b = el('button', { title: title, type: 'button', + style: 'width:34px;height:34px;border-radius:8px;cursor:pointer;border:1px solid #e2e8f0;background:#fff;color:#475569;font-size:' + (small ? 14 : 18) + 'px;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 3px rgba(0,0,0,0.08);' }); + b.textContent = label; + b.addEventListener('click', fn); + return b; + } + var scroller = el('div', { style: 'overflow:auto;padding:20px 18px;max-height:70vh;' }); + var stage = el('div', { style: 'position:relative;width:' + size.width + 'px;height:' + size.height + 'px;min-width:' + size.width + 'px;transform-origin:top left;' }); + function applyZoom() { stage.style.transform = 'scale(' + state.zoom + ')'; } + zoomBox.appendChild(zbtn('+', 'Zoom in', function () { state.zoom = Math.min(2, +(state.zoom + 0.15).toFixed(2)); applyZoom(); })); + zoomBox.appendChild(zbtn('−', 'Zoom out', function () { state.zoom = Math.max(0.4, +(state.zoom - 0.15).toFixed(2)); applyZoom(); })); + zoomBox.appendChild(zbtn('⟳', 'Reset', function () { state.zoom = 1; applyZoom(); }, true)); + applyZoom(); + + // ── Edges (SVG) ── + var svg = svgEl('svg', { width: size.width, height: size.height, style: 'position:absolute;top:0;left:0;pointer-events:none;' }); + edges.forEach(function (e) { + var fp = positions[e.from], tp = positions[e.to]; + if (!fp || !tp) return; + var fcx = fp.x + fp.w / 2, fcy = fp.y + fp.h / 2, tcx = tp.x + tp.w / 2, tcy = tp.y + tp.h / 2; + var sp = borderPoint(fcx, fcy, fp.w / 2, fp.h / 2, tcx, tcy); + var GAP = 7; + var ep = borderPoint(tcx, tcy, tp.w / 2 + GAP, tp.h / 2 + GAP, fcx, fcy); + if (e.split) { + var dx = ep.x - sp.x, dy = ep.y - sp.y, len = Math.hypot(dx, dy) || 1, SEP = 6; + var ox = -dy / len * SEP, oy = dx / len * SEP; + sp = { x: sp.x + ox, y: sp.y + oy }; ep = { x: ep.x + ox, y: ep.y + oy }; + } + var total = e.ok + e.fail, color = colorByRatio(total === 0 ? 0 : e.ok / total); + var mid = 'pw-arrow-' + e.key.replace(/[^a-zA-Z0-9]/g, '_'); + var defs = svgEl('defs'); + var marker = svgEl('marker', { id: mid, markerWidth: 7, markerHeight: 7, refX: 5, refY: 3.5, orient: 'auto' }); + marker.appendChild(svgEl('polygon', { points: '0,0 7,3.5 0,7', fill: color })); + defs.appendChild(marker); svg.appendChild(defs); + svg.appendChild(svgEl('line', { x1: sp.x, y1: sp.y, x2: ep.x, y2: ep.y, stroke: color, + 'stroke-width': 1.8, 'stroke-linecap': 'round', 'marker-end': 'url(#' + mid + ')', opacity: 0.9 })); + }); + stage.appendChild(svg); + + // ── Nodes ── + var drag = null; + graph.nodes.forEach(function (node) { + var pos = positions[node.id]; + if (!pos) return; + var border = ratioColors(node.payments, showSuccess, showFailed).border; + var rgb = border.match(/\d+/g); + var softBg = rgb ? 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',0.12)' : '#eef1f4'; + var strongBg = rgb ? 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')' : '#0f6e56'; + var vis = node.payments.filter(function (p) { + return (p.status === 'success' && showSuccess) || (p.status === 'failed' && showFailed); + }); + var ok = vis.filter(function (p) { return p.status === 'success'; }).length; + var fail = vis.length - ok; + var sel = state.selected === node.id; + + var box = el('div', { + title: (node.isOrigin ? 'Origin' : (aliasMap[node.id] || '?')) + '\n' + node.id + '\n(click to view and copy the pubkey)', + style: 'position:absolute;left:' + pos.x + 'px;top:' + pos.y + 'px;width:' + pos.w + 'px;height:' + pos.h + 'px;' + + 'display:flex;align-items:center;gap:13px;padding:0 12px;box-sizing:border-box;background:#fff;' + + 'border:' + (sel ? 2 : 1) + 'px solid ' + (sel ? border : '#cbd5e1') + ';border-radius:12px;' + + 'cursor:pointer;user-select:none;z-index:' + (sel ? 10 : 5) + ';transition:border-color .15s;' + }); + var badge = el('div', { + style: 'width:64px;height:36px;flex-shrink:0;border-radius:10px;background:' + (node.isOrigin ? strongBg : softBg) + ';' + + 'color:' + (node.isOrigin ? '#fff' : border) + ';display:flex;align-items:center;justify-content:center;' + + 'font-size:12.5px;font-weight:700;padding:0 6px;box-sizing:border-box;white-space:nowrap;overflow:hidden;' + }); + badge.textContent = node.isOrigin ? 'origin' : shortAlias(aliasMap[node.id]); + box.appendChild(badge); + + var info = el('div', { style: 'min-width:0;flex:0 1 auto;' }); + var key = el('div', { style: 'font-family:monospace;font-size:10.5px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;' }); + key.textContent = shortKey(node.id, 4, 4); + var counts = el('div', { style: 'display:flex;gap:8px;margin-top:3px;align-items:center;font-size:10.5px;' }); + if (ok > 0) { var s1 = el('span', { style: 'color:#1D9E75;font-weight:600;' }); s1.textContent = '● ' + ok; counts.appendChild(s1); } + if (fail > 0) { var s2 = el('span', { style: 'color:#E24B4A;font-weight:600;' }); s2.textContent = '● ' + fail; counts.appendChild(s2); } + if (vis.length === 0) { var s3 = el('span', { style: 'color:#94a3b8;' }); s3.textContent = 'no payments'; counts.appendChild(s3); } + info.appendChild(key); info.appendChild(counts); box.appendChild(info); + + box.addEventListener('mousedown', function (ev) { + ev.preventDefault(); ev.stopPropagation(); + drag = { id: node.id, sx: ev.clientX, sy: ev.clientY, x0: pos.x, y0: pos.y, moved: false }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }); + box.addEventListener('click', function () { + if (drag && drag.moved) return; + state.selected = state.selected === node.id ? null : node.id; + if (dotNetRef) dotNetRef.invokeMethodAsync('OnNodeSelected', state.selected); + render(containerId, graph, options); // cheap re-render to reflect selection border + }); + + function onMove(ev) { + if (!drag) return; + var dx = (ev.clientX - drag.sx) / state.zoom, dy = (ev.clientY - drag.sy) / state.zoom; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) drag.moved = true; + state.moved[drag.id] = { x: Math.max(0, drag.x0 + dx), y: Math.max(0, drag.y0 + dy) }; + box.style.left = state.moved[drag.id].x + 'px'; + box.style.top = state.moved[drag.id].y + 'px'; + // Redraw edges live so arrows follow the dragged node. + redrawEdges(); + } + function onUp() { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + var d = drag; setTimeout(function () { if (drag === d) drag = null; }, 0); + } + stage.appendChild(box); + }); + + function redrawEdges() { + // Recompute positions from state.moved and rebuild the SVG in place. + var np = {}; + Object.keys(auto).forEach(function (id) { + np[id] = state.moved[id] ? Object.assign({}, auto[id], state.moved[id]) : auto[id]; + }); + while (svg.firstChild) svg.removeChild(svg.firstChild); + edges.forEach(function (e) { + var fp = np[e.from], tp = np[e.to]; + if (!fp || !tp) return; + var fcx = fp.x + fp.w / 2, fcy = fp.y + fp.h / 2, tcx = tp.x + tp.w / 2, tcy = tp.y + tp.h / 2; + var sp = borderPoint(fcx, fcy, fp.w / 2, fp.h / 2, tcx, tcy); + var GAP = 7, ep = borderPoint(tcx, tcy, tp.w / 2 + GAP, tp.h / 2 + GAP, fcx, fcy); + if (e.split) { + var dx = ep.x - sp.x, dy = ep.y - sp.y, len = Math.hypot(dx, dy) || 1, SEP = 6; + var ox = -dy / len * SEP, oy = dx / len * SEP; + sp = { x: sp.x + ox, y: sp.y + oy }; ep = { x: ep.x + ox, y: ep.y + oy }; + } + var total = e.ok + e.fail, color = colorByRatio(total === 0 ? 0 : e.ok / total); + var mid = 'pw-arrow-' + e.key.replace(/[^a-zA-Z0-9]/g, '_'); + var defs = svgEl('defs'); + var marker = svgEl('marker', { id: mid, markerWidth: 7, markerHeight: 7, refX: 5, refY: 3.5, orient: 'auto' }); + marker.appendChild(svgEl('polygon', { points: '0,0 7,3.5 0,7', fill: color })); + defs.appendChild(marker); svg.appendChild(defs); + svg.appendChild(svgEl('line', { x1: sp.x, y1: sp.y, x2: ep.x, y2: ep.y, stroke: color, + 'stroke-width': 1.8, 'stroke-linecap': 'round', 'marker-end': 'url(#' + mid + ')', opacity: 0.9 })); + }); + } + + scroller.appendChild(stage); + wrap.appendChild(zoomBox); + wrap.appendChild(scroller); + root.appendChild(wrap); + + // ── Legend ── + var legend = el('div', { style: 'padding:12px 4px 4px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;' }); + legend.innerHTML = + '
' + + 'Failure' + + '
' + + 'Success
' + + 'Each channel\'s colour indicates its success ratio' + + 'Click a node → view and copy its pubkey'; + root.appendChild(legend); + + // ── Payment traces ── + root.appendChild(buildTraces(graph, aliasMap, showSuccess, showFailed, dotNetRef)); + } + + // ── Payment traces (matches PaymentTraces.jsx) ─────────────────────────────── + var SEG = { success: '#1D9E75', ok: '#C4841A', failed_here: '#E24B4A', unreached: '#B4B2A9', failed: '#E24B4A' }; + function buildTraces(graph, aliasMap, showSuccess, showFailed, dotNetRef) { + var byAttempt = {}; + graph.channels.forEach(function (ch) { + var key = ch.paymentId + '#' + (ch.attemptIndex || 0); + if (!byAttempt[key]) byAttempt[key] = { paymentId: ch.paymentId, attemptIndex: ch.attemptIndex || 0, paymentStatus: ch.paymentStatus, hops: [] }; + byAttempt[key].hops.push(ch); + }); + var traces = Object.keys(byAttempt).map(function (k) { + var t = byAttempt[k]; + t.hops.sort(function (a, b) { return (a.hopSequence || 0) - (b.hopSequence || 0); }); + t.origin = t.hops[0] ? t.hops[0].from : null; + var fc = t.hops.find(function (h) { return h.failureCode; }); + t.failureCode = fc ? fc.failureCode : null; + return t; + }).sort(function (a, b) { return a.paymentId.localeCompare(b.paymentId) || a.attemptIndex - b.attemptIndex; }); + + var visible = traces.filter(function (t) { + return (t.paymentStatus === 'success' && showSuccess) || (t.paymentStatus === 'failed' && showFailed); + }); + + var container = el('div', { style: 'margin-top:10px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;padding:16px 18px;' }); + var title = el('div', { style: 'font-size:13px;font-weight:700;color:#334155;margin-bottom:12px;' }); + title.textContent = 'Payment traces'; + container.appendChild(title); + if (visible.length === 0) { container.style.display = 'none'; return container; } + + var list = el('div', { style: 'display:flex;flex-direction:column;gap:8px;' }); + var alias = function (id) { return aliasMap[id] || '?'; }; + + // Pagination (10 per page). + var page = 0, PER = 10, totalPages = Math.ceil(visible.length / PER); + function renderPage() { + list.innerHTML = ''; + visible.slice(page * PER, page * PER + PER).forEach(function (t) { + var failed = t.paymentStatus === 'failed'; + var row = el('div', { style: 'display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:10px 12px;border-radius:10px;background:#fafbfc;border:1px solid #eef1f4;' }); + var meta = el('div', { style: 'min-width:148px;display:flex;flex-direction:column;gap:4px;' }); + var hash = el('span', { title: 'Click to copy the payment hash', style: 'font-family:monospace;font-size:12px;color:#334155;cursor:pointer;' }); + hash.textContent = t.paymentId; + hash.addEventListener('click', function () { if (navigator.clipboard) navigator.clipboard.writeText(t.paymentId); }); + var tag = el('span', { style: 'font-size:11px;padding:2px 8px;border-radius:8px;width:fit-content;background:' + (failed ? '#FCEBEB' : '#E1F5EE') + ';color:' + (failed ? '#A32D2D' : '#0F6E56') + ';' }); + tag.textContent = failed ? (t.attemptIndex > 0 ? 'failed · attempt ' + (t.attemptIndex + 1) : 'failed') : 'success'; + meta.appendChild(hash); meta.appendChild(tag); row.appendChild(meta); + + var path = el('div', { style: 'display:flex;align-items:center;flex:1;min-width:0;' }); + path.appendChild(hopPill(shortAlias(alias(t.origin)), failed ? 'ok' : 'success', t.origin, alias(t.origin), dotNetRef)); + t.hops.forEach(function (hop) { + var tone = hop.hopStatus || (failed ? 'failed' : 'success'); + var seg = el('div', { style: 'display:flex;align-items:center;flex:1;min-width:24px;' }); + var line = el('span', { style: 'flex:1;min-width:14px;height:' + (tone === 'unreached' ? '0' : '2.5px') + ';background:' + (tone === 'unreached' ? 'transparent' : SEG[tone]) + ';border-top:' + (tone === 'unreached' ? '2px dashed ' + SEG.unreached : 'none') + ';' }); + seg.appendChild(line); + seg.appendChild(hopPill(shortAlias(alias(hop.to)), tone, hop.to, alias(hop.to), dotNetRef)); + path.appendChild(seg); + }); + if (t.failureCode) { + var code = el('span', { style: 'font-family:monospace;font-size:11px;color:#A32D2D;background:#FCEBEB;padding:3px 9px;border-radius:8px;margin-left:12px;flex-shrink:0;' }); + code.textContent = t.failureCode; path.appendChild(code); + } + row.appendChild(path); + list.appendChild(row); + }); + pager.textContent = 'Page ' + (page + 1) + ' of ' + totalPages; + prev.disabled = page === 0; next.disabled = page >= totalPages - 1; + } + container.appendChild(list); + + var nav = el('div', { style: 'display:flex;align-items:center;justify-content:center;margin-top:14px;gap:14px;' }); + var prev = pageBtn('← Previous', function () { if (page > 0) { page--; renderPage(); } }); + var pager = el('span', { style: 'font-size:12px;color:#64748b;font-weight:600;' }); + var next = pageBtn('Next →', function () { if (page < totalPages - 1) { page++; renderPage(); } }); + nav.appendChild(prev); nav.appendChild(pager); nav.appendChild(next); + container.appendChild(nav); + renderPage(); + return container; + } + + function hopPill(label, tone, nodeId, fullAlias, dotNetRef) { + var dim = tone === 'unreached', failed = tone === 'failed_here'; + var pill = el('div', { + title: fullAlias + '\n' + nodeId, + style: 'position:relative;width:56px;height:26px;flex-shrink:0;border-radius:13px;display:flex;align-items:center;justify-content:center;' + + 'font-size:11px;font-weight:600;cursor:pointer;padding:0 6px;box-sizing:border-box;white-space:nowrap;overflow:hidden;' + + 'background:' + (failed ? '#E24B4A' : dim ? '#f8fafc' : '#fff') + ';border:1.5px solid ' + (SEG[tone] || '#B4B2A9') + ';' + + 'color:' + (failed ? '#fff' : dim ? '#94a3b8' : (SEG[tone] || '#475569')) + ';opacity:' + (dim ? 0.6 : 1) + ';' + }); + pill.textContent = label; + pill.addEventListener('click', function () { if (dotNetRef && nodeId) dotNetRef.invokeMethodAsync('OnNodeSelected', nodeId); }); + return pill; + } + + function pageBtn(label, fn) { + var b = el('button', { type: 'button', style: 'padding:6px 14px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid #cbd5e1;background:#fff;color:#475569;' }); + b.textContent = label; b.addEventListener('click', fn); + return b; + } + + function centered(icon, text) { + var d = el('div', { style: 'display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:360px;text-align:center;padding:20px;' }); + var i = el('div', { style: 'font-size:48px;' }); i.textContent = icon; + var p = el('p', { style: 'color:#94a3b8;margin-top:12px;' }); p.textContent = text; + d.appendChild(i); d.appendChild(p); + return d; + } + + window.paymentsWatcher = { render: render }; +})(); From 7f2d8248ed9279302af0b5f932da42b867afd218 Mon Sep 17 00:00:00 2001 From: Ismael Date: Tue, 21 Jul 2026 08:59:26 +0200 Subject: [PATCH 05/10] Fix multiple hops not shown --- docker/loop/docker-compose.yml | 7 + .../Interfaces/IPaymentRouteRepository.cs | 4 +- .../Repositories/PaymentRouteRepository.cs | 4 +- src/Jobs/MonitorPaymentRoutesJob.cs | 10 +- src/Services/PaymentRoutesGraphService.cs | 76 ++++++++++- .../PaymentGraphSerializationTests.cs | 127 ++++++++++++++++++ 6 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 test/NodeGuard.Tests/Services/PaymentGraphSerializationTests.cs diff --git a/docker/loop/docker-compose.yml b/docker/loop/docker-compose.yml index d052cca3..74ad7a6d 100644 --- a/docker/loop/docker-compose.yml +++ b/docker/loop/docker-compose.yml @@ -15,6 +15,13 @@ services: ports: - "11009:11009" image: lightninglabs/loopserver:latest + # The loopserver:latest image bundles an embedded PostgreSQL. Its migrations + # assert the session timezone is exactly 'Etc/UTC', but the embedded PG + # session otherwise reports 'UTC', which fails the check (SQLSTATE P0001). + # Forcing PGTZ/TZ makes the client session report 'Etc/UTC'. + environment: + PGTZ: Etc/UTC + TZ: Etc/UTC volumes: - shared_data:/shared command: diff --git a/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs index dd276f45..c1d807b6 100644 --- a/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs +++ b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs @@ -26,6 +26,6 @@ public interface IPaymentRouteRepository /// Inserts a payment (with its hops) if it does not already exist. Idempotent by PaymentHash. Task<(bool inserted, string? error)> InsertIfNewAsync(PaymentRoute payment); - /// Payments (with hops eagerly loaded) created within [start, end]. - Task> GetByCreatedAtRangeAsync(DateTimeOffset start, DateTimeOffset end); + /// Payments (with hops eagerly loaded) originated by and created within [start, end]. + Task> GetByCreatedAtRangeAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end); } diff --git a/src/Data/Repositories/PaymentRouteRepository.cs b/src/Data/Repositories/PaymentRouteRepository.cs index 784559cf..37965a98 100644 --- a/src/Data/Repositories/PaymentRouteRepository.cs +++ b/src/Data/Repositories/PaymentRouteRepository.cs @@ -61,12 +61,12 @@ public PaymentRouteRepository(IDbContextFactory dbContextF } } - public async Task> GetByCreatedAtRangeAsync(DateTimeOffset start, DateTimeOffset end) + public async Task> GetByCreatedAtRangeAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end) { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.PaymentRoutes .Include(p => p.Hops) - .Where(p => p.CreatedAt >= start && p.CreatedAt <= end) + .Where(p => p.OriginNodePubKey == originNodePubKey && p.CreatedAt >= start && p.CreatedAt <= end) .ToListAsync(); } } diff --git a/src/Jobs/MonitorPaymentRoutesJob.cs b/src/Jobs/MonitorPaymentRoutesJob.cs index 750e84c8..9c8883ec 100644 --- a/src/Jobs/MonitorPaymentRoutesJob.cs +++ b/src/Jobs/MonitorPaymentRoutesJob.cs @@ -116,8 +116,14 @@ private async Task TrackNodePaymentsAsync(Node node) IndexOffset = indexOffset, MaxPayments = MaxPaymentsPerPage, Reversed = false, - // Matches the Python default: LND won't return IN_FLIGHT/INITIATED payments. - IncludeIncomplete = false + // Must be true: with IncludeIncomplete = false LND returns ONLY SUCCEEDED + // payments, so failed routes never reach the DB and the frontend's "Include + // failed payments" toggle has nothing to show. With it true, LND also returns + // FAILED (and IN_FLIGHT/INITIATED) payments; SavePaymentAsync then keeps only + // terminal states (Success/Failed) and skips the non-terminal ones via + // FromLndPaymentStatus → Unknown. Mirrors the Go infra tracker, which persists + // both SUCCEEDED and FAILED. + IncludeIncomplete = true }; var response = await _lightningClientService.ListPayments(node, request); diff --git a/src/Services/PaymentRoutesGraphService.cs b/src/Services/PaymentRoutesGraphService.cs index dc5dc591..9be22676 100644 --- a/src/Services/PaymentRoutesGraphService.cs +++ b/src/Services/PaymentRoutesGraphService.cs @@ -54,22 +54,84 @@ public interface IPaymentRoutesGraphService public class PaymentRoutesGraphService : IPaymentRoutesGraphService { private readonly IPaymentRouteRepository _paymentRouteRepository; - - public PaymentRoutesGraphService(IPaymentRouteRepository paymentRouteRepository) + private readonly INodeRepository _nodeRepository; + private readonly ILightningClientService _lightningClientService; + private readonly ILogger _logger; + + public PaymentRoutesGraphService(IPaymentRouteRepository paymentRouteRepository, + INodeRepository nodeRepository, + ILightningClientService lightningClientService, + ILogger logger) { _paymentRouteRepository = paymentRouteRepository; + _nodeRepository = nodeRepository; + _lightningClientService = lightningClientService; + _logger = logger; } public async Task BuildGraphAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end) { - var payments = await _paymentRouteRepository.GetByCreatedAtRangeAsync(start, end); + var payments = await _paymentRouteRepository.GetByCreatedAtRangeAsync(originNodePubKey, start, end); if (payments.Count == 0) { return EmptyGraph(originNodePubKey); } var hops = payments.SelectMany(p => p.Hops).ToList(); - return Assemble(originNodePubKey, payments, hops); + var aliases = await ResolveAliasesAsync(originNodePubKey, hops); + return Assemble(originNodePubKey, payments, hops, aliases); + } + + /// + /// Resolves a human-readable alias for every pubkey that appears in the graph, so the + /// frontend can label nodes instead of falling back to A/B/C… letters (the port of + /// LightningEye's nodes_cache/aliases.js). The origin uses its managed + /// ; every other pubkey is looked up from the origin node's LND + /// gossip view via GetNodeInfo. Resolution is best-effort: any pubkey we can't + /// resolve is simply left out of the map (JS then falls back to a letter), and the whole + /// step is skipped if the origin node isn't reachable. + /// + private async Task> ResolveAliasesAsync(string originNodePubKey, List hops) + { + var aliases = new Dictionary(); + + var originNode = await _nodeRepository.GetByPubkey(originNodePubKey); + if (originNode is not null && !string.IsNullOrWhiteSpace(originNode.Name)) + { + aliases[originNodePubKey] = originNode.Name; + } + + // Without a reachable managed node we can't query gossip; keep whatever we have. + if (originNode is null || + string.IsNullOrWhiteSpace(originNode.Endpoint) || + string.IsNullOrWhiteSpace(originNode.ChannelAdminMacaroon)) + { + return aliases; + } + + var pubKeys = hops + .SelectMany(h => new[] { h.FromNode, h.ToNode }) + .Where(pk => !string.IsNullOrWhiteSpace(pk) && !aliases.ContainsKey(pk)) + .Distinct() + .ToList(); + + // One GetNodeInfo per distinct pubkey, in parallel. Failures come back as null and + // are ignored (best-effort labelling must never break the graph). + var lookups = await Task.WhenAll(pubKeys.Select(async pk => + { + var info = await _lightningClientService.GetNodeInfo(originNode, pk); + return (pubKey: pk, alias: info?.Alias); + })); + + foreach (var (pubKey, alias) in lookups) + { + if (!string.IsNullOrWhiteSpace(alias)) + { + aliases[pubKey] = alias; + } + } + + return aliases; } /// @@ -101,7 +163,8 @@ public static (string hopStatus, string? failureCode) HopStatusFor( } // ── Assembly (port of graph_builder._assemble, own-tables source) ─────────── - private static PaymentGraph Assemble(string originId, List payments, List hops) + private static PaymentGraph Assemble(string originId, List payments, List hops, + IReadOnlyDictionary aliases) { var payStatus = payments.ToDictionary(p => p.PaymentHash, p => p.Status); @@ -132,7 +195,8 @@ private static PaymentGraph Assemble(string originId, List payment var nodes = nodePays.Select(kv => new PaymentGraphNode( Id: kv.Key, IsOrigin: kv.Key == originId, - Payments: kv.Value.Select(p => new PaymentGraphNodePayment(p.Key, StatusString(p.Value))).ToList() + Payments: kv.Value.Select(p => new PaymentGraphNodePayment(p.Key, StatusString(p.Value))).ToList(), + Alias: aliases.GetValueOrDefault(kv.Key) )).ToList(); // ── Channels (edges) ────────────────────────────────────────────────────── diff --git a/test/NodeGuard.Tests/Services/PaymentGraphSerializationTests.cs b/test/NodeGuard.Tests/Services/PaymentGraphSerializationTests.cs new file mode 100644 index 00000000..eeb6a481 --- /dev/null +++ b/test/NodeGuard.Tests/Services/PaymentGraphSerializationTests.cs @@ -0,0 +1,127 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Text.Json; +using FluentAssertions; + +namespace NodeGuard.Services; + +/// +/// Contract test guarding the Payments Watcher frontend seam. Blazor's IJSRuntime +/// serializes interop arguments with (camelCase), +/// so passing the record straight to +/// InvokeVoidAsync("paymentsWatcher.render", ...) must yield the exact camelCase keys +/// that wwwroot/js/payments-watcher-graph.js reads. If someone hand-serializes with +/// default (PascalCase) options, the graph renders blank — these tests fail first. +/// +public class PaymentGraphSerializationTests +{ + // The exact options Blazor's IJSRuntime uses for interop argument serialization. + private static readonly JsonSerializerOptions WebOptions = new(JsonSerializerDefaults.Web); + + private static PaymentGraph SampleGraph() => new( + Nodes: new List + { + new(Id: "03origin", IsOrigin: true, + Payments: new List { new("hash1", "success") }, + Alias: "origin-node"), + new(Id: "02hop", IsOrigin: false, + Payments: new List { new("hash1", "failed") }) + }, + Channels: new List + { + new( + Id: "18446744073709551615", // uint64 max — must survive as a JSON string + From: "03origin", + To: "02hop", + PaymentId: "hash1", + PaymentStatus: "failed", + HopStatus: "failed_here", + FailureCode: "TEMPORARY_CHANNEL_FAILURE", + AttemptIndex: 2, + HopSequence: 1) + }); + + [Fact] + public void PaymentGraph_SerializedWithWebDefaults_UsesCamelCaseKeysTheRendererReads() + { + var json = JsonSerializer.Serialize(SampleGraph(), WebOptions); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Container keys. + root.TryGetProperty("nodes", out _).Should().BeTrue("the renderer reads graph.nodes"); + root.TryGetProperty("channels", out _).Should().BeTrue("the renderer reads graph.channels"); + + // Node keys. + var node = root.GetProperty("nodes")[0]; + node.TryGetProperty("id", out _).Should().BeTrue(); + node.TryGetProperty("isOrigin", out var isOrigin).Should().BeTrue("node.isOrigin drives origin styling/layout"); + isOrigin.GetBoolean().Should().BeTrue(); + node.TryGetProperty("payments", out _).Should().BeTrue(); + node.TryGetProperty("alias", out _).Should().BeTrue(); + + // Node payment keys. + var nodePayment = node.GetProperty("payments")[0]; + nodePayment.TryGetProperty("id", out _).Should().BeTrue(); + nodePayment.TryGetProperty("status", out var payStatus).Should().BeTrue("p.status drives node success/fail counts"); + payStatus.GetString().Should().Be("success"); + + // Channel keys the renderer reads. + var channel = root.GetProperty("channels")[0]; + channel.TryGetProperty("id", out _).Should().BeTrue(); + channel.TryGetProperty("from", out _).Should().BeTrue(); + channel.TryGetProperty("to", out _).Should().BeTrue(); + channel.TryGetProperty("paymentId", out _).Should().BeTrue(); + channel.TryGetProperty("paymentStatus", out var chStatus).Should().BeTrue("ch.paymentStatus drives edge visibility/colour"); + chStatus.GetString().Should().Be("failed"); + channel.TryGetProperty("hopStatus", out _).Should().BeTrue("hopStatus drives per-hop trace tone"); + channel.TryGetProperty("failureCode", out _).Should().BeTrue("failureCode is shown next to a failed hop"); + channel.TryGetProperty("attemptIndex", out _).Should().BeTrue(); + channel.TryGetProperty("hopSequence", out _).Should().BeTrue(); + } + + [Fact] + public void PaymentGraph_ChannelId_IsSerializedAsString_NotNumber() + { + // Channel ids are uint64 > 2^53; they must ride the wire as strings or JS loses precision. + var json = JsonSerializer.Serialize(SampleGraph(), WebOptions); + + using var doc = JsonDocument.Parse(json); + var idElement = doc.RootElement.GetProperty("channels")[0].GetProperty("id"); + + idElement.ValueKind.Should().Be(JsonValueKind.String); + idElement.GetString().Should().Be("18446744073709551615"); + } + + [Fact] + public void PaymentGraph_SerializedWithWebDefaults_DoesNotEmitPascalCaseKeys() + { + // Regression guard: the "renders blank" bug is PascalCase output. Prove Web defaults + // do not leak PascalCase variants of the keys the renderer relies on. + var json = JsonSerializer.Serialize(SampleGraph(), WebOptions); + + json.Should().NotContain("\"IsOrigin\""); + json.Should().NotContain("\"PaymentStatus\""); + json.Should().NotContain("\"HopStatus\""); + json.Should().NotContain("\"AttemptIndex\""); + json.Should().NotContain("\"HopSequence\""); + } +} From 633b8a0ceaf11772a74c7025f663699526260607 Mon Sep 17 00:00:00 2001 From: Ismael Date: Thu, 30 Jul 2026 13:27:07 +0200 Subject: [PATCH 06/10] Enable NG to start with any external polar setup --- .env.example | 32 ++++ .gitignore | 2 + .justfile | 31 +++- CLAUDE.md | 3 +- README.md | 27 +++- docker-compose.yml | 1 + docker/bitcoin/docker-compose.external.yml | 32 ++++ docker/bitcoin/setup-external.sh | 104 ++++++++++++ docker/docker-compose.dev.yml | 30 +++- docker/extract-macaroons.sh | 177 ++++++++++++++++++--- 10 files changed, 408 insertions(+), 31 deletions(-) create mode 100644 .env.example create mode 100644 docker/bitcoin/docker-compose.external.yml create mode 100755 docker/bitcoin/setup-external.sh diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2c85e26d --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Copy to .env at the repo root to run NodeGuard against a regtest network that is already up and +# managed outside this repo (typically one started from the Polar app). Both `just` and +# `docker compose` read this file automatically. +# +# Leave .env absent (or these values empty) to use the in-repo polar stack: +# docker compose --profile polar up -d + +# Name of the running bitcoind container NodeGuard and nbxplorer should use. +BITCOIND_CONTAINER=polar-n3-backend1 + +# LND containers to manage. Endpoints are resolved from each container's published gRPC port, and +# the env var prefix comes from the last name segment (polar-n3-alice -> ALICE_HOST/_MACAROON/...). +# Only the alice/bob/carol slots are seeded automatically (src/Data/DbInitializer.cs); other nodes +# are extracted to src/nodeguard-macaroons.env but have to be added through the NodeGuard UI. +MANAGED_NODES=polar-n3-alice,polar-n3-bob,polar-n3-carol + +# bitcoind RPC credentials — Polar's defaults. +# BITCOIND_RPCUSER=polaruser +# BITCOIND_RPCPASSWORD=polarpass + +# Miner wallet funding done by the external profile. It mines to the `default` wallet until that +# wallet holds EXTERNAL_MINE_TARGET_BTC of mature coin, because NodeGuard's dev seeding spends +# 4 x 20 BTC: a first batch of EXTERNAL_MINE_BLOCKS for coinbase maturity, then chunks of 50 up to +# EXTERNAL_MINE_MAX_BLOCKS. Existing wallets and their balances are never touched. +# EXTERNAL_MINE_BLOCKS=0 mines nothing, but the unnamed wallet is still unloaded, so `default` +# stays empty and dev seeding will fail unless you fund it yourself. +# EXTERNAL_MINE_BLOCKS=101 +# EXTERNAL_MINE_TARGET_BTC=100 +# EXTERNAL_MINE_MAX_BLOCKS=1000 + +# Do NOT set BITCOIND_HOST / BITCOIND_RPC_PORT / BITCOIND_P2P_PORT here — `just external-up` +# resolves them from BITCOIND_CONTAINER, and hardcoding them would also affect the polar profile. diff --git a/.gitignore b/.gitignore index ac5bc7ce..62d94e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ devnetwork*/ .DS_Store # NodeGuard macaroon environment file src/nodeguard-macaroons.env +# Local infrastructure overrides (see .env.example) +.env .claude/settings.local.json diff --git a/.justfile b/.justfile index 3d6f7716..cd5dbf23 100644 --- a/.justfile +++ b/.justfile @@ -85,8 +85,9 @@ add-migration name: cd {{PROJECT_DIR}} && dotnet ef migrations add --context ApplicationDbContext {{name}} remove-migration: cd {{PROJECT_DIR}} && dotnet ef migrations remove --context ApplicationDbContext +# Mines a block a minute — set BITCOIND_CONTAINER to target an external regtest (see external-up) mine: - while true; do docker exec polar-n1-backend1 bitcoin-cli -regtest -rpcuser=polaruser -rpcpassword=polarpass -generate 1; sleep 60; done + while true; do docker exec ${BITCOIND_CONTAINER:-polar-n1-backend} bitcoin-cli -regtest -rpcuser=polaruser -rpcpassword=polarpass -generate 1; sleep 60; done # Update protobuf definitions from LND and Loop repositories update-protos: @@ -100,6 +101,34 @@ update-protos: docker-up *args: docker compose --profile polar --profile loop --profile 40swap -f {{DOCKER_COMPOSE_FILE}} up --build -d {{args}} +# Requires BITCOIND_CONTAINER (repo-root .env or inline), e.g.: +# BITCOIND_CONTAINER=polar-n3-backend1 just external-up +# Brings up only NodeGuard's dependencies (postgres + nbxplorer) against an already-running regtest +external-up *args: + #!/usr/bin/env bash + set -euo pipefail + if [ -z "${BITCOIND_CONTAINER:-}" ]; then + echo "BITCOIND_CONTAINER is not set. Copy .env.example to .env and set it, or run:" >&2 + echo " BITCOIND_CONTAINER= just external-up" >&2 + exit 1 + fi + # nbxplorer runs in a container and NodeGuard on the host, so both reach the external + # bitcoind through its published ports rather than through its compose network. + BITCOIND_RPC_PORT=$(docker port "$BITCOIND_CONTAINER" 18443 | grep -m1 '^0\.0\.0\.0:' | cut -d: -f2) + BITCOIND_P2P_PORT=$(docker port "$BITCOIND_CONTAINER" 18444 | grep -m1 '^0\.0\.0\.0:' | cut -d: -f2) + if [ -z "$BITCOIND_RPC_PORT" ] || [ -z "$BITCOIND_P2P_PORT" ]; then + echo "Could not resolve the published 18443/18444 ports of $BITCOIND_CONTAINER" >&2 + docker port "$BITCOIND_CONTAINER" >&2 || true + exit 1 + fi + echo "Using $BITCOIND_CONTAINER: RPC on host port $BITCOIND_RPC_PORT, P2P on $BITCOIND_P2P_PORT" + export BITCOIND_CONTAINER BITCOIND_HOST=host.docker.internal BITCOIND_RPC_PORT BITCOIND_P2P_PORT + docker compose --profile external -f {{DOCKER_COMPOSE_FILE}} up -d {{args}} + +# Stops the containers started by external-up (leaves the external regtest network alone) +external-down: + docker compose --profile external -f {{DOCKER_COMPOSE_FILE}} down + # Stops the development docker containers, add DOCKER_COMPOSE_FILE to override the default file docker-down: docker compose --profile polar --profile loop --profile 40swap -f {{DOCKER_COMPOSE_FILE}} down diff --git a/CLAUDE.md b/CLAUDE.md index f0e02f55..846da2fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,8 @@ If you invoke `dotnet run` / `dotnet watch` directly (not through `just`), run [ **Infra** - `tilt up` is the recommended path — runs all profiles per [Tiltfile](Tiltfile). - Alternatives: `just docker-up` / `just docker-down` / `just docker-rm` (force volume reset). The compose entrypoint is [docker-compose.yml](docker-compose.yml), which `include:`s the per-stack files under [docker/](docker/). -- `just mine` — loops `bitcoin-cli -generate 1` against the Polar `polar-n1-backend1` container for regtest. +- Every chain service sits behind a compose profile, so a bare `docker compose up -d` starts nothing usable. `--profile polar` runs the in-repo regtest network (`polar-n1-*` containers, [docker/bitcoin/docker-compose.polar.yml](docker/bitcoin/docker-compose.polar.yml)); `--profile external` (via `just external-up`, [docker/bitcoin/docker-compose.external.yml](docker/bitcoin/docker-compose.external.yml)) runs only postgres + nbxplorer against a regtest already running elsewhere, parameterized by `BITCOIND_CONTAINER` / `MANAGED_NODES` in a repo-root `.env` (see [.env.example](.env.example)). +- `just mine` — loops `bitcoin-cli -generate 1` against `polar-n1-backend` for regtest (override with `BITCOIND_CONTAINER`). **Protos** - `just update-protos` — regenerates from upstream LND/Loop trees under [lnd/](lnd/) via [src/Proto/update-protos.sh](src/Proto/update-protos.sh). NodeGuard's own proto lives at [src/Proto/nodeguard.proto](src/Proto/nodeguard.proto). diff --git a/README.md b/README.md index 409bef84..05719af4 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,32 @@ dotnet tool install -g dotnet-ef ### Using polar -1. You can run the Polar network by importing the `devnetwork.zip` into it. Then you have to run `docker compose up -d` for the rest of the needed containers to start. +The `polar` profile above ships a self-contained regtest network (`bitcoind` + `alice`/`bob`/`carol`, the `polar-n1-*` containers) that **replaces** the Polar app — it does not attach to a network started from it. Use one or the other, not both. + +If you already have a network running in the Polar app (or any other externally-managed regtest), use the `external` profile instead. It starts only NodeGuard's own dependencies — postgres and nbxplorer — and points them at your chain: + +1. Copy [.env.example](.env.example) to `.env` and set the containers to use: + ``` + BITCOIND_CONTAINER=polar-n3-backend1 + MANAGED_NODES=polar-n3-alice,polar-n3-bob,polar-n3-carol + ``` + `BITCOIND_CONTAINER` is the name of your running bitcoind container; `MANAGED_NODES` is the list of LND containers NodeGuard should manage. Both are reached through their **published host ports** (`docker port `), so there is no need to share a docker network. Each node's env var prefix comes from the last segment of its container name (`polar-n3-alice` → `ALICE_HOST`, `ALICE_MACAROON`, `ALICE_PUBKEY`). +2. Bring the dependencies up: + ``` + just external-up + ``` + This resolves the bitcoind RPC/P2P host ports, starts postgres + nbxplorer against them, and runs a setup container that prepares the miner wallet NodeGuard expects. It never funds your nodes or opens channels — the topology is left alone — but it does two things to your chain: + - Creates a wallet named `default` and leaves it as the **only loaded** wallet, because NodeGuard issues wallet RPCs without `-rpcwallet`. The unnamed wallet Polar ships gets unloaded. + - Mines to `default` until it holds `EXTERNAL_MINE_TARGET_BTC` (default 100) of mature coin, since NodeGuard's dev seeding spends 4 × 20 BTC. On an already-halved regtest chain that can be a couple hundred blocks (`EXTERNAL_MINE_MAX_BLOCKS`, default 1000, caps it). +3. Run NodeGuard as usual (`just run` / `just watch`). [docker/extract-macaroons.sh](docker/extract-macaroons.sh) reads the same `.env`, so macaroons, TLS certs, pubkeys and endpoints come from the containers you listed. +4. `just external-down` stops postgres/nbxplorer and leaves your regtest network alone. + +Caveats: +- Only nodes whose container name ends in `alice`, `bob` or `carol` are seeded automatically, because [src/Data/DbInitializer.cs](src/Data/DbInitializer.cs) has those three fixed dev slots. Any other node is still extracted to `src/nodeguard-macaroons.env` (and the script tells you so) but has to be added from the UI. +- Nothing is lost when the unnamed wallet is unloaded — reload it with `bitcoin-cli -regtest loadwallet ""` — but while NodeGuard runs use `just mine` (which honours `BITCOIND_CONTAINER`) rather than Polar's mining UI, which drives that wallet. +- `EXTERNAL_MINE_BLOCKS=0` skips all mining but still unloads the unnamed wallet, so `default` stays empty and NodeGuard's dev seeding fails when it tries to send its 20 BTC. Only use it if you fund `default` yourself. +- nbxplorer logs a `not whitelisted by your node` warning; it is harmless. Add `whitelist=` to the node's advanced options in Polar to silence it. +- `docker compose up -d` on its own starts nothing usable — every chain service lives behind a profile. Pick `--profile polar` (in-repo network) or `--profile external` (your own). ## Running the project diff --git a/docker-compose.yml b/docker-compose.yml index 0b2e3ba3..e2fd9630 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ include: - ./docker/docker-compose.dev.yml - ./docker/bitcoin/docker-compose.polar.yml + - ./docker/bitcoin/docker-compose.external.yml - ./docker/loop/docker-compose.yml - ./docker/mempool/docker-compose.yml - ./docker/40swap/docker-compose.yml diff --git a/docker/bitcoin/docker-compose.external.yml b/docker/bitcoin/docker-compose.external.yml new file mode 100644 index 00000000..ae00aea8 --- /dev/null +++ b/docker/bitcoin/docker-compose.external.yml @@ -0,0 +1,32 @@ +# `external` profile: run NodeGuard against a regtest network that is already up and managed +# outside of this repo (typically a network started from the Polar app). +# +# Nothing here starts a chain — the only job of this profile is to make an existing bitcoind +# usable by NodeGuard: it checks the container is reachable and makes sure the miner wallet +# NodeGuard expects (`default`) exists and is the only loaded one. +# +# The chain itself is reached over the host: BITCOIND_HOST/BITCOIND_RPC_PORT/BITCOIND_P2P_PORT +# (resolved from BITCOIND_CONTAINER by `just external-up`) are what nbxplorer uses. +name: external + +services: + external-setup: + profiles: [external] + image: alpine:latest + container_name: nodeguard-external-setup + environment: + # Name of the already-running bitcoind container, e.g. polar-n3-backend1. + BITCOIND_CONTAINER: ${BITCOIND_CONTAINER:-} + BITCOIND_RPCUSER: ${BITCOIND_RPCUSER:-polaruser} + BITCOIND_RPCPASSWORD: ${BITCOIND_RPCPASSWORD:-polarpass} + # Mining to the `default` wallet, so NodeGuard's dev wallet funding (4 x 20 BTC) has mature + # coins to spend. First batch establishes coinbase maturity, then it tops up in chunks of 50 + # until the mature balance reaches the target. Set EXTERNAL_MINE_BLOCKS=0 to mine nothing. + EXTERNAL_MINE_BLOCKS: ${EXTERNAL_MINE_BLOCKS:-101} + EXTERNAL_MINE_TARGET_BTC: ${EXTERNAL_MINE_TARGET_BTC:-100} + EXTERNAL_MINE_MAX_BLOCKS: ${EXTERNAL_MINE_MAX_BLOCKS:-1000} + volumes: + - ./:/docker + - /var/run/docker.sock:/var/run/docker.sock + command: sh -c "apk add --no-cache docker-cli jq && /docker/setup-external.sh" + restart: "no" diff --git a/docker/bitcoin/setup-external.sh b/docker/bitcoin/setup-external.sh new file mode 100755 index 00000000..902065de --- /dev/null +++ b/docker/bitcoin/setup-external.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +# Prepares an externally-managed regtest bitcoind (e.g. one started by the Polar app) for +# NodeGuard. Unlike setup.sh this never funds nodes or opens channels — the network is assumed +# to be already set up. It only reconciles the miner wallet: +# +# NodeGuard's DbInitializer does `unloadwallet ""` + `loadwallet "default"` on startup and then +# issues wallet RPCs without -rpcwallet, so bitcoind must have exactly one loaded wallet and it +# must be named `default`. Polar ships the unnamed ("") wallet, hence this reconciliation. +# +# Inputs (env): BITCOIND_CONTAINER, BITCOIND_RPCUSER, BITCOIND_RPCPASSWORD, EXTERNAL_MINE_BLOCKS, +# EXTERNAL_MINE_TARGET_BTC, EXTERNAL_MINE_MAX_BLOCKS. + +set -e + +EXTERNAL_MINE_BLOCKS=${EXTERNAL_MINE_BLOCKS:-101} +EXTERNAL_MINE_TARGET_BTC=${EXTERNAL_MINE_TARGET_BTC:-100} +EXTERNAL_MINE_MAX_BLOCKS=${EXTERNAL_MINE_MAX_BLOCKS:-1000} + +if [ -z "$BITCOIND_CONTAINER" ]; then + echo "ERROR: BITCOIND_CONTAINER is not set." + echo " Set it to the name of your running bitcoind container, e.g.:" + echo " BITCOIND_CONTAINER=polar-n3-backend1 just external-up" + echo " or put it in the .env file at the repo root (see .env.example)." + exit 1 +fi + +if ! docker ps --format '{{.Names}}' | grep -q "^${BITCOIND_CONTAINER}$"; then + echo "ERROR: container '${BITCOIND_CONTAINER}' is not running. Running bitcoind containers:" + docker ps --format '{{.Names}}\t{{.Image}}' | grep -i bitcoind || echo " (none)" + exit 1 +fi + +bitcoin_cli() { + docker exec "$BITCOIND_CONTAINER" bitcoin-cli -regtest \ + -rpcuser="$BITCOIND_RPCUSER" -rpcpassword="$BITCOIND_RPCPASSWORD" "$@" +} + +# Wallet-scoped calls, once `default` is the loaded wallet. +default_wallet_cli() { + docker exec "$BITCOIND_CONTAINER" bitcoin-cli -regtest \ + -rpcuser="$BITCOIND_RPCUSER" -rpcpassword="$BITCOIND_RPCPASSWORD" -rpcwallet=default "$@" +} + +echo "=== Checking ${BITCOIND_CONTAINER} ===" +CHAIN=$(bitcoin_cli getblockchaininfo | jq -r .chain) +if [ "$CHAIN" != "regtest" ]; then + echo "ERROR: ${BITCOIND_CONTAINER} is on chain '${CHAIN}', NodeGuard's dev setup expects regtest." + exit 1 +fi +echo "chain=regtest height=$(bitcoin_cli getblockcount)" + +echo "=== Reconciling the miner wallet ===" +# createwallet fails if it already exists on disk, loadwallet fails if already loaded: either way +# we end up with `default` loaded. +bitcoin_cli createwallet default >/dev/null 2>&1 || bitcoin_cli loadwallet default >/dev/null 2>&1 || true + +if ! bitcoin_cli listwallets | jq -e 'index("default")' >/dev/null; then + echo "ERROR: could not create or load a wallet named 'default'." + bitcoin_cli listwallets + exit 1 +fi + +BALANCE=$(default_wallet_cli getbalance) +echo "default wallet balance: ${BALANCE} BTC" + +# NodeGuard's dev seeding sends 4 x 20 BTC out of the miner wallet, so make sure there is enough +# mature coin. Mining is additive: it never touches the balance of the other wallets. +if [ "$EXTERNAL_MINE_BLOCKS" -gt 0 ] 2>/dev/null; then + mined=0 + # First batch establishes coinbase maturity (100 confirmations), then we top up in chunks — + # on a chain that has already halved a few times one mature coinbase is not worth much. + while awk -v b="$BALANCE" -v t="$EXTERNAL_MINE_TARGET_BTC" 'BEGIN { exit !(b < t) }'; do + if [ "$mined" -ge "$EXTERNAL_MINE_MAX_BLOCKS" ]; then + echo "WARNING: stopped after mining ${mined} blocks with ${BALANCE} BTC mature" + echo " (target ${EXTERNAL_MINE_TARGET_BTC} BTC). NodeGuard's dev wallet funding may fail;" + echo " raise EXTERNAL_MINE_MAX_BLOCKS or fund the 'default' wallet yourself." + break + fi + + batch=$([ "$mined" -eq 0 ] && echo "$EXTERNAL_MINE_BLOCKS" || echo 50) + echo "Mining ${batch} blocks to the default wallet (mature balance ${BALANCE}/${EXTERNAL_MINE_TARGET_BTC} BTC)" + default_wallet_cli -generate "$batch" >/dev/null + mined=$((mined + batch)) + BALANCE=$(default_wallet_cli getbalance) + done + echo "default wallet balance: ${BALANCE} BTC, height $(bitcoin_cli getblockcount)" +fi + +# Unload everything else last, so wallet RPCs without -rpcwallet are unambiguous: NodeGuard talks +# to bitcoind without -rpcwallet and bitcoind then requires exactly one loaded wallet. +# The wallet files stay on disk — reload one with `bitcoin-cli -regtest loadwallet `. +# NOTE: `jq -r '.[]'` cannot emit Bitcoin Core's unnamed wallet (it is the empty string and word +# splitting drops it), so it is unloaded explicitly. +bitcoin_cli unloadwallet "" >/dev/null 2>&1 && echo "Unloaded the unnamed wallet" || true +for wallet in $(bitcoin_cli listwallets | jq -r '.[]'); do + if [ "$wallet" != "default" ]; then + echo "Unloading wallet '${wallet}'" + bitcoin_cli unloadwallet "$wallet" || true + fi +done +echo "Loaded wallets: $(bitcoin_cli listwallets | jq -c .)" + +echo "=== Done, ${BITCOIND_CONTAINER} is ready for NodeGuard ===" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 29cc0bee..639d0a8a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -4,6 +4,9 @@ include: - ./bitcoin/docker-compose.polar.yml services: + # By default nbxplorer talks to the `bitcoind` service of the in-repo polar stack + # (`--profile polar`). The BITCOIND_* variables let it point at an already-running regtest + # instead — see the `external` profile in ./bitcoin/docker-compose.external.yml. nbxplorer: restart: unless-stopped image: ghcr.io/elenpay/nbxplorer:elenpay-develop @@ -12,8 +15,17 @@ services: ports: - "32838:32838" depends_on: - - postgres - - bitcoind + postgres: + condition: service_started + # `required: false` so the project stays valid when the polar profile is off and the + # chain is provided externally — without it compose errors with + # "service nbxplorer depends on undefined service bitcoind". + bitcoind: + condition: service_started + required: false + # Lets BITCOIND_HOST=host.docker.internal reach the host on Linux too. + extra_hosts: + - "host.docker.internal:host-gateway" environment: NBXPLORER_NETWORK: regtest NBXPLORER_BIND: 0.0.0.0:32838 @@ -21,23 +33,25 @@ services: NBXPLORER_SIGNALFILESDIR: /datadir NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Database=nbxplorer; NBXPLORER_CHAINS: "btc" - NBXPLORER_BTCRPCUSER: "polaruser" - NBXPLORER_BTCRPCPASSWORD: "polarpass" - NBXPLORER_BTCRPCURL: http://bitcoind:18443 - NBXPLORER_BTCNODEENDPOINT: bitcoind:18444 + NBXPLORER_BTCRPCUSER: "${BITCOIND_RPCUSER:-polaruser}" + NBXPLORER_BTCRPCPASSWORD: "${BITCOIND_RPCPASSWORD:-polarpass}" + NBXPLORER_BTCRPCURL: http://${BITCOIND_HOST:-bitcoind}:${BITCOIND_RPC_PORT:-18443} + NBXPLORER_BTCNODEENDPOINT: ${BITCOIND_HOST:-bitcoind}:${BITCOIND_P2P_PORT:-18444} NBXPLORER_CUSTOMKEYPATHTEMPLATE: "2/*/0" + # Only consumed by the healthcheck below, NBXplorer ignores it. + BITCOIND_P2P_PORT: ${BITCOIND_P2P_PORT:-18444} command: ["--noauth"] volumes: - "bitcoin_datadir:/root/.bitcoin" - "nbxplorer_datadir:/datadir" healthcheck: - # Check via /proc/net if we are connected to the bitcoin node at port 18444. + # Check via /proc/net if we are connected to the bitcoin node at its p2p port. # NOTE: this could be improved by calling curl but this machine does not have it # installed. test: [ "CMD-SHELL", - "cat /proc/net/tcp /proc/net/tcp6 | awk '{print $3}' | grep $(printf ':%X' 18444)", + "cat /proc/net/tcp /proc/net/tcp6 | awk '{print $3}' | grep $$(printf ':%X' $$BITCOIND_P2P_PORT)", ] interval: 1s timeout: 3m diff --git a/docker/extract-macaroons.sh b/docker/extract-macaroons.sh index 237270fc..d1f59efc 100755 --- a/docker/extract-macaroons.sh +++ b/docker/extract-macaroons.sh @@ -2,9 +2,26 @@ # Script to extract admin macaroons from LND and Loopd containers # This script generates environment variables for NodeGuard C# application - -LND_ROOT="/root/.lnd" +# +# With no configuration it targets the in-repo polar stack (`docker compose --profile polar`). +# To target an already-running regtest network instead (e.g. one started from the Polar app), +# set these in the .env file at the repo root or in the environment: +# +# MANAGED_NODES comma/space separated LND container names, in the order they should be +# mapped onto NodeGuard's dev node slots, e.g. +# MANAGED_NODES=polar-n3-alice,polar-n3-bob,polar-n3-carol +# Endpoints are resolved from each container's published gRPC port. +# BITCOIND_CONTAINER name of the running bitcoind container, e.g. polar-n3-backend1. Its +# published RPC/P2P ports are written out so NodeGuard talks to that node +# instead of the in-repo one. + +# LND's directory inside the container: the in-repo lndinit image runs as root out of /root/.lnd, +# the Polar app's image runs as the `lnd` user out of /home/lnd/.lnd. +LND_ROOT_CANDIDATES="/root/.lnd /home/lnd/.lnd /lnd" LOOP_ROOT="/root/.loop" +LND_GRPC_PORT=10009 +BITCOIND_RPC_PORT_INTERNAL=18443 +BITCOIND_P2P_PORT_INTERNAL=18444 set -e @@ -16,6 +33,57 @@ NC='\033[0m' # No Color echo -e "${GREEN}=== NodeGuard Macaroon Extractor ===${NC}" +# Pick up MANAGED_NODES / BITCOIND_CONTAINER from the repo-root .env when the script is invoked +# directly (just already loads it, VS Code tasks don't). Values already in the environment win. +load_dotenv() { + local dotenv="$1" + [ -f "$dotenv" ] || return 0 + while IFS= read -r line; do + case "$line" in + ''|'#'*) continue ;; + esac + local key="${line%%=*}" + local value="${line#*=}" + # Strip surrounding quotes and any trailing whitespace/comment-free remainder. + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + if [ -n "${!key:-}" ]; then + continue + fi + export "${key}=${value}" + done < "$dotenv" +} + +# Published host port for a container port, e.g. published_port polar-n3-alice 10009 -> 10004 +published_port() { + local container_name=$1 + local internal_port=$2 + + docker port "${container_name}" "${internal_port}" 2>/dev/null \ + | grep -m1 '^0\.0\.0\.0:' \ + | cut -d: -f2 +} + +# polar-n3-alice -> ALICE +slot_name() { + echo "${1##*-}" | tr '[:lower:]' '[:upper:]' +} + +# Where LND keeps its data in this container, detected from where tls.cert lives. +lnd_dir() { + local container_name=$1 + local candidate + + for candidate in ${LND_ROOT_CANDIDATES}; do + if docker exec "${container_name}" test -f "${candidate}/tls.cert" 2>/dev/null; then + echo "${candidate}" + return 0 + fi + done + + return 1 +} + # Function to extract macaroon from container extract_macaroon() { local container_name=$1 @@ -28,15 +96,17 @@ extract_macaroon() { return 1 fi - # Extract macaroon and encode to hex + # Extract macaroon and encode to hex. `od` rather than `xxd`, which the Polar app's LND + # image does not ship. local macaroon_hex - macaroon_hex=$(docker exec "${container_name}" xxd -p -c 256 "${macaroon_path}" | tr -d '\n') - - if [ -z "$macaroon_hex" ]; then - echo -e "${RED}Error: Failed to extract macaroon from ${container_name}${NC}" + macaroon_hex=$(docker exec "${container_name}" od -An -v -tx1 "${macaroon_path}" 2>/dev/null | tr -d ' \n') + + # Guard against docker exec failures whose message lands on stdout and looks like a value. + if [[ ! "$macaroon_hex" =~ ^[0-9a-f]+$ ]]; then + echo -e "${RED}Error: Failed to extract macaroon from ${container_name} (${macaroon_path})${NC}" return 1 fi - + echo ${macaroon_hex} return 0 } @@ -54,9 +124,9 @@ extract_tls() { # Extract TLS certificate and encode to hex local tls_hex - tls_hex=$(docker exec "${container_name}" base64 "${tls_path}" | tr -d '\n') + tls_hex=$(docker exec "${container_name}" base64 "${tls_path}" 2>/dev/null | tr -d '\n') - if [ -z "$tls_hex" ]; then + if [[ ! "$tls_hex" =~ ^[A-Za-z0-9+/=]+$ ]]; then echo -e "${RED}Error: Failed to extract TLS certificate from ${container_name}${NC}" return 1 fi @@ -67,6 +137,7 @@ extract_tls() { extract_pubkey() { local container_name=$1 + local lnd_root=$2 if ! docker ps --format "table {{.Names}}" | grep -q "^${container_name}$"; then echo -e "${RED}Error: Container ${container_name} is not running${NC}" @@ -74,7 +145,7 @@ extract_pubkey() { fi local pubkey - pubkey=$(docker exec "${container_name}" lncli -n regtest getinfo 2>/dev/null | grep identity_pubkey | cut -d'"' -f4) + pubkey=$(docker exec "${container_name}" lncli -n regtest --lnddir "${lnd_root}" getinfo 2>/dev/null | grep identity_pubkey | cut -d'"' -f4) if [ -z "$pubkey" ]; then echo -e "${RED}Error: Failed to extract public key from ${container_name}${NC}" @@ -91,8 +162,14 @@ extract_lnd_node_data() { local container_name=$2 local host=$3 + local lnd_root + if ! lnd_root=$(lnd_dir "${container_name}"); then + echo -e "${RED}✗ ${container_name}: no LND directory found in ${LND_ROOT_CANDIDATES// /, } — is it running and initialized?${NC}" + lnd_root="${LND_ROOT_CANDIDATES%% *}" + fi + echo "# ${node_name} LND Admin Macaroon" >> "${OUTPUT_FILE}" - if macaroon=$(extract_macaroon "${container_name}" "${LND_ROOT}/data/chain/bitcoin/regtest/admin.macaroon"); then + if macaroon=$(extract_macaroon "${container_name}" "${lnd_root}/data/chain/bitcoin/regtest/admin.macaroon"); then echo "${node_name}_MACAROON=\"${macaroon}\"" >> "${OUTPUT_FILE}" echo -e "${GREEN}✓ ${node_name} LND macaroon extracted${NC}" else @@ -102,7 +179,7 @@ extract_lnd_node_data() { echo "" >> "${OUTPUT_FILE}" echo "# ${node_name} LND TLS Certificate" >> "${OUTPUT_FILE}" - if tls_cert=$(extract_tls "${container_name}" "${LND_ROOT}/tls.cert"); then + if tls_cert=$(extract_tls "${container_name}" "${lnd_root}/tls.cert"); then echo "${node_name}_LND_TLS_CERT=\"${tls_cert}\"" >> "${OUTPUT_FILE}" echo -e "${GREEN}✓ ${node_name} TLS certificate extracted${NC}" else @@ -113,7 +190,7 @@ extract_lnd_node_data() { echo "# ${node_name} LND Host and Pubkey" >> "${OUTPUT_FILE}" echo "${node_name}_HOST=\"${host}\"" >> "${OUTPUT_FILE}" - if pubkey=$(extract_pubkey "${container_name}"); then + if pubkey=$(extract_pubkey "${container_name}" "${lnd_root}"); then echo "${node_name}_PUBKEY=\"${pubkey}\"" >> "${OUTPUT_FILE}" echo -e "${GREEN}✓ ${node_name} pubkey extracted${NC}" else @@ -167,6 +244,7 @@ extract_loopd_node_data() { # Create output file SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OUTPUT_FILE="${SCRIPT_DIR}/../src/nodeguard-macaroons.env" +load_dotenv "${SCRIPT_DIR}/../.env" echo -e "${YELLOW}Creating environment file: ${OUTPUT_FILE}${NC}" cat > "${OUTPUT_FILE}" << 'EOF' @@ -179,17 +257,76 @@ echo "" >> "${OUTPUT_FILE}" echo "IS_DEV_ENVIRONMENT=true" >> "${OUTPUT_FILE}" +# Point NodeGuard at an externally-managed bitcoind when one was given. NodeGuard runs on the host, +# so it reaches the container through its published ports. These override the values baked into +# launchSettings.json / launch.json (DotNetEnv clobbers existing vars when loading the env file). +if [ -n "${BITCOIND_CONTAINER:-}" ]; then + echo -e "${GREEN}=== Resolving external bitcoind (${BITCOIND_CONTAINER}) ===${NC}" + external_rpc_port=$(published_port "${BITCOIND_CONTAINER}" "${BITCOIND_RPC_PORT_INTERNAL}") + external_p2p_port=$(published_port "${BITCOIND_CONTAINER}" "${BITCOIND_P2P_PORT_INTERNAL}") + + if [ -z "${external_rpc_port}" ] || [ -z "${external_p2p_port}" ]; then + echo -e "${RED}✗ Could not resolve published ports ${BITCOIND_RPC_PORT_INTERNAL}/${BITCOIND_P2P_PORT_INTERNAL} of ${BITCOIND_CONTAINER}${NC}" + echo -e "${RED} Is the container running and publishing them? (docker port ${BITCOIND_CONTAINER})${NC}" + exit 1 + fi + + { + echo "# External bitcoind: ${BITCOIND_CONTAINER}" + echo "NBXPLORER_BTCRPCURL=\"http://127.0.0.1:${external_rpc_port}/\"" + echo "NBXPLORER_BTCNODEENDPOINT=\"127.0.0.1:${external_p2p_port}\"" + echo "NBXPLORER_BTCRPCUSER=\"${BITCOIND_RPCUSER:-polaruser}\"" + echo "NBXPLORER_BTCRPCPASSWORD=\"${BITCOIND_RPCPASSWORD:-polarpass}\"" + echo "" + } >> "${OUTPUT_FILE}" + echo -e "${GREEN}✓ RPC on 127.0.0.1:${external_rpc_port}, P2P on 127.0.0.1:${external_p2p_port}${NC}" +fi + # Extract LND macaroons echo -e "${GREEN}=== Extracting LND Macaroons ===${NC}" -# Alice LND -extract_lnd_node_data "ALICE" "polar-n1-alice" "localhost:10001" +if [ -n "${MANAGED_NODES:-}" ]; then + # Externally-managed nodes: resolve each endpoint from the container's published gRPC port. + IFS=', ' read -r -a managed_nodes <<< "${MANAGED_NODES}" + managed_slots=() -# Bob LND -extract_lnd_node_data "BOB" "polar-n1-bob" "localhost:10002" + for container in "${managed_nodes[@]}"; do + [ -z "${container}" ] && continue + slot=$(slot_name "${container}") + grpc_port=$(published_port "${container}" "${LND_GRPC_PORT}") + + if [ -z "${grpc_port}" ]; then + echo -e "${RED}✗ ${container}: no published port for ${LND_GRPC_PORT}, skipping${NC}" + continue + fi + + extract_lnd_node_data "${slot}" "${container}" "localhost:${grpc_port}" + managed_slots+=("${slot}") + done + + echo "# Managed nodes extracted from: ${MANAGED_NODES}" >> "${OUTPUT_FILE}" + echo "MANAGED_NODES=\"$(IFS=,; echo "${managed_slots[*]}")\"" >> "${OUTPUT_FILE}" + echo "" >> "${OUTPUT_FILE}" -# Carol LND -extract_lnd_node_data "CAROL" "polar-n1-carol" "localhost:10003" + # DbInitializer only seeds the alice/bob/carol slots (see src/Data/DbInitializer.cs), so any + # other node is extracted but has to be added through the UI. + for slot in "${managed_slots[@]}"; do + case "${slot}" in + ALICE|BOB|CAROL) ;; + *) echo -e "${YELLOW}⚠ ${slot} was extracted but is not auto-seeded — add it in the NodeGuard UI (Nodes > Add node) using ${slot}_HOST/${slot}_PUBKEY/${slot}_MACAROON from ${OUTPUT_FILE}${NC}" ;; + esac + done +else + # In-repo polar stack (docker compose --profile polar), fixed published ports. + # Alice LND + extract_lnd_node_data "ALICE" "polar-n1-alice" "localhost:10001" + + # Bob LND + extract_lnd_node_data "BOB" "polar-n1-bob" "localhost:10002" + + # Carol LND + extract_lnd_node_data "CAROL" "polar-n1-carol" "localhost:10003" +fi # Extract Loopd macaroons echo -e "${GREEN}=== Extracting Loopd Macaroons ===${NC}" From 84b901c66291b12c7f4f5476c8f71c647f5f2df3 Mon Sep 17 00:00:00 2001 From: Ismael Date: Tue, 11 Aug 2026 13:53:37 +0200 Subject: [PATCH 07/10] Track also failed route hops --- src/Data/Models/PaymentRoute.cs | 44 +- src/Jobs/MonitorPaymentRoutesJob.cs | 48 +- ...dPaymentRouteHopAttemptOutcome.Designer.cs | 2022 +++++++++++++++++ ...124619_AddPaymentRouteHopAttemptOutcome.cs | 70 + .../ApplicationDbContextModelSnapshot.cs | 10 + src/Services/PaymentRouteMapping.cs | 41 + src/Services/PaymentRoutesGraphService.cs | 26 +- src/wwwroot/js/payments-watcher-graph.js | 4 +- .../Jobs/MonitorPaymentRoutesJobTests.cs | 233 ++ .../Services/PaymentRouteMappingTests.cs | 35 + .../PaymentRoutesGraphServiceTests.cs | 81 + 11 files changed, 2594 insertions(+), 20 deletions(-) create mode 100644 src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.Designer.cs create mode 100644 src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.cs create mode 100644 test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs diff --git a/src/Data/Models/PaymentRoute.cs b/src/Data/Models/PaymentRoute.cs index 264f9a62..823e8871 100644 --- a/src/Data/Models/PaymentRoute.cs +++ b/src/Data/Models/PaymentRoute.cs @@ -64,7 +64,12 @@ public class PaymentRouteHop [MaxLength(64)] public string PaymentHash { get; set; } = string.Empty; - /// HTLC attempt index (0, 1, 2...) — a failed payment may retry over different routes. + /// + /// HTLC attempt ordinal within this payment (0, 1, 2...) — a payment may retry over + /// different routes. This is the attempt's position in LND's htlcs list, + /// NOT HTLCAttempt.attempt_id: the latter is a node-global uint64 sequence, so + /// storing it here would render as "attempt 4021" in the UI's per-attempt trace. + /// public int AttemptIndex { get; set; } /// Position of the hop within the route (0 = first hop from the origin). @@ -77,9 +82,46 @@ public class PaymentRouteHop public string ToNode { get; set; } = string.Empty; public long? AmountMsat { get; set; } + /// + /// Outcome of the HTLC attempt this hop belongs to. Denormalised onto every hop of the + /// attempt (LND reports it per attempt, not per hop) so the graph can colour a failed + /// attempt of an ultimately-successful payment correctly. + /// + public PaymentRouteAttemptStatus AttemptStatus { get; set; } + + /// + /// LND's Failure.failure_source_index for this attempt: the position in the route + /// of the node that returned the failure, where position 0 is the sender. Null when the + /// attempt did not fail or LND reported no failure detail. + /// + public int? FailureSourceIndex { get; set; } + + /// + /// LND's Failure.code in its wire spelling (e.g. TEMPORARY_CHANNEL_FAILURE). + /// Surfaced verbatim by the frontend as the failure chip on the attempt trace. + /// + [MaxLength(64)] + public string? FailureCode { get; set; } + public PaymentRoute? Payment { get; set; } } +/// +/// Outcome of a single HTLC attempt, mirroring LND's HTLCAttempt.HTLCStatus. +/// +public enum PaymentRouteAttemptStatus +{ + /// + /// Applies to rows written before per-attempt data + /// was persisted; the graph falls back to payment-level status for these, preserving the + /// rendering they had before. Never produced by the tracker for new rows. + /// + Unknown = 0, + InFlight = 1, + Succeeded = 2, + Failed = 3 +} + public enum PaymentRouteStatus { Unknown = 0, diff --git a/src/Jobs/MonitorPaymentRoutesJob.cs b/src/Jobs/MonitorPaymentRoutesJob.cs index 9c8883ec..0c769700 100644 --- a/src/Jobs/MonitorPaymentRoutesJob.cs +++ b/src/Jobs/MonitorPaymentRoutesJob.cs @@ -195,19 +195,32 @@ private async Task SavePaymentAsync(Node node, Payment raw) /// Port of tracker.py _save_hops applied over every HTLC attempt. The first hop /// always leaves from our own node; each subsequent hop starts from the previous /// destination. Hops without a pubkey or channel id are skipped. + /// + /// Each attempt's outcome and failure detail are denormalised onto its hops so the + /// graph can distinguish "this hop forwarded fine", "this hop broke" and "never reached" + /// instead of painting a whole attempt from the payment's final status. + /// + /// Note that a payment with no HTLC attempts at all yields no hops — that is the + /// normal shape for pathfinding-stage failures (NO_ROUTE, INSUFFICIENT_BALANCE), where + /// LND never dispatched an HTLC and so has no route to report. /// private static List BuildHops(Node node, string payHash, Payment raw) { var hops = new List(); - foreach (var attempt in raw.Htlcs) + for (var attemptIndex = 0; attemptIndex < raw.Htlcs.Count; attemptIndex++) { + var attempt = raw.Htlcs[attemptIndex]; var route = attempt.Route; if (route == null) { continue; } + var attemptStatus = PaymentRouteMapping.FromLndHtlcStatus(attempt.Status); + // Singular message field: null whenever the attempt carries no failure detail. + var failure = attempt.Failure; + // The first hop always leaves from our node (ORIGIN). var prevNode = node.PubKey; var seq = 0; @@ -224,12 +237,18 @@ private static List BuildHops(Node node, string payHash, Paymen hops.Add(new PaymentRouteHop { PaymentHash = payHash, - AttemptIndex = (int)attempt.AttemptId, + // Ordinal within this payment's attempt list, NOT attempt.AttemptId (a + // node-global uint64 that would both overflow int and render as + // "attempt 4021" in the UI trace). + AttemptIndex = attemptIndex, HopSequence = seq, ChannelId = channelId, FromNode = prevNode, ToNode = toNode, - AmountMsat = hop.AmtToForwardMsat + AmountMsat = hop.AmtToForwardMsat, + AttemptStatus = attemptStatus, + FailureSourceIndex = failure != null ? (int)failure.FailureSourceIndex : null, + FailureCode = PaymentRouteMapping.FailureCodeName(failure) }); prevNode = toNode; @@ -241,20 +260,21 @@ private static List BuildHops(Node node, string payHash, Paymen } /// - /// Port of tracker.py _extract_destination: the pubkey of the final hop of the - /// first attempt that has a route. + /// The payment's final destination: the last hop of the attempt that actually settled, + /// falling back to the first attempt with a route when none succeeded (a wholly failed + /// payment still aimed somewhere). + /// + /// tracker.py simply took the first attempt with a route. That is the same + /// payment-vs-attempt conflation fixed in : a payment that failed + /// over one route and settled over another would report the abandoned route's endpoint. /// private static string? ExtractDestination(Payment raw) { - foreach (var htlc in raw.Htlcs) - { - var routeHops = htlc.Route?.Hops; - if (routeHops is { Count: > 0 }) - { - return routeHops[^1].PubKey; - } - } + var settled = raw.Htlcs.FirstOrDefault(h => + h.Status == HTLCAttempt.Types.HTLCStatus.Succeeded && h.Route?.Hops.Count > 0); + + var chosen = settled ?? raw.Htlcs.FirstOrDefault(h => h.Route?.Hops.Count > 0); - return null; + return chosen?.Route.Hops[^1].PubKey; } } diff --git a/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.Designer.cs b/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.Designer.cs new file mode 100644 index 00000000..71393f76 --- /dev/null +++ b/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.Designer.cs @@ -0,0 +1,2022 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260810124619_AddPaymentRouteHopAttemptOutcome")] + partial class AddPaymentRouteHopAttemptOutcome + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsDynamicFeeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAppliedInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("LastAppliedInboundPpm") + .HasColumnType("integer"); + + b.Property("LastAppliedOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("LastAppliedOutboundPpm") + .HasColumnType("bigint"); + + b.Property("LastComputedTarget") + .HasColumnType("double precision"); + + b.Property("LastFeeUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastObservedRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelFeeStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("InitialChannelBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("InitialChannelFeeRatePpm") + .HasColumnType("bigint"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgeBlocks") + .HasColumnType("bigint"); + + b.Property("ChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveCategoryCyclesInNewState") + .HasColumnType("bigint"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EmaLocalRatio") + .HasColumnType("double precision"); + + b.Property("FundingBlockHeight") + .HasColumnType("bigint"); + + b.Property("LastCategorizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEvaluatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKnownLifetime") + .HasColumnType("bigint"); + + b.Property("LastKnownNumUpdates") + .HasColumnType("bigint"); + + b.Property("LastKnownUptime") + .HasColumnType("bigint"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetFlowRatio") + .HasColumnType("double precision"); + + b.Property("PeerFlowCategory") + .HasColumnType("integer"); + + b.Property("PeerInitiated") + .HasColumnType("boolean"); + + b.Property("PendingCategory") + .HasColumnType("integer"); + + b.Property("PullMsatWindow") + .HasColumnType("bigint"); + + b.Property("PushMsatWindow") + .HasColumnType("bigint"); + + b.Property("TargetLocalRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelRoutingStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ForwardingHtlcEvent", b => + { + b.Property("ManagedNodePubKey") + .HasColumnType("text"); + + b.Property("IncomingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventCase") + .HasColumnType("integer"); + + b.Property("EventTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureDetail") + .HasColumnType("integer"); + + b.Property("FailureString") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FeeMsat") + .HasColumnType("bigint"); + + b.Property("GrossFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeePpm") + .HasColumnType("bigint"); + + b.Property("IncomingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IncomingTimelock") + .HasColumnType("bigint"); + + b.Property("ManagedNodeName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("OutgoingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OutgoingTimelock") + .HasColumnType("bigint"); + + b.Property("RoutingFeePpm") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WireFailureCode") + .HasColumnType("integer"); + + b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId"); + + b.HasIndex("CreationDatetime"); + + b.HasIndex("EventTimestamp"); + + b.ToTable("ForwardingHtlcEvents"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowPositiveInboundFees") + .HasColumnType("boolean"); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutoRebalanceEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Property("PaymentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Destination") + .HasColumnType("text"); + + b.Property("OriginNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("PaymentHash"); + + b.HasIndex("CreatedAt"); + + b.ToTable("PaymentRoutes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountMsat") + .HasColumnType("bigint"); + + b.Property("AttemptIndex") + .HasColumnType("integer"); + + b.Property("AttemptStatus") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FailureSourceIndex") + .HasColumnType("integer"); + + b.Property("FromNode") + .IsRequired() + .HasColumnType("text"); + + b.Property("HopSequence") + .HasColumnType("integer"); + + b.Property("PaymentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ToNode") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PaymentHash"); + + b.ToTable("PaymentRouteHops"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelFeeState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelRoutingState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRouteHop", b => + { + b.HasOne("NodeGuard.Data.Models.PaymentRoute", "Payment") + .WithMany("Hops") + .HasForeignKey("PaymentHash") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.PaymentRoute", b => + { + b.Navigation("Hops"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.cs b/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.cs new file mode 100644 index 00000000..73747470 --- /dev/null +++ b/src/Migrations/20260810124619_AddPaymentRouteHopAttemptOutcome.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddPaymentRouteHopAttemptOutcome : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttemptStatus", + table: "PaymentRouteHops", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FailureCode", + table: "PaymentRouteHops", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "FailureSourceIndex", + table: "PaymentRouteHops", + type: "integer", + nullable: true); + + // Existing rows stored LND's node-global HTLCAttempt.attempt_id in AttemptIndex; + // the tracker now stores the attempt's ordinal within its own payment. Without + // this backfill the UI renders legacy attempts as "attempt 4021" (the trace label + // is attemptIndex + 1). Dense-rank preserves the original attempt ordering. + // + // Not reversed in Down(): the original attempt_id values are not recoverable, and + // nothing reads them — AttemptIndex only ever identifies and orders attempts + // within one payment. + migrationBuilder.Sql(""" + WITH ranked AS ( + SELECT "Id", + DENSE_RANK() OVER (PARTITION BY "PaymentHash" ORDER BY "AttemptIndex") - 1 AS ordinal + FROM "PaymentRouteHops" + ) + UPDATE "PaymentRouteHops" AS h + SET "AttemptIndex" = ranked.ordinal + FROM ranked + WHERE h."Id" = ranked."Id" AND h."AttemptIndex" <> ranked.ordinal; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AttemptStatus", + table: "PaymentRouteHops"); + + migrationBuilder.DropColumn( + name: "FailureCode", + table: "PaymentRouteHops"); + + migrationBuilder.DropColumn( + name: "FailureSourceIndex", + table: "PaymentRouteHops"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index be5e94b1..3e7c4873 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1133,9 +1133,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AttemptIndex") .HasColumnType("integer"); + b.Property("AttemptStatus") + .HasColumnType("integer"); + b.Property("ChannelId") .HasColumnType("numeric(20,0)"); + b.Property("FailureCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FailureSourceIndex") + .HasColumnType("integer"); + b.Property("FromNode") .IsRequired() .HasColumnType("text"); diff --git a/src/Services/PaymentRouteMapping.cs b/src/Services/PaymentRouteMapping.cs index 81de843f..823d52c8 100644 --- a/src/Services/PaymentRouteMapping.cs +++ b/src/Services/PaymentRouteMapping.cs @@ -17,6 +17,9 @@ * */ +using System.Collections.Concurrent; +using System.Reflection; +using Google.Protobuf.Reflection; using Lnrpc; using NodeGuard.Data.Models; @@ -48,4 +51,42 @@ public static DateTimeOffset CreatedAtFromCreationTimeNs(long creationTimeNs) Payment.Types.PaymentStatus.Failed => PaymentRouteStatus.Failed, _ => PaymentRouteStatus.Unknown }; + + /// + /// Maps a single HTLC attempt's status. Note this is per attempt, not per payment: + /// a SUCCEEDED payment routinely carries FAILED attempts it retried past, and colouring + /// those from the payment's status paints failed routes green. + /// + public static PaymentRouteAttemptStatus FromLndHtlcStatus(HTLCAttempt.Types.HTLCStatus status) => status switch + { + HTLCAttempt.Types.HTLCStatus.Succeeded => PaymentRouteAttemptStatus.Succeeded, + HTLCAttempt.Types.HTLCStatus.Failed => PaymentRouteAttemptStatus.Failed, + HTLCAttempt.Types.HTLCStatus.InFlight => PaymentRouteAttemptStatus.InFlight, + _ => PaymentRouteAttemptStatus.Unknown + }; + + /// + /// Renders Failure.code in its protobuf wire spelling (TEMPORARY_CHANNEL_FAILURE) + /// rather than the generated C# name (TemporaryChannelFailure), because the frontend + /// shows this string verbatim and operators match it against LND's own logs and docs. + /// protoc's C# output exposes Descriptor on messages but not on enums, so the + /// wire name is only reachable through the the generator + /// stamps on each member. The lookup is cached — this runs once per persisted failed attempt. + /// + public static string? FailureCodeName(Failure? failure) + { + if (failure == null) + { + return null; + } + + return FailureCodeNames.GetOrAdd(failure.Code, static code => + { + var member = typeof(Failure.Types.FailureCode).GetField(code.ToString(), + BindingFlags.Public | BindingFlags.Static); + return member?.GetCustomAttribute()?.Name ?? code.ToString(); + }); + } + + private static readonly ConcurrentDictionary FailureCodeNames = new(); } diff --git a/src/Services/PaymentRoutesGraphService.cs b/src/Services/PaymentRoutesGraphService.cs index 9be22676..53e2414a 100644 --- a/src/Services/PaymentRoutesGraphService.cs +++ b/src/Services/PaymentRoutesGraphService.cs @@ -162,6 +162,26 @@ public static (string hopStatus, string? failureCode) HopStatusFor( return ("unreached", null); } + /// + /// Resolves a hop's tone from the outcome of its own attempt, falling back to the + /// payment's status only for rows written before per-attempt data was tracked. + /// Judging a hop by the payment's status is wrong in both directions: a SUCCEEDED + /// payment's abandoned attempts would render green, and a FAILED payment's hops would all + /// render red instead of showing where the route actually broke. + /// + public static (string hopStatus, string? failureCode) HopStatusForHop(PaymentRouteHop hop, PaymentRouteStatus paymentStatus) + => hop.AttemptStatus switch + { + PaymentRouteAttemptStatus.Succeeded => ("success", null), + PaymentRouteAttemptStatus.Failed => + HopStatusFor(PaymentRouteStatus.Failed, hop.HopSequence, hop.FailureSourceIndex, hop.FailureCode), + // Dispatched, no verdict yet. Deliberately not the payment's status: an in-flight + // shard of a settled MPP payment is not itself proven good. + PaymentRouteAttemptStatus.InFlight => ("ok", null), + // Legacy rows: reproduce exactly the payment-level colouring they had before. + _ => HopStatusFor(paymentStatus, hop.HopSequence, hop.FailureSourceIndex, hop.FailureCode) + }; + // ── Assembly (port of graph_builder._assemble, own-tables source) ─────────── private static PaymentGraph Assemble(string originId, List payments, List hops, IReadOnlyDictionary aliases) @@ -211,9 +231,7 @@ private static PaymentGraph Assemble(string originId, List payment } var pStatus = payStatus.GetValueOrDefault(hop.PaymentHash, PaymentRouteStatus.Failed); - // Own-tables source has no per-hop failure data, so derive from payment status - // (matches the Python fallback: "success" if success else "failed"). - var hopStatus = pStatus == PaymentRouteStatus.Success ? "success" : "failed"; + var (hopStatus, failureCode) = HopStatusForHop(hop, pStatus); channels.Add(new PaymentGraphChannel( Id: hop.ChannelId.ToString(), @@ -222,7 +240,7 @@ private static PaymentGraph Assemble(string originId, List payment PaymentId: hop.PaymentHash, PaymentStatus: StatusString(pStatus), HopStatus: hopStatus, - FailureCode: null, + FailureCode: failureCode, AttemptIndex: hop.AttemptIndex, HopSequence: hop.HopSequence)); } diff --git a/src/wwwroot/js/payments-watcher-graph.js b/src/wwwroot/js/payments-watcher-graph.js index bbba0db1..ed65620e 100644 --- a/src/wwwroot/js/payments-watcher-graph.js +++ b/src/wwwroot/js/payments-watcher-graph.js @@ -197,7 +197,9 @@ b.addEventListener('click', fn); return b; } - var scroller = el('div', { style: 'overflow:auto;padding:20px 18px;max-height:70vh;' }); + // Fixed 60vh viewport (not max-height): the graph pane keeps a stable size even + // when the layout is small, instead of collapsing to the content height. + var scroller = el('div', { style: 'overflow:auto;padding:20px 18px;height:60vh;box-sizing:border-box;' }); var stage = el('div', { style: 'position:relative;width:' + size.width + 'px;height:' + size.height + 'px;min-width:' + size.width + 'px;transform-origin:top left;' }); function applyZoom() { stage.style.transform = 'scale(' + state.zoom + ')'; } zoomBox.appendChild(zbtn('+', 'Zoom in', function () { state.zoom = Math.min(2, +(state.zoom + 0.15).toFixed(2)); applyZoom(); })); diff --git a/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs b/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs new file mode 100644 index 00000000..d0fdaac5 --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs @@ -0,0 +1,233 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Lnrpc; +using Microsoft.Extensions.Logging; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Services; +using Quartz; + +namespace NodeGuard.Jobs; + +public class MonitorPaymentRoutesJobTests +{ + private const string OriginPubKey = "02origin"; + private const string HopA = "02aaa"; + private const string HopB = "02bbb"; + private const string HopC = "02ccc"; + + private readonly Mock _nodeRepositoryMock = new(); + private readonly Mock _lightningClientServiceMock = new(); + private readonly Mock _paymentRouteRepositoryMock = new(); + private readonly MonitorPaymentRoutesJob _job; + + private readonly List _persisted = new(); + + public MonitorPaymentRoutesJobTests() + { + _nodeRepositoryMock + .Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = 1, Name = "origin", PubKey = OriginPubKey, Endpoint = "localhost:10009", ChannelAdminMacaroon = "abc" } + }); + + _paymentRouteRepositoryMock + .Setup(x => x.InsertIfNewAsync(It.IsAny())) + .Callback(p => _persisted.Add(p)) + .ReturnsAsync((true, (string?)null)); + + _job = new MonitorPaymentRoutesJob( + new Mock>().Object, + _nodeRepositoryMock.Object, + _lightningClientServiceMock.Object, + _paymentRouteRepositoryMock.Object); + } + + private void GivenPayments(params Payment[] payments) + { + var response = new ListPaymentsResponse { LastIndexOffset = 0 }; + response.Payments.AddRange(payments); + + // LastIndexOffset stays 0, so the tracker's pagination loop stops after one page. + _lightningClientServiceMock + .Setup(x => x.ListPayments(It.IsAny(), It.IsAny(), null)) + .ReturnsAsync(response); + } + + private static Hop MakeHop(string pubKey, ulong chanId) => + new() { PubKey = pubKey, ChanId = chanId, AmtToForwardMsat = 1000 }; + + private static HTLCAttempt MakeAttempt(ulong attemptId, HTLCAttempt.Types.HTLCStatus status, + Failure? failure, params Hop[] hops) + { + var route = new Route(); + route.Hops.AddRange(hops); + return new HTLCAttempt { AttemptId = attemptId, Status = status, Route = route, Failure = failure }; + } + + private static Payment MakePayment(string hash, Payment.Types.PaymentStatus status, params HTLCAttempt[] attempts) + { + var payment = new Payment + { + PaymentHash = hash, + Status = status, + ValueMsat = 1000, + CreationTimeNs = 1_700_000_000L * 1_000_000_000L + }; + payment.Htlcs.AddRange(attempts); + return payment; + } + + /// + /// The regression this whole change exists for: a payment that finally SUCCEEDED after + /// retrying carries its abandoned attempts in the same htlcs list. Deriving hop colour + /// from the payment's status painted those failed routes green. + /// + [Fact] + public async Task Execute_SucceededPaymentWithFailedAttempts_RecordsEachAttemptsOwnOutcome() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + MakeAttempt(4001, HTLCAttempt.Types.HTLCStatus.Failed, + new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure, FailureSourceIndex = 1 }, + MakeHop(HopA, 111), MakeHop(HopB, 222)), + MakeAttempt(4002, HTLCAttempt.Types.HTLCStatus.Failed, + new Failure { Code = Failure.Types.FailureCode.FeeInsufficient, FailureSourceIndex = 2 }, + MakeHop(HopC, 333), MakeHop(HopB, 444)), + MakeAttempt(4003, HTLCAttempt.Types.HTLCStatus.Succeeded, null, + MakeHop(HopA, 555), MakeHop(HopB, 666)))); + + await _job.Execute(new Mock().Object); + + var hops = _persisted.Single().Hops; + + hops.Where(h => h.AttemptIndex == 0).Should() + .OnlyContain(h => h.AttemptStatus == PaymentRouteAttemptStatus.Failed + && h.FailureCode == "TEMPORARY_CHANNEL_FAILURE" + && h.FailureSourceIndex == 1); + + hops.Where(h => h.AttemptIndex == 1).Should() + .OnlyContain(h => h.AttemptStatus == PaymentRouteAttemptStatus.Failed + && h.FailureCode == "FEE_INSUFFICIENT" + && h.FailureSourceIndex == 2); + + hops.Where(h => h.AttemptIndex == 2).Should() + .OnlyContain(h => h.AttemptStatus == PaymentRouteAttemptStatus.Succeeded + && h.FailureCode == null + && h.FailureSourceIndex == null); + } + + /// + /// AttemptIndex must be the attempt's ordinal inside its own payment. LND's attempt_id is + /// a node-global uint64 that both overflows int and renders as "attempt 4002" in the UI. + /// + [Fact] + public async Task Execute_AttemptIndex_IsPerPaymentOrdinalNotLndAttemptId() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, + MakeAttempt(9_000_000_001, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111)), + MakeAttempt(9_000_000_002, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopB, 222)))); + + await _job.Execute(new Mock().Object); + + // Asserted as (index, hop) pairs rather than a bare sequence, so an implementation + // that stamped every hop with the same ordinal can't pass on list order alone. + _persisted.Single().Hops.Should().SatisfyRespectively( + h => { h.AttemptIndex.Should().Be(0); h.ToNode.Should().Be(HopA); }, + h => { h.AttemptIndex.Should().Be(1); h.ToNode.Should().Be(HopB); }); + } + + /// + /// A payment that failed over one route and settled over another must report the route + /// that actually delivered, not the abandoned one it tried first. + /// + [Fact] + public async Task Execute_Destination_ComesFromTheSettledAttemptNotTheFirstOne() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111), MakeHop(HopB, 222)), + MakeAttempt(2, HTLCAttempt.Types.HTLCStatus.Succeeded, null, MakeHop(HopA, 333), MakeHop(HopC, 444)))); + + await _job.Execute(new Mock().Object); + + _persisted.Single().Destination.Should().Be(HopC); + } + + /// + /// Nothing settled, but the payment still aimed somewhere — fall back to the first + /// attempt that had a route rather than losing the destination entirely. + /// + [Fact] + public async Task Execute_Destination_FallsBackToFirstRoutedAttemptWhenNoneSettled() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, + MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111), MakeHop(HopB, 222)), + MakeAttempt(2, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 333), MakeHop(HopC, 444)))); + + await _job.Execute(new Mock().Object); + + _persisted.Single().Destination.Should().Be(HopB); + } + + [Fact] + public async Task Execute_HopSequenceAndFromNode_ChainFromTheOriginPerAttempt() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Succeeded, null, + MakeHop(HopA, 111), MakeHop(HopB, 222), MakeHop(HopC, 333)))); + + await _job.Execute(new Mock().Object); + + var hops = _persisted.Single().Hops; + hops.Select(h => h.HopSequence).Should().Equal(0, 1, 2); + hops.Select(h => h.FromNode).Should().Equal(OriginPubKey, HopA, HopB); + hops.Select(h => h.ToNode).Should().Equal(HopA, HopB, HopC); + } + + /// + /// Pathfinding-stage failures (NO_ROUTE, INSUFFICIENT_BALANCE) reach us with an empty + /// htlcs list — LND never dispatched an HTLC. The payment is still tracked; it just has + /// no route to draw. + /// + [Fact] + public async Task Execute_FailedPaymentWithNoAttempts_IsPersistedWithoutHops() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed)); + + await _job.Execute(new Mock().Object); + + var payment = _persisted.Single(); + payment.Status.Should().Be(PaymentRouteStatus.Failed); + payment.Hops.Should().BeEmpty(); + payment.Destination.Should().BeNull(); + } + + [Fact] + public async Task Execute_NonTerminalPayment_IsSkipped() + { + GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.InFlight, + MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.InFlight, null, MakeHop(HopA, 111)))); + + await _job.Execute(new Mock().Object); + + _persisted.Should().BeEmpty(); + } +} diff --git a/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs b/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs index 5831c744..47db9b9b 100644 --- a/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs +++ b/test/NodeGuard.Tests/Services/PaymentRouteMappingTests.cs @@ -48,4 +48,39 @@ public void FromLndPaymentStatus_MapsTerminalStatesAndSkipsTransient( { PaymentRouteMapping.FromLndPaymentStatus(lnd).Should().Be(expected); } + + [Theory] + [InlineData(HTLCAttempt.Types.HTLCStatus.Succeeded, PaymentRouteAttemptStatus.Succeeded)] + [InlineData(HTLCAttempt.Types.HTLCStatus.Failed, PaymentRouteAttemptStatus.Failed)] + [InlineData(HTLCAttempt.Types.HTLCStatus.InFlight, PaymentRouteAttemptStatus.InFlight)] + public void FromLndHtlcStatus_MapsEveryAttemptState( + HTLCAttempt.Types.HTLCStatus lnd, PaymentRouteAttemptStatus expected) + { + PaymentRouteMapping.FromLndHtlcStatus(lnd).Should().Be(expected); + } + + [Fact] + public void FromLndHtlcStatus_NeverProducesUnknown_WhichIsReservedForLegacyRows() + { + // Unknown drives the graph's payment-level fallback. If the tracker could emit it for + // a live attempt, fresh rows would silently take the legacy colouring path. + foreach (var status in Enum.GetValues()) + { + PaymentRouteMapping.FromLndHtlcStatus(status).Should().NotBe(PaymentRouteAttemptStatus.Unknown); + } + } + + [Fact] + public void FailureCodeName_UsesProtobufWireSpelling_NotTheCSharpName() + { + var failure = new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure }; + + PaymentRouteMapping.FailureCodeName(failure).Should().Be("TEMPORARY_CHANNEL_FAILURE"); + } + + [Fact] + public void FailureCodeName_NullFailure_IsNull() + { + PaymentRouteMapping.FailureCodeName(null).Should().BeNull(); + } } diff --git a/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs index 4d6228f4..4e1d3df8 100644 --- a/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs +++ b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs @@ -53,4 +53,85 @@ public void HopStatusFor_FailedWithSourceIndex_ClassifiesPerHop(int hopIndex, st status.Should().Be(expected); code.Should().Be(expected == "failed_here" ? "TEMPORARY_CHANNEL_FAILURE" : null); } + + // ── HopStatusForHop: attempt-level resolution ─────────────────────────────── + + private static PaymentRouteHop Hop(PaymentRouteAttemptStatus attemptStatus, int hopSequence = 0, + int? failureSourceIndex = null, string? failureCode = null) => new() + { + AttemptStatus = attemptStatus, + HopSequence = hopSequence, + FailureSourceIndex = failureSourceIndex, + FailureCode = failureCode + }; + + /// + /// The mislabel this resolver exists to prevent: an abandoned attempt of a payment that + /// ultimately succeeded must not render as a successful route. + /// + [Fact] + public void HopStatusForHop_FailedAttemptOfSucceededPayment_IsNotSuccess() + { + var (status, code) = PaymentRoutesGraphService.HopStatusForHop( + Hop(PaymentRouteAttemptStatus.Failed, hopSequence: 1, failureSourceIndex: 2, failureCode: "FEE_INSUFFICIENT"), + PaymentRouteStatus.Success); + + status.Should().Be("failed_here"); + code.Should().Be("FEE_INSUFFICIENT"); + } + + [Fact] + public void HopStatusForHop_SucceededAttempt_IsSuccessRegardlessOfStaleFailureData() + { + var (status, code) = PaymentRoutesGraphService.HopStatusForHop( + Hop(PaymentRouteAttemptStatus.Succeeded, failureSourceIndex: 1, failureCode: "X"), + PaymentRouteStatus.Success); + + status.Should().Be("success"); + code.Should().BeNull(); + } + + /// + /// An in-flight shard of a settled MPP payment is dispatched but unproven — it must not + /// borrow the payment's success. + /// + [Fact] + public void HopStatusForHop_InFlightAttempt_IsOkNotTheParentPaymentsStatus() + { + var (status, code) = PaymentRoutesGraphService.HopStatusForHop( + Hop(PaymentRouteAttemptStatus.InFlight), PaymentRouteStatus.Success); + + status.Should().Be("ok"); + code.Should().BeNull(); + } + + /// + /// Rows tracked before per-attempt data existed carry AttemptStatus = Unknown; they must + /// keep rendering exactly as they did under the old payment-level derivation. + /// + [Theory] + [InlineData(PaymentRouteStatus.Success, "success")] + [InlineData(PaymentRouteStatus.Failed, "failed")] + public void HopStatusForHop_UnknownAttemptStatus_FallsBackToPaymentStatus( + PaymentRouteStatus paymentStatus, string expected) + { + var (status, _) = PaymentRoutesGraphService.HopStatusForHop( + Hop(PaymentRouteAttemptStatus.Unknown), paymentStatus); + + status.Should().Be(expected); + } + + /// + /// A failed attempt LND gave us no failure detail for still has to be legible: every hop + /// goes red rather than silently classifying against a missing source index. + /// + [Fact] + public void HopStatusForHop_FailedAttemptWithoutFailureDetail_IsFailed() + { + var (status, code) = PaymentRoutesGraphService.HopStatusForHop( + Hop(PaymentRouteAttemptStatus.Failed, hopSequence: 3), PaymentRouteStatus.Failed); + + status.Should().Be("failed"); + code.Should().BeNull(); + } } From 0d4b4051f811720453a6281a6fb0bac1ea1851e3 Mon Sep 17 00:00:00 2001 From: Ismael Date: Wed, 12 Aug 2026 08:33:29 +0200 Subject: [PATCH 08/10] Fix catching failed routes --- .../Interfaces/IPaymentRouteRepository.cs | 11 +- .../Repositories/PaymentRouteRepository.cs | 52 +- src/Jobs/MonitorPaymentRoutesJob.cs | 491 ++++++++++++++---- src/Program.cs | 56 +- src/Services/LightningClientService.cs | 6 +- .../PaymentRouteRepositoryTests.cs | 137 +++++ .../Jobs/MonitorPaymentRoutesJobTests.cs | 139 +++-- 7 files changed, 666 insertions(+), 226 deletions(-) create mode 100644 test/NodeGuard.Tests/Data/Repositories/PaymentRouteRepositoryTests.cs diff --git a/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs index c1d807b6..480ecd61 100644 --- a/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs +++ b/src/Data/Repositories/Interfaces/IPaymentRouteRepository.cs @@ -23,8 +23,15 @@ namespace NodeGuard.Data.Repositories.Interfaces; public interface IPaymentRouteRepository { - /// Inserts a payment (with its hops) if it does not already exist. Idempotent by PaymentHash. - Task<(bool inserted, string? error)> InsertIfNewAsync(PaymentRoute payment); + /// + /// Inserts a payment (with its hops), or refreshes an existing row in place. Keyed by + /// PaymentHash; returns whether a new row was created. + /// + /// An insert-only write cannot be correct here: LND lets a failed payment hash be + /// retried, so the same hash can reach a terminal state twice (FAILED, then SUCCEEDED on the + /// retry). Skipping the second one leaves the payment permanently recorded as failed. + /// + Task<(bool inserted, string? error)> UpsertAsync(PaymentRoute payment); /// Payments (with hops eagerly loaded) originated by and created within [start, end]. Task> GetByCreatedAtRangeAsync(string originNodePubKey, DateTimeOffset start, DateTimeOffset end); diff --git a/src/Data/Repositories/PaymentRouteRepository.cs b/src/Data/Repositories/PaymentRouteRepository.cs index 37965a98..0e403243 100644 --- a/src/Data/Repositories/PaymentRouteRepository.cs +++ b/src/Data/Repositories/PaymentRouteRepository.cs @@ -35,24 +35,56 @@ public PaymentRouteRepository(IDbContextFactory dbContextF _logger = logger; } - public async Task<(bool inserted, string? error)> InsertIfNewAsync(PaymentRoute payment) + public async Task<(bool inserted, string? error)> UpsertAsync(PaymentRoute payment) { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); try { - // Idempotency: never re-insert a payment we already tracked (mirror of the - // Python tracker's `db.get(Payment, pay_hash) is not None` check). - if (await dbContext.PaymentRoutes.AnyAsync(p => p.PaymentHash == payment.PaymentHash)) + var existing = await dbContext.PaymentRoutes + .Include(p => p.Hops) + .FirstOrDefaultAsync(p => p.PaymentHash == payment.PaymentHash); + + var now = DateTimeOffset.UtcNow; + + if (existing == null) { - return (false, null); + payment.CreationDatetime = now; + payment.UpdateDatetime = now; + await dbContext.PaymentRoutes.AddAsync(payment); + await dbContext.SaveChangesAsync(); + return (true, null); + } + + existing.OriginNodePubKey = payment.OriginNodePubKey; + existing.Status = payment.Status; + existing.CreatedAt = payment.CreatedAt; + existing.AmountMsat = payment.AmountMsat; + existing.Destination = payment.Destination; + existing.UpdateDatetime = now; + + // Replace the hop set wholesale — each LND payment update carries the payment's full + // attempt list, so the incoming snapshot supersedes what we stored. + // + // But never let an EMPTY snapshot erase hops we already captured. LND deletes failed + // HTLC attempts once a payment is terminal (unless the node runs with + // --keep-failed-payment-attempts), so any later read of the same payment can honestly + // come back with no attempts at all. Wiping on that would destroy exactly the failed + // routes this feature exists to show. + if (payment.Hops.Count > 0) + { + dbContext.PaymentRouteHops.RemoveRange(existing.Hops); + await dbContext.SaveChangesAsync(); + + foreach (var hop in payment.Hops) + { + hop.PaymentHash = existing.PaymentHash; + } + + await dbContext.PaymentRouteHops.AddRangeAsync(payment.Hops); } - var now = DateTimeOffset.UtcNow; - payment.CreationDatetime = now; - payment.UpdateDatetime = now; - await dbContext.PaymentRoutes.AddAsync(payment); await dbContext.SaveChangesAsync(); - return (true, null); + return (false, null); } catch (Exception e) { diff --git a/src/Jobs/MonitorPaymentRoutesJob.cs b/src/Jobs/MonitorPaymentRoutesJob.cs index 0c769700..4c62123f 100644 --- a/src/Jobs/MonitorPaymentRoutesJob.cs +++ b/src/Jobs/MonitorPaymentRoutesJob.cs @@ -17,166 +17,315 @@ * */ +using System.Collections.Concurrent; +using Grpc.Core; +using Grpc.Net.Client; using Lnrpc; +using Microsoft.Extensions.Logging.Abstractions; using NodeGuard.Data.Models; using NodeGuard.Data.Repositories.Interfaces; using NodeGuard.Services; -using Quartz; +using Routerrpc; namespace NodeGuard.Jobs; /// -/// Polls each managed node's outbound payments via LND's ListPayments gRPC and -/// persists new ones (with their route hops) for route visualisation. Port of -/// LightningEye's PaymentTracker (app/services/tracker.py). +/// Long-running listener that keeps one LND payment-tracking stream open per managed node and +/// persists each payment (with its route hops) as LND reports it, for route visualisation. /// -/// The Python tracker held its index_offset cursor in memory (reset on -/// restart, re-scanned from 0). Quartz jobs are stateless per execution and the -/// entity has no cursor column, so this job paginates from -/// index_offset = 0 every run and relies on -/// for idempotency — behaviour -/// identical to the original. +/// Why a stream and not polling. This started life as a Quartz job polling +/// ListPayments. That can never see failed HTLC attempts: LND deletes them the moment a +/// payment reaches a terminal state (unless the node runs with +/// --keep-failed-payment-attempts), and the deletion is synchronous with that transition, so +/// no polling interval is fast enough. Measured on a regtest node, a payment that burned 34 failed +/// attempts across four-hop routes reported htlcs = 0 from ListPayments immediately +/// afterwards. The router's payment stream delivers the same payment's terminal update with all 34 +/// attempts still attached, which is the only place that data is observable. /// -/// Fails safe on a fresh/default environment: with no managed nodes (or nodes -/// missing a macaroon/endpoint) the loop body never runs and the job is a no-op. +/// Why the whole payment is re-persisted per update. Every update carries the +/// payment's complete attempt list, and that list is append-only: verified over a 72-update MPP +/// stream, position i always refers to the same attempt_id and the list never shrank. +/// So can stay an ordinal into that list, and each +/// update can safely replace the stored hop set rather than having to merge into it. +/// +/// Coverage is best-effort by construction. The stream only reports payments while we +/// are attached. A payment that reaches a terminal state while NodeGuard is down loses its attempt +/// detail permanently — LND will already have pruned it, so nothing can backfill it later. There is +/// deliberately no ListPayments catch-up sweep here; historical payments predating this +/// service are not imported. +/// +/// Fails safe on a fresh/default environment: with no managed nodes (or nodes missing a +/// macaroon/endpoint) no listener is ever started and the service idles. /// -[DisallowConcurrentExecution] -public class MonitorPaymentRoutesJob : IJob +public sealed class MonitorPaymentRoutesJob : BackgroundService { - private const int MaxPaymentsPerPage = 100; + /// How often the set of managed nodes is re-read so listeners follow node changes. + private static readonly TimeSpan NodeReconcileInterval = TimeSpan.FromMinutes(1); + + /// Backoff before re-opening a stream that dropped, so a node that is down does not spin. + private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(10); private readonly ILogger _logger; - private readonly INodeRepository _nodeRepository; - private readonly ILightningClientService _lightningClientService; - private readonly IPaymentRouteRepository _paymentRouteRepository; + + /// + /// Repositories are resolved per use from a fresh scope rather than injected. A + /// is a singleton, so injecting them directly captures + /// non-singleton services and the container's scope validation rejects the whole graph at + /// startup ("Cannot consume scoped service ... from singleton IHostedService"). + /// + private readonly IServiceScopeFactory _scopeFactory; + + /// Live listeners by node id — the "managed node => payment listener" mapping. + private readonly ConcurrentDictionary _listeners = new(); + + /// + /// gRPC channels by node endpoint. Deliberately owned here rather than borrowed from + /// LightningRouterService: the payment watcher keeps its own connection so a stream that + /// dies (and the channel eviction that follows) cannot disturb the routing/liquidity code paths + /// that share that service. + /// + private readonly ConcurrentDictionary _channels = new(); public MonitorPaymentRoutesJob(ILogger logger, - INodeRepository nodeRepository, - ILightningClientService lightningClientService, - IPaymentRouteRepository paymentRouteRepository) + IServiceScopeFactory scopeFactory) { _logger = logger; - _nodeRepository = nodeRepository; - _lightningClientService = lightningClientService; - _paymentRouteRepository = paymentRouteRepository; + _scopeFactory = scopeFactory; } - public async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Starting {JobName}... ", nameof(MonitorPaymentRoutesJob)); - try + _logger.LogInformation("Starting {ServiceName}... ", nameof(MonitorPaymentRoutesJob)); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileListenersAsync(stoppingToken); + } + catch (Exception e) + { + // Never let a bad reconcile pass kill the service; the next one retries. + _logger.LogError(e, "Error reconciling payment route listeners"); + } + + try + { + await Task.Delay(NodeReconcileInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + await StopAllListenersAsync(); + _logger.LogInformation("{ServiceName} ended", nameof(MonitorPaymentRoutesJob)); + } + + /// + /// Brings the live listener set in line with the managed nodes: starts one for every eligible + /// node that has none, and stops those whose node is gone or no longer reachable. + /// + private async Task ReconcileListenersAsync(CancellationToken stoppingToken) + { + List managedNodes; + using (var scope = _scopeFactory.CreateScope()) { - var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(false); + var nodeRepository = scope.ServiceProvider.GetRequiredService(); + managedNodes = await nodeRepository.GetAllManagedByNodeGuard(false); + } + + var eligible = managedNodes + // Fail safe: skip anything we can't reach. On a default environment this means no + // listener is started rather than an error being thrown. + .Where(n => !string.IsNullOrWhiteSpace(n.ChannelAdminMacaroon) && + !string.IsNullOrWhiteSpace(n.Endpoint)) + .ToList(); - foreach (var node in managedNodes) + foreach (var node in eligible) + { + if (_listeners.TryGetValue(node.Id, out var running)) { - // Fail safe: skip anything we can't reach. On a default environment this - // means the job does nothing rather than erroring. - if (string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon) || - string.IsNullOrWhiteSpace(node.Endpoint)) + // A listener normally runs until cancelled, so a completed one means its loop fell + // over; drop it here and let the code below start a replacement. + var faulted = running.Task is { IsCompleted: true }; + + // The node's connection details are captured when its stream starts, so an + // endpoint or macaroon edit has to restart the listener to take effect. + var reconnectionNeeded = running.Endpoint != node.Endpoint || + running.Macaroon != node.ChannelAdminMacaroon; + + if (!faulted && !reconnectionNeeded) { continue; } - try - { - await TrackNodePaymentsAsync(node); - } - catch (Exception ex) - { - // One node failing must not abort the rest (mirror of MonitorSwapsJob). - _logger.LogError(ex, - "Unexpected error while tracking payment routes for node {NodeId}. Monitoring will continue for other nodes", - node.Id); - } + _logger.LogInformation( + "Restarting payment listener for node {NodeId} (faulted: {Faulted}, connection changed: {Changed})", + node.Id, faulted, reconnectionNeeded); + _listeners.TryRemove(node.Id, out _); + await running.StopAsync(); + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var listener = new NodeListener(cts, node.Endpoint, node.ChannelAdminMacaroon); + if (!_listeners.TryAdd(node.Id, listener)) + { + cts.Dispose(); + continue; } + + _logger.LogInformation("Subscribing to payments of node {NodeId} ({NodeName})", node.Id, node.Name); + listener.Task = ListenToNodeAsync(node, cts.Token); } - catch (Exception e) + + var eligibleIds = eligible.Select(n => n.Id).ToHashSet(); + foreach (var (nodeId, listener) in _listeners) { - _logger.LogError(e, "Error on {JobName}", nameof(MonitorPaymentRoutesJob)); - throw new JobExecutionException(e, false); - } + if (eligibleIds.Contains(nodeId)) + { + continue; + } - _logger.LogInformation("{JobName} ended", nameof(MonitorPaymentRoutesJob)); + _logger.LogInformation("Node {NodeId} is no longer tracked, stopping its payment listener", nodeId); + _listeners.TryRemove(nodeId, out _); + await listener.StopAsync(); + } } /// - /// Port of tracker.py _poll: paginates ListPayments by index_offset from 0, - /// persisting each new terminal payment until a page comes back empty. + /// Keeps a payment stream open for one node, re-opening it after any failure until cancelled. /// - private async Task TrackNodePaymentsAsync(Node node) + private async Task ListenToNodeAsync(Node node, CancellationToken cancellationToken) { - ulong indexOffset = 0; - var savedTotal = 0; - - while (true) + while (!cancellationToken.IsCancellationRequested) { - var request = new ListPaymentsRequest + try { - IndexOffset = indexOffset, - MaxPayments = MaxPaymentsPerPage, - Reversed = false, - // Must be true: with IncludeIncomplete = false LND returns ONLY SUCCEEDED - // payments, so failed routes never reach the DB and the frontend's "Include - // failed payments" toggle has nothing to show. With it true, LND also returns - // FAILED (and IN_FLIGHT/INITIATED) payments; SavePaymentAsync then keeps only - // terminal states (Success/Failed) and skips the non-terminal ones via - // FromLndPaymentStatus → Unknown. Mirrors the Go infra tracker, which persists - // both SUCCEEDED and FAILED. - IncludeIncomplete = true - }; + await ConsumePaymentStreamAsync(node, cancellationToken); - var response = await _lightningClientService.ListPayments(node, request); - // The ListPayments wrapper returns null on error; don't NRE, just stop this node. - if (response == null || response.Payments.Count == 0) + // A clean end of stream is still unexpected while we want to keep watching. + _logger.LogWarning("Payment stream for node {NodeId} ended, reconnecting", node.Id); + } + // Shutting down is not a failure. Grpc.Net surfaces a cancelled call as an RpcException + // wrapping the OperationCanceledException rather than letting the latter through, so + // both shapes have to be recognised or every clean stop logs an error. + catch (Exception e) when (cancellationToken.IsCancellationRequested && + e is OperationCanceledException or + RpcException { StatusCode: StatusCode.Cancelled }) { break; } - - foreach (var payment in response.Payments) + catch (Exception e) { - if (await SavePaymentAsync(node, payment)) - { - savedTotal++; - } + _logger.LogError(e, + "Payment stream for node {NodeId} failed. Reconnecting in {Delay}s. Monitoring continues for other nodes", + node.Id, ReconnectDelay.TotalSeconds); } - // Advance the cursor for the next page (port of last_index_offset handling). - var newIndex = response.LastIndexOffset; - if (newIndex <= indexOffset) + // The channel may be half-open after a stream failure; drop it so the retry dials fresh. + InvalidateChannel(node.Endpoint); + + try + { + await Task.Delay(ReconnectDelay, cancellationToken); + } + catch (OperationCanceledException) { break; } - indexOffset = newIndex; } + } + + /// + /// Opens the node's payment stream and persists every update it delivers. + /// + /// Uses TrackPayments (all of the node's payments) rather than + /// TrackPaymentV2, which takes a single payment_hash and so cannot express a + /// per-node subscription: knowing the hashes up front would require the polling this service + /// exists to replace. + /// + /// NoInflightUpdates is set so LND streams only each payment's final update. + /// Intermediate updates would be discarded anyway — + /// persists terminal payments only — and there is nothing to gain by reading them: a payment's + /// attempt list is cumulative, so the final update already carries every attempt it ever made. + /// One MPP payment measured on regtest produced 74 intermediate updates against a single + /// terminal one, all with the same 34 attempts by the end, so suppressing them is a large + /// reduction in stream volume for no loss of data. + /// + private async Task ConsumePaymentStreamAsync(Node node, CancellationToken cancellationToken) + { + // Reconcile only starts listeners for nodes that have both, but assert it here so a node + // edited to drop its macaroon mid-stream fails loudly on the next reconnect. + ArgumentException.ThrowIfNullOrWhiteSpace(node.ChannelAdminMacaroon); + + var routerClient = GetRouterClient(node.Endpoint); + + using var stream = routerClient.TrackPayments( + new TrackPaymentsRequest { NoInflightUpdates = true }, + new Metadata { { "macaroon", node.ChannelAdminMacaroon } }, + cancellationToken: cancellationToken); + + await foreach (var payment in stream.ResponseStream.ReadAllAsync(cancellationToken)) + { + try + { + await HandlePaymentUpdateAsync(node, payment); + } + catch (Exception e) + { + // One malformed/unsaveable payment must not tear down the whole stream. + _logger.LogError(e, "Error persisting payment {PaymentHash} of node {NodeId}", + payment.PaymentHash, node.Id); + } + } + } - if (savedTotal > 0) + /// + /// Persists one payment update: parses the LND payment into a + /// (+ hops) and upserts it. Returns true when a new payment row was created. + /// + /// Non-terminal statuses (IN_FLIGHT / INITIATED / UNKNOWN) are skipped, as they were + /// under polling. Nothing is lost by waiting: an update's attempt list is cumulative, so the + /// terminal update carries every attempt the payment ever made — including the failed ones — + /// and has no in-flight member to record them under anyway. + /// + public async Task HandlePaymentUpdateAsync(Node node, Payment raw) + { + var paymentRoute = MapToPaymentRoute(node, raw); + if (paymentRoute == null) { - _logger.LogInformation("Saved {Count} new payment route(s) for node {NodeId}", savedTotal, node.Id); + return false; } + + using var scope = _scopeFactory.CreateScope(); + var paymentRouteRepository = scope.ServiceProvider.GetRequiredService(); + + var (inserted, _) = await paymentRouteRepository.UpsertAsync(paymentRoute); + return inserted; } /// - /// Port of tracker.py _save_payment: parses one LND payment into a - /// (+ hops) and inserts it if new. Returns true when a new - /// payment was persisted. Non-terminal statuses (IN_FLIGHT / INITIATED / UNKNOWN) are - /// skipped, exactly as the Python tracker ignored anything but SUCCEEDED/FAILED. + /// The pure LND-payment → projection, split out from the persistence + /// so it can be exercised without a container or a database. Returns null for an update that + /// should not be stored: no payment hash, or a non-terminal status. /// - private async Task SavePaymentAsync(Node node, Payment raw) + public static PaymentRoute? MapToPaymentRoute(Node node, Payment raw) { var payHash = raw.PaymentHash?.Trim(); if (string.IsNullOrEmpty(payHash)) { - return false; + return null; } var status = PaymentRouteMapping.FromLndPaymentStatus(raw.Status); if (status == PaymentRouteStatus.Unknown) { - return false; + return null; } - var paymentRoute = new PaymentRoute + return new PaymentRoute { PaymentHash = payHash, OriginNodePubKey = node.PubKey, @@ -186,23 +335,20 @@ private async Task SavePaymentAsync(Node node, Payment raw) Destination = ExtractDestination(raw), Hops = BuildHops(node, payHash, raw) }; - - var (inserted, _) = await _paymentRouteRepository.InsertIfNewAsync(paymentRoute); - return inserted; } /// - /// Port of tracker.py _save_hops applied over every HTLC attempt. The first hop - /// always leaves from our own node; each subsequent hop starts from the previous - /// destination. Hops without a pubkey or channel id are skipped. + /// Flattens every HTLC attempt of a payment into hop rows. The first hop always leaves from our + /// own node; each subsequent hop starts from the previous destination. Hops without a pubkey or + /// channel id are skipped. /// - /// Each attempt's outcome and failure detail are denormalised onto its hops so the - /// graph can distinguish "this hop forwarded fine", "this hop broke" and "never reached" - /// instead of painting a whole attempt from the payment's final status. + /// Each attempt's outcome and failure detail are denormalised onto its hops so the graph + /// can distinguish "this hop forwarded fine", "this hop broke" and "never reached" instead of + /// painting a whole attempt from the payment's final status. /// - /// Note that a payment with no HTLC attempts at all yields no hops — that is the - /// normal shape for pathfinding-stage failures (NO_ROUTE, INSUFFICIENT_BALANCE), where - /// LND never dispatched an HTLC and so has no route to report. + /// Note that a payment with no HTLC attempts at all yields no hops — that is the normal + /// shape for pathfinding-stage failures (NO_ROUTE, INSUFFICIENT_BALANCE), where LND never + /// dispatched an HTLC and so has no route to report. /// private static List BuildHops(Node node, string payHash, Payment raw) { @@ -223,10 +369,15 @@ private static List BuildHops(Node node, string payHash, Paymen // The first hop always leaves from our node (ORIGIN). var prevNode = node.PubKey; - var seq = 0; - foreach (var hop in route.Hops) + // seq is the hop's position in LND's route, counted even for hops we skip persisting. + // It must stay aligned with Failure.failure_source_index, which indexes that same + // route: PaymentRoutesGraphService.HopStatusFor compares HopSequence + 1 against it to + // decide which hop broke, so dropping a position here would point the failure at the + // wrong node for every later hop. + for (var seq = 0; seq < route.Hops.Count; seq++) { + var hop = route.Hops[seq]; var toNode = hop.PubKey; var channelId = hop.ChanId; if (string.IsNullOrEmpty(toNode) || channelId == 0) @@ -239,7 +390,8 @@ private static List BuildHops(Node node, string payHash, Paymen PaymentHash = payHash, // Ordinal within this payment's attempt list, NOT attempt.AttemptId (a // node-global uint64 that would both overflow int and render as - // "attempt 4021" in the UI trace). + // "attempt 4021" in the UI trace). Safe as an identity across stream updates + // because that list is append-only — see the type remarks. AttemptIndex = attemptIndex, HopSequence = seq, ChannelId = channelId, @@ -252,7 +404,6 @@ private static List BuildHops(Node node, string payHash, Paymen }); prevNode = toNode; - seq++; } } @@ -260,13 +411,13 @@ private static List BuildHops(Node node, string payHash, Paymen } /// - /// The payment's final destination: the last hop of the attempt that actually settled, - /// falling back to the first attempt with a route when none succeeded (a wholly failed - /// payment still aimed somewhere). + /// The payment's final destination: the last hop of the attempt that actually settled, falling + /// back to the first attempt with a route when none succeeded (a wholly failed payment still + /// aimed somewhere). /// - /// tracker.py simply took the first attempt with a route. That is the same - /// payment-vs-attempt conflation fixed in : a payment that failed - /// over one route and settled over another would report the abandoned route's endpoint. + /// Taking the first routed attempt unconditionally would conflate payment with attempt, + /// the same way avoids: a payment that failed over one route and + /// settled over another would report the abandoned route's endpoint. /// private static string? ExtractDestination(Payment raw) { @@ -277,4 +428,116 @@ private static List BuildHops(Node node, string payHash, Paymen return chosen?.Route.Hops[^1].PubKey; } + + /// + /// Router client for a node endpoint, over a channel cached per endpoint. LND serves a + /// self-signed certificate, hence the permissive validator — same posture as the other LND + /// clients in the codebase. + /// + private Router.RouterClient GetRouterClient(string? endpoint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + + var channel = _channels.GetOrAdd(endpoint, ep => + { + var httpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + + _logger.LogInformation("New payment watcher grpc channel created for endpoint {Endpoint}", ep); + + return GrpcChannel.ForAddress($"https://{ep}", + new GrpcChannelOptions { HttpHandler = httpHandler, LoggerFactory = NullLoggerFactory.Instance }); + }); + + return new Router.RouterClient(channel); + } + + /// + /// Evicts and disposes the cached channel for an endpoint so the next attempt dials fresh, + /// rather than reusing a half-open connection that would keep hanging. + /// + private void InvalidateChannel(string? endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint) || !_channels.TryRemove(endpoint, out var channel)) + { + return; + } + + try + { + channel.Dispose(); + } + catch (Exception e) + { + _logger.LogWarning(e, "Error disposing payment watcher grpc channel for endpoint {Endpoint}", endpoint); + } + } + + private async Task StopAllListenersAsync() + { + foreach (var (nodeId, listener) in _listeners) + { + _listeners.TryRemove(nodeId, out _); + await listener.StopAsync(); + } + + foreach (var endpoint in _channels.Keys) + { + InvalidateChannel(endpoint); + } + } + + public override void Dispose() + { + foreach (var endpoint in _channels.Keys) + { + InvalidateChannel(endpoint); + } + + base.Dispose(); + } + + /// + /// One node's stream loop plus the handle used to stop it. Also records the connection details + /// the loop was started with, so a node edited in the UI can be detected and re-subscribed. + /// + private sealed class NodeListener + { + private readonly CancellationTokenSource _cts; + + public NodeListener(CancellationTokenSource cts, string? endpoint, string? macaroon) + { + _cts = cts; + Endpoint = endpoint; + Macaroon = macaroon; + } + + public string? Endpoint { get; } + public string? Macaroon { get; } + + public Task? Task { get; set; } + + public async Task StopAsync() + { + await _cts.CancelAsync(); + + if (Task != null) + { + // The loop swallows its own cancellation, so awaiting here just drains it. + try + { + await Task; + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + _cts.Dispose(); + } + } } diff --git a/src/Program.cs b/src/Program.cs index c15012cc..1c39bb5c 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -139,6 +139,12 @@ public static async Task Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Payment watcher. A hosted service rather than a Quartz job (the convention for the + // other jobs) because it holds one long-lived LND payment stream per managed node for + // the life of the process: LND deletes failed HTLC attempts synchronously when a + // payment goes terminal, so an interval trigger can never observe them. + builder.Services.AddHostedService(); + //BlazoredToast builder.Services.AddBlazoredToast(); @@ -419,29 +425,6 @@ public static async Task Main(string[] args) }); }); - // Monitor Payment Routes Job - q.AddJob(opts => - { - opts.DisallowConcurrentExecution(); - opts.WithIdentity(nameof(MonitorPaymentRoutesJob)); - }); - - q.AddTrigger(opts => - { - opts.ForJob(nameof(MonitorPaymentRoutesJob)) - .WithIdentity($"{nameof(MonitorPaymentRoutesJob)}Trigger") - .StartNow().WithSimpleSchedule(scheduleBuilder => - { - if (Constants.IS_DEV_ENVIRONMENT) - { - scheduleBuilder.WithIntervalInMinutes(1).RepeatForever(); - } - else - { - scheduleBuilder.WithIntervalInMinutes(10).RepeatForever(); - } - }); - }); // Audit Log Cleanup Job q.AddJob(opts => { @@ -520,6 +503,33 @@ public static async Task Main(string[] args) logger.LogError(ex, "An error occurred while seeding the database."); throw; } + + // MonitorPaymentRoutesJob used to be a Quartz job and Quartz's store is persistent, + // so upgrading deployments still hold a durable job + WAITING trigger pointing at a + // class that is now a hosted service and no longer an IJob. Quartz would fail to + // instantiate it on every fire, so retire the leftover schedule here. Safe to keep + // permanently: deleting an absent job key is a no-op. + try + { + var schedulerFactory = servicesProvider.GetRequiredService(); + var scheduler = await schedulerFactory.GetScheduler(); + var staleJobKey = new JobKey(nameof(MonitorPaymentRoutesJob)); + + if (await scheduler.DeleteJob(staleJobKey)) + { + servicesProvider.GetRequiredService>() + .LogInformation( + "Removed the obsolete Quartz schedule for {JobName}; it now runs as a hosted service", + nameof(MonitorPaymentRoutesJob)); + } + } + catch (Exception ex) + { + // Never block startup over a cleanup: log and continue. + servicesProvider.GetRequiredService>() + .LogWarning(ex, "Could not remove the obsolete Quartz schedule for {JobName}", + nameof(MonitorPaymentRoutesJob)); + } } // Configure the HTTP request pipeline. diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index e65f96b0..7ac24711 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -185,8 +185,10 @@ public Lightning.LightningClient GetLightningClient(string? endpoint) public async Task ListPayments(Node node, ListPaymentsRequest request, Lightning.LightningClient? client = null) { - // LightningEye polled LND's REST /v1/payments; NodeGuard talks gRPC, so this is - // the ListPayments RPC. The tracker paginates by index_offset just like the Python one. + // Currently unused: the payment watcher used to paginate this by index_offset, but moved to + // the router's payment stream because ListPayments cannot see failed HTLC attempts (LND + // deletes them when a payment goes terminal). Kept as the only payment-history read + // available, e.g. for a future catch-up import. try { client ??= GetLightningClient(node.Endpoint); diff --git a/test/NodeGuard.Tests/Data/Repositories/PaymentRouteRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/PaymentRouteRepositoryTests.cs new file mode 100644 index 00000000..58d20444 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/PaymentRouteRepositoryTests.cs @@ -0,0 +1,137 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories; + +public class PaymentRouteRepositoryTests +{ + private readonly Random _random = new(); + private const string Hash = "abc123"; + private const string Origin = "02origin"; + + private (PaymentRouteRepository sut, DbContextOptions options) SetupDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "PaymentRoutes" + _random.Next()) + .Options; + var factory = new Mock>(); + factory.Setup(x => x.CreateDbContext()).Returns(() => new ApplicationDbContext(options)); + factory.Setup(x => x.CreateDbContextAsync(default)).ReturnsAsync(() => new ApplicationDbContext(options)); + + return (new PaymentRouteRepository(factory.Object, + new Mock>().Object), options); + } + + private static PaymentRoute Route(PaymentRouteStatus status, params PaymentRouteHop[] hops) => new() + { + PaymentHash = Hash, + OriginNodePubKey = Origin, + Status = status, + CreatedAt = DateTimeOffset.UtcNow, + AmountMsat = 1000, + Destination = hops.LastOrDefault()?.ToNode, + Hops = hops.ToList() + }; + + private static PaymentRouteHop Hop(int attemptIndex, int seq, string toNode, + PaymentRouteAttemptStatus status) => new() + { + PaymentHash = Hash, + AttemptIndex = attemptIndex, + HopSequence = seq, + ChannelId = 111, + FromNode = Origin, + ToNode = toNode, + AttemptStatus = status + }; + + [Fact] + public async Task UpsertAsync_NewPayment_IsInsertedWithItsHops() + { + var (sut, options) = SetupDb(); + + var (inserted, error) = await sut.UpsertAsync( + Route(PaymentRouteStatus.Failed, Hop(0, 0, "02aaa", PaymentRouteAttemptStatus.Failed))); + + inserted.Should().BeTrue(); + error.Should().BeNull(); + + await using var db = new ApplicationDbContext(options); + db.PaymentRoutes.Single().Status.Should().Be(PaymentRouteStatus.Failed); + db.PaymentRouteHops.Single().ToNode.Should().Be("02aaa"); + } + + /// + /// LND lets a failed payment hash be retried, so the same hash can reach a terminal state + /// twice — FAILED, then SUCCEEDED on the retry. An insert-only write left the payment recorded + /// as failed forever, which is the false-failure this replaces. + /// + [Fact] + public async Task UpsertAsync_PaymentRetriedAndSettled_RefreshesStatusAndReplacesHops() + { + var (sut, options) = SetupDb(); + + await sut.UpsertAsync(Route(PaymentRouteStatus.Failed, + Hop(0, 0, "02aaa", PaymentRouteAttemptStatus.Failed))); + + var (inserted, error) = await sut.UpsertAsync(Route(PaymentRouteStatus.Success, + Hop(0, 0, "02aaa", PaymentRouteAttemptStatus.Failed), + Hop(1, 0, "02bbb", PaymentRouteAttemptStatus.Succeeded))); + + inserted.Should().BeFalse(); + error.Should().BeNull(); + + await using var db = new ApplicationDbContext(options); + var stored = db.PaymentRoutes.Include(p => p.Hops).Single(); + stored.Status.Should().Be(PaymentRouteStatus.Success); + stored.Destination.Should().Be("02bbb"); + // Replaced, not accumulated: two hops in the snapshot means two rows, not three. + stored.Hops.Should().HaveCount(2); + stored.Hops.Select(h => h.ToNode).Should().BeEquivalentTo(["02aaa", "02bbb"]); + } + + /// + /// The load-bearing guard. LND deletes failed HTLC attempts once a payment is terminal, so a + /// later read of the same payment honestly comes back with no attempts at all. Replacing the + /// stored hops with that empty set would destroy exactly the failed routes this feature exists + /// to show. + /// + [Fact] + public async Task UpsertAsync_SnapshotWithNoHops_DoesNotEraseHopsAlreadyCaptured() + { + var (sut, options) = SetupDb(); + + await sut.UpsertAsync(Route(PaymentRouteStatus.Failed, + Hop(0, 0, "02aaa", PaymentRouteAttemptStatus.Failed), + Hop(0, 1, "02bbb", PaymentRouteAttemptStatus.Failed))); + + // Same payment, but LND has since pruned its attempts. + await sut.UpsertAsync(Route(PaymentRouteStatus.Failed)); + + await using var db = new ApplicationDbContext(options); + var stored = db.PaymentRoutes.Include(p => p.Hops).Single(); + stored.Hops.Should().HaveCount(2); + stored.Hops.Select(h => h.ToNode).Should().BeEquivalentTo(["02aaa", "02bbb"]); + } +} diff --git a/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs b/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs index d0fdaac5..19921cfc 100644 --- a/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/MonitorPaymentRoutesJobTests.cs @@ -19,14 +19,17 @@ using FluentAssertions; using Lnrpc; -using Microsoft.Extensions.Logging; using NodeGuard.Data.Models; -using NodeGuard.Data.Repositories.Interfaces; -using NodeGuard.Services; -using Quartz; namespace NodeGuard.Jobs; +/// +/// Covers the LND-payment → mapping via +/// , the projection every update from the +/// per-node payment stream goes through before it is persisted. Kept free of the repository so +/// these assertions describe the mapping only; the write path is covered by +/// PaymentRouteRepositoryTests. +/// public class MonitorPaymentRoutesJobTests { private const string OriginPubKey = "02origin"; @@ -34,44 +37,17 @@ public class MonitorPaymentRoutesJobTests private const string HopB = "02bbb"; private const string HopC = "02ccc"; - private readonly Mock _nodeRepositoryMock = new(); - private readonly Mock _lightningClientServiceMock = new(); - private readonly Mock _paymentRouteRepositoryMock = new(); - private readonly MonitorPaymentRoutesJob _job; - - private readonly List _persisted = new(); - - public MonitorPaymentRoutesJobTests() - { - _nodeRepositoryMock - .Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) - .ReturnsAsync(new List - { - new() { Id = 1, Name = "origin", PubKey = OriginPubKey, Endpoint = "localhost:10009", ChannelAdminMacaroon = "abc" } - }); - - _paymentRouteRepositoryMock - .Setup(x => x.InsertIfNewAsync(It.IsAny())) - .Callback(p => _persisted.Add(p)) - .ReturnsAsync((true, (string?)null)); - - _job = new MonitorPaymentRoutesJob( - new Mock>().Object, - _nodeRepositoryMock.Object, - _lightningClientServiceMock.Object, - _paymentRouteRepositoryMock.Object); - } - - private void GivenPayments(params Payment[] payments) + private readonly Node _node = new() { - var response = new ListPaymentsResponse { LastIndexOffset = 0 }; - response.Payments.AddRange(payments); + Id = 1, Name = "origin", PubKey = OriginPubKey, Endpoint = "localhost:10009", ChannelAdminMacaroon = "abc" + }; - // LastIndexOffset stays 0, so the tracker's pagination loop stops after one page. - _lightningClientServiceMock - .Setup(x => x.ListPayments(It.IsAny(), It.IsAny(), null)) - .ReturnsAsync(response); - } + /// + /// The projection one stream update goes through. Returns null for updates that are not stored, + /// so a test that expects nothing persisted asserts on null. + /// + private PaymentRoute? WhenUpdateReceived(Payment payment) + => MonitorPaymentRoutesJob.MapToPaymentRoute(_node, payment); private static Hop MakeHop(string pubKey, ulong chanId) => new() { PubKey = pubKey, ChanId = chanId, AmtToForwardMsat = 1000 }; @@ -103,9 +79,9 @@ private static Payment MakePayment(string hash, Payment.Types.PaymentStatus stat /// from the payment's status painted those failed routes green. /// [Fact] - public async Task Execute_SucceededPaymentWithFailedAttempts_RecordsEachAttemptsOwnOutcome() + public void HandlePaymentUpdate_SucceededPaymentWithFailedAttempts_RecordsEachAttemptsOwnOutcome() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, MakeAttempt(4001, HTLCAttempt.Types.HTLCStatus.Failed, new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure, FailureSourceIndex = 1 }, MakeHop(HopA, 111), MakeHop(HopB, 222)), @@ -115,9 +91,7 @@ public async Task Execute_SucceededPaymentWithFailedAttempts_RecordsEachAttempts MakeAttempt(4003, HTLCAttempt.Types.HTLCStatus.Succeeded, null, MakeHop(HopA, 555), MakeHop(HopB, 666)))); - await _job.Execute(new Mock().Object); - - var hops = _persisted.Single().Hops; + var hops = mapped!.Hops; hops.Where(h => h.AttemptIndex == 0).Should() .OnlyContain(h => h.AttemptStatus == PaymentRouteAttemptStatus.Failed @@ -140,17 +114,15 @@ public async Task Execute_SucceededPaymentWithFailedAttempts_RecordsEachAttempts /// a node-global uint64 that both overflows int and renders as "attempt 4002" in the UI. /// [Fact] - public async Task Execute_AttemptIndex_IsPerPaymentOrdinalNotLndAttemptId() + public void HandlePaymentUpdate_AttemptIndex_IsPerPaymentOrdinalNotLndAttemptId() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, MakeAttempt(9_000_000_001, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111)), MakeAttempt(9_000_000_002, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopB, 222)))); - await _job.Execute(new Mock().Object); - // Asserted as (index, hop) pairs rather than a bare sequence, so an implementation // that stamped every hop with the same ordinal can't pass on list order alone. - _persisted.Single().Hops.Should().SatisfyRespectively( + mapped!.Hops.Should().SatisfyRespectively( h => { h.AttemptIndex.Should().Be(0); h.ToNode.Should().Be(HopA); }, h => { h.AttemptIndex.Should().Be(1); h.ToNode.Should().Be(HopB); }); } @@ -160,15 +132,13 @@ public async Task Execute_AttemptIndex_IsPerPaymentOrdinalNotLndAttemptId() /// that actually delivered, not the abandoned one it tried first. /// [Fact] - public async Task Execute_Destination_ComesFromTheSettledAttemptNotTheFirstOne() + public void HandlePaymentUpdate_Destination_ComesFromTheSettledAttemptNotTheFirstOne() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111), MakeHop(HopB, 222)), MakeAttempt(2, HTLCAttempt.Types.HTLCStatus.Succeeded, null, MakeHop(HopA, 333), MakeHop(HopC, 444)))); - await _job.Execute(new Mock().Object); - - _persisted.Single().Destination.Should().Be(HopC); + mapped!.Destination.Should().Be(HopC); } /// @@ -176,58 +146,77 @@ public async Task Execute_Destination_ComesFromTheSettledAttemptNotTheFirstOne() /// attempt that had a route rather than losing the destination entirely. /// [Fact] - public async Task Execute_Destination_FallsBackToFirstRoutedAttemptWhenNoneSettled() + public void HandlePaymentUpdate_Destination_FallsBackToFirstRoutedAttemptWhenNoneSettled() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 111), MakeHop(HopB, 222)), MakeAttempt(2, HTLCAttempt.Types.HTLCStatus.Failed, null, MakeHop(HopA, 333), MakeHop(HopC, 444)))); - await _job.Execute(new Mock().Object); - - _persisted.Single().Destination.Should().Be(HopB); + mapped!.Destination.Should().Be(HopB); } [Fact] - public async Task Execute_HopSequenceAndFromNode_ChainFromTheOriginPerAttempt() + public void HandlePaymentUpdate_HopSequenceAndFromNode_ChainFromTheOriginPerAttempt() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Succeeded, MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Succeeded, null, MakeHop(HopA, 111), MakeHop(HopB, 222), MakeHop(HopC, 333)))); - await _job.Execute(new Mock().Object); - - var hops = _persisted.Single().Hops; + var hops = mapped!.Hops; hops.Select(h => h.HopSequence).Should().Equal(0, 1, 2); hops.Select(h => h.FromNode).Should().Equal(OriginPubKey, HopA, HopB); hops.Select(h => h.ToNode).Should().Equal(HopA, HopB, HopC); } + /// + /// HopSequence indexes LND's route, and so does Failure.failure_source_index — the graph + /// compares HopSequence + 1 against it to decide which hop broke. A hop we decline to persist + /// (no pubkey, or no channel id) must therefore still consume its position, otherwise every + /// later hop shifts down one and the failure is attributed to the wrong node. + /// + [Fact] + public void HandlePaymentUpdate_UnpersistableHop_StillConsumesItsRoutePosition() + { + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Failed, + MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.Failed, + new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure, FailureSourceIndex = 3 }, + MakeHop(HopA, 111), + MakeHop(HopB, 0), // unusable: no channel id, so it is not persisted + MakeHop(HopC, 333)))); + + var hops = mapped!.Hops; + hops.Select(h => h.ToNode).Should().Equal(HopA, HopC); + // HopC sits at route position 2, not 1 — so destPos (2 + 1) matches failureSourceIndex 3. + hops.Select(h => h.HopSequence).Should().Equal(0, 2); + } + /// /// Pathfinding-stage failures (NO_ROUTE, INSUFFICIENT_BALANCE) reach us with an empty /// htlcs list — LND never dispatched an HTLC. The payment is still tracked; it just has /// no route to draw. /// [Fact] - public async Task Execute_FailedPaymentWithNoAttempts_IsPersistedWithoutHops() + public void HandlePaymentUpdate_FailedPaymentWithNoAttempts_IsPersistedWithoutHops() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.Failed)); - - await _job.Execute(new Mock().Object); + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.Failed)); - var payment = _persisted.Single(); + var payment = mapped!; payment.Status.Should().Be(PaymentRouteStatus.Failed); payment.Hops.Should().BeEmpty(); payment.Destination.Should().BeNull(); } + /// + /// In-flight updates are still skipped, as they were under polling. Nothing is lost by + /// waiting for the terminal update: an update's attempt list is cumulative, so the terminal + /// one carries every attempt the payment ever made, failed ones included. + /// [Fact] - public async Task Execute_NonTerminalPayment_IsSkipped() + public void HandlePaymentUpdate_NonTerminalPayment_IsSkipped() { - GivenPayments(MakePayment("hash1", Payment.Types.PaymentStatus.InFlight, + var mapped = WhenUpdateReceived(MakePayment("hash1", Payment.Types.PaymentStatus.InFlight, MakeAttempt(1, HTLCAttempt.Types.HTLCStatus.InFlight, null, MakeHop(HopA, 111)))); - await _job.Execute(new Mock().Object); - - _persisted.Should().BeEmpty(); + mapped.Should().BeNull(); } } From 64f3dc047b1e0904e9976fbfbc0013aa6fd5d45b Mon Sep 17 00:00:00 2001 From: Ismael Date: Mon, 17 Aug 2026 11:39:01 +0200 Subject: [PATCH 09/10] Fix visualization issues on PW canvas --- src/wwwroot/js/payments-watcher-graph.js | 80 ++++++++++++++---------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/src/wwwroot/js/payments-watcher-graph.js b/src/wwwroot/js/payments-watcher-graph.js index ed65620e..d2e1dfc2 100644 --- a/src/wwwroot/js/payments-watcher-graph.js +++ b/src/wwwroot/js/payments-watcher-graph.js @@ -188,7 +188,10 @@ edges.forEach(function (e) { e.split = !!chanMap[e.to + '|' + e.from]; }); // ── Zoom controls ── - var wrap = el('div', { style: 'position:relative;' }); + // Fixed 60vh viewport (not max-height): the graph pane keeps a stable size even + // when the layout is small, instead of collapsing to the content height. The height + // lives here, on the in-flow box, because the scroller below is taken out of flow. + var wrap = el('div', { style: 'position:relative;height:60vh;' }); var zoomBox = el('div', { style: 'position:absolute;top:12px;right:16px;z-index:20;display:flex;flex-direction:column;gap:6px;' }); function zbtn(label, title, fn, small) { var b = el('button', { title: title, type: 'button', @@ -197,9 +200,13 @@ b.addEventListener('click', fn); return b; } - // Fixed 60vh viewport (not max-height): the graph pane keeps a stable size even - // when the layout is small, instead of collapsing to the content height. - var scroller = el('div', { style: 'overflow:auto;padding:20px 18px;height:60vh;box-sizing:border-box;' }); + // Absolutely positioned on purpose: MainLayout's
is a `flex:1` item with no + // `min-width:0`, so its automatic minimum size is its min-content width. An in-flow + // scroller still contributes the stage's width to that (overflow:auto only zeroes + // the automatic minimum size of the flex item that carries it —
— not of a + // descendant), so a node dragged right would widen
and stretch the whole + // toolbar. Out of flow, the stage contributes nothing to the page's intrinsic width. + var scroller = el('div', { style: 'position:absolute;top:0;left:0;right:0;bottom:0;overflow:auto;padding:20px 18px;box-sizing:border-box;' }); var stage = el('div', { style: 'position:relative;width:' + size.width + 'px;height:' + size.height + 'px;min-width:' + size.width + 'px;transform-origin:top left;' }); function applyZoom() { stage.style.transform = 'scale(' + state.zoom + ')'; } zoomBox.appendChild(zbtn('+', 'Zoom in', function () { state.zoom = Math.min(2, +(state.zoom + 0.15).toFixed(2)); applyZoom(); })); @@ -208,29 +215,16 @@ applyZoom(); // ── Edges (SVG) ── - var svg = svgEl('svg', { width: size.width, height: size.height, style: 'position:absolute;top:0;left:0;pointer-events:none;' }); - edges.forEach(function (e) { - var fp = positions[e.from], tp = positions[e.to]; - if (!fp || !tp) return; - var fcx = fp.x + fp.w / 2, fcy = fp.y + fp.h / 2, tcx = tp.x + tp.w / 2, tcy = tp.y + tp.h / 2; - var sp = borderPoint(fcx, fcy, fp.w / 2, fp.h / 2, tcx, tcy); - var GAP = 7; - var ep = borderPoint(tcx, tcy, tp.w / 2 + GAP, tp.h / 2 + GAP, fcx, fcy); - if (e.split) { - var dx = ep.x - sp.x, dy = ep.y - sp.y, len = Math.hypot(dx, dy) || 1, SEP = 6; - var ox = -dy / len * SEP, oy = dx / len * SEP; - sp = { x: sp.x + ox, y: sp.y + oy }; ep = { x: ep.x + ox, y: ep.y + oy }; - } - var total = e.ok + e.fail, color = colorByRatio(total === 0 ? 0 : e.ok / total); - var mid = 'pw-arrow-' + e.key.replace(/[^a-zA-Z0-9]/g, '_'); - var defs = svgEl('defs'); - var marker = svgEl('marker', { id: mid, markerWidth: 7, markerHeight: 7, refX: 5, refY: 3.5, orient: 'auto' }); - marker.appendChild(svgEl('polygon', { points: '0,0 7,3.5 0,7', fill: color })); - defs.appendChild(marker); svg.appendChild(defs); - svg.appendChild(svgEl('line', { x1: sp.x, y1: sp.y, x2: ep.x, y2: ep.y, stroke: color, - 'stroke-width': 1.8, 'stroke-linecap': 'round', 'marker-end': 'url(#' + mid + ')', opacity: 0.9 })); - }); + // Nodes are absolutely positioned HTML, so dragging one past the initial layout + // bounds just overflows the stage. SVG children are not so lucky: the UA style + // sheet clips the root to its width/height, which cut lines and arrowheads + // mid-canvas. Hence `overflow:visible` (stops the clipping) *and* drawEdges() + // growing the svg/stage (makes the overflowing area reachable by the scroller — + // ink outside an svg's box contributes no scrollable overflow on its own). + var svg = svgEl('svg', { width: size.width, height: size.height, + style: 'position:absolute;top:0;left:0;overflow:visible;pointer-events:none;' }); stage.appendChild(svg); + drawEdges(positions); // ── Nodes ── var drag = null; @@ -274,7 +268,11 @@ box.addEventListener('mousedown', function (ev) { ev.preventDefault(); ev.stopPropagation(); - drag = { id: node.id, sx: ev.clientX, sy: ev.clientY, x0: pos.x, y0: pos.y, moved: false }; + // Anchor on state.moved, not on `pos`: a drag doesn't re-render, so `pos` + // still holds the position this node had when the graph was last drawn and + // a second drag would snap the node back there. + var cur = state.moved[node.id] || pos; + drag = { id: node.id, sx: ev.clientX, sy: ev.clientY, x0: cur.x, y0: cur.y, moved: false }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); @@ -293,7 +291,7 @@ box.style.left = state.moved[drag.id].x + 'px'; box.style.top = state.moved[drag.id].y + 'px'; // Redraw edges live so arrows follow the dragged node. - redrawEdges(); + drawEdges(); } function onUp() { window.removeEventListener('mousemove', onMove); @@ -303,12 +301,28 @@ stage.appendChild(box); }); - function redrawEdges() { + // Grow the stage and the SVG viewport to cover the current node positions, never + // shrinking below the auto layout so the scroll position doesn't jump when a node + // is dragged back towards the origin. + function fitStage(np) { + var s = canvasSize(np); + var w = Math.max(s.width, size.width), h = Math.max(s.height, size.height); + stage.style.width = w + 'px'; + stage.style.minWidth = w + 'px'; + stage.style.height = h + 'px'; + svg.setAttribute('width', w); + svg.setAttribute('height', h); + } + + function drawEdges(np) { // Recompute positions from state.moved and rebuild the SVG in place. - var np = {}; - Object.keys(auto).forEach(function (id) { - np[id] = state.moved[id] ? Object.assign({}, auto[id], state.moved[id]) : auto[id]; - }); + if (!np) { + np = {}; + Object.keys(auto).forEach(function (id) { + np[id] = state.moved[id] ? Object.assign({}, auto[id], state.moved[id]) : auto[id]; + }); + } + fitStage(np); while (svg.firstChild) svg.removeChild(svg.firstChild); edges.forEach(function (e) { var fp = np[e.from], tp = np[e.to]; From 1bbb1b8802d9feab112d8faa646e4c8d90bac840 Mon Sep 17 00:00:00 2001 From: Ismael Date: Thu, 20 Aug 2026 10:52:58 +0200 Subject: [PATCH 10/10] Fix edges clipping --- .../Interfaces/INodeRepository.cs | 7 ++ src/Data/Repositories/NodeRepository.cs | 16 ++++ src/Services/PaymentRoutesGraphService.cs | 34 +++++--- src/wwwroot/js/payments-watcher-graph.js | 41 +++++---- .../PaymentRoutesGraphServiceTests.cs | 85 +++++++++++++++++++ 5 files changed, 155 insertions(+), 28 deletions(-) diff --git a/src/Data/Repositories/Interfaces/INodeRepository.cs b/src/Data/Repositories/Interfaces/INodeRepository.cs index 60cb6351..85bb7511 100644 --- a/src/Data/Repositories/Interfaces/INodeRepository.cs +++ b/src/Data/Repositories/Interfaces/INodeRepository.cs @@ -32,6 +32,13 @@ public interface INodeRepository Task> GetAll(); + /// + /// Names of the known nodes among , keyed by pubkey. Nodes stored + /// with an empty name are left out, so callers can tell "we have no name for this pubkey" + /// from "we have one". Lightweight on purpose (no eager loading) — meant for labelling. + /// + Task> GetNamesByPubKeys(IReadOnlyCollection pubKeys); + Task> GetAllManagedByUser(string userId); /// diff --git a/src/Data/Repositories/NodeRepository.cs b/src/Data/Repositories/NodeRepository.cs index 29b709b0..0a35e85d 100644 --- a/src/Data/Repositories/NodeRepository.cs +++ b/src/Data/Repositories/NodeRepository.cs @@ -116,6 +116,22 @@ public async Task> GetAll() .ToListAsync(); } + public async Task> GetNamesByPubKeys(IReadOnlyCollection pubKeys) + { + if (pubKeys.Count == 0) return new Dictionary(); + + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + var rows = await applicationDbContext.Nodes + .Where(node => pubKeys.Contains(node.PubKey) && node.Name != null && node.Name != "") + .Select(node => new { node.PubKey, node.Name }) + .ToListAsync(); + + // GroupBy rather than ToDictionary: nothing enforces PubKey uniqueness at the DB level. + return rows.GroupBy(row => row.PubKey) + .ToDictionary(group => group.Key, group => group.First().Name); + } + public async Task> GetAllManagedByNodeGuard(bool withDisabled = true) { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Services/PaymentRoutesGraphService.cs b/src/Services/PaymentRoutesGraphService.cs index 53e2414a..cdb64289 100644 --- a/src/Services/PaymentRoutesGraphService.cs +++ b/src/Services/PaymentRoutesGraphService.cs @@ -83,17 +83,29 @@ public async Task BuildGraphAsync(string originNodePubKey, DateTim } /// - /// Resolves a human-readable alias for every pubkey that appears in the graph, so the - /// frontend can label nodes instead of falling back to A/B/C… letters (the port of - /// LightningEye's nodes_cache/aliases.js). The origin uses its managed - /// ; every other pubkey is looked up from the origin node's LND - /// gossip view via GetNodeInfo. Resolution is best-effort: any pubkey we can't - /// resolve is simply left out of the map (JS then falls back to a letter), and the whole - /// step is skipped if the origin node isn't reachable. + /// Resolves a human-readable alias for every pubkey that appears in the graph. Two sources, + /// in order: NodeGuard's own Nodes table (managed nodes plus the peers recorded by + /// GetOrCreateByPubKey), then the origin node's LND gossip view via GetNodeInfo. + /// The table is consulted first because it is free and, unlike gossip, it survives LND + /// zombie-pruning a stale channel out of the origin's graph — once that happens the routing + /// node disappears from GetNodeInfo and its alias is unrecoverable from that node. + /// Resolution is best-effort: an unresolved pubkey is left out of the map and the frontend + /// labels it by its pubkey prefix. It must never invent a name — a made-up label is + /// indistinguishable from a real alias to whoever reads the graph. /// private async Task> ResolveAliasesAsync(string originNodePubKey, List hops) { - var aliases = new Dictionary(); + var graphPubKeys = hops + .SelectMany(h => new[] { h.FromNode, h.ToNode }) + .Append(originNodePubKey) + .Where(pk => !string.IsNullOrWhiteSpace(pk)) + .Distinct() + .ToList(); + + // Names we already hold locally. GetNamesByPubKeys drops empty names, which matters: + // GetOrCreateByPubKey stores Name = "" when its own alias lookup failed, and treating + // that as resolved would suppress the gossip call below that may well succeed. + var aliases = await _nodeRepository.GetNamesByPubKeys(graphPubKeys) ?? new Dictionary(); var originNode = await _nodeRepository.GetByPubkey(originNodePubKey); if (originNode is not null && !string.IsNullOrWhiteSpace(originNode.Name)) @@ -109,11 +121,7 @@ private async Task> ResolveAliasesAsync(string origin return aliases; } - var pubKeys = hops - .SelectMany(h => new[] { h.FromNode, h.ToNode }) - .Where(pk => !string.IsNullOrWhiteSpace(pk) && !aliases.ContainsKey(pk)) - .Distinct() - .ToList(); + var pubKeys = graphPubKeys.Where(pk => !aliases.ContainsKey(pk)).ToList(); // One GetNodeInfo per distinct pubkey, in parallel. Failures come back as null and // are ignored (best-effort labelling must never break the graph). diff --git a/src/wwwroot/js/payments-watcher-graph.js b/src/wwwroot/js/payments-watcher-graph.js index d2e1dfc2..d861b972 100644 --- a/src/wwwroot/js/payments-watcher-graph.js +++ b/src/wwwroot/js/payments-watcher-graph.js @@ -47,19 +47,27 @@ return { border: colorByRatio(ok / relevant.length) }; } - // ── Aliases (matches aliases.js) ───────────────────────────────────────────── + // ── Aliases ────────────────────────────────────────────────────────────────── + // Real aliases only. Nodes the backend could not resolve are labelled by their own + // pubkey prefix, never by an invented name: LightningEye's A/B/C… letters read like + // real node aliases, and they weren't even stable — they were assigned by index over + // whichever nodes happened to fail resolution in that particular query, so the same + // node could be "A" in one search and "B" in the next. function buildAliasMap(nodes) { var map = {}; if (!nodes) return map; nodes.forEach(function (n) { if (n.alias && n.alias.trim()) map[n.id] = n.alias.trim(); }); - var noAlias = nodes.filter(function (n) { return !map[n.id]; }); - var origin = noAlias.find(function (n) { return n.isOrigin; }); - if (origin) map[origin.id] = '★'; - var rest = noAlias.filter(function (n) { return !n.isOrigin; }).map(function (n) { return n.id; }).sort(); - var LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - rest.forEach(function (id, i) { map[id] = i < LETTERS.length ? LETTERS[i] : 'N' + (i + 1); }); return map; } + function nodeLabel(aliasMap, id) { return aliasMap[id] || (id ? id.slice(0, 6) : '?'); } + // Unresolved labels are raw pubkey, so render them monospaced and dimmed — they must not + // be mistakable for an alias at a glance. The smaller size keeps all 6 hex chars inside the + // 56px trace pill: monospace is wider per character than the sans it replaces, and a + // silently clipped pubkey prefix is worse than no prefix (operators paste these into + // `lncli getnodeinfo`). + function unresolvedStyle(aliasMap, id) { + return aliasMap[id] ? '' : 'font-family:monospace;font-size:10.5px;opacity:0.7;'; + } function shortAlias(alias, max) { max = max || 6; if (!alias) return '?'; @@ -243,7 +251,7 @@ var sel = state.selected === node.id; var box = el('div', { - title: (node.isOrigin ? 'Origin' : (aliasMap[node.id] || '?')) + '\n' + node.id + '\n(click to view and copy the pubkey)', + title: (node.isOrigin ? 'Origin' : nodeLabel(aliasMap, node.id)) + '\n' + node.id + '\n(click to view and copy the pubkey)', style: 'position:absolute;left:' + pos.x + 'px;top:' + pos.y + 'px;width:' + pos.w + 'px;height:' + pos.h + 'px;' + 'display:flex;align-items:center;gap:13px;padding:0 12px;box-sizing:border-box;background:#fff;' + 'border:' + (sel ? 2 : 1) + 'px solid ' + (sel ? border : '#cbd5e1') + ';border-radius:12px;' + @@ -252,9 +260,10 @@ var badge = el('div', { style: 'width:64px;height:36px;flex-shrink:0;border-radius:10px;background:' + (node.isOrigin ? strongBg : softBg) + ';' + 'color:' + (node.isOrigin ? '#fff' : border) + ';display:flex;align-items:center;justify-content:center;' + - 'font-size:12.5px;font-weight:700;padding:0 6px;box-sizing:border-box;white-space:nowrap;overflow:hidden;' + 'font-size:12.5px;font-weight:700;padding:0 6px;box-sizing:border-box;white-space:nowrap;overflow:hidden;' + + (node.isOrigin ? '' : unresolvedStyle(aliasMap, node.id)) }); - badge.textContent = node.isOrigin ? 'origin' : shortAlias(aliasMap[node.id]); + badge.textContent = node.isOrigin ? 'origin' : shortAlias(nodeLabel(aliasMap, node.id)); box.appendChild(badge); var info = el('div', { style: 'min-width:0;flex:0 1 auto;' }); @@ -395,7 +404,8 @@ if (visible.length === 0) { container.style.display = 'none'; return container; } var list = el('div', { style: 'display:flex;flex-direction:column;gap:8px;' }); - var alias = function (id) { return aliasMap[id] || '?'; }; + var alias = function (id) { return nodeLabel(aliasMap, id); }; + var mono = function (id) { return unresolvedStyle(aliasMap, id); }; // Pagination (10 per page). var page = 0, PER = 10, totalPages = Math.ceil(visible.length / PER); @@ -413,13 +423,13 @@ meta.appendChild(hash); meta.appendChild(tag); row.appendChild(meta); var path = el('div', { style: 'display:flex;align-items:center;flex:1;min-width:0;' }); - path.appendChild(hopPill(shortAlias(alias(t.origin)), failed ? 'ok' : 'success', t.origin, alias(t.origin), dotNetRef)); + path.appendChild(hopPill(shortAlias(alias(t.origin)), failed ? 'ok' : 'success', t.origin, alias(t.origin), dotNetRef, mono(t.origin))); t.hops.forEach(function (hop) { var tone = hop.hopStatus || (failed ? 'failed' : 'success'); var seg = el('div', { style: 'display:flex;align-items:center;flex:1;min-width:24px;' }); var line = el('span', { style: 'flex:1;min-width:14px;height:' + (tone === 'unreached' ? '0' : '2.5px') + ';background:' + (tone === 'unreached' ? 'transparent' : SEG[tone]) + ';border-top:' + (tone === 'unreached' ? '2px dashed ' + SEG.unreached : 'none') + ';' }); seg.appendChild(line); - seg.appendChild(hopPill(shortAlias(alias(hop.to)), tone, hop.to, alias(hop.to), dotNetRef)); + seg.appendChild(hopPill(shortAlias(alias(hop.to)), tone, hop.to, alias(hop.to), dotNetRef, mono(hop.to))); path.appendChild(seg); }); if (t.failureCode) { @@ -444,14 +454,15 @@ return container; } - function hopPill(label, tone, nodeId, fullAlias, dotNetRef) { + function hopPill(label, tone, nodeId, fullAlias, dotNetRef, extraStyle) { var dim = tone === 'unreached', failed = tone === 'failed_here'; var pill = el('div', { title: fullAlias + '\n' + nodeId, style: 'position:relative;width:56px;height:26px;flex-shrink:0;border-radius:13px;display:flex;align-items:center;justify-content:center;' + 'font-size:11px;font-weight:600;cursor:pointer;padding:0 6px;box-sizing:border-box;white-space:nowrap;overflow:hidden;' + 'background:' + (failed ? '#E24B4A' : dim ? '#f8fafc' : '#fff') + ';border:1.5px solid ' + (SEG[tone] || '#B4B2A9') + ';' + - 'color:' + (failed ? '#fff' : dim ? '#94a3b8' : (SEG[tone] || '#475569')) + ';opacity:' + (dim ? 0.6 : 1) + ';' + 'color:' + (failed ? '#fff' : dim ? '#94a3b8' : (SEG[tone] || '#475569')) + ';opacity:' + (dim ? 0.6 : 1) + ';' + + (extraStyle || '') }); pill.textContent = label; pill.addEventListener('click', function () { if (dotNetRef && nodeId) dotNetRef.invokeMethodAsync('OnNodeSelected', nodeId); }); diff --git a/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs index 4e1d3df8..9f49a157 100644 --- a/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs +++ b/test/NodeGuard.Tests/Services/PaymentRoutesGraphServiceTests.cs @@ -18,7 +18,11 @@ */ using FluentAssertions; +using Lnrpc; +using Microsoft.Extensions.Logging; using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NSubstitute; namespace NodeGuard.Services; @@ -134,4 +138,85 @@ public void HopStatusForHop_FailedAttemptWithoutFailureDetail_IsFailed() status.Should().Be("failed"); code.Should().BeNull(); } + + // ── Alias resolution ──────────────────────────────────────────────────────── + // A node whose alias we can't resolve must come back with Alias = null so the frontend + // labels it by its pubkey. It must never receive an invented name. + + private const string OriginKey = "03origin"; + private const string HopKey = "02hop"; + + private static PaymentRoute PaymentWithOneHop() => new() + { + PaymentHash = "hash1", + OriginNodePubKey = OriginKey, + Status = PaymentRouteStatus.Success, + Hops = new List + { + new() { PaymentHash = "hash1", FromNode = OriginKey, ToNode = HopKey, AttemptStatus = PaymentRouteAttemptStatus.Succeeded } + } + }; + + private static (PaymentRoutesGraphService service, INodeRepository nodes, ILightningClientService clients) BuildService( + Dictionary storedNames, string? gossipAlias) + { + var payments = Substitute.For(); + payments.GetByCreatedAtRangeAsync(OriginKey, Arg.Any(), Arg.Any()) + .Returns(new List { PaymentWithOneHop() }); + + var nodes = Substitute.For(); + nodes.GetNamesByPubKeys(Arg.Any>()).Returns(storedNames); + nodes.GetByPubkey(OriginKey).Returns(new Node + { + PubKey = OriginKey, Name = "alice", Endpoint = "localhost:10009", ChannelAdminMacaroon = "0201" + }); + + var clients = Substitute.For(); + clients.GetNodeInfo(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(gossipAlias is null ? (LightningNode?)null : new LightningNode { Alias = gossipAlias }); + + return (new PaymentRoutesGraphService(payments, nodes, clients, + Substitute.For>()), nodes, clients); + } + + private static async Task HopNodeOf(PaymentRoutesGraphService service) + { + var graph = await service.BuildGraphAsync(OriginKey, DateTimeOffset.MinValue, DateTimeOffset.MaxValue); + return graph.Nodes.Single(n => n.Id == HopKey); + } + + /// + /// The Nodes table outlives gossip: once LND zombie-prunes the channel, GetNodeInfo stops + /// resolving the routing node entirely, so a name we already hold must be used and must not + /// cost an RPC. + /// + [Fact] + public async Task BuildGraphAsync_PubKeyKnownLocally_UsesStoredNameWithoutQueryingGossip() + { + var (service, _, clients) = BuildService(new Dictionary { [HopKey] = "frank" }, gossipAlias: "stale"); + + (await HopNodeOf(service)).Alias.Should().Be("frank"); + await clients.DidNotReceive().GetNodeInfo(Arg.Any(), HopKey, Arg.Any()); + } + + /// + /// GetOrCreateByPubKey stores Name = "" when its own alias lookup failed; GetNamesByPubKeys + /// drops those, so they arrive here as "unknown" and must still reach the gossip lookup. + /// + [Fact] + public async Task BuildGraphAsync_PubKeyNotKnownLocally_FallsBackToGossip() + { + var (service, _, clients) = BuildService(new Dictionary(), gossipAlias: "frank"); + + (await HopNodeOf(service)).Alias.Should().Be("frank"); + await clients.Received().GetNodeInfo(Arg.Any(), HopKey, Arg.Any()); + } + + [Fact] + public async Task BuildGraphAsync_PubKeyUnresolvableAnywhere_LeavesAliasNull() + { + var (service, _, _) = BuildService(new Dictionary(), gossipAlias: null); + + (await HopNodeOf(service)).Alias.Should().BeNull(); + } }