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/agent-memory/dotnet-blazor-expert/MEMORY.md b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md new file mode 100644 index 00000000..37dacecb --- /dev/null +++ b/.claude/agent-memory/dotnet-blazor-expert/MEMORY.md @@ -0,0 +1,5 @@ +# 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 +- [.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/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/.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/.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. 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" + ] + } + } +} 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/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 26b8ce74..488073b1 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -109,6 +109,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.SourceChannelId) .OnDelete(DeleteBehavior.Restrict); + 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); } @@ -149,5 +161,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet AuditLogs { get; set; } public DbSet ForwardingHtlcEvents { 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..c1d807b6 --- /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) 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 new file mode 100644 index 00000000..37965a98 --- /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(string originNodePubKey, DateTimeOffset start, DateTimeOffset end) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.PaymentRoutes + .Include(p => p.Hops) + .Where(p => p.OriginNodePubKey == originNodePubKey && 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..9c8883ec --- /dev/null +++ b/src/Jobs/MonitorPaymentRoutesJob.cs @@ -0,0 +1,260 @@ +/* + * 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, + // 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); + // 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 bcba49cf..44c4b220 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -924,6 +924,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") @@ -1578,6 +1653,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") @@ -1704,6 +1790,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/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/Program.cs b/src/Program.cs index 838c0984..ffd246ed 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -126,6 +126,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(); @@ -365,6 +367,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 93eb9d92..d136359e 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -38,6 +38,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); @@ -128,6 +129,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..9be22676 --- /dev/null +++ b/src/Services/PaymentRoutesGraphService.cs @@ -0,0 +1,242 @@ +/* + * 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; + 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(originNodePubKey, start, end); + if (payments.Count == 0) + { + return EmptyGraph(originNodePubKey); + } + + var hops = payments.SelectMany(p => p.Hops).ToList(); + 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; + } + + /// + /// 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, + IReadOnlyDictionary aliases) + { + 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(), + Alias: aliases.GetValueOrDefault(kv.Key) + )).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/src/Shared/NavMenu.razor b/src/Shared/NavMenu.razor index 48181b9b..1b75cbe7 100644 --- a/src/Shared/NavMenu.razor +++ b/src/Shared/NavMenu.razor @@ -87,6 +87,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 }; +})(); 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\""); + } +} 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); + } +}