Merge pull request #9637 from Roasbeef/chan-type-required
What changed, and why it matters
This commit is a massive repository import or rebase that adds the entire LND codebase plus many new GitHub workflow, documentation, and configuration files. The stated title refers to a Lightning protocol feature ('start to set the require bit for channel_type'), but the supplied diff does not show any code changes related to channel_type; it only shows newly added repository scaffolding. There is no evidence in the provided materials of a security vulnerability or a security-relevant code change.
No security action is required based on the supplied commit. If reviewing for security, inspect the actual feature branch diff for PR #9637 (Roasbeef/chan-type-required) to find the channel_type logic changes, as this merge commit's diff appears to be a full-tree import rather than the focused feature patch.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit adds ~834,996 lines across 2,090 files, effectively importing the whole lnd tree. The visible diff fragment contains only new files: GitHub issue templates, CODEOWNERS hints, CI workflows (lint, unit/integration tests, release, Docker, backport, PR severity bot, Claude bot integration), editor/style configuration, Dockerfile, Makefile, LICENSE, etc. No functional Go code changes are shown, and the channel_type feature mentioned in the title is not visible in the diff. Verified references are absent. Therefore, from the supplied materials alone, this is a repository bootstrap/merge commit with no identifiable security defect.
Changed components
Inspect captured patch +834996 / −0
diff --git a/.claude/commands/dedupe.md b/.claude/commands/dedupe.md
new file mode 100644
index 0000000..2711e98
--- /dev/null
+++ b/.claude/commands/dedupe.md
@@ -0,0 +1,23 @@
+---
+allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(./scripts/comment-on-duplicates.sh:*)
+description: Find duplicate GitHub issues
+---
+
+Find up to 3 likely duplicate issues for a given GitHub issue.
+
+To do this, follow these steps precisely:
+
+1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
+2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
+3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #2
+4. Next, feed the results from #2 and #3 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
+5. Finally, use the comment script to post duplicates:
+ ```
+ ./scripts/comment-on-duplicates.sh --base-issue <issue-number> --potential-duplicates <dup1> <dup2> <dup3>
+ ```
+
+Notes (be sure to tell this to your agents, too):
+
+- Use `gh` to interact with Github, rather than web fetch
+- Do not use other tools, beyond `gh` and the comment script (eg. don't use other MCP servers, file edit, etc.)
+- Make a todo list first
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..110b5c2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# Top-most EditorConfig file.
+root = true
+
+# Unix-style newlines with a newline ending every file.
+[*.md]
+end_of_line = lf
+insert_final_newline = true
+max_line_length = 80
+
+# 8 space indentation for Golang code.
+[*.go]
+indent_style = tab
+indent_size = 8
+max_line_length = 80
diff --git a/.gemini/config.yaml b/.gemini/config.yaml
new file mode 100644
index 0000000..9b4e971
--- /dev/null
+++ b/.gemini/config.yaml
@@ -0,0 +1,12 @@
+# Config for the Gemini Pull Request Review Bot.
+# https://github.com/marketplace/gemini-code-assist
+have_fun: false
+code_review:
+ disable: false
+ comment_severity_threshold: MEDIUM
+ max_review_comments: -1
+ pull_request_opened:
+ help: false
+ summary: true
+ code_review: true
+ignore_patterns: []
diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md
new file mode 100644
index 0000000..a9b498a
--- /dev/null
+++ b/.gemini/styleguide.md
@@ -0,0 +1,331 @@
+# LND Style Guide
+
+## Code Documentation and Commenting
+
+- Always use the Golang code style described below in this document.
+- Readable code is the most important requirement for any commit created.
+- Comments must not explain the code 1:1 but instead explain the _why_ behind a
+ certain block of code, in case it requires contextual knowledge.
+- Unit tests must always use the `require` library. Either table driven unit
+ tests or tests using the `rapid` library are preferred.
+- The line length MUST NOT exceed 80 characters, this is very important.
+ You must count the Golang indentation (tabulator character) as 8 spaces when
+ determining the line length. Use creative approaches or the wrapping rules
+ specified below to make sure the line length isn't exceeded. HOWEVER: during
+ code reviews, please leave this check up to the linter and do not comment on
+ it (why? because gemini is bad at counting characters).
+- Every function must be commented with its purpose and assumptions.
+- Function comments must begin with the function name.
+- Function comments should be complete sentences.
+- Exported functions require detailed comments for the caller.
+
+**WRONG**
+```go
+// generates a revocation key
+func DeriveRevocationPubkey(commitPubKey *btcec.PublicKey,
+ revokePreimage []byte) *btcec.PublicKey {
+```
+**RIGHT**
+```go
+// DeriveRevocationPubkey derives the revocation public key given the
+// counterparty's commitment key, and revocation preimage derived via a
+// pseudo-random-function. In the event that we (for some reason) broadcast a
+// revoked commitment transaction, then if the other party knows the revocation
+// preimage, then they'll be able to derive the corresponding private key to
+// this private key by exploiting the homomorphism in the elliptic curve group.
+//
+// The derivation is performed as follows:
+//
+// revokeKey := commitKey + revokePoint
+// := G*k + G*h
+// := G * (k+h)
+//
+// Therefore, once we divulge the revocation preimage, the remote peer is able
+// to compute the proper private key for the revokeKey by computing:
+// revokePriv := commitPriv + revokePreimge mod N
+//
+// Where N is the order of the sub-group.
+func DeriveRevocationPubkey(commitPubKey *btcec.PublicKey,
+ revokePreimage []byte) *btcec.PublicKey {
+```
+- In-body comments should explain the *intention* of the code.
+
+**WRONG**
+```go
+// return err if amt is less than 546
+if amt < 546 {
+ return err
+}
+```
+**RIGHT**
+```go
+// Treat transactions with amounts less than the amount which is considered dust
+// as non-standard.
+if amt < 546 {
+ return err
+}
+```
+
+## Code Spacing and formatting
+
+- Segment code into logical stanzas separated by newlines.
+
+**WRONG**
+```go
+ witness := make([][]byte, 4)
+ witness[0] = nil
+ if bytes.Compare(pubA, pubB) == -1 {
+ witness[1] = sigB
+ witness[2] = sigA
+ } else {
+ witness[1] = sigA
+ witness[2] = sigB
+ }
+ witness[3] = witnessScript
+ return witness
+```
+**RIGHT**
+```go
+ witness := make([][]byte, 4)
+
+ // When spending a p2wsh multi-sig script, rather than an OP_0, we add
+ // a nil stack element to eat the extra pop.
+ witness[0] = nil
+
+ // When initially generating the witnessScript, we sorted the serialized
+ // public keys in descending order. So we do a quick comparison in order
+ // to ensure the signatures appear on the Script Virtual Machine stack in
+ // the correct order.
+ if bytes.Compare(pubA, pubB) == -1 {
+ witness[1] = sigB
+ witness[2] = sigA
+ } else {
+ witness[1] = sigA
+ witness[2] = sigB
+ }
+
+ // Finally, add the preimage as the last witness element.
+ witness[3] = witnessScript
+
+ return witness
+```
+
+- Use spacing between `case` and `select` stanzas.
+
+**WRONG**
+```go
+ switch {
+ case a:
+ <code block>
+ case b:
+ <code block>
+ case c:
+ <code block>
+ case d:
+ <code block>
+ default:
+ <code block>
+ }
+```
+**RIGHT**
+```go
+ switch {
+ // Brief comment detailing instances of this case (repeat below).
+ case a:
+ <code block>
+
+ case b:
+ <code block>
+
+ case c:
+ <code block>
+
+ case d:
+ <code block>
+
+ default:
+ <code block>
+ }
+```
+
+## Additional Style Constraints
+
+### 80 character line length
+
+- Wrap columns at 80 characters.
+- Tabs are 8 spaces.
+
+**WRONG**
+```go
+myKey := "0214cd678a565041d00e6cf8d62ef8add33b4af4786fb2beb87b366a2e151fcee7"
+```
+
+**RIGHT**
+```go
+myKey := "0214cd678a565041d00e6cf8d62ef8add33b4af4786fb2beb87b366a2e1" +
+ "51fcee7"
+```
+
+### Wrapping long function calls
+
+- If a function call exceeds the column limit, place the closing parenthesis
+ on its own line and start all arguments on a new line after the opening
+ parenthesis.
+
+**WRONG**
+```go
+value, err := bar(a,
+ a, b, c)
+```
+
+**RIGHT**
+```go
+value, err := bar(
+ a, a, b, c,
+)
+```
+
+- Compact form is acceptable if visual symmetry of parentheses is preserved.
+
+**ACCEPTABLE**
+```go
+ response, err := node.AddInvoice(
+ ctx, &lnrpc.Invoice{
+ Memo: "invoice",
+ ValueMsat: int64(oneUnitMilliSat - 1),
+ },
+ )
+```
+
+**PREFERRED**
+```go
+ response, err := node.AddInvoice(ctx, &lnrpc.Invoice{
+ Memo: "invoice",
+ ValueMsat: int64(oneUnitMilliSat - 1),
+ })
+```
+
+### Exception for log and error message formatting
+
+- Minimize lines for log and error messages, while adhering to the
+ 80-character limit.
+
+**WRONG**
+```go
+return fmt.Errorf(
+ "this is a long error message with a couple (%d) place holders",
+ len(things),
+)
+
+log.Debugf(
+ "Something happened here that we need to log: %v",
+ longVariableNameHere,
+)
+```
+
+**RIGHT**
+```go
+return fmt.Errorf("this is a long error message with a couple (%d) place "+
+ "holders", len(things))
+
+log.Debugf("Something happened here that we need to log: %v",
+ longVariableNameHere)
+```
+
+### Exceptions and additional styling for structured logging
+
+- **Static messages:** Use key-value pairs instead of formatted strings for the
+ `msg` parameter.
+- **Key-value attributes:** Use `slog.Attr` helper functions.
+- **Line wrapping:** Structured log lines are an exception to the 80-character
+ rule. Use one line per key-value pair for multiple attributes.
+
+**WRONG**
+```go
+log.DebugS(ctx, fmt.Sprintf("User %d just spent %.8f to open a channel", userID, 0.0154))
+```
+
+**RIGHT**
+```go
+log.InfoS(ctx, "Channel open performed",
+ slog.Int("user_id", userID),
+ btclog.Fmt("amount", "%.8f", 0.00154))
+```
+
+### Wrapping long function definitions
+
+- If function arguments exceed the 80-character limit, maintain indentation
+ on following lines.
+- Do not end a line with an open parenthesis if the function definition is not
+ finished.
+
+**WRONG**
+```go
+func foo(a, b, c,
+) (d, error) {
+
+func bar(a, b, c) (
+ d, error,
+) {
+
+func baz(a, b, c) (
+ d, error) {
+```
+**RIGHT**
+```go
+func foo(a, b,
+ c) (d, error) {
+
+func baz(a, b, c) (d,
+ error) {
+
+func longFunctionName(
+ a, b, c) (d, error) {
+```
+
+- If a function declaration spans multiple lines, the body should start with an
+ empty line.
+
+**WRONG**
+```go
+func foo(a, b, c,
+ d, e) error {
+ var a int
+}
+```
+**RIGHT**
+```go
+func foo(a, b, c,
+ d, e) error {
+
+ var a int
+}
+```
+
+## Use of Log Levels
+
+- Available levels: `trace`, `debug`, `info`, `warn`, `error`, `critical`.
+- Only use `error` for internal errors not triggered by external sources.
+
+## Testing
+
+- To run all tests for a specific package:
+ `make unit pkg=$pkg`
+- To run a specific test case within a package:
+ `make unit pkg=$pkg case=$case`
+
+## Git Commit Messages
+
+- **Subject Line:**
+ - Format: `subsystem: short description of changes`
+ - `subsystem` should be the package primarily affected (e.g., `lnwallet`, `rpcserver`).
+ - For multiple packages, use `+` or `,` as a delimiter (e.g., `lnwallet+htlcswitch`).
+ - For widespread changes, use `multi:`.
+ - Keep it under 50 characters.
+ - Use the present tense (e.g., "Fix bug", not "Fixed bug").
+
+- **Message Body:**
+ - Separate from the subject with a blank line.
+ - Explain the "what" and "why" of the change.
+ - Wrap text to 72 characters.
+ - Use bullet points for lists.
diff --git a/.github/CODEOWNERS-HINT b/.github/CODEOWNERS-HINT
new file mode 100644
index 0000000..523fa8e
--- /dev/null
+++ b/.github/CODEOWNERS-HINT
@@ -0,0 +1,195 @@
+# This file lists the owners of code in different areas of the lnd codebase
+# Codeowners will own the review for the changes being merged into their
+# respective areas
+
+# aezeed
+/aezeed/ @guggero @yyforyongyu @roasbeef
+
+# alias manager
+/aliasmgr/ @Crypt-iQ
+
+# amp
+/amp/ @yyforyongyu @roasbeef
+
+# auto pilot
+/autopilot/ @bitromortac
+
+# batch
+/batch/ @bhandras
+
+# block cache
+/blockcache/ @yyforyongyu @ellemouton
+
+# brontide
+/brontide/ @Roasbeef @yyforyongyu @morehouse
+
+# buffer
+/buffer/ @yyforyongyu
+
+# build
+/build/ @Roasbeef @guggero
+
+# certificates
+/cert/ @guggero
+
+# chain notifications
+/chainntnfs/ @Roasbeef @yyforyongyu
+
+# chain registry
+/chainreg/ @ellemouton
+
+# channel acceptor
+/chanacceptor/ @Crypt-iQ
+
+# channel backup
+/chanbackup/ @guggero @ellemouton
+
+# channel fitness
+/chanfitness/ @yyforyongyu
+
+# channel db
+/channeldb/ @Roasbeef @yyforyongyu
+
+# channel notifier
+/channelnotifier/ @yyforyongyu
+
+# clock
+/clock/ @bhandras
+
+# cluster
+/cluster/ @bhandras
+
+# command line
+/cmd/ @ellemouton
+
+# contract court
+/contractcourt/ @yyforyongyu @Roasbeef @Crypt-iQ
+
+# contrib
+/contrib/ @guggero
+
+# discovery
+/discovery/ @ellemouton @yyforyongyu
+
+# docker
+/docker/ @guggero
+
+# feature
+/feature/ @ProofOfKeags
+
+# functions/methods
+/fn/ @Roasbeef @ProofOfKeags
+
+# funding
+/funding/ @Crypt-iQ @morehouse
+
+# health check
+/healthcheck/ @guggero
+
+# htlc switch
+/htlcswitch/ @Roasbeef @yyforyongyu
+
+# musig2
+/internal/musig2v040/ @guggero
+
+# invoices
+/invoices/ @yyforyongyu @bhandras
+
+# key chain
+/keychain/ @guggero @roasbeef
+
+# kvdb
+/kvdb/ @bhandras
+
+# lncfg
+/lncfg/ @Roasbeef
+
+# lnencrypt
+/lnencrypt/ @guggero
+
+# lnpeer
+/lnpeer/ @Roasbeef
+
+#lntest
+/lntest/ @yyforyongyu
+
+# lntypes
+/lntypes/ @bitromortac
+
+# lnutils
+/lnutils/ @yyforyongyu
+
+# lnwallet
+/lnwallet/ @Roasbeef @yyforyongyu @Crypt-iQ
+
+# lnwire
+/lnwire/ @ellemouton @morehouse
+
+# macaroons
+/macaroons/ @guggero
+
+# mobile
+/mobile/ @guggero
+
+# monitoring
+/monitoring/ @guggero
+
+# multimutex
+/multimutex/ @Roasbeef
+
+# nat
+/nat/ @Roasbeef
+
+# network announcements
+/netann/ @ellemouton @yyforyongyu
+
+# peer
+/peer/ @ProofOfKeags @Crypt-iQ @morehouse
+
+# peernotifier
+/peernotifier/ @yyforyongyu
+
+# pool
+/pool/ @yyforyongyu
+
+# queue
+/queue/ @bhandras
+
+# record
+/record/ @guggero
+
+# routing
+/routing/ @bitromortac @ellemouton @yyforyongyu
+
+# rpcperms
+/rpcperms/ @guggero
+
+# shachain
+/shachain/ @Roasbeef
+
+# signal
+/signal/ @Roasbeef
+
+# sqldb
+/sqldb/ @bhandras
+
+# sweep
+/sweep/ @yyforyongyu @ziggie1984
+
+# ticker
+/ticker/ @guggero
+
+# tlv
+/tlv/ @Roasbeef
+
+# tor
+/tor/ @yyforyongyu
+
+# walletunlocker
+/walletunlocker/ @guggero
+
+# watchtower
+/watchtower/ @ellemouton
+
+# zpay32
+/zpay32/ @Roasbeef @morehouse
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..ff813f1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,119 @@
+name: Bug report
+description: Create a bug report. Please use the discussions section for general or troubleshooting questions.
+title: '[bug]: '
+labels: ["bug", "needs triage"]
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report! Please provide
+ as much detail as possible.
+
+ - type: checkboxes
+ id: pre-check
+ attributes:
+ label: Pre-Submission Checklist
+ description: "Please verify the following before submitting the bug report."
+ options:
+ - label: "I have searched the existing issues and believe this is a new bug."
+ required: true
+ - label: "I am not asking a question about how to use lnd, but reporting
+ a bug (otherwise open a discussion)."
+ required: true
+
+ - type: input
+ id: environment-lnd-version
+ attributes:
+ label: LND Version
+ description: "The version of lnd, which can be found using `lncli version`
+ or `lnd --version`."
+ placeholder: "e.g., v0.19.3-beta-1-gfbe71ced3"
+ validations:
+ required: true
+
+ - type: textarea
+ id: environment-lnd-config
+ attributes:
+ label: LND Configuration
+ description: "Please provide details about your lnd setup. Include/upload
+ `lnd.conf` contents (**with sensitive data removed**)."
+ placeholder: |
+ lnd.conf:
+ ...
+ validations:
+ required: true
+
+ - type: input
+ id: environment-backend-version
+ attributes:
+ label: Backend Version
+ description: "The version of bitcoind/btcd, which can be found using
+ `bitcoind/btcd --version`."
+ placeholder: "e.g., Bitcoin Core v29.0.0"
+ validations:
+ required: true
+
+ - type: textarea
+ id: environment-backend-config
+ attributes:
+ label: Backend Configuration
+ description: "Please provide details about your backend. Include/upload
+ `bitcoin.conf`/`btcd.conf` contents (**with sensitive data removed**)."
+ placeholder: |
+ bitcoin/btcd.conf:
+ ...
+ validations:
+ required: true
+
+ - type: input
+ id: environment-os
+ attributes:
+ label: OS/Distribution
+ description: "Your operating system/distribution (e.g., from `uname -a`)
+ and any node packaging used."
+ placeholder: "e.g., Ubuntu 22.04 or SomeNodeProject"
+ validations:
+ required: true
+
+ - type: textarea
+ id: bug-description
+ attributes:
+ label: Bug Details & Steps to Reproduce
+ description: "Describe the action you attempted and what didn't work as
+ expected. Provide clear steps to reproduce the issue."
+ placeholder: |
+ 1. I tried to...
+ 2. Then I...
+ 3. The error occurred.
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-behaviour
+ attributes:
+ label: Expected Behavior
+ description: "Explain what you expected to happen."
+ validations:
+ required: true
+
+ - type: textarea
+ id: debuginfo
+ attributes:
+ label: Debug Information
+ description: "Please include/upload relevant logs or stack traces with
+ [appropriate subsystem log levels](https://github.com/lightningnetwork/lnd/blob/master/docs/debugging_lnd.md#debug-logging).
+ Also relevant are [goroutine](https://github.com/lightningnetwork/lnd/blob/master/docs/debugging_lnd.md#goroutine-profile)
+ for hanging commands or [memory](https://github.com/lightningnetwork/lnd/blob/master/docs/debugging_lnd.md#heap-profile)
+ profiles."
+
+ - type: textarea
+ id: environment-misc
+ attributes:
+ label: Environment
+ description: "If available, give additional information about the
+ environment LND runs in. Describe your network setup, including any
+ proxies or container configurations. Also, list any other applications
+ that interact with LND."
+ placeholder: "e.g., behind VPN, in a Docker container, using a rebalance
+ script and lightning-terminal"
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..a04ad5a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,14 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Discussions
+ url: https://github.com/lightningnetwork/lnd/discussions
+ about: For general or troubleshooting questions or if you're not sure what issue type to pick.
+ - name: Documentation for lnd and lightning-terminal
+ url: https://docs.lightning.engineering/
+ about: Please make sure the documentation cannot answer your question first.
+ - name: Lightning Community Slack
+ url: https://lightning.engineering/slack.html
+ about: Please ask and answer questions here.
+ - name: Security issue disclosure policy
+ url: https://github.com/lightningnetwork/lnd#security
+ about: Please refer to this document when reporting security related issues.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..ed8bfb3
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,19 @@
+---
+name: Feature request
+about: Suggest a new feature for `lnd`.
+title: '[feature]: '
+labels: enhancement
+assignees: ''
+---
+
+**Is your feature request related to a problem? Please describe.**
+<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
+
+**Describe the solution you'd like**
+<!-- A clear and concise description of what you want to happen. -->
+
+**Describe alternatives you've considered**
+<!-- A clear and concise description of any alternative solutions or features you've considered. -->
+
+**Additional context**
+<!-- Add any other context or screenshots about the feature request here. -->
diff --git a/.github/actions/check-label/action.yml b/.github/actions/check-label/action.yml
new file mode 100644
index 0000000..00c344a
--- /dev/null
+++ b/.github/actions/check-label/action.yml
@@ -0,0 +1,30 @@
+name: "Check PR labels"
+description: "Checks if specific labels are present on a PR and sets outputs accordingly."
+
+inputs:
+ label:
+ description: "The label to check for"
+ required: true
+ skip-message:
+ description: "The message to display when skipping due to label"
+ required: true
+
+outputs:
+ skip:
+ description: "Whether to skip the tests (true/false)"
+ value: ${{ steps.check.outputs.skip }}
+
+runs:
+ using: "composite"
+ steps:
+ - name: Check for label
+ id: check
+ shell: bash
+ run: |
+ if [[ "${{ contains(github.event.pull_request.labels.*.name, inputs.label) }}" == "true" ]]; then
+ echo "::notice::${{ inputs.skip-message }}"
+ echo "${{ inputs.skip-message }}" >> $GITHUB_STEP_SUMMARY
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
\ No newline at end of file
diff --git a/.github/actions/cleanup-space/action.yml b/.github/actions/cleanup-space/action.yml
new file mode 100644
index 0000000..78b5a75
--- /dev/null
+++ b/.github/actions/cleanup-space/action.yml
@@ -0,0 +1,38 @@
+name: "Clean up runner disk space"
+description: "Removes large, non-essential toolsets to free up disk space on the runner."
+
+runs:
+ using: "composite"
+ steps:
+ - name: Free up disk space
+ shell: bash
+ run: |
+ echo "Removing large toolsets to free up disk space..."
+ echo "Disk space before cleanup:"
+ df -h
+
+ # Remove dotnet to save disk space.
+ sudo rm -rf /usr/share/dotnet
+ # Remove android to save disk space.
+ sudo rm -rf /usr/local/lib/android
+ # Remove ghc to save disk space.
+ sudo rm -rf /opt/ghc
+ # Remove large packages.
+ sudo rm -rf /usr/share/swift
+ sudo rm -rf /usr/local/julia*
+ sudo rm -rf /opt/hostedtoolcache
+
+ # Remove docker images to save space.
+ docker image prune -a -f || true
+
+ # Remove large apt packages.
+ sudo apt-get remove -y '^aspnetcore-.*' '^dotnet-.*' '^llvm-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri 2>/dev/null || true
+ sudo apt-get autoremove -y
+ sudo apt-get clean
+
+ # Remove caches.
+ sudo rm -rf /usr/local/share/boost
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+
+ echo "Disk space after cleanup:"
+ df -h
diff --git a/.github/actions/rebase/action.yml b/.github/actions/rebase/action.yml
new file mode 100644
index 0000000..cf2e72f
--- /dev/null
+++ b/.github/actions/rebase/action.yml
@@ -0,0 +1,15 @@
+name: "Rebase on to the PR target base branch"
+description: "A reusable workflow that's used to rebase the PR code on to the target base branch."
+
+runs:
+ using: "composite"
+
+ steps:
+ - name: fetch and rebase on ${{ github.base_ref }}
+ shell: bash
+ run: |
+ git remote add upstream https://github.com/${{ github.repository }}
+ git fetch upstream ${{ github.base_ref }}:refs/remotes/upstream/${{ github.base_ref }}
+ export GIT_COMMITTER_EMAIL="lnd-ci@example.com"
+ export GIT_COMMITTER_NAME="LND CI"
+ git rebase upstream/${{ github.base_ref }}
diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml
new file mode 100644
index 0000000..09f47d7
--- /dev/null
+++ b/.github/actions/setup-go/action.yml
@@ -0,0 +1,90 @@
+name: "Setup Golang environment"
+description: "A reusable workflow that's used to set up the Go environment and cache."
+inputs:
+ go-version:
+ description: "The version of Golang to set up"
+ required: true
+ key-prefix:
+ description: "A prefix to use for the cache key, to separate cache entries from other workflows"
+ required: false
+ use-build-cache:
+ description: "Whether to use the build cache"
+ required: false
+ # Boolean values aren't supported in the workflow syntax, so we use a
+ # string. To not confuse the value with true/false, we use 'yes' and 'no'.
+ default: 'yes'
+
+runs:
+ using: "composite"
+
+ steps:
+ - name: setup go ${{ inputs.go-version }}
+ uses: actions/setup-go@v5
+ with:
+ go-version: '${{ inputs.go-version }}'
+ cache: 'false'
+
+ # When we run go build or go test, the Go compiler calculates a signature
+ # for each package based on the content of its `.go` source files, its
+ # dependencies, and the compiler flags. It then checks the restored build
+ # cache for an entry matching that exact signature to speed up the jobs.
+ # - Cache Hit: If an entry exists (meaning the source files and
+ # dependencies haven't changed), it reuses the compiled artifact directly
+ # from the cache.
+ # - Cache Miss: If no entry exists (because we changed a line of code), it
+ # recompiles that specific package and stores the new result in the cache
+ # for the next time.
+ - name: go cache
+ if: ${{ inputs.use-build-cache == 'yes' }}
+ uses: actions/cache@v4
+ with:
+ # In order:
+ # * Module download cache
+ # * Build cache (Linux)
+ # * Build cache (Mac)
+ # * Build cache (Windows)
+ path: |
+ ~/go/pkg/mod
+ ~/.cache/go-build
+ ~/Library/Caches/go-build
+ ~\AppData\Local\go-build
+
+ # The key is used to create and later look up the cache. It's made of
+ # four parts:
+ # - The base part is made from the OS name, Go version and a
+ # job-specified key prefix. Example: `linux-go-1.25.5-unit-test-`.
+ # It ensures that a job running on Linux with Go 1.25 only looks for
+ # caches from the same environment.
+ # - The unique part is the `hashFiles('**/go.sum')`, which calculates a
+ # hash (a fingerprint) of the go.sum file.
+ key: ${{ runner.os }}-go-${{ inputs.go-version }}-${{ inputs.key-prefix }}-${{ hashFiles('**/go.sum') }}
+
+ # The restore-keys provides a list of fallback keys. If no cache
+ # matches the key exactly, the action will look for a cache where the
+ # key starts with one of the restore-keys. The action searches the
+ # restore-keys list in order and restores the most recently created
+ # cache that matches the prefix. Once the job is done, a new cache is
+ # created and saved using the new key.
+ restore-keys: |
+ ${{ runner.os }}-go-${{ inputs.go-version }}-${{ inputs.key-prefix }}-
+
+ # The complete, downloaded source code of all our dependencies (the
+ # libraries lnd project imports). This prevents the go command from having
+ # to re-download every dependency from the internet on every single job
+ # run. It's like having a local library of all the third-party code lnd
+ # needs.
+ - name: go module cache
+ if: ${{ inputs.use-build-cache == 'no' }}
+ uses: actions/cache@v4
+ with:
+ # Just the module download cache.
+ path: |
+ ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ inputs.go-version }}-${{ inputs.key-prefix }}-no-build-cache-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-${{ inputs.go-version }}-${{ inputs.key-prefix }}-no-build-cache-
+
+ - name: set GOPATH
+ shell: bash
+ run: |
+ echo "GOPATH=$(go env GOPATH)" >> $GITHUB_ENV
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..22b0f99
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,21 @@
+## Change Description
+Description of change / link to associated issue.
+
+## Steps to Test
+Steps for reviewers to follow to test the change.
+
+## Pull Request Checklist
+### Testing
+- [ ] Your PR passes all CI checks.
+- [ ] Tests covering the positive and negative (error paths) are included.
+- [ ] Bug fixes contain tests triggering the bug to prevent regressions.
+
+### Code Style and Documentation
+- [ ] The change is not [insubstantial](https://github.com/lightningnetwork/lnd/blob/master/docs/code_contribution_guidelines.md#substantial-contributions-only). Typo fixes are not accepted to fight bot spam.
+- [ ] The change obeys the [Code Documentation and Commenting](https://github.com/lightningnetwork/lnd/blob/master/docs/development_guidelines.md#code-documentation-and-commenting) guidelines, and lines wrap at 80.
+- [ ] Commits follow the [Ideal Git Commit Structure](https://github.com/lightningnetwork/lnd/blob/master/docs/development_guidelines.md#ideal-git-commit-structure).
+- [ ] Any new logging statements use an appropriate subsystem and logging level.
+- [ ] Any new lncli commands have appropriate tags in the comments for the rpc in the proto file.
+- [ ] [There is a change description in the release notes](https://github.com/lightningnetwork/lnd/tree/master/docs/release-notes), or `[skip ci]` in the commit message for small changes.
+
+📝 Please see our [Contribution Guidelines](https://github.com/lightningnetwork/lnd/blob/master/docs/code_contribution_guidelines.md) for further guidance.
diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml
new file mode 100644
index 0000000..c691bcf
--- /dev/null
+++ b/.github/workflows/backport.yml
@@ -0,0 +1,124 @@
+name: Backport
+
+on:
+ pull_request_target:
+ types: [closed, labeled]
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: read
+
+jobs:
+ backport:
+ name: Backport PR
+ runs-on: ubuntu-latest
+ # Only run on merged PRs with backport labels.
+ # Labels must match pattern: backport-v* (e.g., backport-v0.20.x-branch).
+ # This excludes labels like "backport candidate" or "backport-candidate".
+ if: |
+ github.event.pull_request.merged == true &&
+ contains(join(github.event.pull_request.labels.*.name, ','), 'backport-v')
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.base.ref }}
+
+ - name: Validate target branches exist
+ id: validate
+ shell: bash
+ run: |
+ # Extract all backport labels
+ labels='${{ toJSON(github.event.pull_request.labels.*.name) }}'
+ echo "All labels: $labels"
+
+ # Parse labels and extract branch names
+ # Only match labels starting with "backport-v" to exclude labels like
+ # "backport candidate" or "backport-candidate"
+ backport_labels=$(echo "$labels" | jq -r '.[] | select(startswith("backport-v"))')
+
+ if [ -z "$backport_labels" ]; then
+ echo "::error::No valid backport labels found (must start with 'backport-v')"
+ exit 1
+ fi
+
+ echo "Found backport labels:"
+ echo "$backport_labels"
+
+ # Check each target branch exists
+ missing_branches=()
+ valid_branches=()
+ while IFS= read -r label; do
+ # Extract branch name (everything after "backport-")
+ branch_name="${label#backport-}"
+ echo "Checking if branch exists: $branch_name"
+
+ # Check if branch exists in remote
+ if ! git ls-remote --heads origin "$branch_name" | grep -q "$branch_name"; then
+ echo "::warning::Target branch '$branch_name' does not exist (from label '$label')"
+ missing_branches+=("$branch_name")
+ else
+ echo "✓ Branch '$branch_name' exists"
+ valid_branches+=("$branch_name")
+ fi
+ done <<< "$backport_labels"
+
+ # Report validation results
+ if [ ${#missing_branches[@]} -gt 0 ]; then
+ echo "::warning::The following target branches do not exist and will be skipped: ${missing_branches[*]}"
+ echo "::warning::Please check the branch names or create the branches before retrying"
+ fi
+
+ # Only fail if ALL branches are invalid
+ if [ ${#valid_branches[@]} -eq 0 ]; then
+ echo "::error::No valid target branches found. All backport labels reference non-existent branches."
+ exit 1
+ fi
+
+ echo "✓ Found ${#valid_branches[@]} valid target branch(es): ${valid_branches[*]}"
+ if [ ${#missing_branches[@]} -gt 0 ]; then
+ echo "⚠ Skipping ${#missing_branches[@]} invalid branch(es): ${missing_branches[*]}"
+ fi
+
+ - name: Create backport PRs
+ # Uses version v3.4, we pin to a hash here. For more details to
+ # available versions, see:
+ # https://github.com/korthout/backport-action/releases.
+ uses: korthout/backport-action@d07416681cab29bf2661702f925f020aaa962997
+ with:
+ # Automatically detect target branches from labels.
+ # Labels must be in format: backport-v0.20.x-branch (must start
+ # with "backport-v"). This excludes labels like "backport candidate"
+ # or "backport-candidate". The pattern extracts everything after
+ # "backport-" as the branch name.
+ label_pattern: '^backport-(v.+)$'
+
+ # GitHub token for creating PRs.
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+
+ # PR title format - shows it's a backport with original PR number.
+ pull_title: '[${target_branch}] Backport #${pull_number}: ${pull_title}'
+
+ # PR description template - links back to original PR.
+ pull_description: |-
+ Backport of #${pull_number}
+
+ ---
+
+ ${pull_description}
+
+ # Automatically add labels to backport PRs.
+ # The 'no-changelog' label skips the release notes check in CI.
+ add_labels: no-changelog
+
+ # Copy milestone from original PR to backport PR.
+ copy_milestone: true
+
+ # Merge strategy - skip merge commits, use cherry-pick only.
+ merge_commits: skip
+
+ # If conflicts occur, create a draft PR with conflict markers.
+ experimental: '{"conflict_resolution": "draft_commit_conflicts"}'
diff --git a/.github/workflows/claude-dedupe-issues.yml b/.github/workflows/claude-dedupe-issues.yml
new file mode 100644
index 0000000..5f73298
--- /dev/null
+++ b/.github/workflows/claude-dedupe-issues.yml
@@ -0,0 +1,35 @@
+name: Claude Issue Dedupe
+description: Automatically dedupe GitHub issues using Claude Code
+on:
+ issues:
+ types: [opened]
+ workflow_dispatch:
+ inputs:
+ issue_number:
+ description: 'Issue number to process for duplicate detection'
+ required: true
+ type: string
+
+jobs:
+ claude-dedupe-issues:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ issues: write
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run Claude Code slash command
+ uses: anthropics/claude-code-action@v1
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ allowed_non_write_users: "*"
+ model: claude-haiku-4-5-20251001
+ prompt: "/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}"
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 0000000..d211eb0
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,62 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Checkout PR branch (handles fork PRs)
+ if: github.event.issue.pull_request || github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if [ "${{ github.event_name }}" = "issue_comment" ]; then
+ PR_NUMBER=${{ github.event.issue.number }}
+ else
+ PR_NUMBER=${{ github.event.pull_request.number }}
+ fi
+ gh pr checkout "$PR_NUMBER"
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
+
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..a9bb6cb
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,76 @@
+name: Docker image build
+
+on:
+ push:
+ tags:
+ - 'v*'
+ schedule:
+ # Every day at midnight (UTC).
+ - cron: '0 0 * * *'
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ DOCKER_REPO: lightninglabs
+ DOCKER_IMAGE: lnd
+
+jobs:
+ ########################
+ # Check release signing keys
+ ########################
+ pgp-key-check:
+ name: Check PGP key expirations
+ runs-on: ubuntu-latest
+ # We don't want to fail the build because of PGP key expirations because
+ # they are only used for release builds and this job failing should catch
+ # the attention of the maintainers.
+ continue-on-error: true
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v4
+
+ - name: Check PGP key expirations
+ run: scripts/check-pgp-expiry.sh
+
+ ########################
+ # Build and push the daily docker image
+ ########################
+ main:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up QEMU
+ uses: lightninglabs/gh-actions/setup-qemu-action@2021.01.25.00
+
+ - name: Set up Docker Buildx
+ uses: lightninglabs/gh-actions/setup-buildx-action@2021.01.25.00
+
+ - name: Login to DockerHub
+ uses: lightninglabs/gh-actions/login-action@2021.01.25.00
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_API_KEY }}
+
+ - name: Set env
+ run: |
+ echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+ echo "IMAGE_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+
+ - name: Set daily tag
+ if: github.event.schedule == '0 0 * * *'
+ run: |
+ echo "RELEASE_VERSION=master" >> $GITHUB_ENV
+ echo "IMAGE_TAG=daily-testing-$(date -u +%Y%m%d),${DOCKER_REPO}/${DOCKER_IMAGE}:daily-testing-only" >> $GITHUB_ENV
+
+ - name: Build and push
+ id: docker_build
+ uses: lightninglabs/gh-actions/build-push-action@2021.01.25.00
+ with:
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: "${{ env.DOCKER_REPO }}/${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}"
+ build-args: checkout=${{ env.RELEASE_VERSION }}
+
+ - name: Image digest
+ run: echo ${{ steps.docker_build.outputs.digest }}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..4d54c1d
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,665 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - "master"
+ pull_request:
+ branches:
+ - "*"
+ merge_group:
+ branches:
+ - "master"
+
+permissions:
+ # Required to manage and delete caches.
+ actions: write
+ # Default permission for checking out code.
+ contents: read
+
+concurrency:
+ # Cancel any previous workflows if they are from a PR or push.
+ group: ${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ BITCOIN_VERSION: "29"
+
+ # TRANCHES defines the number of tranches used in the itests.
+ TRANCHES: 16
+
+ # SMALL_TRANCHES defines the number of tranches used in the less stable itest
+ # builds
+ #
+ # TODO(yy): remove this value and use TRANCHES.
+ SMALL_TRANCHES: 8
+
+ # If you change this please also update GO_VERSION in Makefile (then run
+ # `make lint` to see where else it needs to be updated as well).
+ GO_VERSION: 1.25.5
+
+jobs:
+ static-checks:
+ name: Static Checks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ # Needed for some checks.
+ fetch-depth: 0
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: Setup Go ${{ env.GO_VERSION }}
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ use-build-cache: 'no'
+
+ ########################
+ # sample configuration check
+ ########################
+ - name: Check default values in sample-lnd.conf file
+ run: make sample-conf-check
+
+ ########################
+ # Check code and RPC format
+ ########################
+ - name: Check code format
+ run: make fmt-check
+
+ - name: Check go modules tidiness
+ run: make tidy-module-check
+
+ - name: Lint proto files
+ run: make protolint
+
+ ########################
+ # SQLC code gen check
+ ########################
+ - name: Docker image cache
+ uses: satackey/action-docker-layer-caching@v0.0.11
+ # Ignore the failure of a step and avoid terminating the job.
+ continue-on-error: true
+
+ - name: Check SQL models
+ run: make sqlc-check
+
+ ########################
+ # RPC and mobile compilation check
+ ########################
+ - name: Check RPC format
+ run: make rpc-check
+
+ - name: Check JSON/WASM stub compilation
+ run: make rpc-js-compile
+
+ - name: Check mobile RPC bindings
+ run: make mobile-rpc
+
+ - name: Check mobile specific code
+ run: go build --tags="mobile" ./mobile
+
+ ########################
+ # check commits
+ ########################
+ check-commits:
+ if: github.event_name == 'pull_request'
+ name: Check commits
+ runs-on: ubuntu-latest
+ steps:
+ - name: git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: setup go ${{ env.GO_VERSION }}
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ # Use the same cache from unit test job to save time.
+ key-prefix: unit-test
+
+ - name: fetch and rebase on ${{ github.base_ref }}
+ uses: ./.github/actions/rebase
+
+ - name: check commits
+ run: scripts/check-each-commit.sh upstream/${{ github.base_ref }}
+
+ ########################
+ # lint code
+ ########################
+ lint:
+ name: Lint code
+ runs-on: ubuntu-latest
+ steps:
+ - name: git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: setup go ${{ env.GO_VERSION }}
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ # Use the same cache from unit test job to save time.
+ key-prefix: unit-test
+
+ - name: lint
+ run: GOGC=50 make lint
+
+ ########################
+ # cross compilation
+ ########################
+ cross-compile:
+ name: Cross compilation
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: true
+ matrix:
+ # Please keep this list in sync with make/release_flags.mk!
+ include:
+ - name: i386
+ sys: freebsd-386 linux-386 windows-386
+ - name: amd64
+ sys: darwin-amd64 freebsd-amd64 linux-amd64 netbsd-amd64 openbsd-amd64 windows-amd64
+ - name: arm
+ sys: darwin-arm64 freebsd-arm linux-armv6 linux-armv7 linux-arm64 windows-arm
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: cross-compile
+ use-build-cache: 'no'
+
+ - name: Build release for all architectures
+ run: make release sys="${{ matrix.sys }}"
+
+ ########################
+ # run unit tests
+ ########################
+ unit-test:
+ name: Run unit tests
+ runs-on: ubuntu-latest
+ strategy:
+ # Allow other tests in the matrix to continue if one fails.
+ fail-fast: false
+ matrix:
+ unit_type:
+ - unit-cover
+ - unit tags="kvdb_etcd"
+ - unit tags="kvdb_postgres"
+ - unit tags="kvdb_sqlite"
+ - unit tags="test_db_sqlite"
+ - unit tags="test_db_postgres"
+ - unit-race
+ - unit-race tags="test_db_sqlite"
+ - unit-race tags="test_db_postgres"
+ - unit-module
+
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: Fetch and rebase on ${{ github.base_ref }}
+ if: github.event_name == 'pull_request'
+ uses: ./.github/actions/rebase
+
+ - name: Git checkout fuzzing seeds
+ uses: actions/checkout@v5
+ with:
+ repository: lightninglabs/lnd-fuzz
+ path: lnd-fuzz
+
+ - name: Rsync fuzzing seeds
+ run: rsync -a --ignore-existing lnd-fuzz/ ./
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: unit-test
+
+ - name: Install bitcoind
+ run: ./scripts/install_bitcoind.sh $BITCOIN_VERSION
+
+ - name: Run ${{ matrix.unit_type }}
+ run: make ${{ matrix.unit_type }}
+
+ - name: Clean coverage
+ run: grep -Ev '(\.pb\.go|\.pb\.json\.go|\.pb\.gw\.go)' coverage.txt > coverage-norpc.txt
+ if: matrix.unit_type == 'unit-cover'
+
+ - name: Send coverage
+ uses: coverallsapp/github-action@v2
+ if: matrix.unit_type == 'unit-cover'
+ continue-on-error: true
+ with:
+ file: coverage-norpc.txt
+ flag-name: 'unit'
+ format: 'golang'
+ parallel: true
+
+
+ ########################
+ # run integration tests with TRANCHES
+ ########################
+ basic-integration-test:
+ name: Run basic itests
+ runs-on: ubuntu-latest
+ strategy:
+ # Allow other tests in the matrix to continue if one fails.
+ fail-fast: false
+ matrix:
+ include:
+ - name: btcd
+ args: backend=btcd cover=1
+ - name: bitcoind
+ args: backend=bitcoind cover=1
+ - name: bitcoind-notxindex
+ args: backend="bitcoind notxindex"
+ - name: neutrino
+ args: backend=neutrino cover=1
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Check for no-itest label
+ id: check-label
+ uses: ./.github/actions/check-label
+ with:
+ label: 'no-itest'
+ skip-message: "Tests auto-passed due to 'no-itest' label"
+
+ - name: Clean up runner space
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/cleanup-space
+
+ - name: Fetch and rebase on ${{ github.base_ref }}
+ if: github.event_name == 'pull_request' && steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/rebase
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: integration-test
+
+ - name: Install bitcoind
+ if: steps.check-label.outputs.skip != 'true'
+ run: ./scripts/install_bitcoind.sh $BITCOIN_VERSION
+
+ - name: Run ${{ matrix.name }}
+ if: steps.check-label.outputs.skip != 'true'
+ run: make itest-parallel tranches=${{ env.TRANCHES }} ${{ matrix.args }} shuffleseed=${{ github.run_id }}${{ strategy.job-index }}
+
+ - name: Clean coverage
+ run: grep -Ev '(\.pb\.go|\.pb\.json\.go|\.pb\.gw\.go)' coverage.txt > coverage-norpc.txt
+ if: ${{ contains(matrix.args, 'cover=1') && steps.check-label.outputs.skip != 'true' }}
+
+ - name: Send coverage
+ if: ${{ contains(matrix.args, 'cover=1') && steps.check-label.outputs.skip != 'true' }}
+ continue-on-error: true
+ uses: coverallsapp/github-action@v2
+ with:
+ file: coverage-norpc.txt
+ flag-name: 'itest-${{ matrix.name }}'
+ format: 'golang'
+ parallel: true
+
+ - name: Zip log files on failure
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ timeout-minutes: 5 # timeout after 5 minute
+ run: 7z a logs-itest-${{ matrix.name }}.zip itest/**/*.log itest/postgres.log
+
+ - name: Upload log files on failure
+ uses: actions/upload-artifact@v4
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ with:
+ name: logs-itest-${{ matrix.name }}
+ path: logs-itest-${{ matrix.name }}.zip
+ retention-days: 5
+
+ ########################
+ # run integration tests with SMALL_TRANCHES
+ ########################
+ integration-test:
+ name: Run itests
+ runs-on: ubuntu-latest
+ strategy:
+ # Allow other tests in the matrix to continue if one fails.
+ fail-fast: false
+ matrix:
+ include:
+ - name: bitcoind-rpcpolling
+ args: backend="bitcoind rpcpolling"
+ - name: bitcoind-etcd
+ args: backend=bitcoind dbbackend=etcd
+ - name: bitcoind-sqlite
+ args: backend=bitcoind dbbackend=sqlite
+ - name: bitcoind-sqlite-nativesql
+ args: backend=bitcoind dbbackend=sqlite nativesql=true
+ - name: bitcoind-sqlite=nativesql-experiment
+ args: backend=bitcoind dbbackend=sqlite nativesql=true tags=test_native_sql
+ - name: bitcoind-postgres
+ args: backend=bitcoind dbbackend=postgres
+ - name: bitcoind-postgres-nativesql
+ args: backend=bitcoind dbbackend=postgres nativesql=true
+ - name: bitcoind-postgres-nativesql-experiment
+ args: backend=bitcoind dbbackend=postgres nativesql=true tags=test_native_sql
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Check for no-itest label
+ id: check-label
+ uses: ./.github/actions/check-label
+ with:
+ label: 'no-itest'
+ skip-message: "Tests auto-passed due to 'no-itest' label"
+
+ - name: Clean up runner space
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/cleanup-space
+
+ - name: Fetch and rebase on ${{ github.base_ref }}
+ if: github.event_name == 'pull_request' && steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/rebase
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: integration-test
+
+ - name: Install bitcoind
+ if: steps.check-label.outputs.skip != 'true'
+ run: ./scripts/install_bitcoind.sh $BITCOIN_VERSION
+
+ - name: Run ${{ matrix.name }}
+ if: steps.check-label.outputs.skip != 'true'
+ run: make itest-parallel tranches=${{ env.SMALL_TRANCHES }} ${{ matrix.args }} shuffleseed=${{ github.run_id }}${{ strategy.job-index }}
+
+ - name: Clean coverage
+ run: grep -Ev '(\.pb\.go|\.pb\.json\.go|\.pb\.gw\.go)' coverage.txt > coverage-norpc.txt
+ if: ${{ contains(matrix.args, 'cover=1') && steps.check-label.outputs.skip != 'true' }}
+
+ - name: Send coverage
+ if: ${{ contains(matrix.args, 'cover=1') && steps.check-label.outputs.skip != 'true' }}
+ continue-on-error: true
+ uses: coverallsapp/github-action@v2
+ with:
+ file: coverage-norpc.txt
+ flag-name: 'itest-${{ matrix.name }}'
+ format: 'golang'
+ parallel: true
+
+ - name: Zip log files on failure
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ timeout-minutes: 5 # timeout after 5 minute
+ run: 7z a logs-itest-${{ matrix.name }}.zip itest/**/*.log
+
+ - name: Upload log files on failure
+ uses: actions/upload-artifact@v4
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ with:
+ name: logs-itest-${{ matrix.name }}
+ path: logs-itest-${{ matrix.name }}.zip
+ retention-days: 5
+
+
+ ########################
+ # run windows integration test
+ ########################
+ windows-integration-test:
+ name: Run windows itest
+ runs-on: windows-latest
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Check for no-itest label
+ id: check-label
+ uses: ./.github/actions/check-label
+ with:
+ label: 'no-itest'
+ skip-message: "Tests auto-passed due to 'no-itest' label"
+
+ - name: Fetch and rebase on ${{ github.base_ref }}
+ if: github.event_name == 'pull_request' && steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/rebase
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: integration-test
+
+ - name: Run itest
+ if: steps.check-label.outputs.skip != 'true'
+ run: make itest-parallel tranches=${{ env.SMALL_TRANCHES }} windows=1 shuffleseed=${{ github.run_id }}
+
+ - name: Kill any remaining lnd processes
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ shell: powershell
+ run: taskkill /IM lnd-itest.exe /T /F
+
+ - name: Zip log files on failure
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ timeout-minutes: 5 # timeout after 5 minute
+ run: 7z a logs-itest-windows.zip itest/**/*.log
+
+ - name: Upload log files on failure
+ uses: actions/upload-artifact@v4
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ with:
+ name: logs-itest-windows
+ path: logs-itest-windows.zip
+ retention-days: 5
+
+ ########################
+ # run macOS integration test
+ ########################
+ macos-integration-test:
+ name: Run macOS itest
+ runs-on: macos-14
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: Check for no-itest label
+ id: check-label
+ uses: ./.github/actions/check-label
+ with:
+ label: 'no-itest'
+ skip-message: "Tests auto-passed due to 'no-itest' label"
+
+ - name: Fetch and rebase on ${{ github.base_ref }}
+ if: github.event_name == 'pull_request' && steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/rebase
+
+ - name: Setup go ${{ env.GO_VERSION }}
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/setup-go
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ key-prefix: integration-test
+
+ - name: Run itest
+ if: steps.check-label.outputs.skip != 'true'
+ run: make itest-parallel tranches=${{ env.SMALL_TRANCHES }} shuffleseed=${{ github.run_id }}
+
+ - name: Zip log files on failure
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ timeout-minutes: 5 # timeout after 5 minute
+ run: 7z a logs-itest-macos.zip itest/**/*.log
+
+ - name: Upload log files on failure
+ uses: actions/upload-artifact@v4
+ if: ${{ failure() && steps.check-label.outputs.skip != 'true' }}
+ with:
+ name: logs-itest-macos
+ path: logs-itest-macos.zip
+ retention-days: 5
+
+ ########################
+ # check pinned dependencies
+ ########################
+ dep-pin:
+ name: Check pinned dependencies
+ runs-on: ubuntu-latest
+ strategy:
+ # Allow other tests in the matrix to continue if one fails.
+ fail-fast: false
+ matrix:
+ pinned_dep:
+ - google.golang.org/grpc v1.59.0
+ - github.com/golang/protobuf v1.5.4
+
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+
+ - name: Clean up runner space
+ uses: ./.github/actions/cleanup-space
+
+ - name: Ensure dependencies at correct version
+ run: if ! grep -q "${{ matrix.pinned_dep }}" go.mod; then echo dependency ${{ matrix.pinned_dep }} should not be altered ; exit 1 ; fi
+
+ ########################
+ # check PR updates release notes
+ ########################
+ milestone-check:
+ name: Check release notes updated
+ runs-on: ubuntu-latest
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+
+ - name: Check for no-changelog label
+ id: check-label
+ uses: ./.github/actions/check-label
+ with:
+ label: 'no-changelog'
+ skip-message: "Changelog check auto-passed due to 'no-changelog' label"
+
+ - name: Clean up runner space
+ if: steps.check-label.outputs.skip != 'true'
+ uses: ./.github/actions/cleanup-space
+
+ - name: Release notes check
+ if: steps.check-label.outputs.skip != 'true'
+ run: scripts/check-release-notes.sh
+
+ ########################
+ # Backwards Compatibility Test
+ ########################
+ backwards-compatibility-test:
+ name: Backwards compatibility test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Git checkout
+ uses: actions/checkout@v5
+
+ - name: 🐳 Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: 🛡️ Backwards compatibility test
+ run: make backwards-compat-test
+
+ - name: 📋 Upload node logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: bw-compat-logs
+ path: scripts/bw-compatibility-test/logs/
+ retention-days: 7
+
+ #########################################
+ # Auto Cache Cleanup on Pull Requests
+ #########################################
+ auto-cleanup-cache:
+ name: Cache Cleanup
+ runs-on: ubuntu-latest
+
+ # This condition checks for pull requests from authors with write access.
+ if: >-
+ contains('OWNER, MEMBER, COLLABORATOR', github.event.pull_request.author_association)
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Delete caches older than 12 hours
+ continue-on-error: true
+ env:
+ # GITHUB_TOKEN is required for the gh CLI.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ echo "Finding caches not used in the last 12 hours..."
+
+ # Get the current time and the cutoff time (12 hours ago) in Unix
+ # timestamp format.
+ cutoff_timestamp=$(date -d "12 hours ago" +%s)
+
+ # Use gh and jq to parse caches. Delete any cache last accessed
+ # before the cutoff time.
+ gh cache list --json key,lastAccessedAt | jq -r '.[] |
+ select(.lastAccessedAt != null) | "\(.lastAccessedAt) \(.key)"' |
+ while read -r last_accessed_at key; do
+ last_accessed_timestamp=$(date -d "$last_accessed_at" +%s)
+
+ if (( last_accessed_timestamp < cutoff_timestamp )); then
+ echo "Deleting old cache. Key: $key, Last Used: $last_accessed_at"
+ gh cache delete "$key"
+ fi
+ done
+
+ # Notify about the completion of all coverage collecting jobs.
+ finish:
+ name: Send coverage report
+ if: ${{ !cancelled() }}
+ needs: [unit-test, basic-integration-test]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Send coverage
+ uses: coverallsapp/github-action@v2
+ continue-on-error: true
+ with:
+ parallel-finished: true
diff --git a/.github/workflows/pr-severity.yml b/.github/workflows/pr-severity.yml
new file mode 100644
index 0000000..59ca136
--- /dev/null
+++ b/.github/workflows/pr-severity.yml
@@ -0,0 +1,218 @@
+name: PR Severity Classification
+
+on:
+ # Use pull_request_target to allow running on fork PRs with access to secrets.
+ # This is safe because we don't checkout or execute any code from the PR -
+ # we only read PR metadata (changed files, labels) via the GitHub API.
+ pull_request_target:
+ types: [opened, synchronize, labeled]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: pr-severity-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ classify:
+ name: Classify PR Severity
+ runs-on: ubuntu-latest
+ # Skip if PR has skip-severity-check label.
+ # For labeled events, only run if 'reclassify' label was added.
+ if: |
+ !contains(github.event.pull_request.labels.*.name, 'skip-severity-check') &&
+ (github.event.action != 'labeled' || github.event.label.name == 'reclassify')
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Classify PR with Claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ github_token: ${{ secrets.PR_SEVERITY_BOT_TOKEN }}
+
+ # Allow any user since this workflow only reads PR metadata via API
+ # and doesn't execute any code from the PR. Tool permissions are
+ # restricted to gh pr commands only.
+ allowed_non_write_users: "*"
+
+ # Allow Claude to manage labels and post comments.
+ # Keep permissions minimal to limit prompt injection risk.
+ claude_args: --allowedTools "Bash(gh pr view:*)" "Bash(gh pr edit:*)" "Bash(gh pr comment:*)"
+
+ prompt: |
+ You are a PR severity classifier for the lnd (Lightning Network Daemon) repository.
+
+ ## Tool Constraints
+
+ You ONLY have access to these commands:
+ - `gh pr view` - to read PR metadata
+ - `gh pr edit` - to add/remove labels
+ - `gh pr comment` - to post comments
+
+ You do NOT have access to `gh api`, `gh label`, or any other
+ `gh` subcommand. Do not attempt to use them. For ALL label
+ operations, use `gh pr edit` with `--add-label` or
+ `--remove-label`.
+
+ ## Your Task
+
+ Analyze PR #${{ github.event.pull_request.number }} and:
+ 1. Determine its severity level based on the files changed
+ 2. Apply the appropriate severity label
+ 3. Post a detailed comment explaining your determination
+
+ ## Severity Levels
+
+ **CRITICAL** (severity-critical) - Requires expert review:
+ - lnwallet/* - Wallet operations, channel funding, signing, commitment transactions
+ - htlcswitch/* - HTLC forwarding, payment routing state machine
+ - contractcourt/* - On-chain dispute resolution, breach handling
+ - sweep/* - Output sweeping, fund recovery, fee bumping
+ - peer/*, brontide/* - Encrypted peer connections, Noise protocol
+ - keychain/* - Private key derivation and management
+ - input/* - Script signing, witness generation, MuSig2
+ - channeldb/* - Channel state persistence, database migrations
+ - funding/* - Channel funding workflow coordination
+ - lnwire/* - Lightning wire protocol messages
+ - server.go, rpcserver.go - Core server coordination
+
+ **HIGH** (severity-high) - Requires knowledgeable engineer:
+ - routing/* - Payment pathfinding algorithms
+ - invoices/* - Invoice management and settlement
+ - discovery/* - Gossip protocol
+ - graph/* - Network graph maintenance
+ - watchtower/* - Breach remediation
+ - feature/* - Feature bit management
+ - lnrpc/* - RPC/API definitions
+ - macaroons/*, walletunlocker/*, cert/* - Auth/security
+ - chainntnfs/*, chanacceptor/*, protofsm/*, sqldb/*
+
+ **MEDIUM** (severity-medium) - Focused review:
+ - cmd/* - CLI client commands (do NOT inherit severity from server-side packages with similar names)
+ - payments/*, autopilot/*, lncfg/*, chanfitness/*
+ - netann/*, kvdb/*, chanbackup/*, aezeed/*, tor/*
+ - zpay32/*, tlv/*, fn/*, record/*, amp/*
+ - *.proto files (API changes)
+ - Other Go files not categorized above
+
+ **LOW** (severity-low) - Best-effort review:
+ - docs/*, release-notes/*, *.md files
+ - scripts/*, tools/*, contrib/*, make/*, docker/*
+ - itest/*, lntest/*, *_test.go (test-only changes)
+ - .github/* (CI/CD configuration)
+
+ ## Classification Rules
+
+ 1. The HIGHEST severity file determines the PR severity
+ 2. Classify files by their actual package path, NOT by filename keywords.
+ Files under cmd/* are CLI client code and should always be MEDIUM,
+ even if the filename contains a server-side package name (e.g.
+ cmd/commands/cmd_walletunlocker.go is MEDIUM, not HIGH).
+ 3. Bump severity UP one level if:
+ - PR touches >20 files (excluding tests and auto-generated files)
+ - PR has >500 lines changed (excluding tests and auto-generated files)
+ - PR touches multiple distinct critical packages
+ 4. Check for override labels first (severity-override-*). If present, respect the override.
+ 5. Database migrations (channeldb/migration*, sqldb/*, wtdb/*) are always CRITICAL
+
+ ## Files to Exclude from Line/File Counting
+ When calculating file count and lines changed for severity bumps, exclude:
+ - Test files: *_test.go, itest/*, lntest/*
+ - Auto-generated files: *.pb.go, *.pb.gw.go, *.pb.json.go, *.sql.go, *_generated.go
+ - Mock files: mock_*.go, *_mock.go
+
+ ## Steps
+
+ 1. First, check for existing override labels AND existing severity labels:
+ ```
+ gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name'
+ ```
+ Note which `severity-*` label (if any) is currently applied. This is
+ the "previous severity".
+
+ 2. If an override label exists (severity-override-*), use that level and skip classification.
+
+ 3. Check for existing bot comments. Look for the HTML marker `<!-- pr-severity-bot -->`:
+ ```
+ gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[].body' | grep -c 'pr-severity-bot' || true
+ ```
+ This tells you whether the bot has commented before.
+
+ 4. Get the list of changed files:
+ ```
+ gh pr view ${{ github.event.pull_request.number }} --json files,additions,deletions
+ ```
+
+ 5. Classify each file and determine the new overall severity.
+
+ 6. **Decide whether to comment.** Only post a comment if EITHER:
+ - The bot has NOT commented before (no existing comment with `<!-- pr-severity-bot -->`), OR
+ - The newly determined severity is DIFFERENT from the previous severity label.
+
+ If the bot already commented AND the severity has NOT changed, just
+ stop here — do NOT post another comment. Still update the label if
+ needed (step 7-8), but skip the comment.
+
+ 7. Remove any existing severity-* labels (not override labels):
+ ```
+ gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-critical" 2>/dev/null || true
+ gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-high" 2>/dev/null || true
+ gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-medium" 2>/dev/null || true
+ gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-low" 2>/dev/null || true
+ ```
+
+ 8. Apply the new severity label:
+ ```
+ gh pr edit ${{ github.event.pull_request.number }} --add-label "severity-<level>"
+ ```
+
+ 9. If you determined in step 6 that a comment should be posted, post it
+ with your analysis. Use this format:
+
+ If this is a severity CHANGE (previous label existed but differs),
+ prepend: `> ⚠️ Severity changed: **<OLD>** → **<NEW>** (files changed since last classification)`
+
+ ```markdown
+ ## <emoji> PR Severity: **<LEVEL>**
+
+ > <source> | <N> files | <M> lines changed
+
+ <details>
+ <summary>🔴 <strong>Critical</strong> (N files)</summary>
+
+ - `path/to/file1.go` - reason
+ - `path/to/file2.go` - reason
+
+ </details>
+
+ [repeat for other tiers if applicable]
+
+ ### Analysis
+
+ <Your explanation of why this severity was chosen, any concerns, etc.>
+
+ ---
+ <sub>To override, add a `severity-override-{critical,high,medium,low}` label.</sub>
+ <!-- pr-severity-bot -->
+ ```
+
+ 10. Post the comment using `gh pr comment`:
+ ```
+ gh pr comment ${{ github.event.pull_request.number }} --body "YOUR_COMMENT_HERE"
+ ```
+
+ 11. If you decided in step 6 to SKIP commenting, do NOT post any comment.
+ Just ensure the label is correct and exit.
+
+ ## Emoji Mapping
+ - critical: 🔴
+ - high: 🟠
+ - medium: 🟡
+ - low: 🟢
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..012a716
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,153 @@
+name: Release build
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ # If you change this please also update GO_VERSION in Makefile (then run
+ # `make lint` to see where else it needs to be updated as well).
+ GO_VERSION: 1.25.5
+
+jobs:
+ ########################
+ # Create release
+ ########################
+ main:
+ name: Release build
+ runs-on: ubuntu-latest
+ steps:
+ - name: git checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: cleanup space
+ uses: ./.github/actions/cleanup-space
+
+ - name: setup go ${{ env.GO_VERSION }}
+ uses: actions/setup-go@v5
+ with:
+ go-version: '${{ env.GO_VERSION }}'
+ cache: 'false'
+
+ - name: Set env
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+
+ - name: build release for all architectures
+ run: SKIP_VERSION_CHECK=1 make release tag=${{ env.RELEASE_VERSION }}
+
+ - name: Create Release
+ uses: lightninglabs/gh-actions/action-gh-release@c7149b6a7818d1c39b36b69e727569897b6f2c5a
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ name: lnd ${{ env.RELEASE_VERSION }}
+ draft: true
+ prerelease: false
+ files: lnd-${{ env.RELEASE_VERSION }}/*
+ body: |
+ # Database Migrations
+ TODO
+
+ # Verifying the Release
+
+ In order to verify the release, you'll need to have `gpg` or `gpg2` installed on your system. Once you've obtained a copy (and hopefully verified that as well), you'll first need to import the keys that have signed this release if you haven't done so already:
+
+ ```
+ curl https://raw.githubusercontent.com/lightningnetwork/lnd/master/scripts/keys/roasbeef.asc | gpg --import
+ ```
+
+ Once you have the required PGP keys, you can verify the release (assuming `manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig` and `manifest-${{ env.RELEASE_VERSION }}.txt` are in the current directory) with:
+
+ ```
+ gpg --verify manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig manifest-${{ env.RELEASE_VERSION }}.txt
+ ```
+
+ You should see the following if the verification was successful:
+
+ ```
+ gpg: Signature made Wed Sep 30 17:35:20 2020 PDT
+ gpg: using RSA key 60A1FA7DA5BFF08BDCBBE7903BBD59E99B280306
+ gpg: Good signature from "Olaoluwa Osuntokun <laolu32@gmail.com>" [ultimate]
+ ```
+
+ That will verify the signature of the manifest file, which ensures integrity and authenticity of the archive you've downloaded locally containing the binaries. Next, depending on your operating system, you should then re-compute the `sha256` hash of the archive with `shasum -a 256 <filename>`, compare it with the corresponding one in the manifest file, and ensure they match *exactly*.
+
+ ## Verifying the Release Timestamp
+
+ From this new version onwards, in addition time-stamping the _git tag_ with [OpenTimestamps](https://opentimestamps.org/), we'll also now timestamp the manifest file along with its signature. Two new files are now included along with the rest of our release artifacts: ` manifest-roasbeef-${{ env.RELEASE_VERSION }}.txt.asc.ots`.
+
+ Assuming you have the opentimestamps client installed locally, the timestamps can be verified with the following commands:
+ ```
+ ots verify manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig.ots -f manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig
+ ```
+
+ Alternatively, [the OpenTimestamps website](https://opentimestamps.org/) can be used to verify timestamps if one doesn't have a `bitcoind` instance accessible locally.
+
+ These timestamps should give users confidence in the integrity of this release even after the key that signed the release expires.
+
+ ## Verifying the Release Binaries
+
+ Our release binaries are fully reproducible. Third parties are able to verify that the release binaries were produced properly without having to trust the release manager(s). See our [reproducible builds guide](https://github.com/lightningnetwork/lnd/blob/master/docs/release.md) for how this can be achieved.
+ The release binaries are compiled with `go${{ env.GO_VERSION }}`, which is required by verifiers to arrive at the same ones.
+ They include the following build tags: `autopilotrpc`, `signrpc`, `walletrpc`, `chainrpc`, `invoicesrpc`, `neutrinorpc`, `routerrpc`, `watchtowerrpc`, `monitoring`, `peersrpc`, `kvdb_postrgres`, `kvdb_etcd` and `kvdb_sqlite`. Note that these are already included in the release script, so they do not need to be provided.
+
+ The `make release` command can be used to ensure one rebuilds with all the same flags used for the release. If one wishes to build for only a single platform, then `make release sys=<OS-ARCH> tag=<tag>` can be used.
+
+ Finally, you can also verify the _tag_ itself with the following command:
+
+ ```
+ $ git verify-tag ${{ env.RELEASE_VERSION }}
+ gpg: Signature made Tue Sep 15 18:55:00 2020 PDT
+ gpg: using RSA key 60A1FA7DA5BFF08BDCBBE7903BBD59E99B280306
+ gpg: Good signature from "Olaoluwa Osuntokun <laolu32@gmail.com>" [ultimate]
+ ```
+
+ ## Verifying the Docker Images
+
+ To verify the `lnd` and `lncli` binaries inside the docker images against the signed, reproducible release binaries, there is a verification script in the image that can be called (before starting the container for example):
+
+ ```shell
+ $ docker run --rm --entrypoint="" lightninglabs/lnd:${{ env.RELEASE_VERSION }} /verify-install.sh ${{ env.RELEASE_VERSION }}
+ $ OK=$?
+ $ if [ "$OK" -ne "0" ]; then echo "Verification failed!"; exit 1; done
+ $ docker run lightninglabs/lnd [command-line options]
+ ```
+
+ # Building the Contained Release
+
+ Users are able to rebuild the target release themselves without having to fetch any of the dependencies. In order to do so, assuming
+ that `vendor.tar.gz` and `lnd-source-${{ env.RELEASE_VERSION }}.tar.gz` are in the current directory, follow these steps:
+
+ ```
+ tar -xvzf lnd-source-${{ env.RELEASE_VERSION }}.tar.gz
+ mv vendor.tar.gz lnd-source/
+ cd lnd-source
+ tar -xvzf vendor.tar.gz
+ go install -v -mod=vendor -ldflags "-X github.com/lightningnetwork/lnd/build.Commit=${{ env.RELEASE_VERSION }}" ./cmd/lnd
+ go install -v -mod=vendor -ldflags "-X github.com/lightningnetwork/lnd/build.Commit=${{ env.RELEASE_VERSION }}" ./cmd/lncli
+ ```
+
+ The `-mod=vendor` flag tells the `go build` command that it doesn't need to fetch the dependencies, and instead, they're all enclosed in the local vendor directory.
+
+ Additionally, it's now possible to use the [enclosed `release.sh` script to bundle a release for a _specific_ system like so](https://github.com/lightningnetwork/lnd/pull/2191):
+
+ ```
+ make release sys="linux-arm64 darwin-amd64"
+ ```
+
+ ⚡️⚡️⚡️ OK, now to the rest of the release notes! ⚡️⚡️⚡️
+
+ # Release Notes
+
+ TODO
+
+ # Contributors (Alphabetical Order)
+
+ TODO
diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml
new file mode 100644
index 0000000..62b79f9
--- /dev/null
+++ b/.github/workflows/stats.yml
@@ -0,0 +1,27 @@
+name: Pull Request Stats
+
+on:
+ pull_request:
+ types: [opened]
+
+permissions:
+ # Required to post stats as comments.
+ actions: write
+ # Default permission for checking out code.
+ contents: read
+
+jobs:
+ stats:
+ runs-on: ubuntu-latest
+
+ # Check if the PR is from the base repo (not a fork). Only the
+ # collaborators have the permission to create a side branch from the base
+ # repo, so this implicitly restricts who can run this job.
+ if: github.event.pull_request.head.repo.fork == false
+
+ steps:
+ - name: Run pull request stats
+ uses: flowwer-dev/pull-request-stats@v2.11.0
+ with:
+ period: 30 # 30 days of review stats
+ charts: true
diff --git a/.github/workflows/verify-release.yaml b/.github/workflows/verify-release.yaml
new file mode 100644
index 0000000..a9aaa26
--- /dev/null
+++ b/.github/workflows/verify-release.yaml
@@ -0,0 +1,36 @@
+name: Verify release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Release version tag (e.g. v0.20.1-beta)'
+ required: true
+
+permissions:
+ contents: write
+
+jobs:
+ verify-release:
+ name: Verify release signatures and binaries
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify release
+ env:
+ VERSION: ${{ inputs.version || github.event.release.tag_name }}
+ run: |
+ docker run --rm --entrypoint="" \
+ lightninglabs/lnd:${VERSION} \
+ /verify-install.sh ${VERSION}
+
+ - name: Set release back to draft on failure
+ if: failure()
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ inputs.version || github.event.release.tag_name }}
+ run: |
+ gh release edit ${VERSION} \
+ --repo ${{ github.repository }} \
+ --draft
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..11c67fe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,87 @@
+# ---> Go
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
+
+/lnd
+/lnd-debug
+/lncli
+/lncli-debug
+/lnd-itest
+/lncli-itest
+
+# Integration test log files
+itest/*.log
+itest/.backendlogs
+itest/.minerlogs
+itest/lnd-itest
+itest/btcd-itest
+itest/.logs-*
+itest/cover
+
+cmd/cmd
+*.key
+*.hex
+
+# Ignore the custom linter binary if it is built.
+custom-gcl
+
+cmd/lncli/lncli
+
+# Files from mobile build.
+mobile/build
+mobile/*_generated.go
+
+# vim
+*.swp
+
+*.hex
+*.db
+*.bin
+
+vendor
+*.idea
+*.iml
+profile.cov
+profile.tmp
+
+.DS_Store
+
+.vscode
+*.code-workspace
+
+# Coverage test
+coverage.txt
+
+# Visual Studio cache/options directory
+.vs/
+
+# Release build directory (to avoid build.vcs.modified Golang build tag to be
+# set to true by having untracked files in the working directory).
+/lnd-*/
+
+.aider*
+
+# All test data generated from rapid.
+*/testdata
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..4cb8939
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,336 @@
+version: "2"
+
+run:
+ # If you change this please also update GO_VERSION in Makefile (then run
+ # `make lint` to see where else it needs to be updated as well).
+ go: "1.25.5"
+
+ # Abort after 10 minutes.
+ timeout: 10m
+
+ build-tags:
+ - autopilotrpc
+ - chainrpc
+ - dev
+ - invoicesrpc
+ - neutrinorpc
+ - peersrpc
+ - signrpc
+ - walletrpc
+ - watchtowerrpc
+ - kvdb_etcd
+ - kvdb_postgres
+ - kvdb_sqlite
+ - integration
+
+linters:
+ default: all
+ disable:
+ # We instead use our own custom line length linter called `ll` since
+ # then we can ignore log lines.
+ - lll
+
+ # Global variables are used in many places throughout the code base.
+ - gochecknoglobals
+
+ # We want to allow short variable names.
+ - varnamelen
+
+ # We want to allow TODOs.
+ - godox
+
+ # Instances of table driven tests that don't pre-allocate shouldn't trigger
+ # the linter.
+ - prealloc
+
+ # Init functions are used by loggers throughout the codebase.
+ - gochecknoinits
+
+ # Deprecated linters. See https://golangci-lint.run/usage/linters/.
+ - bodyclose
+ - contextcheck
+ - nilerr
+ - noctx
+ - rowserrcheck
+ - sqlclosecheck
+ - tparallel
+ - unparam
+ - wastedassign
+
+ # Disable whitespace linters as it has conflict rules against our
+ # contribution guidelines.
+ - wsl
+ - wsl_v5
+
+ # Allow using default empty values.
+ - exhaustruct
+
+ # Allow exiting case select faster by putting everything in default.
+ - exhaustive
+
+ # Allow tests to be put in the same package.
+ - testpackage
+
+ # Don't run the cognitive related linters.
+ - gocognit
+ - gocyclo
+ - maintidx
+ - cyclop
+
+ # Allow customized interfaces to be returned from functions.
+ - ireturn
+
+ # Disable too many blank identifiers check. We won't be able to run this
+ # unless a large refactor has been applied to old code.
+ - dogsled
+
+ # We don't wrap errors.
+ - wrapcheck
+
+ # Allow dynamic errors.
+ - err113
+
+ # We use ErrXXX instead.
+ - errname
+
+ # Disable nil check to allow returning multiple nil values.
+ - nilnil
+
+ # We often split tests into separate test functions. If we are forced to
+ # call t.Helper() within those functions, we lose the information where
+ # exactly a test failed in the generated failure stack trace.
+ - thelper
+
+ # The linter is too aggressive and doesn't add much value since reviewers
+ # will also catch magic numbers that make sense to extract.
+ - mnd
+
+ # Some of the tests cannot be parallelized. On the other hand, we don't
+ # gain much performance with this check so we disable it for now until
+ # unit tests become our CI bottleneck.
+ - paralleltest
+
+ # New linters that we haven't had time to address yet.
+ - testifylint
+ - perfsprint
+ - inamedparam
+ - copyloopvar
+ - tagalign
+ - protogetter
+ - revive
+ - depguard
+ - gosmopolitan
+ - intrange
+ - goconst
+
+ # Disable function order linter because we structure exported and unexported
+ # functions differently.
+ - funcorder
+
+ # Disable noinlineerr linter because we use it to inline errors.
+ - noinlineerr
+
+ # Disable embeddedstructfieldcheck linter because we use it to align
+ # structs. Because sometimes we have atomic fields that need to be aligned
+ # with means we need to assure that the field is at the beginning of the
+ # struct.
+ - embeddedstructfieldcheck
+
+
+ settings:
+ dupl:
+ # Tokens count to trigger issue.
+ threshold: 200
+
+ errorlint:
+ # Check for incorrect fmt.Errorf error wrapping.
+ errorf: true
+
+ funlen:
+ # Checks the number of lines in a function.
+ # If lower than 0, disable the check.
+ lines: 200
+ # Checks the number of statements in a function.
+ statements: 80
+
+ gomoddirectives:
+ # See project's go.mod for the explanation why these are needed.
+ replace-allow-list:
+ - github.com/ulikunitz/xz
+ - github.com/gogo/protobuf
+ - google.golang.org/protobuf
+ - github.com/lightningnetwork/lnd/sqldb
+ replace-local: true
+
+ gosec:
+ excludes:
+ - G402 # Look for bad TLS connection settings.
+ - G306 # Poor file permissions used when writing to a new file.
+ - G601 # Implicit memory aliasing in for loop.
+ - G115 # Integer overflow in conversion.
+
+ nestif:
+ # Minimal complexity of if statements to report.
+ min-complexity: 10
+
+ nlreturn:
+ # Size of the block (including return statement that is still "OK")
+ # so no return split required.
+ block-size: 3
+
+ staticcheck:
+ checks:
+ - -SA1019
+
+ tagliatelle:
+ case:
+ rules:
+ json: snake
+
+ usetesting:
+ context-background: true
+
+ whitespace:
+ multi-if: true
+ multi-func: true
+
+ custom:
+ ll:
+ type: module
+ description: Custom lll linter with 'S' log line exclusion.
+ settings:
+ # Max line length, lines longer will be reported.
+ line-length: 80
+ # The regex that we will use to detect the start of an `S` log line.
+ log-regex: ^\s*.*(L|l)og\.(Info|Debug|Trace|Warn|Error|Critical)S\(
+ # Tab width in spaces.
+ tab-width: 8
+
+ exclusions:
+ # Mode of the generated files analysis.
+ #
+ # - `strict`: sources are excluded by strictly following the Go generated file convention.
+ # Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
+ # This line must appear before the first non-comment, non-blank text in the file.
+ # https://go.dev/s/generatedcode
+ # - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
+ # - `disable`: disable the generated files exclusion.
+ #
+ # Default: strict
+ generated: lax
+
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+
+ rules:
+ - linters:
+ # Allow duplications in tests so it's easier to follow a single unit
+ - dupl
+ - funlen
+ - gosec
+ - revive
+ # Exclude gosec from running for tests so that tests with weak
+ # randomness (math/rand) will pass the linter.
+ path: _test\.go
+
+ - linters:
+ # forcetypeassert is skipped for the mock because the test would fail
+ # if the returned value doesn't match the type, so there's no need to
+ # check the convert.
+ - forcetypeassert
+ - revive
+ path: mock*
+
+ - linters:
+ - funlen
+ - gosec
+ path: test*
+
+ # Allow duplicated code and fmt.Printf() in DB migrations.
+ - linters:
+ - dupl
+ - forbidigo
+ - godot
+ path: channeldb/migration*
+
+ # Allow duplicated code and fmt.Printf() in DB migration tests.
+ - linters:
+ - dupl
+ - forbidigo
+ - godot
+ path: channeldb/migtest
+
+ # Allow fmt.Printf() in commands.
+ - linters:
+ - forbidigo
+ path: cmd/commands/*
+
+ # Allow fmt.Printf() in config parsing.
+ - linters:
+ - forbidigo
+ path: config\.go
+ - linters:
+ - forbidigo
+ path: lnd\.go
+
+ - linters:
+ # forcetypeassert is skipped for the mock because the test would fail
+ # if the returned value doesn't match the type, so there's no need to
+ # check the convert.
+ - forcetypeassert
+ path: lnmock/*
+
+ - linters:
+ # forcetypeassert is skipped for the mock because the test would fail
+ # if the returned value doesn't match the type, so there's no need to
+ # check the convert.
+ - forcetypeassert
+ path: mock*
+
+ # Skip autogenerated files for mobile and gRPC as well as copied code for
+ # internal use.
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+ - "mobile\\/.*generated\\.go"
+ - "\\.pb\\.go$"
+ - "\\.pb\\.gw\\.go$"
+ - "internal\\/musig2v040"
+ - channeldb/migration_01_to_11
+ - channeldb/migration/lnwire21
+ - payments/db/migration1/lnwire
+ - payments/db/migration1/record
+
+issues:
+ # Only show newly introduced problems.
+ new-from-rev: 03eab4db64540aa5f789c617793e4459f4ba9e78
+
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - goimports
+
+ settings:
+ gofmt:
+ # simplify code: gofmt with `-s` option, true by default
+ simplify: true
+
+ exclusions:
+ generated: lax
+ # Skip autogenerated files for mobile and gRPC as well as copied code for
+ # internal use.
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+ - "mobile\\/.*generated\\.go"
+ - "\\.pb\\.go$"
+ - "\\.pb\\.gw\\.go$"
+ - "internal\\/musig2v040"
+ - channeldb/migration_01_to_11
+ - channeldb/migration/lnwire21
diff --git a/.protolint.yaml b/.protolint.yaml
new file mode 100644
index 0000000..269d74a
--- /dev/null
+++ b/.protolint.yaml
@@ -0,0 +1,70 @@
+# The example configuration file for the protolint is located here:
+# https://github.com/yoheimuta/protolint/blob/master/_example/config/.protolint.yaml
+---
+# Lint directives.
+lint:
+ # Linter rules.
+ # Run `protolint list` to see all available rules.
+ rules:
+ # Determines whether or not to include the default set of linters.
+ no_default: true
+
+ # Set the default to all linters. This option works the other way around as no_default does.
+ # If you want to enable this option, delete the comment out below and no_default.
+ # all_default: true.
+
+ # The specific linters to add.
+ add:
+ - MESSAGE_NAMES_UPPER_CAMEL_CASE
+ - MAX_LINE_LENGTH
+ - INDENT
+ - FILE_NAMES_LOWER_SNAKE_CASE
+ - IMPORTS_SORTED
+ - PACKAGE_NAME_LOWER_CASE
+ - ORDER
+ - SERVICES_HAVE_COMMENT
+ - RPCS_HAVE_COMMENT
+ - PROTO3_FIELDS_AVOID_REQUIRED
+ - PROTO3_GROUPS_AVOID
+ - SYNTAX_CONSISTENT
+ - RPC_NAMES_CASE
+ - QUOTE_CONSISTENT
+
+ # Linter rules option.
+ rules_option:
+ # MAX_LINE_LENGTH rule option.
+ max_line_length:
+ # Enforces a maximum line length.
+ max_chars: 80
+ # Specifies the character count for tab characters.
+ tab_chars: 2
+
+ # INDENT rule option.
+ indent:
+ # Available styles are 4(4-spaces), 2(2-spaces) or tab.
+ style: 4
+ # Specifies if it should stop considering and inserting new lines at the appropriate positions.
+ # when the inner elements are on the same line. Default is false.
+ not_insert_newline: true
+
+ # QUOTE_CONSISTENT rule option.
+ quote_consistent:
+ # Available quote are "double" or "single".
+ quote: double
+
+ # ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH rule option.
+ enum_field_names_zero_value_end_with:
+ suffix: INVALID
+
+ # SERVICE_NAMES_END_WITH rule option.
+ service_names_end_with:
+ text: Service
+
+ # REPEATED_FIELD_NAMES_PLURALIZED rule option.
+ ## The spec for each rules follows the implementation of https://github.com/gertd/go-pluralize.
+ ## Plus, you can refer to this rule's test code.
+ repeated_field_names_pluralized:
+ uncountable_rules:
+ - paper
+ irregular_rules:
+ Irregular: Regular
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..84e81e0
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "editor.tabSize": 8,
+ "editor.rulers": [
+ 80
+ ],
+ "go.buildTags": "autopilotrpc chainrpc dev invoicesrpc neutrinorpc peersrpc signrpc walletrpc watchtowerrpc"
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9cbe354
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,55 @@
+# If you change this please also update GO_VERSION in Makefile (then run
+# `make lint` to see where else it needs to be updated as well).
+FROM golang:1.25.5-alpine as builder
+
+# Force Go to use the cgo based DNS resolver. This is required to ensure DNS
+# queries required to connect to linked containers succeed.
+ENV GODEBUG netdns=cgo
+
+# Pass a tag, branch or a commit using build-arg. This allows a docker
+# image to be built from a specified Git state. The default image
+# will use the Git tip of master by default.
+ARG checkout="master"
+ARG git_url="https://github.com/lightningnetwork/lnd"
+
+# Install dependencies and build the binaries.
+RUN apk add --no-cache --update alpine-sdk \
+ git \
+ make \
+ gcc \
+&& git clone $git_url /go/src/github.com/lightningnetwork/lnd \
+&& cd /go/src/github.com/lightningnetwork/lnd \
+&& git checkout $checkout \
+&& make release-install
+
+# Start a new, final image.
+FROM alpine as final
+
+# Define a root volume for data persistence.
+VOLUME /root/.lnd
+
+# Add utilities for quality of life and SSL-related reasons. We also require
+# wget and gpg for the signature verification script.
+RUN apk --no-cache add \
+ bash \
+ jq \
+ ca-certificates \
+ gnupg \
+ wget
+
+# Copy the binaries from the builder image.
+COPY --from=builder /go/bin/lncli /bin/
+COPY --from=builder /go/bin/lnd /bin/
+COPY --from=builder /go/src/github.com/lightningnetwork/lnd/scripts/verify-install.sh /
+COPY --from=builder /go/src/github.com/lightningnetwork/lnd/scripts/keys/* /keys/
+
+# Store the SHA256 hash of the binaries that were just produced for later
+# verification.
+RUN sha256sum /bin/lnd /bin/lncli > /shasums.txt \
+ && cat /shasums.txt
+
+# Expose lnd ports (p2p, rpc).
+EXPOSE 9735 10009
+
+# Specify the start command and entrypoint as the lnd daemon.
+ENTRYPOINT ["lnd"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..cfab3da
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2015-2022 Lightning Labs and The Lightning Network Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7df5941
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,538 @@
+PKG := github.com/lightningnetwork/lnd
+MOBILE_PKG := $(PKG)/mobile
+TOOLS_DIR := tools
+TOOLS_MOD := $(TOOLS_DIR)/go.mod
+
+GOCC ?= go
+PREFIX ?= /usr/local
+
+GOTOOL := GOWORK=off $(GOCC) tool -modfile=$(TOOLS_MOD)
+
+
+BTCD_PKG := github.com/btcsuite/btcd
+GOIMPORTS_PKG := github.com/rinchsan/gosimports/cmd/gosimports
+GOLINT_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint
+
+GO_BIN := ${GOPATH}/bin
+BTCD_BIN := $(GO_BIN)/btcd
+GOMOBILE_BIN := $(GO_BIN)/gomobile
+
+MOBILE_BUILD_DIR :=${GOPATH}/src/$(MOBILE_PKG)/build
+IOS_BUILD_DIR := $(MOBILE_BUILD_DIR)/ios
+IOS_BUILD := $(IOS_BUILD_DIR)/Lndmobile.xcframework
+ANDROID_BUILD_DIR := $(MOBILE_BUILD_DIR)/android
+ANDROID_BUILD := $(ANDROID_BUILD_DIR)/Lndmobile.aar
+
+COMMIT := $(shell git describe --tags --dirty)
+
+# Determine the minor version of the active Go installation.
+ACTIVE_GO_VERSION := $(shell $(GOCC) version | sed -nre 's/^[^0-9]*(([0-9]+\.)*[0-9]+).*/\1/p')
+ACTIVE_GO_VERSION_MINOR := $(shell echo $(ACTIVE_GO_VERSION) | cut -d. -f2)
+
+# GO_VERSION is the Go version used for the release build, docker files, and
+# GitHub Actions. This is the reference version for the project. All other Go
+# versions are checked against this version.
+GO_VERSION = 1.25.5
+
+GOBUILD := $(GOCC) build -v
+GOINSTALL := $(GOCC) install -v
+GOTEST := $(GOCC) test
+
+GOFILES_NOVENDOR = $(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -name "*pb.go" -not -name "*pb.gw.go" -not -name "*.pb.json.go")
+
+RM := rm -f
+CP := cp
+MAKE := make
+XARGS := xargs -L 1
+
+include make/testing_flags.mk
+include make/release_flags.mk
+include make/fuzz_flags.mk
+
+DEV_TAGS := $(if ${tags},$(DEV_TAGS) ${tags},$(DEV_TAGS))
+
+# We only return the part inside the double quote here to avoid escape issues
+# when calling the external release script. The second parameter can be used to
+# add additional ldflags if needed (currently only used for the release).
+make_ldflags = $(1) -X $(PKG)/build.Commit=$(COMMIT)
+
+DEV_GCFLAGS := -gcflags "all=-N -l"
+DEV_LDFLAGS := -ldflags "$(call make_ldflags)"
+# For the release, we want to remove the symbol table and debug information (-s)
+# and omit the DWARF symbol table (-w). Also we clear the build ID.
+RELEASE_LDFLAGS := $(call make_ldflags, -s -w -buildid=)
+
+# Linting uses a lot of memory, so keep it under control by limiting the number
+# of workers if requested.
+ifneq ($(workers),)
+LINT_WORKERS = --concurrency=$(workers)
+endif
+
+# Docker cache mounting strategy:
+# - CI (GitHub Actions): Use bind mounts to host paths that GA caches persist.
+# - Local: Use Docker named volumes (much faster on macOS/Windows due to
+# avoiding slow host-syncing overhead).
+# Paths inside container must match GOCACHE/GOMODCACHE in tools/Dockerfile.
+ifdef CI
+# CI mode: bind mount to host paths that GitHub Actions caches.
+DOCKER_TOOLS_BASE = docker run \
+ --rm \
+ -v $${HOME}/.cache/go-build:/tmp/build/.cache \
+ -v $${HOME}/go/pkg/mod:/tmp/build/.modcache \
+ -v $${HOME}/.cache/golangci-lint:/root/.cache/golangci-lint \
+ -v $$(pwd):/build
+DOCKER_TOOLS = $(DOCKER_TOOLS_BASE) lnd-tools
+DOCKER_TOOLS_LINT = $(DOCKER_TOOLS)
+else
+# Local mode: Docker named volumes for fast macOS/Windows performance.
+# Detect if we're in a git worktree. Use git rev-parse --git-common-dir to get
+# the path to the main git directory for the linter's diff processor to work
+# correctly with the new-from-rev setting.
+GIT_COMMON_DIR := $(shell \
+ common_dir="$$(git rev-parse --git-common-dir 2>/dev/null)"; \
+ if [ "$$common_dir" != ".git" ] && [ -n "$$common_dir" ]; then \
+ echo "$$common_dir"; \
+ fi)
+GIT_VOLUME := $(if $(GIT_COMMON_DIR),-v "$(GIT_COMMON_DIR):$(GIT_COMMON_DIR):ro",)
+DOCKER_TOOLS_BASE = docker run \
+ --rm \
+ -v lnd-go-build-cache:/tmp/build/.cache \
+ -v lnd-go-mod-cache:/tmp/build/.modcache \
+ -v lnd-go-lint-cache:/root/.cache/golangci-lint \
+ -v $$(pwd):/build
+DOCKER_TOOLS = $(DOCKER_TOOLS_BASE) lnd-tools
+DOCKER_TOOLS_LINT = $(DOCKER_TOOLS_BASE) $(GIT_VOLUME) lnd-tools
+endif
+
+GREEN := "\\033[0;32m"
+NC := "\\033[0m"
+define print
+ echo $(GREEN)$1$(NC)
+endef
+
+default: scratch
+
+all: scratch check install
+
+# ============
+# DEPENDENCIES
+# ============
+$(BTCD_BIN):
+ @$(call print, "Installing btcd.")
+ cd $(TOOLS_DIR); $(GOCC) install -trimpath $(BTCD_PKG)
+
+# ============
+# INSTALLATION
+# ============
+
+#? build: Build lnd and lncli binaries, place them in project directory
+build:
+ @$(call print, "Building debug lnd and lncli.")
+ $(GOBUILD) -tags="$(DEV_TAGS)" -o lnd-debug $(DEV_GCFLAGS) $(DEV_LDFLAGS) $(PKG)/cmd/lnd
+ $(GOBUILD) -tags="$(DEV_TAGS)" -o lncli-debug $(DEV_GCFLAGS) $(DEV_LDFLAGS) $(PKG)/cmd/lncli
+
+#? build-itest: Build integration test binaries, place them in itest directory
+build-itest:
+ @$(call print, "Building itest btcd and lnd.")
+ CGO_ENABLED=0 $(GOBUILD) -tags="integration" -o itest/btcd-itest$(EXEC_SUFFIX) $(DEV_LDFLAGS) $(BTCD_PKG)
+ CGO_ENABLED=0 $(GOBUILD) -tags="$(ITEST_TAGS)" $(ITEST_COVERAGE) -o itest/lnd-itest$(EXEC_SUFFIX) $(DEV_LDFLAGS) $(PKG)/cmd/lnd
+
+ @$(call print, "Building itest binary for ${backend} backend.")
+ CGO_ENABLED=0 $(GOTEST) -v ./itest -tags="$(DEV_TAGS) $(RPC_TAGS) integration $(backend)" -c -o itest/itest.test$(EXEC_SUFFIX)
+
+#? build-itest-race: Build integration test binaries in race detector mode, place them in itest directory
+build-itest-race:
+ @$(call print, "Building itest btcd and lnd with race detector.")
+ CGO_ENABLED=0 $(GOBUILD) -tags="integration" -o itest/btcd-itest$(EXEC_SUFFIX) $(DEV_LDFLAGS) $(BTCD_PKG)
+ CGO_ENABLED=1 $(GOBUILD) -race -tags="$(ITEST_TAGS)" -o itest/lnd-itest$(EXEC_SUFFIX) $(DEV_LDFLAGS) $(PKG)/cmd/lnd
+
+ @$(call print, "Building itest binary for ${backend} backend.")
+ CGO_ENABLED=0 $(GOTEST) -v ./itest -tags="$(DEV_TAGS) $(RPC_TAGS) integration $(backend)" -c -o itest/itest.test$(EXEC_SUFFIX)
+
+#? install-binaries: Build and install lnd and lncli binaries, place them in $GOPATH/bin
+install-binaries:
+ @$(call print, "Installing lnd and lncli.")
+ $(GOINSTALL) -tags="${tags}" -ldflags="$(RELEASE_LDFLAGS)" $(PKG)/cmd/lnd
+ $(GOINSTALL) -tags="${tags}" -ldflags="$(RELEASE_LDFLAGS)" $(PKG)/cmd/lncli
+
+#? manpages: generate and install man pages
+manpages:
+ @$(call print, "Generating man pages lncli.1 and lnd.1.")
+ ./scripts/gen_man_pages.sh $(DESTDIR) $(PREFIX)
+
+#? install: Build and install lnd and lncli binaries and place them in $GOPATH/bin.
+install: install-binaries
+
+#? install-all: Performs all the same tasks as the install command along with generating and installing the man pages for the lnd and lncli binaries. This command is useful in an environment where a user has root access and so has write access to the man page directory.
+install-all: install manpages
+
+#? release-install: Build and install lnd and lncli release binaries, place them in $GOPATH/bin
+release-install:
+ @$(call print, "Installing release lnd and lncli.")
+ env CGO_ENABLED=0 $(GOINSTALL) -v -trimpath -ldflags="$(RELEASE_LDFLAGS)" -tags="$(RELEASE_TAGS)" $(PKG)/cmd/lnd
+ env CGO_ENABLED=0 $(GOINSTALL) -v -trimpath -ldflags="$(RELEASE_LDFLAGS)" -tags="$(RELEASE_TAGS)" $(PKG)/cmd/lncli
+
+#? cross-release-install: Build lnd and lncli release binaries for single/all supported platforms to /tmp (useful for checking cross compilation or priming release build cache).
+cross-release-install:
+ @$(call print, "Cross compiling release lnd and lncli.")
+ for sys in $(BUILD_SYSTEM); do \
+ echo "Building lnd and lncli for $$sys"; \
+ export CGO_ENABLED=0 GOOS=$$(echo $$sys | cut -d- -f1) GOARCH=$$(echo $$sys | cut -d- -f2); \
+ if [ "$$GOARCH" = "armv6" ]; then \
+ export GOARCH=arm; GOARM=6; \
+ elif [ "$$GOARCH" = "armv7" ]; then \
+ export GOARCH=arm; GOARM=7; \
+ fi; \
+ $(GOBUILD) -trimpath -ldflags="$(RELEASE_LDFLAGS)" -tags="$(RELEASE_TAGS)" -o /tmp/lnd-$$sys $(PKG)/cmd/lnd; \
+ $(GOBUILD) -trimpath -ldflags="$(RELEASE_LDFLAGS)" -tags="$(RELEASE_TAGS)" -o /tmp/lncli-$$sys $(PKG)/cmd/lncli; \
+ echo; \
+ done
+
+#? release: Build the full set of reproducible release binaries for all supported platforms. Make sure the generated mobile RPC stubs don't influence our vendor package by removing them first in the clean-mobile target.
+release: clean-mobile
+ @$(call print, "Releasing lnd and lncli binaries.")
+ $(VERSION_CHECK)
+ ./scripts/release.sh build-release "$(VERSION_TAG)" "$(BUILD_SYSTEM)" "$(RELEASE_TAGS)" "$(RELEASE_LDFLAGS)" "$(GO_VERSION)"
+
+#? docker-release: Same as release but within a docker container to support reproducible builds on BSD/MacOS platforms
+docker-release:
+ @$(call print, "Building release helper docker image.")
+ if [ "$(tag)" = "" ]; then echo "Must specify tag=<commit_or_tag>!"; exit 1; fi
+
+ docker build -t lnd-release-helper -f make/builder.Dockerfile make/
+
+ # Run the actual compilation inside the docker image. We pass in all flags
+ # that we might want to overwrite in manual tests.
+ $(DOCKER_RELEASE_HELPER) make release tag="$(tag)" sys="$(sys)" COMMIT="$(COMMIT)"
+
+docker-tools:
+ @$(call print, "Building tools docker image.")
+ docker build -q -t lnd-tools $(TOOLS_DIR)
+
+scratch: build
+
+
+# =======
+# TESTING
+# =======
+
+#? check: Run unit and integration tests
+check: unit itest
+
+db-instance:
+ifeq ($(dbbackend),postgres)
+ # Remove a previous postgres instance if it exists.
+ docker rm lnd-postgres --force || echo "Starting new postgres container"
+
+ # Start a fresh postgres instance. Allow a maximum of 500 connections so
+ # that multiple lnd instances with a maximum number of connections of 20
+ # each can run concurrently. Note that many of the settings here are
+ # specifically for integration testing and are not fit for running
+ # production nodes. The increase in max connections ensures that there
+ # are enough entries allocated for the RWConflictPool to allow multiple
+ # conflicting transactions to track serialization conflicts. The
+ # increase in predicate locks and locks per transaction is to allow the
+ # queries to lock individual rows instead of entire tables, helping
+ # reduce serialization conflicts. Disabling sequential scan for small
+ # tables also helps prevent serialization conflicts by ensuring lookups
+ # lock only relevant rows in the index rather than the entire table.
+ docker run --name lnd-postgres -e POSTGRES_PASSWORD=postgres -p 6432:5432 -d postgres:13-alpine -N 1500 -c max_pred_locks_per_transaction=1024 -c max_locks_per_transaction=128 -c enable_seqscan=off
+ docker logs -f lnd-postgres >itest/postgres.log 2>&1 &
+
+ # Wait for the instance to be started.
+ sleep $(POSTGRES_START_DELAY)
+endif
+
+clean-itest-logs:
+ rm -rf itest/*.log itest/.logs-*
+
+#? itest-only: Only run integration tests without re-building binaries
+itest-only: clean-itest-logs db-instance
+ @$(call print, "Running integration tests with ${backend} backend.")
+ date
+ EXEC_SUFFIX=$(EXEC_SUFFIX) scripts/itest_part.sh 0 1 $(SHUFFLE_SEED) $(TEST_FLAGS) $(ITEST_FLAGS) -test.v
+ $(COLLECT_ITEST_COVERAGE)
+
+#? itest: Build and run integration tests
+itest: build-itest itest-only
+
+#? itest-race: Build and run integration tests in race detector mode
+itest-race: build-itest-race itest-only
+
+#? itest-parallel: Build and run integration tests in parallel mode, running up to ITEST_PARALLELISM test tranches in parallel (default 4)
+itest-parallel: clean-itest-logs build-itest db-instance
+ @$(call print, "Running tests")
+ date
+ EXEC_SUFFIX=$(EXEC_SUFFIX) scripts/itest_parallel.sh $(ITEST_PARALLELISM) $(NUM_ITEST_TRANCHES) $(SHUFFLE_SEED) $(TEST_FLAGS) $(ITEST_FLAGS)
+ $(COLLECT_ITEST_COVERAGE)
+
+#? itest-clean: Kill all running itest processes
+itest-clean:
+ @$(call print, "Cleaning old itest processes")
+ killall lnd-itest || echo "no running lnd-itest process found";
+
+#? unit: Run unit tests
+unit: $(BTCD_BIN)
+ @$(call print, "Running unit tests.")
+ $(UNIT)
+
+#? unit-module: Run unit tests of all submodules
+unit-module:
+ @$(call print, "Running submodule unit tests.")
+ scripts/unit_test_modules.sh
+
+#? unit-debug: Run unit tests with debug log output enabled
+unit-debug: $(BTCD_BIN)
+ @$(call print, "Running debug unit tests.")
+ $(UNIT_DEBUG)
+
+#? unit-cover: Run unit tests in coverage mode
+unit-cover: $(BTCD_BIN)
+ @$(call print, "Running unit coverage tests.")
+ $(UNIT_COVER)
+
+#? unit-race: Run unit tests in race detector mode
+unit-race: $(BTCD_BIN)
+ @$(call print, "Running unit race tests.")
+ env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(UNIT_RACE)
+
+#? unit-bench: Run benchmark tests
+unit-bench: $(BTCD_BIN)
+ @$(call print, "Running benchmark tests.")
+ $(UNIT_BENCH)
+
+# =============
+# FLAKE HUNTING
+# =============
+
+#? flakehunter-itest: Run the integration tests continuously until one fails
+flakehunter-itest: build-itest
+ @$(call print, "Flake hunting ${backend} integration tests.")
+ while [ $$? -eq 0 ]; do make itest-only icase='${icase}' backend='${backend}'; done
+
+#? flakehunter-unit: Run the unit tests continuously until one fails
+flakehunter-unit:
+ @$(call print, "Flake hunting unit test.")
+ scripts/unit-test-flake-hunter.sh ${pkg} ${case}
+
+#? flakehunter-unit-all: Run all unit tests continuously until one fails
+flakehunter-unit-all: $(BTCD_BIN)
+ @$(call print, "Flake hunting unit tests.")
+ while [ $$? -eq 0 ]; do make unit; done
+
+#? flakehunter-unit-race: Run all unit tests in race detector mode continuously until one fails
+flakehunter-unit-race: $(BTCD_BIN)
+ @$(call print, "Flake hunting unit tests in race detector mode.")
+ while [ $$? -eq 0 ]; do make unit-race; done
+
+#? flakehunter-itest-parallel: Run the integration tests continuously until one fails, running up to ITEST_PARALLELISM test tranches in parallel (default 4)
+flakehunter-itest-parallel:
+ @$(call print, "Flake hunting ${backend} integration tests in parallel.")
+ while [ $$? -eq 0 ]; do make itest-parallel tranches=1 parallel=${ITEST_PARALLELISM} icase='${icase}' backend='${backend}'; done
+
+# =============
+# FUZZING
+# =============
+
+#? fuzz: Run the fuzzing tests
+fuzz:
+ @$(call print, "Fuzzing packages '$(FUZZPKG)'.")
+ scripts/fuzz.sh run "$(FUZZPKG)" "$(FUZZ_TEST_RUN_TIME)" "$(FUZZ_NUM_PROCESSES)"
+
+# =========
+# UTILITIES
+# =========
+
+#? fmt: Format source code and fix imports
+fmt:
+ @$(call print, "Fixing imports.")
+ $(GOTOOL) $(GOIMPORTS_PKG) -w $(GOFILES_NOVENDOR)
+ @$(call print, "Formatting source.")
+ gofmt -l -w -s $(GOFILES_NOVENDOR)
+
+#? fmt-check: Make sure source code is formatted and imports are correct
+fmt-check: fmt
+ @$(call print, "Checking fmt results.")
+ if test -n "$$(git status --porcelain)"; then echo "code not formatted correctly, please run `make fmt` again!"; git status; git diff; exit 1; fi
+
+#? check-go-version-yaml: Verify that the Go version is correct in all YAML files
+check-go-version-yaml:
+ @$(call print, "Checking for target Go version (v$(GO_VERSION)) in YAML files (*.yaml, *.yml)")
+ ./scripts/check-go-version-yaml.sh $(GO_VERSION)
+
+#? check-go-version-dockerfile: Verify that the Go version is correct in all Dockerfile files
+check-go-version-dockerfile:
+ @$(call print, "Checking for target Go version (v$(GO_VERSION)) in Dockerfile files (*Dockerfile)")
+ ./scripts/check-go-version-dockerfile.sh $(GO_VERSION)
+
+#? check-go-version: Verify that the Go version is correct in all project files
+check-go-version: check-go-version-dockerfile check-go-version-yaml
+
+#? lint-source: Run static code analysis
+lint-source: docker-tools
+ @$(call print, "Linting source.")
+ $(DOCKER_TOOLS_LINT) custom-gcl run -v $(LINT_WORKERS)
+
+#? lint-config-check: Verify that the lint config is up to date
+# We use the official linter here not our custom one because for checking the
+# config file it does not matter.
+lint-config-check:
+ @$(call print, "Checking lint config is up to date.")
+ $(GOTOOL) $(GOLINT_PKG) config verify -v
+
+#? lint: Run static code analysis
+lint: check-go-version lint-config-check lint-source
+
+#? protolint: Lint proto files using protolint
+protolint:
+ @$(call print, "Linting proto files.")
+ docker run --rm --volume "$$(pwd):/workspace" --workdir /workspace yoheimuta/protolint lint lnrpc/
+
+#? tidy-module: Run `go mod` tidy for all modules
+tidy-module:
+ echo "Running 'go mod tidy' for all modules"
+ scripts/tidy_modules.sh
+
+#? tidy-module-check: Make sure all modules are up to date
+tidy-module-check: tidy-module
+ if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi
+
+#? list: List all available make targets
+list:
+ @$(call print, "Listing commands:")
+ @$(MAKE) -qp | \
+ awk -F':' '/^[a-zA-Z0-9][^$$#\/\t=]*:([^=]|$$)/ {split($$1,A,/ /);for(i in A)print A[i]}' | \
+ grep -v Makefile | \
+ sort
+
+#? help: List all available make targets with their descriptions
+help: Makefile
+ @$(call print, "Listing commands:")
+ @sed -n 's/^#?//p' $< | column -t -s ':' | sort | sed -e 's/^/ /'
+
+#? backwards-compat-test: Run basic backwards compatibility test
+backwards-compat-test:
+ @$(call print, "Running backwards compatability test")
+ ./scripts/bw-compatibility-test/test.sh
+
+#? sqlc: Generate sql models and queries in Go
+sqlc:
+ @$(call print, "Generating sql models and queries in Go")
+ ./scripts/gen_sqlc_docker.sh
+
+#? sqlc-check: Make sure sql models and queries are up to date
+sqlc-check: sqlc
+ @$(call print, "Verifying sql code generation.")
+ if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi
+
+#? rpc: Compile protobuf definitions and generate REST proxy stubs
+rpc:
+ @$(call print, "Compiling protos.")
+ cd ./lnrpc; ./gen_protos_docker.sh
+
+#? rpc-format: Format protobuf definition files
+rpc-format:
+ @$(call print, "Formatting protos.")
+ cd ./lnrpc; find . -name "*.proto" | xargs clang-format --style=file -i
+
+#? rpc-check: Make sure protobuf definitions are up to date
+rpc-check: rpc
+ @$(call print, "Verifying protos.")
+ cd ./lnrpc; ../scripts/check-rest-annotations.sh
+ if test -n "$$(git status --porcelain)"; then echo "Protos not properly formatted or not compiled with v3.4.0"; git status; git diff; exit 1; fi
+
+#? rpc-js-compile: Compile protobuf definitions and generate JSON/WASM stubs
+rpc-js-compile:
+ @$(call print, "Compiling JSON/WASM stubs.")
+ GOOS=js GOARCH=wasm $(GOBUILD) -tags="$(WASM_RELEASE_TAGS)" $(PKG)/lnrpc/...
+
+#? sample-conf-check: Make sure default values in the sample-lnd.conf file are set correctly
+sample-conf-check:
+ @$(call print, "Checking that default values in the sample-lnd.conf file are set correctly")
+ scripts/check-sample-lnd-conf.sh "$(RELEASE_TAGS)"
+
+#? mobile-rpc: Compile mobile RPC stubs from the protobuf definitions
+mobile-rpc:
+ @$(call print, "Creating mobile RPC from protos.")
+ cd ./lnrpc; COMPILE_MOBILE=1 SUBSERVER_PREFIX=1 ./gen_protos_docker.sh
+
+#? vendor: Create a vendor directory with all dependencies
+vendor:
+ @$(call print, "Re-creating vendor directory.")
+ rm -r vendor/; $(GOCC) mod vendor
+
+#? apple: Build mobile RPC stubs and project template for iOS and macOS
+apple: mobile-rpc
+ @$(call print, "Building iOS and macOS cxframework ($(IOS_BUILD)).")
+ mkdir -p $(IOS_BUILD_DIR)
+ $(GOMOBILE_BIN) bind -target=ios,iossimulator,macos -tags="mobile $(DEV_TAGS) $(RPC_TAGS)" -ldflags "$(RELEASE_LDFLAGS)" -v -o $(IOS_BUILD) $(MOBILE_PKG)
+
+#? ios: Build mobile RPC stubs and project template for iOS
+ios: mobile-rpc
+ @$(call print, "Building iOS cxframework ($(IOS_BUILD)).")
+ mkdir -p $(IOS_BUILD_DIR)
+ $(GOMOBILE_BIN) bind -target=ios,iossimulator -tags="mobile $(DEV_TAGS) $(RPC_TAGS)" -ldflags "$(RELEASE_LDFLAGS)" -v -o $(IOS_BUILD) $(MOBILE_PKG)
+
+#? macos: Build mobile RPC stubs and project template for macOS
+macos: mobile-rpc
+ @$(call print, "Building macOS cxframework ($(IOS_BUILD)).")
+ mkdir -p $(IOS_BUILD_DIR)
+ $(GOMOBILE_BIN) bind -target=macos -tags="mobile $(DEV_TAGS) $(RPC_TAGS)" -ldflags "$(RELEASE_LDFLAGS)" -v -o $(IOS_BUILD) $(MOBILE_PKG)
+
+#? android: Build mobile RPC stubs and project template for Android
+android: mobile-rpc
+ @$(call print, "Building Android library ($(ANDROID_BUILD)).")
+ mkdir -p $(ANDROID_BUILD_DIR)
+ $(GOMOBILE_BIN) bind -target=android -androidapi 21 -tags="mobile $(DEV_TAGS) $(RPC_TAGS)" -ldflags "$(RELEASE_LDFLAGS)" -v -o $(ANDROID_BUILD) $(MOBILE_PKG)
+
+#? mobile: Build mobile RPC stubs and project templates for iOS and Android
+mobile: ios android
+
+#? clean: Remove all generated files
+clean:
+ @$(call print, "Cleaning source.$(NC)")
+ $(RM) ./lnd-debug ./lncli-debug
+ $(RM) ./lnd-itest ./lncli-itest
+ $(RM) -r ./vendor .vendor-new
+
+#? clean-mobile: Remove all generated mobile files
+clean-mobile:
+ @$(call print, "Cleaning autogenerated mobile RPC stubs.")
+ $(RM) -r mobile/build
+ $(RM) mobile/*_generated.go
+
+#? clean-docker-volumes: Remove Docker cache volumes used for local development
+clean-docker-volumes:
+ @$(call print, "Removing Docker cache volumes.")
+ docker volume rm lnd-go-build-cache lnd-go-mod-cache lnd-go-lint-cache 2>/dev/null || true
+
+.PHONY: all \
+ btcd \
+ default \
+ build \
+ install \
+ scratch \
+ check \
+ help \
+ itest-only \
+ itest \
+ unit \
+ unit-debug \
+ unit-cover \
+ unit-race \
+ flakehunter \
+ flake-unit \
+ fmt \
+ lint \
+ list \
+ rpc \
+ rpc-format \
+ rpc-check \
+ rpc-js-compile \
+ mobile-rpc \
+ vendor \
+ ios \
+ android \
+ mobile \
+ clean \
+ clean-docker-volumes
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e75f3dd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+## Lightning Network Daemon
+
+[](https://github.com/lightningnetwork/lnd/actions/workflows/release.yaml)
+[](https://github.com/lightningnetwork/lnd/blob/master/LICENSE)
+[](https://web.libera.chat/#lnd)
+[](https://godoc.org/github.com/lightningnetwork/lnd)
+[](https://goreportcard.com/report/github.com/lightningnetwork/lnd)
+
+<img src="logo.png">
+
+The Lightning Network Daemon (`lnd`) - is a complete implementation of a
+[Lightning Network](https://lightning.network) node. `lnd` has several pluggable back-end
+chain services including [`btcd`](https://github.com/btcsuite/btcd) (a
+full-node), [`bitcoind`](https://github.com/bitcoin/bitcoin), and
+[`neutrino`](https://github.com/lightninglabs/neutrino) (a new experimental light client). The project's codebase uses the
+[btcsuite](https://github.com/btcsuite/) set of Bitcoin libraries, and also
+exports a large set of isolated re-usable Lightning Network related libraries
+within it. In the current state `lnd` is capable of:
+* Creating channels.
+* Closing channels.
+* Completely managing all channel states (including the exceptional ones!).
+* Maintaining a fully authenticated+validated channel graph.
+* Performing path finding within the network, passively forwarding incoming payments.
+* Sending outgoing [onion-encrypted payments](https://github.com/lightningnetwork/lightning-onion)
+through the network.
+* Updating advertised fee schedules.
+* Automatic channel management ([`autopilot`](https://github.com/lightningnetwork/lnd/tree/master/autopilot)).
+
+## Lightning Network Specification Compliance
+`lnd` _fully_ conforms to the [Lightning Network specification
+(BOLTs)](https://github.com/lightningnetwork/lightning-rfc). BOLT stands for:
+Basis of Lightning Technology. The specifications are currently being drafted
+by several groups of implementers based around the world including the
+developers of `lnd`. The set of specification documents as well as our
+implementation of the specification are still a work-in-progress. With that
+said, the current status of `lnd`'s BOLT compliance is:
+
+ - [X] BOLT 1: Base Protocol
+ - [X] BOLT 2: Peer Protocol for Channel Management
+ - [X] BOLT 3: Bitcoin Transaction and Script Formats
+ - [X] BOLT 4: Onion Routing Protocol
+ - [X] BOLT 5: Recommendations for On-chain Transaction Handling
+ - [X] BOLT 7: P2P Node and Channel Discovery
+ - [X] BOLT 8: Encrypted and Authenticated Transport
+ - [X] BOLT 9: Assigned Feature Flags
+ - [X] BOLT 10: DNS Bootstrap and Assisted Node Location
+ - [X] BOLT 11: Invoice Protocol for Lightning Payments
+
+## Developer Resources
+
+The daemon has been designed to be as developer friendly as possible in order
+to facilitate application development on top of `lnd`. Two primary RPC
+interfaces are exported: an HTTP REST API, and a [gRPC](https://grpc.io/)
+service. The exported APIs are not yet stable, so be warned: they may change
+drastically in the near future.
+
+An automatically generated set of documentation for the RPC APIs can be found
+at [api.lightning.community](https://api.lightning.community). A set of developer
+resources including guides, articles, example applications and community resources can be found at:
+[docs.lightning.engineering](https://docs.lightning.engineering).
+
+Finally, we also have an active
+[Slack](https://lightning.engineering/slack.html) where protocol developers, application developers, testers and users gather to
+discuss various aspects of `lnd` and also Lightning in general.
+
+First-time contributors are [highly encouraged to start with code review
+first](docs/review.md), before creating their own Pull Requests.
+
+## Installation
+ In order to build from source, please see [the installation
+ instructions](docs/INSTALL.md).
+
+## Docker
+ To run lnd from Docker, please see the main [Docker instructions](docs/DOCKER.md)
+
+## IRC
+ * irc.libera.chat
+ * channel #lnd
+ * [webchat](https://web.libera.chat/#lnd)
+
+## Safety
+
+When operating a mainnet `lnd` node, please refer to our [operational safety
+guidelines](docs/safety.md). It is important to note that `lnd` is still
+**beta** software and that ignoring these operational guidelines can lead to
+loss of funds.
+
+## Security
+
+The developers of `lnd` take security _very_ seriously. The disclosure of
+security vulnerabilities helps us secure the health of `lnd`, privacy of our
+users, and also the health of the Lightning Network as a whole. If you find
+any issues regarding security or privacy, please disclose the information
+responsibly by sending an email to security at lightning dot engineering,
+preferably encrypted using our designated PGP key
+(`91FE464CD75101DA6B6BAB60555C6465E5BCB3AF`) which can be found
+[here](https://gist.githubusercontent.com/Roasbeef/6fb5b52886183239e4aa558f83d085d3/raw/1ecb328bbcf36f76ead67f08008f8db1da07e60e/security@lightning.engineering).
+
+## Further reading
+* [Step-by-step send payment guide with docker](https://github.com/lightningnetwork/lnd/tree/master/docker)
+* [Contribution guide](https://github.com/lightningnetwork/lnd/blob/master/docs/code_contribution_guidelines.md)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..45453f1
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security Policy
+
+## Supported Versions
+
+The last major lnd release is to be considered the current support version. Given an issue severe enough, a backport will be issued either to the prior major release or the set of releases considered utilized enough.
+
+## Reporting a Vulnerability
+
+To report security issues, send an email to security@lightning.engineering (this list isn't to be used for support).
+
+The following key can be used to communicate sensitive information: [`91FE 464C D751 01DA 6B6B AB60 555C 6465 E5BC B3AF`](https://gist.githubusercontent.com/Roasbeef/6fb5b52886183239e4aa558f83d085d3/raw/1ecb328bbcf36f76ead67f08008f8db1da07e60e/security@lightning.engineering).
diff --git a/accessman.go b/accessman.go
new file mode 100644
index 0000000..971132f
--- /dev/null
+++ b/accessman.go
@@ -0,0 +1,664 @@
+package lnd
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "sync"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btclog/v2"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/lnutils"
+)
+
+// accessMan is responsible for managing the server's access permissions.
+type accessMan struct {
+ cfg *accessManConfig
+
+ // banScoreMtx is used for the server's ban tracking. If the server
+ // mutex is also going to be locked, ensure that this is locked after
+ // the server mutex.
+ banScoreMtx sync.RWMutex
+
+ // peerChanInfo is a mapping from remote public key to {bool, uint64}
+ // where the bool indicates that we have an open/closed channel with the
+ // peer and where the uint64 indicates the number of pending-open
+ // channels we currently have with them. This mapping will be used to
+ // determine access permissions for the peer. The map key is the
+ // string-version of the serialized public key.
+ //
+ // NOTE: This MUST be accessed with the banScoreMtx held.
+ peerChanInfo map[string]channeldb.ChanCount
+
+ // peerScores stores each connected peer's access status. The map key
+ // is the string-version of the serialized public key.
+ //
+ // NOTE: This MUST be accessed with the banScoreMtx held.
+ //
+ // TODO(yy): unify `peerScores` and `peerChanInfo` - there's no need to
+ // create two maps tracking essentially the same info. `numRestricted`
+ // can also be derived from `peerChanInfo`.
+ peerScores map[string]peerSlotStatus
+
+ // numRestricted tracks the number of peers with restricted access in
+ // peerScores. This MUST be accessed with the banScoreMtx held.
+ numRestricted int64
+}
+
+type accessManConfig struct {
+ // initAccessPerms checks the channeldb for initial access permissions
+ // and then populates the peerChanInfo and peerScores maps.
+ initAccessPerms func() (map[string]channeldb.ChanCount, error)
+
+ // shouldDisconnect determines whether we should disconnect a peer or
+ // not.
+ shouldDisconnect func(*btcec.PublicKey) (bool, error)
+
+ // maxRestrictedSlots is the number of restricted slots we'll allocate.
+ maxRestrictedSlots int64
+}
+
+func newAccessMan(cfg *accessManConfig) (*accessMan, error) {
+ a := &accessMan{
+ cfg: cfg,
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ counts, err := a.cfg.initAccessPerms()
+ if err != nil {
+ return nil, err
+ }
+
+ // We'll populate the server's peerChanInfo map with the counts fetched
+ // via initAccessPerms. Also note that we haven't yet connected to the
+ // peers.
+ maps.Copy(a.peerChanInfo, counts)
+
+ acsmLog.Info("Access Manager initialized")
+
+ return a, nil
+}
+
+// hasPeer checks whether a given peer already exists in the internal maps.
+func (a *accessMan) hasPeer(ctx context.Context,
+ pub string) (peerAccessStatus, bool) {
+
+ // Lock banScoreMtx for reading so that we can read the banning maps
+ // below.
+ a.banScoreMtx.RLock()
+ defer a.banScoreMtx.RUnlock()
+
+ count, found := a.peerChanInfo[pub]
+ if found {
+ if count.HasOpenOrClosedChan {
+ acsmLog.DebugS(ctx, "Peer has open/closed channel, "+
+ "assigning protected access")
+
+ // Exit early if the peer is no longer restricted.
+ return peerStatusProtected, true
+ }
+
+ if count.PendingOpenCount != 0 {
+ acsmLog.DebugS(ctx, "Peer has pending channel(s), "+
+ "assigning temporary access")
+
+ // Exit early if the peer is no longer restricted.
+ return peerStatusTemporary, true
+ }
+
+ return peerStatusRestricted, true
+ }
+
+ // Check if the peer is found in the scores map.
+ status, found := a.peerScores[pub]
+ if found {
+ acsmLog.DebugS(ctx, "Peer already has access", "access",
+ status.state)
+
+ return status.state, true
+ }
+
+ return peerStatusRestricted, false
+}
+
+// assignPeerPerms assigns a new peer its permissions. This does not track the
+// access in the maps. This is intentional.
+func (a *accessMan) assignPeerPerms(remotePub *btcec.PublicKey) (
+ peerAccessStatus, error) {
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ acsmLog.DebugS(ctx, "Assigning permissions")
+
+ // Default is restricted unless the below filters say otherwise.
+ access, peerExist := a.hasPeer(ctx, peerMapKey)
+
+ // Exit early if the peer is not restricted.
+ if access != peerStatusRestricted {
+ return access, nil
+ }
+
+ // If we are here, it means the peer has peerStatusRestricted.
+ //
+ // Check whether this peer is banned.
+ shouldDisconnect, err := a.cfg.shouldDisconnect(remotePub)
+ if err != nil {
+ acsmLog.ErrorS(ctx, "Error checking disconnect status", err)
+
+ // Access is restricted here.
+ return access, err
+ }
+
+ if shouldDisconnect {
+ acsmLog.WarnS(ctx, "Peer is banned, assigning restricted access",
+ ErrGossiperBan)
+
+ // Access is restricted here.
+ return access, ErrGossiperBan
+ }
+
+ // If we've reached this point and access hasn't changed from
+ // restricted, then we need to check if we even have a slot for this
+ // peer.
+ acsmLog.DebugS(ctx, "Peer has no channels, assigning restricted access")
+
+ // If this is an existing peer, there's no need to check for slot limit.
+ if peerExist {
+ acsmLog.DebugS(ctx, "Skipped slot check for existing peer")
+ return access, nil
+ }
+
+ a.banScoreMtx.RLock()
+ defer a.banScoreMtx.RUnlock()
+
+ if a.numRestricted >= a.cfg.maxRestrictedSlots {
+ acsmLog.WarnS(ctx, "No more restricted slots available, "+
+ "denying peer", ErrNoMoreRestrictedAccessSlots,
+ "num_restricted", a.numRestricted, "max_restricted",
+ a.cfg.maxRestrictedSlots)
+
+ return access, ErrNoMoreRestrictedAccessSlots
+ }
+
+ return access, nil
+}
+
+// newPendingOpenChan is called after the pending-open channel has been
+// committed to the database. This may transition a restricted-access peer to a
+// temporary-access peer.
+func (a *accessMan) newPendingOpenChan(remotePub *btcec.PublicKey) error {
+ a.banScoreMtx.Lock()
+ defer a.banScoreMtx.Unlock()
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ acsmLog.DebugS(ctx, "Processing new pending open channel")
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ // Fetch the peer's access status from peerScores.
+ status, found := a.peerScores[peerMapKey]
+ if !found {
+ acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
+
+ // If we didn't find the peer, we'll return an error.
+ return ErrNoPeerScore
+ }
+
+ switch status.state {
+ case peerStatusProtected:
+ acsmLog.DebugS(ctx, "Peer already protected, no change")
+
+ // If this peer's access status is protected, we don't need to
+ // do anything.
+ return nil
+
+ case peerStatusTemporary:
+ // If this peer's access status is temporary, we'll need to
+ // update the peerChanInfo map. The peer's access status will
+ // stay temporary.
+ peerCount, found := a.peerChanInfo[peerMapKey]
+ if !found {
+ // Error if we did not find any info in peerChanInfo.
+ acsmLog.ErrorS(ctx, "Pending peer info not found",
+ ErrNoPendingPeerInfo)
+
+ return ErrNoPendingPeerInfo
+ }
+
+ // Increment the pending channel amount.
+ peerCount.PendingOpenCount += 1
+ a.peerChanInfo[peerMapKey] = peerCount
+
+ acsmLog.DebugS(ctx, "Peer is temporary, incremented "+
+ "pending count",
+ "pending_count", peerCount.PendingOpenCount)
+
+ case peerStatusRestricted:
+ // If the peer's access status is restricted, then we can
+ // transition it to a temporary-access peer. We'll need to
+ // update numRestricted and also peerScores. We'll also need to
+ // update peerChanInfo.
+ peerCount := channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 1,
+ }
+
+ a.peerChanInfo[peerMapKey] = peerCount
+
+ // A restricted-access slot has opened up.
+ oldRestricted := a.numRestricted
+ a.numRestricted -= 1
+
+ a.peerScores[peerMapKey] = peerSlotStatus{
+ state: peerStatusTemporary,
+ }
+
+ acsmLog.InfoS(ctx, "Peer transitioned restricted -> "+
+ "temporary (pending open)",
+ "old_restricted", oldRestricted,
+ "new_restricted", a.numRestricted)
+
+ default:
+ // This should not be possible.
+ err := fmt.Errorf("invalid peer access status %v for %x",
+ status.state, peerMapKey)
+ acsmLog.ErrorS(ctx, "Invalid peer access status", err)
+
+ return err
+ }
+
+ return nil
+}
+
+// newPendingCloseChan is called when a pending-open channel prematurely closes
+// before the funding transaction has confirmed. This potentially demotes a
+// temporary-access peer to a restricted-access peer. If no restricted-access
+// slots are available, the peer will be disconnected.
+func (a *accessMan) newPendingCloseChan(remotePub *btcec.PublicKey) error {
+ a.banScoreMtx.Lock()
+ defer a.banScoreMtx.Unlock()
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ acsmLog.DebugS(ctx, "Processing pending channel close")
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ // Fetch the peer's access status from peerScores.
+ status, found := a.peerScores[peerMapKey]
+ if !found {
+ acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
+
+ return ErrNoPeerScore
+ }
+
+ switch status.state {
+ case peerStatusProtected:
+ // If this peer is protected, we don't do anything.
+ acsmLog.DebugS(ctx, "Peer is protected, no change")
+
+ return nil
+
+ case peerStatusTemporary:
+ // If this peer is temporary, we need to check if it will
+ // revert to a restricted-access peer.
+ peerCount, found := a.peerChanInfo[peerMapKey]
+ if !found {
+ acsmLog.ErrorS(ctx, "Pending peer info not found",
+ ErrNoPendingPeerInfo)
+
+ // Error if we did not find any info in peerChanInfo.
+ return ErrNoPendingPeerInfo
+ }
+
+ currentNumPending := peerCount.PendingOpenCount - 1
+
+ acsmLog.DebugS(ctx, "Peer is temporary, decrementing "+
+ "pending count",
+ "pending_count", currentNumPending)
+
+ if currentNumPending == 0 {
+ // Remove the entry from peerChanInfo.
+ delete(a.peerChanInfo, peerMapKey)
+
+ // If this is the only pending-open channel for this
+ // peer and it's getting removed, attempt to demote
+ // this peer to a restricted peer.
+ if a.numRestricted == a.cfg.maxRestrictedSlots {
+ // There are no available restricted slots, so
+ // we need to disconnect this peer. We leave
+ // this up to the caller.
+ acsmLog.WarnS(ctx, "Peer last pending "+
+ "channel closed: ",
+ ErrNoMoreRestrictedAccessSlots,
+ "num_restricted", a.numRestricted,
+ "max_restricted", a.cfg.maxRestrictedSlots)
+
+ return ErrNoMoreRestrictedAccessSlots
+ }
+
+ // Otherwise, there is an available restricted-access
+ // slot, so we can demote this peer.
+ a.peerScores[peerMapKey] = peerSlotStatus{
+ state: peerStatusRestricted,
+ }
+
+ // Update numRestricted.
+ oldRestricted := a.numRestricted
+ a.numRestricted++
+
+ acsmLog.InfoS(ctx, "Peer transitioned "+
+ "temporary -> restricted "+
+ "(last pending closed)",
+ "old_restricted", oldRestricted,
+ "new_restricted", a.numRestricted)
+
+ return nil
+ }
+
+ // Else, we don't need to demote this peer since it has other
+ // pending-open channels with us.
+ peerCount.PendingOpenCount = currentNumPending
+ a.peerChanInfo[peerMapKey] = peerCount
+
+ acsmLog.DebugS(ctx, "Peer still has other pending channels",
+ "pending_count", currentNumPending)
+
+ return nil
+
+ case peerStatusRestricted:
+ // This should not be possible. This indicates an error.
+ err := fmt.Errorf("invalid peer access state transition: "+
+ "pending close for restricted peer %x", peerMapKey)
+ acsmLog.ErrorS(ctx, "Invalid peer access state transition", err)
+
+ return err
+
+ default:
+ // This should not be possible.
+ err := fmt.Errorf("invalid peer access status %v for %x",
+ status.state, peerMapKey)
+ acsmLog.ErrorS(ctx, "Invalid peer access status", err)
+
+ return err
+ }
+}
+
+// newOpenChan is called when a pending-open channel becomes an open channel
+// (i.e. the funding transaction has confirmed). If the remote peer is a
+// temporary-access peer, it will be promoted to a protected-access peer.
+func (a *accessMan) newOpenChan(remotePub *btcec.PublicKey) error {
+ a.banScoreMtx.Lock()
+ defer a.banScoreMtx.Unlock()
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ acsmLog.DebugS(ctx, "Processing new open channel")
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ // Fetch the peer's access status from peerScores.
+ status, found := a.peerScores[peerMapKey]
+ if !found {
+ // If we didn't find the peer, we'll return an error.
+ acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
+
+ return ErrNoPeerScore
+ }
+
+ switch status.state {
+ case peerStatusProtected:
+ acsmLog.DebugS(ctx, "Peer already protected, no change")
+
+ // If the peer's state is already protected, we don't need to do
+ // anything more.
+ return nil
+
+ case peerStatusTemporary:
+ // If the peer's state is temporary, we'll upgrade the peer to
+ // a protected peer.
+ peerCount, found := a.peerChanInfo[peerMapKey]
+ if !found {
+ // Error if we did not find any info in peerChanInfo.
+ acsmLog.ErrorS(ctx, "Pending peer info not found",
+ ErrNoPendingPeerInfo)
+
+ return ErrNoPendingPeerInfo
+ }
+
+ peerCount.HasOpenOrClosedChan = true
+ peerCount.PendingOpenCount -= 1
+
+ a.peerChanInfo[peerMapKey] = peerCount
+
+ newStatus := peerSlotStatus{
+ state: peerStatusProtected,
+ }
+ a.peerScores[peerMapKey] = newStatus
+
+ acsmLog.InfoS(ctx, "Peer transitioned temporary -> "+
+ "protected (channel opened)")
+
+ return nil
+
+ case peerStatusRestricted:
+ // This should not be possible. For the server to receive a
+ // state-transition event via NewOpenChan, the server must have
+ // previously granted this peer "temporary" access. This
+ // temporary access would not have been revoked or downgraded
+ // without `CloseChannel` being called with the pending
+ // argument set to true. This means that an open-channel state
+ // transition would be impossible. Therefore, we can return an
+ // error.
+ err := fmt.Errorf("invalid peer access status: new open "+
+ "channel for restricted peer %x", peerMapKey)
+
+ acsmLog.ErrorS(ctx, "Invalid peer access status", err)
+
+ return err
+
+ default:
+ // This should not be possible.
+ err := fmt.Errorf("invalid peer access status %v for %x",
+ status.state, peerMapKey)
+
+ acsmLog.ErrorS(ctx, "Invalid peer access status", err)
+
+ return err
+ }
+}
+
+// checkAcceptIncomingConn checks whether, given the remote's public hex-
+// encoded key, we should not accept this incoming connection or immediately
+// disconnect. This does not assign to the server's peerScores maps. This is
+// just an inbound filter that the brontide listeners use.
+//
+// TODO(yy): We should also consider removing this `checkAcceptIncomingConn`
+// check as a) it doesn't check for ban score; and b) we should, and already
+// have this check when we handle incoming connection in `InboundPeerConnected`.
+func (a *accessMan) checkAcceptIncomingConn(remotePub *btcec.PublicKey) (
+ bool, error) {
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ acsmLog.TraceS(ctx, "Checking incoming connection ban score")
+
+ a.banScoreMtx.RLock()
+ defer a.banScoreMtx.RUnlock()
+
+ _, found := a.peerChanInfo[peerMapKey]
+
+ // Exit early if found.
+ if found {
+ acsmLog.DebugS(ctx, "Peer found (protected/temporary), "+
+ "accepting")
+
+ return true, nil
+ }
+
+ _, found = a.peerScores[peerMapKey]
+
+ // Exit early if found.
+ if found {
+ acsmLog.DebugS(ctx, "Found existing peer, accepting")
+
+ return true, nil
+ }
+
+ acsmLog.DebugS(ctx, "Peer not found in counts, checking restricted "+
+ "slots")
+
+ // Check numRestricted to see if there is an available slot. In
+ // the future, it's possible to add better heuristics.
+ if a.numRestricted < a.cfg.maxRestrictedSlots {
+ // There is an available slot.
+ acsmLog.DebugS(ctx, "Restricted slot available, accepting ",
+ "num_restricted", a.numRestricted, "max_restricted",
+ a.cfg.maxRestrictedSlots)
+
+ return true, nil
+ }
+
+ // If there are no slots left, then we reject this connection.
+ acsmLog.WarnS(ctx, "No restricted slots available, rejecting ",
+ ErrNoMoreRestrictedAccessSlots, "num_restricted",
+ a.numRestricted, "max_restricted", a.cfg.maxRestrictedSlots)
+
+ return false, ErrNoMoreRestrictedAccessSlots
+}
+
+// addPeerAccess tracks a peer's access in the maps. This should be called when
+// the peer has fully connected.
+func (a *accessMan) addPeerAccess(remotePub *btcec.PublicKey,
+ access peerAccessStatus, inbound bool) {
+
+ ctx := btclog.WithCtx(
+ context.TODO(), lnutils.LogPubKey("peer", remotePub),
+ )
+
+ acsmLog.DebugS(ctx, "Adding peer access", "access", access)
+
+ // Add the remote public key to peerScores.
+ a.banScoreMtx.Lock()
+ defer a.banScoreMtx.Unlock()
+
+ peerMapKey := string(remotePub.SerializeCompressed())
+
+ // Exit early if this is an existing peer, which means it won't take
+ // another slot.
+ _, found := a.peerScores[peerMapKey]
+ if found {
+ acsmLog.DebugS(ctx, "Skipped taking restricted slot for "+
+ "existing peer")
+
+ return
+ }
+
+ a.peerScores[peerMapKey] = peerSlotStatus{state: access}
+
+ // Exit early if this is not a restricted peer.
+ if access != peerStatusRestricted {
+ acsmLog.DebugS(ctx, "Skipped taking restricted slot as peer "+
+ "already has access", "access", access)
+
+ return
+ }
+
+ // Increment numRestricted if this is an inbound connection.
+ if inbound {
+ oldRestricted := a.numRestricted
+ a.numRestricted++
+
+ acsmLog.DebugS(ctx, "Incremented restricted slots",
+ "old_restricted", oldRestricted,
+ "new_restricted", a.numRestricted)
+
+ return
+ }
+
+ // Otherwise, this is a newly created outbound connection. We won't
+ // place any restriction on it, instead, we will do a hot upgrade here
+ // to move it from restricted to temporary.
+ peerCount := channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 0,
+ }
+
+ a.peerChanInfo[peerMapKey] = peerCount
+ a.peerScores[peerMapKey] = peerSlotStatus{
+ state: peerStatusTemporary,
+ }
+
+ acsmLog.InfoS(ctx, "Upgraded outbound peer: restricted -> temporary")
+}
+
+// removePeerAccess removes the peer's access from the maps. This should be
+// called when the peer has been disconnected.
+func (a *accessMan) removePeerAccess(ctx context.Context, peerPubKey string) {
+ acsmLog.DebugS(ctx, "Removing access:")
+
+ a.banScoreMtx.Lock()
+ defer a.banScoreMtx.Unlock()
+
+ status, found := a.peerScores[peerPubKey]
+ if !found {
+ acsmLog.InfoS(ctx, "Peer score not found during removal")
+ return
+ }
+
+ if status.state == peerStatusRestricted {
+ // If the status is restricted, then we decrement from
+ // numRestrictedSlots.
+ oldRestricted := a.numRestricted
+ a.numRestricted--
+
+ acsmLog.DebugS(ctx, "Decremented restricted slots",
+ "old_restricted", oldRestricted,
+ "new_restricted", a.numRestricted)
+ }
+
+ acsmLog.TraceS(ctx, "Deleting from peerScores:")
+
+ delete(a.peerScores, peerPubKey)
+
+ // We now check whether this peer has channels with us or not.
+ info, found := a.peerChanInfo[peerPubKey]
+ if !found {
+ acsmLog.DebugS(ctx, "Chan info not found during removal:")
+ return
+ }
+
+ // Exit early if the peer has channel(s) with us.
+ if info.HasOpenOrClosedChan {
+ acsmLog.DebugS(ctx, "Skipped removing peer with channels:")
+ return
+ }
+
+ // Skip removing the peer if it has pending open/close with us.
+ if info.PendingOpenCount != 0 {
+ acsmLog.DebugS(ctx, "Skipped removing peer with pending "+
+ "channels:")
+ return
+ }
+
+ // Given this peer has no channels with us, we can now remove it.
+ delete(a.peerChanInfo, peerPubKey)
+ acsmLog.TraceS(ctx, "Removed peer from peerChanInfo:")
+}
diff --git a/accessman_test.go b/accessman_test.go
new file mode 100644
index 0000000..4f0c492
--- /dev/null
+++ b/accessman_test.go
@@ -0,0 +1,813 @@
+package lnd
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/stretchr/testify/require"
+)
+
+// assertInboundConnection asserts that we're able to accept an inbound
+// connection successfully without any access permissions being violated.
+func assertInboundConnection(t *testing.T, a *accessMan,
+ remotePub *btcec.PublicKey, status peerAccessStatus) {
+
+ remotePubSer := string(remotePub.SerializeCompressed())
+
+ isSlotAvailable, err := a.checkAcceptIncomingConn(remotePub)
+ require.NoError(t, err)
+ require.True(t, isSlotAvailable)
+
+ peerAccess, err := a.assignPeerPerms(remotePub)
+ require.NoError(t, err)
+ require.Equal(t, status, peerAccess)
+
+ a.addPeerAccess(remotePub, peerAccess, true)
+ peerScore, ok := a.peerScores[remotePubSer]
+ require.True(t, ok)
+ require.Equal(t, status, peerScore.state)
+}
+
+func assertAccessState(t *testing.T, a *accessMan, remotePub *btcec.PublicKey,
+ expectedStatus peerAccessStatus) {
+
+ remotePubSer := string(remotePub.SerializeCompressed())
+ peerScore, ok := a.peerScores[remotePubSer]
+ require.True(t, ok)
+ require.Equal(t, expectedStatus, peerScore.state)
+}
+
+// TestAccessManRestrictedSlots tests that the configurable number of
+// restricted slots are properly allocated. It also tests that certain peers
+// with access permissions are allowed to bypass the slot mechanism.
+func TestAccessManRestrictedSlots(t *testing.T) {
+ t.Parallel()
+
+ // We'll pre-populate the map to mock the database fetch. We'll make
+ // three peers. One has an open/closed channel. One has both an open
+ // / closed channel and a pending channel. The last one has only a
+ // pending channel.
+ peerPriv1, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ peerKey1 := peerPriv1.PubKey()
+ peerKeySer1 := string(peerKey1.SerializeCompressed())
+
+ peerPriv2, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ peerKey2 := peerPriv2.PubKey()
+ peerKeySer2 := string(peerKey2.SerializeCompressed())
+
+ peerPriv3, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ peerKey3 := peerPriv3.PubKey()
+ peerKeySer3 := string(peerKey3.SerializeCompressed())
+
+ var (
+ peer1PendingCount = 0
+ peer2PendingCount = 1
+ peer3PendingCount = 1
+ )
+
+ initPerms := func() (map[string]channeldb.ChanCount, error) {
+ return map[string]channeldb.ChanCount{
+ peerKeySer1: {
+ HasOpenOrClosedChan: true,
+ PendingOpenCount: uint64(peer1PendingCount),
+ },
+ peerKeySer2: {
+ HasOpenOrClosedChan: true,
+ PendingOpenCount: uint64(peer2PendingCount),
+ },
+ peerKeySer3: {
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: uint64(peer3PendingCount),
+ },
+ }, nil
+ }
+
+ disconnect := func(*btcec.PublicKey) (bool, error) {
+ return false, nil
+ }
+
+ cfg := &accessManConfig{
+ initAccessPerms: initPerms,
+ shouldDisconnect: disconnect,
+ maxRestrictedSlots: 1,
+ }
+
+ a, err := newAccessMan(cfg)
+ require.NoError(t, err)
+
+ // Check that the peerChanInfo map is correctly populated with three
+ // peers.
+ require.Equal(t, 0, int(a.numRestricted))
+ require.Equal(t, 3, len(a.peerChanInfo))
+
+ peerCount1, ok := a.peerChanInfo[peerKeySer1]
+ require.True(t, ok)
+ require.True(t, peerCount1.HasOpenOrClosedChan)
+ require.Equal(t, peer1PendingCount, int(peerCount1.PendingOpenCount))
+
+ peerCount2, ok := a.peerChanInfo[peerKeySer2]
+ require.True(t, ok)
+ require.True(t, peerCount2.HasOpenOrClosedChan)
+ require.Equal(t, peer2PendingCount, int(peerCount2.PendingOpenCount))
+
+ peerCount3, ok := a.peerChanInfo[peerKeySer3]
+ require.True(t, ok)
+ require.False(t, peerCount3.HasOpenOrClosedChan)
+ require.Equal(t, peer3PendingCount, int(peerCount3.PendingOpenCount))
+
+ // We'll now start to connect the peers. We'll add a new fourth peer
+ // that will take up the restricted slot. The first three peers should
+ // be able to bypass this restricted slot mechanism.
+ peerPriv4, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ peerKey4 := peerPriv4.PubKey()
+
+ // Follow the normal process of an incoming connection. We check if we
+ // can accommodate this peer in checkAcceptIncomingConn and then we
+ // assign its access permissions and then insert into the map.
+ assertInboundConnection(t, a, peerKey4, peerStatusRestricted)
+
+ // Connect the three peers. This should happen without any issue.
+ assertInboundConnection(t, a, peerKey1, peerStatusProtected)
+ assertInboundConnection(t, a, peerKey2, peerStatusProtected)
+ assertInboundConnection(t, a, peerKey3, peerStatusTemporary)
+
+ // Check that a pending-open channel promotes the restricted peer.
+ err = a.newPendingOpenChan(peerKey4)
+ require.NoError(t, err)
+ assertAccessState(t, a, peerKey4, peerStatusTemporary)
+
+ // Assert that accessman's internal state is updated with peer4. We
+ // expect this new peer to have 1 pending open count.
+ peerCount4, ok := a.peerChanInfo[string(peerKey4.SerializeCompressed())]
+ require.True(t, ok)
+ require.False(t, peerCount4.HasOpenOrClosedChan)
+ require.Equal(t, 1, int(peerCount4.PendingOpenCount))
+
+ // Check that an open channel promotes the temporary peer.
+ err = a.newOpenChan(peerKey3)
+ require.NoError(t, err)
+ assertAccessState(t, a, peerKey3, peerStatusProtected)
+
+ // Assert that accessman's internal state is updated with peer3. We
+ // expect this existing peer to decrement its pending open count and the
+ // flag `HasOpenOrClosedChan` should be true.
+ peerCount3, ok = a.peerChanInfo[peerKeySer3]
+ require.True(t, ok)
+ require.True(t, peerCount3.HasOpenOrClosedChan)
+ require.Equal(t, peer3PendingCount-1, int(peerCount3.PendingOpenCount))
+
+ // We should be able to accommodate a new peer.
+ peerPriv5, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ peerKey5 := peerPriv5.PubKey()
+
+ assertInboundConnection(t, a, peerKey5, peerStatusRestricted)
+
+ // Check that a pending-close channel event for peer 4 demotes the
+ // peer.
+ err = a.newPendingCloseChan(peerKey4)
+ require.ErrorIs(t, err, ErrNoMoreRestrictedAccessSlots)
+
+ // Assert that peer4 is removed.
+ _, ok = a.peerChanInfo[string(peerKey4.SerializeCompressed())]
+ require.False(t, ok)
+}
+
+// TestAssignPeerPerms asserts that the peer's access status is correctly
+// assigned.
+func TestAssignPeerPerms(t *testing.T) {
+ t.Parallel()
+
+ // genPeerPub is a helper closure that generates a random public key.
+ genPeerPub := func() *btcec.PublicKey {
+ peerPriv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ return peerPriv.PubKey()
+ }
+
+ disconnect := func(_ *btcec.PublicKey) (bool, error) {
+ return true, nil
+ }
+
+ noDisconnect := func(_ *btcec.PublicKey) (bool, error) {
+ return false, nil
+ }
+
+ var testCases = []struct {
+ name string
+ peerPub *btcec.PublicKey
+ chanCount channeldb.ChanCount
+ shouldDisconnect func(*btcec.PublicKey) (bool, error)
+ numRestricted int
+
+ expectedStatus peerAccessStatus
+ expectedErr error
+ }{
+ // peer1 has a channel with us, and we expect it to have a
+ // protected status.
+ {
+ name: "peer with channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ },
+ shouldDisconnect: noDisconnect,
+ expectedStatus: peerStatusProtected,
+ expectedErr: nil,
+ },
+ // peer2 has a channel open and a pending channel with us, we
+ // expect it to have a protected status.
+ {
+ name: "peer with channels and pending channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ PendingOpenCount: 1,
+ },
+ shouldDisconnect: noDisconnect,
+ expectedStatus: peerStatusProtected,
+ expectedErr: nil,
+ },
+ // peer3 has a pending channel with us, and we expect it to have
+ // a temporary status.
+ {
+ name: "peer with pending channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 1,
+ },
+ shouldDisconnect: noDisconnect,
+ expectedStatus: peerStatusTemporary,
+ expectedErr: nil,
+ },
+ // peer4 has no channel with us, and we expect it to have a
+ // restricted status.
+ {
+ name: "peer with no channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 0,
+ },
+ shouldDisconnect: noDisconnect,
+ expectedStatus: peerStatusRestricted,
+ expectedErr: nil,
+ },
+ // peer5 has no channel with us, and we expect it to have a
+ // restricted status. We also expect the error `ErrGossiperBan`
+ // to be returned given we will use a mocked `shouldDisconnect`
+ // in this test to disconnect on peer5 only.
+ {
+ name: "peer with no channels and banned",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 0,
+ },
+ shouldDisconnect: disconnect,
+ expectedStatus: peerStatusRestricted,
+ expectedErr: ErrGossiperBan,
+ },
+ // peer6 has no channel with us, and we expect it to have a
+ // restricted status. Since this peer is seen, we don't expect
+ // the error `ErrNoMoreRestrictedAccessSlots` to be returned.
+ {
+ name: "peer with no channels and restricted",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 0,
+ },
+ shouldDisconnect: noDisconnect,
+ numRestricted: 1,
+
+ expectedStatus: peerStatusRestricted,
+ expectedErr: nil,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ peerStr := string(tc.peerPub.SerializeCompressed())
+
+ initPerms := func() (map[string]channeldb.ChanCount,
+ error) {
+
+ return map[string]channeldb.ChanCount{
+ peerStr: tc.chanCount,
+ }, nil
+ }
+
+ cfg := &accessManConfig{
+ initAccessPerms: initPerms,
+ shouldDisconnect: tc.shouldDisconnect,
+ maxRestrictedSlots: 1,
+ }
+
+ a, err := newAccessMan(cfg)
+ require.NoError(t, err)
+
+ // Initialize the internal state of the accessman.
+ a.numRestricted = int64(tc.numRestricted)
+
+ status, err := a.assignPeerPerms(tc.peerPub)
+ require.Equal(t, tc.expectedStatus, status)
+ require.ErrorIs(t, tc.expectedErr, err)
+ })
+ }
+}
+
+// TestAssignPeerPermsBypassRestriction asserts that when a peer has a channel
+// with us, either it being open, pending, or closed, no restriction is placed
+// on this peer.
+func TestAssignPeerPermsBypassRestriction(t *testing.T) {
+ t.Parallel()
+
+ // genPeerPub is a helper closure that generates a random public key.
+ genPeerPub := func() *btcec.PublicKey {
+ peerPriv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ return peerPriv.PubKey()
+ }
+
+ // Mock shouldDisconnect to always return true and assert that it has no
+ // effect on the peer.
+ disconnect := func(_ *btcec.PublicKey) (bool, error) {
+ return true, nil
+ }
+
+ var testCases = []struct {
+ name string
+ peerPub *btcec.PublicKey
+ chanCount channeldb.ChanCount
+ expectedStatus peerAccessStatus
+ }{
+ // peer1 has a channel with us, and we expect it to have a
+ // protected status.
+ {
+ name: "peer with channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ },
+ expectedStatus: peerStatusProtected,
+ },
+ // peer2 has a channel open and a pending channel with us, we
+ // expect it to have a protected status.
+ {
+ name: "peer with channels and pending channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ PendingOpenCount: 1,
+ },
+ expectedStatus: peerStatusProtected,
+ },
+ // peer3 has a pending channel with us, and we expect it to have
+ // a temporary status.
+ {
+ name: "peer with pending channels",
+ peerPub: genPeerPub(),
+ chanCount: channeldb.ChanCount{
+ HasOpenOrClosedChan: false,
+ PendingOpenCount: 1,
+ },
+ expectedStatus: peerStatusTemporary,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ peerStr := string(tc.peerPub.SerializeCompressed())
+
+ initPerms := func() (map[string]channeldb.ChanCount,
+ error) {
+
+ return map[string]channeldb.ChanCount{
+ peerStr: tc.chanCount,
+ }, nil
+ }
+
+ // Config the accessman such that it has zero max slots
+ // and always return true on `shouldDisconnect`. We
+ // should see the peers in this test are not affected by
+ // these checks.
+ cfg := &accessManConfig{
+ initAccessPerms: initPerms,
+ shouldDisconnect: disconnect,
+ maxRestrictedSlots: 0,
+ }
+
+ a, err := newAccessMan(cfg)
+ require.NoError(t, err)
+
+ status, err := a.assignPeerPerms(tc.peerPub)
+ require.NoError(t, err)
+ require.Equal(t, tc.expectedStatus, status)
+ })
+ }
+}
+
+// TestAssignPeerPermsBypassExisting asserts that when the peer is a
+// pre-existing peer, it won't be restricted.
+func TestAssignPeerPermsBypassExisting(t *testing.T) {
+ t.Parallel()
+
+ // genPeerPub is a helper closure that generates a random public key.
+ genPeerPub := func() *btcec.PublicKey {
+ peerPriv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ return peerPriv.PubKey()
+ }
+
+ // peer1 exists in `peerChanInfo` map.
+ peer1 := genPeerPub()
+ peer1Str := string(peer1.SerializeCompressed())
+
+ // peer2 exists in `peerScores` map.
+ peer2 := genPeerPub()
+ peer2Str := string(peer2.SerializeCompressed())
+
+ // peer3 is a new peer.
+ peer3 := genPeerPub()
+
+ // Create params to init the accessman.
+ initPerms := func() (map[string]channeldb.ChanCount, error) {
+ return map[string]channeldb.ChanCount{
+ peer1Str: {},
+ }, nil
+ }
+
+ disconnect := func(*btcec.PublicKey) (bool, error) {
+ return false, nil
+ }
+
+ cfg := &accessManConfig{
+ initAccessPerms: initPerms,
+ shouldDisconnect: disconnect,
+ maxRestrictedSlots: 0,
+ }
+
+ a, err := newAccessMan(cfg)
+ require.NoError(t, err)
+
+ // Add peer2 to the `peerScores`.
+ a.peerScores[peer2Str] = peerSlotStatus{
+ state: peerStatusTemporary,
+ }
+
+ // Assigning to peer1 should not return an error.
+ status, err := a.assignPeerPerms(peer1)
+ require.NoError(t, err)
+ require.Equal(t, peerStatusRestricted, status)
+
+ // Assigning to peer2 should not return an error.
+ status, err = a.assignPeerPerms(peer2)
+ require.NoError(t, err)
+ require.Equal(t, peerStatusTemporary, status)
+
+ // Assigning to peer3 should return an error.
+ status, err = a.assignPeerPerms(peer3)
+ require.ErrorIs(t, err, ErrNoMoreRestrictedAccessSlots)
+ require.Equal(t, peerStatusRestricted, status)
+}
+
+// TestHasPeer asserts `hasPeer` returns the correct results.
+func TestHasPeer(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+
+ // Create a testing accessMan.
+ a := &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // peer1 exists with an open channel.
+ peer1 := "peer1"
+ a.peerChanInfo[peer1] = channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ }
+ peer1Access := peerStatusProtected
+
+ // peer2 exists with a pending channel.
+ peer2 := "peer2"
+ a.peerChanInfo[peer2] = channeldb.ChanCount{
+ PendingOpenCount: 1,
+ }
+ peer2Access := peerStatusTemporary
+
+ // peer3 exists without any channels.
+ peer3 := "peer3"
+ a.peerChanInfo[peer3] = channeldb.ChanCount{}
+ peer3Access := peerStatusRestricted
+
+ // peer4 exists with a score.
+ peer4 := "peer4"
+ peer4Access := peerStatusTemporary
+ a.peerScores[peer4] = peerSlotStatus{state: peer4Access}
+
+ // peer5 doesn't exist.
+ peer5 := "peer5"
+
+ // We now assert `hasPeer` returns the correct results.
+ //
+ // peer1 should be found with peerStatusProtected.
+ access, found := a.hasPeer(ctx, peer1)
+ require.True(t, found)
+ require.Equal(t, peer1Access, access)
+
+ // peer2 should be found with peerStatusTemporary.
+ access, found = a.hasPeer(ctx, peer2)
+ require.True(t, found)
+ require.Equal(t, peer2Access, access)
+
+ // peer3 should be found with peerStatusRestricted.
+ access, found = a.hasPeer(ctx, peer3)
+ require.True(t, found)
+ require.Equal(t, peer3Access, access)
+
+ // peer4 should be found with peerStatusTemporary.
+ access, found = a.hasPeer(ctx, peer4)
+ require.True(t, found)
+ require.Equal(t, peer4Access, access)
+
+ // peer5 should NOT be found.
+ access, found = a.hasPeer(ctx, peer5)
+ require.False(t, found)
+ require.Equal(t, peerStatusRestricted, access)
+}
+
+// TestAddPeerAccessInbound asserts the num of slots is correctly incremented
+// only for a new inbound peer with restricted access.
+func TestAddPeerAccessInbound(t *testing.T) {
+ t.Parallel()
+
+ // Create a testing accessMan.
+ a := &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // Create a testing key.
+ priv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pub := priv.PubKey()
+ pubStr := string(pub.SerializeCompressed())
+
+ // Add this peer as an inbound peer with peerStatusRestricted.
+ a.addPeerAccess(pub, peerStatusRestricted, true)
+
+ // Assert the accessMan's internal state.
+ //
+ // We expect to see one peer found in the score map, and one slot is
+ // taken, and this peer is not found in the counts map.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(1), a.numRestricted)
+ require.NotContains(t, a.peerChanInfo, pubStr)
+
+ // The peer should be found in the score map.
+ score, ok := a.peerScores[pubStr]
+ require.True(t, ok)
+
+ expecedScore := peerSlotStatus{state: peerStatusRestricted}
+ require.Equal(t, expecedScore, score)
+
+ // Add this peer again, we expect the available slots to stay unchanged.
+ a.addPeerAccess(pub, peerStatusRestricted, true)
+
+ // Assert the internal state is not changed.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(1), a.numRestricted)
+ require.NotContains(t, a.peerChanInfo, pubStr)
+
+ // Reset the accessMan.
+ a = &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // Add this peer as an inbound peer with peerStatusTemporary.
+ a.addPeerAccess(pub, peerStatusTemporary, true)
+
+ // Assert the accessMan's internal state.
+ //
+ // We expect to see one peer found in the score map, and no slot is
+ // taken since this peer is not restricted.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(0), a.numRestricted)
+
+ // NOTE: in reality this is not possible as the peer must have been put
+ // into the map `peerChanInfo` before its perm can be upgraded.
+ require.NotContains(t, a.peerChanInfo, pubStr)
+
+ // The peer should be found in the score map.
+ score, ok = a.peerScores[pubStr]
+ require.True(t, ok)
+
+ expecedScore = peerSlotStatus{state: peerStatusTemporary}
+ require.Equal(t, expecedScore, score)
+}
+
+// TestAddPeerAccessOutbound asserts that outbound peer is not restricted and
+// its perm is upgraded when it has peerStatusRestricted.
+func TestAddPeerAccessOutbound(t *testing.T) {
+ t.Parallel()
+
+ // Create a testing accessMan.
+ a := &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // Create a testing key.
+ priv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pub := priv.PubKey()
+ pubStr := string(pub.SerializeCompressed())
+
+ // Add this peer as an outbound peer with peerStatusRestricted.
+ a.addPeerAccess(pub, peerStatusRestricted, false)
+
+ // Assert the accessMan's internal state.
+ //
+ // We expect to see one peer found in the score map, and no slot is
+ // taken, and this peer is found in the counts map.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(0), a.numRestricted)
+ require.Contains(t, a.peerChanInfo, pubStr)
+
+ // The peer should be found in the score map.
+ score, ok := a.peerScores[pubStr]
+ require.True(t, ok)
+
+ // Its perm should be upgraded to temporary.
+ expecedScore := peerSlotStatus{state: peerStatusTemporary}
+ require.Equal(t, expecedScore, score)
+
+ // The peer should be found in the peer counts map.
+ count, ok := a.peerChanInfo[pubStr]
+ require.True(t, ok)
+
+ // The peer's count should be initialized correctly.
+ require.Zero(t, count.PendingOpenCount)
+ require.False(t, count.HasOpenOrClosedChan)
+
+ // Add this peer again, we expect the available slots to stay unchanged.
+ a.addPeerAccess(pub, peerStatusRestricted, true)
+
+ // Assert the internal state is not changed.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(0), a.numRestricted)
+ require.Contains(t, a.peerChanInfo, pubStr)
+
+ // Reset the accessMan.
+ a = &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // Add this peer as an inbound peer with peerStatusTemporary.
+ a.addPeerAccess(pub, peerStatusTemporary, true)
+
+ // Assert the accessMan's internal state.
+ //
+ // We expect to see one peer found in the score map, and no slot is
+ // taken since this peer is not restricted.
+ require.Len(t, a.peerScores, 1)
+ require.Equal(t, int64(0), a.numRestricted)
+
+ // NOTE: in reality this is not possible as the peer must have been put
+ // into the map `peerChanInfo` before its perm can be upgraded.
+ require.NotContains(t, a.peerChanInfo, pubStr)
+
+ // The peer should be found in the score map.
+ score, ok = a.peerScores[pubStr]
+ require.True(t, ok)
+
+ expecedScore = peerSlotStatus{state: peerStatusTemporary}
+ require.Equal(t, expecedScore, score)
+}
+
+// TestRemovePeerAccess asserts `removePeerAccess` correctly update the
+// accessman's internal state based on the peer's status.
+func TestRemovePeerAccess(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+
+ // Create a testing accessMan.
+ a := &accessMan{
+ peerChanInfo: make(map[string]channeldb.ChanCount),
+ peerScores: make(map[string]peerSlotStatus),
+ }
+
+ // numRestrictedExpected specifies the final value to expect once the
+ // test finishes.
+ var numRestrictedExpected int
+
+ // peer1 exists with an open channel, which should not be removed. Since
+ // it has protected status, the numRestricted should stay unchanged.
+ peer1 := "peer1"
+ a.peerChanInfo[peer1] = channeldb.ChanCount{
+ HasOpenOrClosedChan: true,
+ }
+ peer1Access := peerStatusProtected
+ a.peerScores[peer1] = peerSlotStatus{state: peer1Access}
+
+ // peer2 exists with a pending channel, which should not be removed.
+ // Since it has temporary status, the numRestricted should stay
+ // unchanged.
+ peer2 := "peer2"
+ a.peerChanInfo[peer2] = channeldb.ChanCount{
+ PendingOpenCount: 1,
+ }
+ peer2Access := peerStatusTemporary
+ a.peerScores[peer2] = peerSlotStatus{state: peer2Access}
+
+ // peer3 exists without any channels, which will be removed. Since it
+ // has restricted status, the numRestricted should be decremented.
+ peer3 := "peer3"
+ a.peerChanInfo[peer3] = channeldb.ChanCount{}
+ peer3Access := peerStatusRestricted
+ a.peerScores[peer3] = peerSlotStatus{state: peer3Access}
+ numRestrictedExpected--
+
+ // peer4 exists with a score and a temporary status, which will be
+ // removed.
+ peer4 := "peer4"
+ peer4Access := peerStatusTemporary
+ a.peerScores[peer4] = peerSlotStatus{state: peer4Access}
+
+ // peer5 doesn't exist, removing it will be a NOOP.
+ peer5 := "peer5"
+
+ // We now assert `removePeerAccess` behaves as expected.
+ //
+ // Remove peer1 should change nothing.
+ a.removePeerAccess(ctx, peer1)
+
+ // peer1 should be removed from peerScores but not peerChanInfo.
+ _, found := a.peerScores[peer1]
+ require.False(t, found)
+ _, found = a.peerChanInfo[peer1]
+ require.True(t, found)
+
+ // Remove peer2 should change nothing.
+ a.removePeerAccess(ctx, peer2)
+
+ // peer2 should be removed from peerScores but not peerChanInfo.
+ _, found = a.peerScores[peer2]
+ require.False(t, found)
+ _, found = a.peerChanInfo[peer2]
+ require.True(t, found)
+
+ // Remove peer3 should remove it from the maps.
+ a.removePeerAccess(ctx, peer3)
+
+ // peer3 should be removed from peerScores and peerChanInfo.
+ _, found = a.peerScores[peer3]
+ require.False(t, found)
+ _, found = a.peerChanInfo[peer3]
+ require.False(t, found)
+
+ // Remove peer4 should remove it from the maps.
+ a.removePeerAccess(ctx, peer4)
+
+ // peer4 should be removed from peerScores and NOT be found in
+ // peerChanInfo.
+ _, found = a.peerScores[peer4]
+ require.False(t, found)
+ _, found = a.peerChanInfo[peer4]
+ require.False(t, found)
+
+ // Remove peer5 should be NOOP.
+ a.removePeerAccess(ctx, peer5)
+
+ // peer5 should NOT be found.
+ _, found = a.peerScores[peer5]
+ require.False(t, found)
+ _, found = a.peerChanInfo[peer5]
+ require.False(t, found)
+
+ // Finally, assert the numRestricted is decremented as expected. Given
+ // we only have peer3 which has restricted access, it should decrement
+ // once.
+ //
+ // NOTE: The value is actually negative here, which is allowed in
+ // accessman.
+ require.EqualValues(t, numRestrictedExpected, a.numRestricted)
+}
diff --git a/actor/README.md b/actor/README.md
new file mode 100644
index 0000000..0d138bb
--- /dev/null
+++ b/actor/README.md
@@ -0,0 +1,478 @@
+# Actor Package
+
+## Introduction to Actors
+
+The actor model is a conceptual model for concurrent computation that treats
+"actors" as the universal primitives of concurrent computation. Originating from
+Carl Hewitt's work in the 1970s and popularized by languages like Erlang and
+frameworks like Akka, actors provide a high-level abstraction for building
+robust, concurrent, and distributed systems.
+
+At its core, an actor is an independent unit of computation that encapsulates:
+- **State**: An actor can maintain private state that it alone can modify.
+- **Behavior**: An actor defines how it reacts to messages it receives.
+- **Mailbox**: Each actor has a mailbox to queue incoming messages.
+
+Actors communicate exclusively through asynchronous message passing. When an
+actor receives a message, it can:
+1. Send a finite number of messages to other actors.
+2. Create a finite number of new actors.
+3. Designate the behavior to be used for the next message it receives (which
+ can be the same behavior).
+
+Concurrency is managed by the actor system, allowing many actors to execute
+concurrently without explicit lock management by the developer for actor state.
+This model inherently promotes loose coupling, as actors do not share state and
+interact only through messages.
+
+## Motivation for this Package
+
+In large, long-lived systems like `lnd`, managing complexity, concurrency, and
+component lifecycles becomes increasingly challenging. This `actor` package is
+introduced to address several key motivations:
+
+### Structured Message Passing
+
+To move away from direct, synchronous method calls between major components,
+especially where concurrency or complex state interactions are involved. Message
+passing encourages clearer, more auditable interactions and helps manage
+concurrent access to component state.
+
+### Eliminating "God Structs"
+
+Over time, systems can develop large "god structs" that hold references to
+numerous sub-systems. This leads to tight coupling, makes dependency management
+difficult, and can obscure the flow of control and data. Actors, by
+encapsulating state and behavior and interacting via messages, help break down
+these monolithic structures into more manageable, independent units.
+
+### Decoupled Lifecycles
+
+Often, the lifecycle of a sub-system is unnecessarily tied to a parent system,
+or access to a sub-system requires traversing through a central "manager"
+object. Actors can have independent lifecycles managed by an actor system,
+allowing for more granular control over starting, stopping, and restarting
+components.
+
+An example of such interaction is when an RPC call needs to go through several
+other structs to obtain a reference to a given sub-system, in order to make a
+direct method call on that sub-system.
+
+With the model described in this document, the RPC server just needs to know
+about what is effectively an _abstract address_ of that sub-system. It can then
+use that to obtain something similar to a mailbox to do the method call.
+
+This allows for a more decoupled architecture, as the RPC server doesn't need to
+know the exact "shape" of the method to call, just which message to send.
+Refactors of the sub-system won't break the RPC server, as long as the message
+(which can be constructed via a dedicated constructor) is the same.
+
+---
+
+This package provides a foundational actor framework tailored for Go, enabling
+developers to build components that are easier to reason about, test, and
+maintain in a concurrent environment.
+
+## Core Concepts
+
+Let's explore the fundamental building blocks provided by this package.
+
+### Messages
+
+Actors communicate by sending and receiving messages. Any type that an actor
+needs to process must implement the `actor.Message` interface. A simple way to
+do this is by embedding `actor.BaseMessage`:
+
+```go
+package mymodule
+
+import "github.com/lightningnetwork/lnd/actor"
+
+// MyRequest is a custom message type.
+type MyRequest struct {
+ // Embed BaseMessage to satisfy the Message interface.
+ actor.BaseMessage
+ Data string
+}
+
+// MessageType returns a string identifier for this message type.
+func (m *MyRequest) MessageType() string {
+ return "MyRequest"
+}
+
+// MyResponse might be a corresponding response type.
+type MyResponse struct {
+ actor.BaseMessage
+ Reply string
+}
+
+func (m *MyResponse) MessageType() string {
+ return "MyResponse"
+}
+```
+The `MessageType()` method provides a string representation of the message type,
+which can be useful for debugging or routing.
+
+
+### Actor Behavior
+
+The logic of an actor (how it responds to messages) is defined by its
+`ActorBehavior`. This is an interface that you implement:
+
+```go
+package actor
+
+// ActorBehavior defines the logic for how an actor processes incoming messages.
+type ActorBehavior[M Message, R any] interface {
+ Receive(actorCtx context.Context, msg M) fn.Result[R]
+}
+```
+The `Receive` method passes in a caller context (useful for shutdown detection)
+and the incoming message. It returns an `fn.Result[R]`, which can encapsulate
+either a successful response of type `R` or an error.
+
+For simple cases, you can use `actor.FunctionBehavior` to adapt a Go function
+into an `ActorBehavior`:
+
+```go
+import (
+ "context"
+ "fmt"
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// myActorLogic defines the processing for MyRequest messages.
+func myActorLogic(ctx context.Context, msg *MyRequest) fn.Result[*MyResponse] {
+ // In a real actor, you might interact with state or other services.
+ // The actor's context (ctx) can be checked for shutdown signals.
+ select {
+ case <-ctx.Done():
+ return fn.Err[*MyResponse](errors.New("actor shutting down"))
+ default:
+ }
+
+ response := &MyResponse{Reply: fmt.Sprintf("Processed: %s", msg.Data)}
+ return fn.Ok(response)
+}
+
+// Create a behavior from the function.
+behavior := actor.NewFunctionBehavior(myActorLogic)
+```
+
+For more complex cases, you can implement the `Receive` method on a new struct,
+and pass that around directly.
+
+### Service Keys and Actor References: The Interaction Layer
+
+Direct interaction with an actor's internal state or its concrete struct is
+discouraged. Instead, communication and discovery are managed through two key
+abstractions: `ServiceKey` and `ActorRef`. These provide a layer of indirection,
+promoting loose coupling and location transparency (though the current
+implementation is in-process).
+
+#### `ServiceKey[M Message, R any]`
+
+A `ServiceKey` is a type-safe identifier used for registering actors that
+provide a particular service and for discovering them later. The generic type
+parameters `M` (the type of message the actor handles) and `R` (the type of
+response the actor produces for `Ask` operations) ensure that you discover
+actors compatible with the interactions you intend to perform.
+
+```go
+// Define a service key for actors that handle MyRequest and produce MyResponse.
+myServiceKey := actor.NewServiceKey[*MyRequest, *MyResponse]("my-custom-service")
+
+// Later, this key would be used with a Receptionist (part of an ActorSystem)
+// to find ActorRefs for actors offering this service.
+```
+
+#### `ActorRef[M Message, R any]`
+
+An `ActorRef` is a lightweight, shareable reference to an actor. It's the
+primary means by which you send messages to an actor. It is also generic over
+the message type `M` and response type `R` that the target actor handles.
+
+You typically obtain an `ActorRef` by looking it up in a `Receptionist` using a
+`ServiceKey` (covered later when discussing the `ActorSystem`), or directly from
+an actor instance via its `.Ref()` method (e.g., `sampleActor.Ref()` if you have
+the `Actor` instance).
+
+There are two main ways to send messages using an `ActorRef`:
+
+1. **Tell (Fire-and-Forget)**: Used for sending messages when you don't need a
+ direct reply. The call returns immediately after attempting to enqueue the
+ message.
+
+ ```go
+ // Assuming 'actorRef' is an ActorRef[*MyRequest, *MyResponse] obtained for an actor.
+ requestMsg := &MyRequest{Data: "A fire-and-forget message"}
+ actorRef.Tell(context.Background(), requestMsg)
+ // The message is now in the actor's mailbox (or will be shortly).
+ ```
+ The `context.Context` passed to `Tell` can be used to cancel the send
+ operation if, for example, the actor's mailbox is full and the send would
+ block for too long.
+
+2. **Ask (Request-Response)**: Used when you need a response from the actor.
+ This returns a `Future[R]`, which represents the eventual reply.
+
+ ```go
+ // Assuming 'actorRef' is an ActorRef[*MyRequest, *MyResponse].
+ askMsg := &MyRequest{Data: "A request needing a response"}
+ futureResponse := actorRef.Ask(context.Background(), askMsg)
+ ```
+ A `Future[R]` represents a result that will be available at some point. You
+ can block until it's ready using `Await`:
+
+ ```go
+ // Await the result. It's good practice to use a context with a timeout.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ result := futureResponse.Await(ctx)
+ response, err := result.Unpack()
+ if err != nil {
+ fmt.Printf("Ask failed: %v\n", err)
+ // return or handle error
+ } else {
+ fmt.Printf("Received reply: %s\n", response.Reply)
+ }
+ ```
+ The `Future` interface also offers non-blocking ways to handle results, like
+ `OnComplete` (for callbacks) and `ThenApply` (for chaining transformations).
+ A more restricted `TellOnlyRef[M]` is also available if only fire-and-forget
+ semantics are required (obtained via an actor's `TellRef()` method).
+
+### Actors
+
+An `Actor` is the concrete entity that runs a behavior, manages a mailbox, and
+has a lifecycle. You create an actor using `actor.NewActor` with an
+`ActorConfig`:
+
+```go
+cfg := actor.ActorConfig[*MyRequest, *MyResponse]{
+ ID: "my-sample-actor",
+ Behavior: behavior,
+ MailboxSize: 10,
+ // Dead Letter Office (covered later)
+ DLO: nil,
+}
+sampleActor, err := actor.NewActor(cfg)
+if err != nil {
+ // Handle invalid config (empty ID, nil behavior).
+ return err
+}
+```
+
+An actor doesn't start processing messages until its `Start()` method is called.
+This launches a dedicated goroutine for the actor.
+
+```go
+sampleActor.Start()
+```
+To stop an actor, you call its `Stop()` method. This cancels the actor's
+internal context, causing its goroutine to clean up and exit.
+
+```go
+// Sometime later...
+sampleActor.Stop()
+```
+
+
+## Visualizing Actor Relationships
+
+The following diagram illustrates the primary components of the actor package
+and their relationships. It provides a high-level overview of how actors are
+managed, discovered, and interacted with.
+
+```mermaid
+classDiagram
+ direction TB
+
+ class ActorSystem {
+ +Receptionist
+ +DeadLetters
+ +Shutdown()
+ }
+
+ class Receptionist {
+ +Find(ServiceKey) ActorRef[]
+ +Register(ServiceKey, ActorRef)
+ }
+
+ class DeadLetterOffice {
+ +Receive(undeliverable Message)
+ }
+
+ class ServiceKey {
+ +Spawn(ActorSystem, Behavior) ActorRef
+ }
+
+ class Actor {
+ -mailbox
+ -behavior
+ +Ref() ActorRef
+ +Start()
+ +Stop()
+ }
+
+ class ActorRef {
+ <<Interface>>
+ +Tell(Message)
+ +Ask(Message) Future
+ }
+
+ class Message {
+ <<Interface>>
+ }
+
+ class Future {
+ +Await() Result
+ }
+
+ class Router {
+ +Tell(Message)
+ +Ask(Message) Future
+ }
+
+ %% Core system relationships
+ ActorSystem *-- Receptionist : has
+ ActorSystem *-- DeadLetterOffice : provides
+ ActorSystem o-- "manages" Actor
+
+ %% Actor and communication
+ Actor --> ActorRef : provides
+ Actor ..> Message : processes
+ ActorRef ..> Message : sends
+ ActorRef ..> Future : returns for Ask
+
+ %% Service discovery and routing
+ Receptionist o-- ServiceKey : uses for lookup
+ ServiceKey ..> Actor : creates
+ Router --> ActorRef : routes to
+ Router --> Receptionist : discovers actors via
+
+ note for ActorSystem "Central manager for actor lifecycle and service discovery"
+ note for Actor "Independent unit with encapsulated state and behavior"
+ note for ActorRef "Location-transparent handle for sending messages"
+ note for Message "Data exchanged between actors"
+ note for ServiceKey "Type-safe identifier for actor registration and discovery"
+ note for Router "Distributes messages among multiple actors"
+ note for DeadLetterOffice "Handles messages that cannot be delivered"
+```
+
+## The Actor System
+
+While individual actors are useful, they often need to be managed and
+coordinated. The `ActorSystem` serves this purpose.
+
+```go
+system := actor.NewActorSystem()
+// Ensures all actors in the system are stopped.
+defer system.Shutdown()
+```
+
+### Actor Lifecycle and Registration
+
+The `ActorSystem` can manage the lifecycle of actors. You can register actors
+with the system:
+
+```go
+// Using 'behavior' from earlier and 'myServiceKey' defined in the
+// "Service Keys and Actor References" section.
+
+// RegisterWithSystem creates, starts, and registers the actor.
+actorRefFromSystem := actor.RegisterWithSystem(
+ system, "system-managed-actor", myServiceKey, behavior,
+)
+```
+
+Alternatively, a `ServiceKey` itself provides a `Spawn` method for convenience:
+```go
+actorRefSpawned := myServiceKey.Spawn(system, "spawned-actor", behavior)
+```
+
+Actors registered with the system are automatically stopped when
+`system.Shutdown()` is called. You can also stop and remove individual actors
+using `system.StopAndRemoveActor(actorID)`.
+
+A `ServiceKey` is essentially the mailbox address of an actor.
+
+### Receptionist: Service Discovery
+
+Actors often need to find other actors to communicate with. The `Receptionist`
+facilitates this. Actors are registered with the receptionist using a
+`ServiceKey`, which is type-safe.
+
+```go
+// Get the system's receptionist.
+receptionist := system.Receptionist()
+
+// Find actors registered for a specific service key.
+foundRefs := actor.FindInReceptionist(receptionist, myServiceKey)
+if len(foundRefs) > 0 {
+ targetActor := foundRefs[0]
+ targetActor.Tell(context.Background(), &MyRequest{Data: "Hello from a discoverer!"})
+} else {
+ fmt.Println("No actors found for service key:", myServiceKey)
+}
+```
+When an actor is stopped (e.g., via `ServiceKey.Unregister` or system shutdown),
+it should also be unregistered from the receptionist.
+
+### Dead Letter Office (DLO)
+
+What happens to messages that cannot be delivered? For example, if an actor is
+stopped while messages are still in its mailbox, or if a message is sent to an
+actor that doesn't exist (though the current `ActorRef` design makes the latter
+less likely for direct sends).
+
+The `ActorSystem` provides a default `DeadLetterActor`. When an actor is
+configured (via `ActorConfig.DLO`), undeliverable messages (e.g., those drained
+from its mailbox upon shutdown) can be routed to this DLO. This allows for
+logging, auditing, or potential manual intervention for "lost" messages.
+
+```go
+// Actors created via RegisterWithSystem or ServiceKey.Spawn
+// are automatically configured to use the system's DLO.
+// system.DeadLetters() returns an ActorRef to the system's DLO.
+```
+
+## Routers: Distributing Work
+
+Sometimes, you might have multiple actors performing the same kind of task, and
+you want to distribute messages among them. A `Router` can do this. It's not an
+actor itself but acts as a dispatcher.
+
+A `Router` uses a `RoutingStrategy` to pick one actor from a group registered
+under a `ServiceKey`.
+
+```go
+// Assume 'system' and 'myServiceKey' are set up, and multiple actors
+// are registered with 'myServiceKey'.
+
+// Create a round-robin routing strategy.
+roundRobinStrategy := actor.NewRoundRobinStrategy[*MyRequest, *MyResponse]()
+
+// Create a router for 'myServiceKey' using this strategy.
+// Messages sent to this router will be forwarded to one of the actors
+// registered under 'myServiceKey'.
+// The router also needs a DLO for messages it can't route (e.g., if no actors are available).
+serviceRouter := actor.NewRouter(
+ system.Receptionist(),
+ myServiceKey,
+ roundRobinStrategy,
+ system.DeadLetters(),
+)
+
+// Now, interact with the router as if it were an ActorRef:
+serviceRouter.Tell(context.Background(), &MyRequest{Data: "Message via router"})
+
+futureReplyFromRouter := serviceRouter.Ask(context.Background(), &MyRequest{Data: "Ask via router"})
+// ... await futureReplyFromRouter ...
+```
+If the router cannot find any available actors for the `ServiceKey` (e.g., none
+are registered or running), `Tell` operations will typically send the message to
+the router's configured DLO, and `Ask` operations will return a `Future`
+completed with `ErrNoActorsAvailable`.
diff --git a/actor/actor.go b/actor/actor.go
new file mode 100644
index 0000000..f75b4bb
--- /dev/null
+++ b/actor/actor.go
@@ -0,0 +1,271 @@
+package actor
+
+import (
+ "context"
+ "sync"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// ActorConfig holds the configuration parameters for creating a new Actor.
+// It is generic over M (Message type) and R (Response type) to accommodate
+// the actor's specific behavior.
+type ActorConfig[M Message, R any] struct {
+ // ID is the unique identifier for the actor.
+ ID string
+
+ // Behavior defines how the actor responds to messages.
+ Behavior ActorBehavior[M, R]
+
+ // DLO is a reference to the dead letter office for this actor system.
+ // If nil, undeliverable messages during shutdown or due to a full
+ // mailbox (if such logic were added) might be dropped.
+ DLO ActorRef[Message, any]
+
+ // MailboxSize defines the buffer capacity of the actor's mailbox.
+ MailboxSize int
+}
+
+// envelope wraps a message with its associated promise. This allows the sender
+// of an "ask" message to await a response. If the promise is nil, it
+// signifies a "tell" operation (fire-and-forget).
+type envelope[M Message, R any] struct {
+ message M
+ promise Promise[R]
+}
+
+// Actor represents a concrete actor implementation. It encapsulates a behavior,
+// manages its internal state implicitly through that behavior, and processes
+// messages from its mailbox sequentially in its own goroutine.
+type Actor[M Message, R any] struct {
+ // id is the unique identifier for the actor.
+ id string
+
+ // behavior defines how the actor responds to messages.
+ behavior ActorBehavior[M, R]
+
+ // mailbox is the incoming message queue for the actor.
+ mailbox Mailbox[M, R]
+
+ // ctx is the context governing the actor's lifecycle.
+ ctx context.Context
+
+ // cancel is the function to cancel the actor's context.
+ cancel context.CancelFunc
+
+ // dlo is a reference to the dead letter office for this actor system.
+ dlo ActorRef[Message, any]
+
+ // startOnce ensures the actor's processing loop is started only once.
+ startOnce sync.Once
+
+ // stopOnce ensures the actor's processing loop is stopped only once.
+ stopOnce sync.Once
+
+ // ref is the cached ActorRef for this actor.
+ ref ActorRef[M, R]
+}
+
+// NewActor creates a new actor instance with the given ID and behavior.
+// It initializes the actor's internal structures but does not start its
+// message processing goroutine. The Start() method must be called to begin
+// processing messages.
+func NewActor[M Message, R any](cfg ActorConfig[M, R]) (*Actor[M, R],
+ error) {
+
+ if cfg.ID == "" {
+ return nil, ErrEmptyActorID
+ }
+
+ if cfg.Behavior == nil {
+ return nil, ErrNilBehavior
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ // Ensure MailboxSize has a sane default if not specified or zero. A
+ // capacity of 0 would make the channel unbuffered, which is generally
+ // not desired for actor mailboxes.
+ mailboxCapacity := cfg.MailboxSize
+ if mailboxCapacity <= 0 {
+ // Default to a small capacity if an invalid one is given. This
+ // could also come from a global constant.
+ mailboxCapacity = 1
+ }
+
+ // Create mailbox - could be injected via config in the future.
+ mailbox := NewChannelMailbox[M, R](ctx, mailboxCapacity)
+
+ actor := &Actor[M, R]{
+ id: cfg.ID,
+ behavior: cfg.Behavior,
+ mailbox: mailbox,
+ ctx: ctx,
+ cancel: cancel,
+ dlo: cfg.DLO,
+ }
+
+ // Create and cache the actor's own reference.
+ actor.ref = &actorRefImpl[M, R]{
+ actor: actor,
+ }
+
+ return actor, nil
+}
+
+// Start initiates the actor's message processing loop in a new goroutine. This
+// method should be called once after the actor is created.
+func (a *Actor[M, R]) Start() {
+ a.startOnce.Do(func() {
+ log.Infof("Actor %s: starting", a.id)
+
+ go a.process()
+ })
+}
+
+// process is the main event loop for the actor. It continuously monitors its
+// mailbox for incoming messages and its context for cancellation signals.
+func (a *Actor[M, R]) process() {
+ // Use the new iterator pattern for receiving messages.
+ for env := range a.mailbox.Receive(a.ctx) {
+ result := a.behavior.Receive(a.ctx, env.message)
+
+ // If a promise was provided (i.e., it was an "ask"
+ // operation), complete the promise with the result from
+ // the behavior.
+ if env.promise != nil {
+ env.promise.Complete(result)
+ }
+ }
+
+ // Context was cancelled or mailbox closed, drain remaining messages.
+ a.mailbox.Close()
+
+ for env := range a.mailbox.Drain() {
+ // If a DLO is configured, send the original message there
+ // for auditing or potential manual reprocessing.
+ if a.dlo != nil {
+ a.dlo.Tell(context.Background(), env.message)
+ }
+
+ // If it was an Ask, complete the promise with an error
+ // indicating the actor terminated.
+ if env.promise != nil {
+ env.promise.Complete(fn.Err[R](ErrActorTerminated))
+ }
+ }
+}
+
+// Stop signals the actor to terminate its processing loop and shut down.
+// This is achieved by cancelling the actor's internal context. The actor's
+// goroutine will exit once it detects the context cancellation.
+func (a *Actor[M, R]) Stop() {
+ a.stopOnce.Do(func() {
+ log.Infof("Actor %s: stopping", a.id)
+
+ a.cancel()
+ })
+}
+
+// actorRefImpl provides a concrete implementation of the ActorRef interface. It
+// holds a reference to the target Actor instance, enabling message sending.
+type actorRefImpl[M Message, R any] struct {
+ actor *Actor[M, R]
+}
+
+// Tell sends a message without waiting for a response. If the context is
+// cancelled before the message can be sent to the actor's mailbox, the message
+// may be dropped.
+//
+//nolint:ll
+func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) {
+ // If the actor's own context is already done, don't try to send.
+ // Route to DLO if available.
+ if ref.actor.ctx.Err() != nil {
+ ref.trySendToDLO(msg)
+ return
+ }
+
+ env := envelope[M, R]{message: msg, promise: nil}
+
+ // Use mailbox Send method which internally checks both contexts.
+ if !ref.actor.mailbox.Send(ctx, env) {
+ // Failed to send - check if actor terminated.
+ if ref.actor.ctx.Err() != nil {
+ ref.trySendToDLO(msg)
+ }
+ // Otherwise it was the caller's context that cancelled.
+ }
+}
+
+// Ask sends a message and returns a Future for the response. The Future will be
+// completed with the actor's reply or an error if the operation fails (e.g.,
+// context cancellation before send).
+//
+//nolint:ll
+func (ref *actorRefImpl[M, R]) Ask(ctx context.Context, msg M) Future[R] {
+ // Create a new promise that will be fulfilled with the actor's response.
+ promise := NewPromise[R]()
+
+ // If the actor's own context is already done, complete the promise with
+ // ErrActorTerminated and return immediately. This is the primary guard
+ // against trying to send to a stopped actor.
+ if ref.actor.ctx.Err() != nil {
+ promise.Complete(fn.Err[R](ErrActorTerminated))
+ return promise.Future()
+ }
+
+ // Check if the context is already done before attempting to send. This
+ // ensures deterministic behavior and prevents a race where the message
+ // could be enqueued even though the context was already cancelled.
+ if ctx.Err() != nil {
+ promise.Complete(fn.Err[R](ctx.Err()))
+ return promise.Future()
+ }
+
+ env := envelope[M, R]{message: msg, promise: promise}
+
+ // Use mailbox Send method which internally checks both contexts.
+ if !ref.actor.mailbox.Send(ctx, env) {
+ // Determine the error based on what failed.
+ if ref.actor.ctx.Err() != nil {
+ promise.Complete(fn.Err[R](ErrActorTerminated))
+ } else {
+ promise.Complete(fn.Err[R](ctx.Err()))
+ }
+ }
+
+ // Return the future associated with the promise, allowing the caller to
+ // await the response.
+ return promise.Future()
+}
+
+// trySendToDLO attempts to send the message to the actor's DLO if configured.
+func (ref *actorRefImpl[M, R]) trySendToDLO(msg M) {
+ if ref.actor.dlo != nil {
+ // Use context.Background() for sending to DLO as the
+ // original context might be done or the operation
+ // should not be bound by it.
+ // This Tell to DLO is fire-and-forget.
+ ref.actor.dlo.Tell(context.Background(), msg)
+ }
+}
+
+// ID returns the unique identifier for this actor.
+func (ref *actorRefImpl[M, R]) ID() string {
+ return ref.actor.id
+}
+
+// Ref returns an ActorRef for this actor. This allows clients to interact with
+// the actor (send messages) without having direct access to the Actor struct
+// itself, promoting encapsulation and location transparency.
+func (a *Actor[M, R]) Ref() ActorRef[M, R] {
+ return a.ref
+}
+
+// TellRef returns a TellOnlyRef for this actor. This allows clients to send
+// messages to the actor using only the "tell" pattern (fire-and-forget),
+// without having access to "ask" capabilities.
+func (a *Actor[M, R]) TellRef() TellOnlyRef[M] {
+ return a.ref
+}
diff --git a/actor/actor_test.go b/actor/actor_test.go
new file mode 100644
index 0000000..3d49aa6
--- /dev/null
+++ b/actor/actor_test.go
@@ -0,0 +1,446 @@
+package actor
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// testMsg is a simple message type for testing. It embeds BaseMessage to
+// satisfy the actor.Message interface.
+type testMsg struct {
+ BaseMessage
+ data string
+
+ replyChan chan string
+}
+
+// MessageType returns the type name of the message.
+func (m *testMsg) MessageType() string {
+ return "testMsg"
+}
+
+// newTestMsg creates a new test message.
+func newTestMsg(data string) *testMsg {
+ return &testMsg{data: data}
+}
+
+// newTestMsgWithReply creates a new test message that includes a reply channel.
+// This can be used by test behaviors to send data back to the test
+// synchronously, especially for Tell operations.
+func newTestMsgWithReply(data string, replyChan chan string) *testMsg {
+ return &testMsg{data: data, replyChan: replyChan}
+}
+
+// echoBehavior is a simple actor behavior that processes *testMsg messages. It
+// stores the last message's data and, for Ask, echoes it back. For Tell, if
+// replyChan is set in testMsg, it sends data back on it.
+type echoBehavior struct {
+ lastMsgData atomic.Value
+ processingDelay time.Duration
+ t *testing.T
+}
+
+// newEchoBehavior creates a new echoBehavior.
+func newEchoBehavior(t *testing.T, delay time.Duration) *echoBehavior {
+ return &echoBehavior{t: t, processingDelay: delay}
+}
+
+// Receive handles incoming messages. It simulates work if processingDelay is
+// set, stores the message data, and responds for Ask operations or via
+// replyChan for Tell.
+func (b *echoBehavior) Receive(_ context.Context,
+ msg *testMsg) fn.Result[string] {
+
+ if b.processingDelay > 0 {
+ time.Sleep(b.processingDelay)
+ }
+
+ b.lastMsgData.Store(msg.data)
+
+ if msg.replyChan != nil {
+ // Attempt to send the data on the reply channel, but quit if
+ // it takes longer than 1 second (e.g., channel unbuffered
+ // and no receiver).
+ select {
+ case msg.replyChan <- msg.data:
+ case <-time.After(time.Second):
+ b.t.Logf("warning: replyChan send timed out")
+ }
+ }
+
+ return fn.Ok(fmt.Sprintf("echo: %s", msg.data))
+}
+
+// GetLastMsgData retrieves the data from the last message processed.
+func (b *echoBehavior) GetLastMsgData() (string, bool) {
+ val := b.lastMsgData.Load()
+ if val == nil {
+ return "", false
+ }
+ data, ok := val.(string)
+ return data, ok
+}
+
+// errorBehavior is an actor behavior that always returns a predefined error
+// upon receiving a message.
+type errorBehavior struct {
+ err error
+}
+
+// newErrorBehavior creates a new errorBehavior.
+func newErrorBehavior(err error) *errorBehavior {
+ return &errorBehavior{err: err}
+}
+
+// Receive always returns the configured error.
+func (b *errorBehavior) Receive(_ context.Context,
+ _ *testMsg) fn.Result[string] {
+
+ return fn.Err[string](b.err)
+}
+
+// blockingBehavior is an actor behavior that blocks until its actorCtx is done.
+type blockingBehavior struct{}
+
+// Receive blocks until the actor's context is cancelled, then returns the
+// context's error.
+func (b *blockingBehavior) Receive(actorCtx context.Context,
+ _ *testMsg) fn.Result[string] {
+
+ <-actorCtx.Done()
+ return fn.Err[string](actorCtx.Err())
+}
+
+// deadLetterTestMsg is a distinct message type used for testing DLO
+// interactions.
+type deadLetterTestMsg struct {
+ BaseMessage
+ id string
+}
+
+// MessageType returns the type name of the message.
+func (m *deadLetterTestMsg) MessageType() string {
+ return "deadLetterTestMsg"
+}
+
+// deadLetterObserverBehavior is a behavior for a test Dead Letter Office actor.
+// It records all messages sent to it, allowing tests to verify DLO
+// interactions.
+type deadLetterObserverBehavior struct {
+ mu sync.Mutex
+ receivedMsgs []Message
+}
+
+// newDeadLetterObserverBehavior creates a new deadLetterObserverBehavior.
+func newDeadLetterObserverBehavior() *deadLetterObserverBehavior {
+ return &deadLetterObserverBehavior{
+ receivedMsgs: make([]Message, 0),
+ }
+}
+
+// Receive records the incoming message and returns a successful result.
+func (b *deadLetterObserverBehavior) Receive(_ context.Context,
+ msg Message) fn.Result[any] {
+
+ b.mu.Lock()
+ b.receivedMsgs = append(b.receivedMsgs, msg)
+ b.mu.Unlock()
+
+ return fn.Ok[any](nil)
+}
+
+// GetReceivedMsgs returns a copy of all messages received by this DLO.
+func (b *deadLetterObserverBehavior) GetReceivedMsgs() []Message {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ msgs := make([]Message, len(b.receivedMsgs))
+ copy(msgs, b.receivedMsgs)
+
+ return msgs
+}
+
+// actorTestHarness provides helper methods for setting up actors in tests. It
+// manages a dedicated DLO for actors created through it.
+type actorTestHarness struct {
+ t *testing.T
+ dlo *Actor[Message, any]
+ dloBeh *deadLetterObserverBehavior
+}
+
+// newActorTestHarness sets up a test harness with a dedicated DLO. The DLO is
+// automatically stopped when the test cleans up.
+func newActorTestHarness(t *testing.T) *actorTestHarness {
+ t.Helper()
+
+ dloBeh := newDeadLetterObserverBehavior()
+ dloCfg := ActorConfig[Message, any]{
+ ID: "test-dlo-" + t.Name(),
+ Behavior: dloBeh,
+ DLO: nil,
+ MailboxSize: 10,
+ }
+ dloActor, err := NewActor[Message, any](dloCfg)
+ require.NoError(t, err)
+ dloActor.Start()
+
+ t.Cleanup(dloActor.Stop)
+
+ return &actorTestHarness{
+ t: t,
+ dlo: dloActor,
+ dloBeh: dloBeh,
+ }
+}
+
+// newActor creates, starts, and registers a new actor for cleanup. The actor
+// will use the harness's DLO.
+func (h *actorTestHarness) newActor(id string,
+ beh ActorBehavior[*testMsg, string],
+ mailboxSize int) *Actor[*testMsg, string] {
+
+ h.t.Helper()
+
+ cfg := ActorConfig[*testMsg, string]{
+ ID: id,
+ Behavior: beh,
+ DLO: h.dlo.Ref(),
+ MailboxSize: mailboxSize,
+ }
+ actor, err := NewActor(cfg)
+ require.NoError(h.t, err)
+ actor.Start()
+
+ h.t.Cleanup(actor.Stop)
+
+ return actor
+}
+
+// assertDLOMessage checks that the DLO eventually receives a specific message.
+func (h *actorTestHarness) assertDLOMessage(expectedMsg Message) {
+ h.t.Helper()
+ require.Eventually(h.t, func() bool {
+ msgs := h.dloBeh.GetReceivedMsgs()
+ for _, m := range msgs {
+ if reflect.DeepEqual(m, expectedMsg) {
+ return true
+ }
+ }
+ return false
+ }, time.Second, 10*time.Millisecond,
+ "dLO did not receive expected message: %v", expectedMsg,
+ )
+}
+
+// assertNoDLOMessages checks that the DLO has not received any messages.
+func (h *actorTestHarness) assertNoDLOMessages() {
+ h.t.Helper()
+
+ // Allow a very brief moment for any async DLO sends to occur.
+ time.Sleep(20 * time.Millisecond)
+
+ msgs := h.dloBeh.GetReceivedMsgs()
+
+ require.Empty(h.t, msgs, "dLO received unexpected messages")
+}
+
+// TestActorNewActorIDAndRefs verifies that NewActor correctly initializes an
+// actor's ID and provides functional ActorRef and TellOnlyRef instances.
+func TestActorNewActorIDAndRefs(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+ actorID := "test-actor-1"
+ beh := newEchoBehavior(t, 0)
+ actor := h.newActor(actorID, beh, 1)
+
+ require.Equal(t, actorID, actor.Ref().ID(), "actorRef ID mismatch")
+ require.Equal(
+ t, actorID, actor.TellRef().ID(), "tellOnlyRef ID mismatch",
+ )
+ require.NotNil(t, actor.Ref(), "actorRef should not be nil")
+ require.NotNil(t, actor.TellRef(), "tellOnlyRef should not be nil")
+}
+
+// TestActorStartStop verifies the basic lifecycle of an actor: starting,
+// processing messages, and stopping.
+func TestActorStartStop(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+ beh := newEchoBehavior(t, 0)
+ actor := h.newActor("test-actor-lifecycle", beh, 1)
+
+ // Actor should be running and process a message.
+ msgData := "hello"
+ replyChan := make(chan string, 1)
+ actor.Ref().Tell(
+ context.Background(), newTestMsgWithReply(msgData, replyChan),
+ )
+
+ received, err := fn.RecvOrTimeout(replyChan, 100*time.Millisecond)
+ require.NoError(t, err, "timed out waiting for actor to process message")
+ require.Equal(
+ t, msgData, received, "actor did not process message before stop",
+ )
+
+ actor.Stop()
+ time.Sleep(50 * time.Millisecond)
+
+ // Try sending another message; it should ideally not be processed or go
+ // to DLO.
+ msgDataAfterStop := "message-after-stop"
+ replyChanAfterStop := make(chan string, 1)
+ actor.Ref().Tell(
+ context.Background(),
+ newTestMsgWithReply(msgDataAfterStop, replyChanAfterStop),
+ )
+
+ // We expect a timeout here, meaning the message was not processed by
+ // the echoBehavior's replyChan.
+ _, err = fn.RecvOrTimeout(replyChanAfterStop, 100*time.Millisecond)
+ // err == nil would mean a message was received, meaning the actor
+ // processed it after Stop().
+ require.Error(t, err, "actor processed message after Stop()")
+ require.ErrorContains(t, err, "timeout hit")
+
+ h.assertDLOMessage(
+ &testMsg{data: msgDataAfterStop, replyChan: replyChanAfterStop},
+ )
+}
+
+// TestActorTellBasic verifies that a message sent via Tell is processed by the
+// actor's behavior.
+func TestActorTellBasic(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+ beh := newEchoBehavior(t, 0)
+ actor := h.newActor("test-actor-tell", beh, 1)
+
+ msgData := "tell-message"
+ replyChan := make(chan string, 1)
+ actor.Ref().Tell(
+ context.Background(), newTestMsgWithReply(msgData, replyChan),
+ )
+
+ receivedTell, errTell := fn.RecvOrTimeout(replyChan, 100*time.Millisecond)
+ require.NoError(t, errTell, "timed out waiting for Tell message processing")
+ require.Equal(
+ t, msgData, receivedTell, "behavior did not receive Tell message data",
+ )
+
+ lastData, ok := beh.GetLastMsgData()
+ require.True(t, ok, "last message data not set in behavior")
+ require.Equal(t, msgData, lastData, "last message data mismatch")
+ h.assertNoDLOMessages()
+}
+
+// TestActorAskSuccess verifies that a message sent via Ask is processed, and
+// the returned Future is completed with the behavior's successful result.
+func TestActorAskSuccess(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+ beh := newEchoBehavior(t, 0)
+ actor := h.newActor("test-actor-ask-success", beh, 1)
+
+ msgData := "ask-message"
+ future := actor.Ref().Ask(context.Background(), newTestMsg(msgData))
+
+ result := future.Await(context.Background())
+ require.False(t, result.IsErr(), "ask returned an error: %v", result.Err())
+
+ result.WhenOk(func(val string) {
+ expectedReply := fmt.Sprintf("echo: %s", msgData)
+ require.Equal(t, expectedReply, val, "ask response mismatch")
+ })
+
+ lastData, ok := beh.GetLastMsgData()
+ require.True(t, ok, "last message data not set in behavior")
+ require.Equal(t, msgData, lastData, "last message data mismatch")
+ h.assertNoDLOMessages()
+}
+
+// TestActorAskErrorBehavior verifies that if an actor's behavior returns an
+// error, the Future from an Ask call is completed with that error.
+func TestActorAskErrorBehavior(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+ expectedErr := errors.New("behavior error")
+ beh := newErrorBehavior(expectedErr)
+ actor := h.newActor("test-actor-ask-error", beh, 1)
+
+ future := actor.Ref().Ask(
+ context.Background(), newTestMsg("ask-error-test"),
+ )
+
+ result := future.Await(context.Background())
+ require.True(t, result.IsErr(), "ask should have returned an error")
+ require.ErrorIs(t, result.Err(), expectedErr, "ask error mismatch")
+
+ h.assertNoDLOMessages()
+}
+
+// TestFunctionBehaviorFromSimple verifies that FunctionBehaviorFromSimple
+// correctly adapts a simple (msg) -> (result, error) function into an
+// ActorBehavior, handling both success and error cases.
+func TestFunctionBehaviorFromSimple(t *testing.T) {
+ t.Parallel()
+
+ t.Run("success", func(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+
+ beh := FunctionBehaviorFromSimple(
+ func(msg *testMsg) (string, error) {
+ return "simple: " + msg.data, nil
+ },
+ )
+ actor := h.newActor("test-simple-success", beh, 1)
+
+ future := actor.Ref().Ask(
+ context.Background(), newTestMsg("hello"),
+ )
+ result := future.Await(context.Background())
+ require.False(
+ t, result.IsErr(),
+ "expected success, got: %v", result.Err(),
+ )
+ result.WhenOk(func(val string) {
+ require.Equal(t, "simple: hello", val)
+ })
+ })
+
+ t.Run("error", func(t *testing.T) {
+ t.Parallel()
+
+ h := newActorTestHarness(t)
+
+ expectedErr := errors.New("simple behavior error")
+ beh := FunctionBehaviorFromSimple(
+ func(msg *testMsg) (string, error) {
+ return "", expectedErr
+ },
+ )
+ actor := h.newActor("test-simple-error", beh, 1)
+
+ future := actor.Ref().Ask(
+ context.Background(), newTestMsg("hello"),
+ )
+ result := future.Await(context.Background())
+ require.True(t, result.IsErr())
+ require.ErrorIs(t, result.Err(), expectedErr)
+ })
+}
diff --git a/actor/example_basic_actor_test.go b/actor/example_basic_actor_test.go
new file mode 100644
index 0000000..960602a
--- /dev/null
+++ b/actor/example_basic_actor_test.go
@@ -0,0 +1,101 @@
+package actor_test
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// BasicGreetingMsg is a simple message type for the basic actor example.
+type BasicGreetingMsg struct {
+ actor.BaseMessage
+ Name string
+}
+
+// MessageType implements actor.Message.
+func (m BasicGreetingMsg) MessageType() string { return "BasicGreetingMsg" }
+
+// BasicGreetingResponse is a simple response type.
+type BasicGreetingResponse struct {
+ Greeting string
+}
+
+// ExampleActor demonstrates creating a single actor, sending it a message
+// directly using Ask, and then unregistering and stopping it.
+func ExampleActor() {
+ system := actor.NewActorSystem()
+ defer system.Shutdown()
+
+ //nolint:ll
+ greeterKey := actor.NewServiceKey[BasicGreetingMsg, BasicGreetingResponse](
+ "basic-greeter",
+ )
+
+ actorID := "my-greeter"
+ greeterBehavior := actor.NewFunctionBehavior(
+ func(ctx context.Context,
+ msg BasicGreetingMsg) fn.Result[BasicGreetingResponse] {
+
+ return fn.Ok(BasicGreetingResponse{
+ Greeting: "Hello, " + msg.Name + " from " +
+ actorID,
+ })
+ },
+ )
+
+ // Spawn the actor. This registers it with the system and receptionist,
+ // and starts it. It returns an ActorRef.
+ greeterRef, err := greeterKey.Spawn(system, actorID, greeterBehavior)
+ if err != nil {
+ fmt.Printf("Failed to spawn actor: %v\n", err)
+ return
+ }
+ fmt.Printf("Actor %s spawned.\n", greeterRef.ID())
+
+ // Send a message directly to the actor's reference.
+ askCtx, askCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ defer askCancel()
+ futureResponse := greeterRef.Ask(
+ askCtx, BasicGreetingMsg{Name: "World"},
+ )
+
+ awaitCtx, awaitCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ defer awaitCancel()
+ result := futureResponse.Await(awaitCtx)
+
+ result.WhenErr(func(err error) {
+ fmt.Printf("Error awaiting response: %v\n", err)
+ })
+ result.WhenOk(func(response BasicGreetingResponse) {
+ fmt.Printf("Received: %s\n", response.Greeting)
+ })
+
+ // Unregister the actor. This also stops the actor.
+ unregistered := greeterKey.Unregister(system, greeterRef)
+ if unregistered {
+ fmt.Printf("Actor %s unregistered and stopped.\n",
+ greeterRef.ID())
+ } else {
+ fmt.Printf("Failed to unregister actor %s.\n", greeterRef.ID())
+ }
+
+ // Verify it's no longer in the receptionist.
+ refsAfterUnregister := actor.FindInReceptionist(
+ system.Receptionist(), greeterKey,
+ )
+ fmt.Printf("Actors for key '%s' after unregister: %d\n",
+ "basic-greeter", len(refsAfterUnregister))
+
+ // Output:
+ // Actor my-greeter spawned.
+ // Received: Hello, World from my-greeter
+ // Actor my-greeter unregistered and stopped.
+ // Actors for key 'basic-greeter' after unregister: 0
+}
diff --git a/actor/example_router_test.go b/actor/example_router_test.go
new file mode 100644
index 0000000..f68cff6
--- /dev/null
+++ b/actor/example_router_test.go
@@ -0,0 +1,120 @@
+package actor_test
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// RouterGreetingMsg is a message type for the router example.
+type RouterGreetingMsg struct {
+ actor.BaseMessage
+ Name string
+}
+
+// MessageType implements actor.Message.
+func (m RouterGreetingMsg) MessageType() string { return "RouterGreetingMsg" }
+
+// RouterGreetingResponse is a response type for the router example.
+type RouterGreetingResponse struct {
+ Greeting string
+ HandlerID string
+}
+
+// ExampleRouter demonstrates creating multiple actors under the same service
+// key and using a router to dispatch messages to them.
+func ExampleRouter() {
+ system := actor.NewActorSystem()
+ defer system.Shutdown()
+
+ //nolint:ll
+ routerGreeterKey := actor.NewServiceKey[RouterGreetingMsg, RouterGreetingResponse](
+ "router-greeter-service",
+ )
+
+ // Behavior for the first greeter actor.
+ actorID1 := "router-greeter-1"
+ greeterBehavior1 := actor.NewFunctionBehavior(
+ func(ctx context.Context,
+ msg RouterGreetingMsg) fn.Result[RouterGreetingResponse] {
+
+ return fn.Ok(RouterGreetingResponse{
+ Greeting: "Greetings, " + msg.Name + "!",
+ HandlerID: actorID1,
+ })
+ },
+ )
+ _, err := routerGreeterKey.Spawn(system, actorID1, greeterBehavior1)
+ if err != nil {
+ fmt.Printf("Failed to spawn actor: %v\n", err)
+ return
+ }
+ fmt.Printf("Actor %s spawned.\n", actorID1)
+
+ // Behavior for the second greeter actor.
+ actorID2 := "router-greeter-2"
+ greeterBehavior2 := actor.NewFunctionBehavior(
+ func(ctx context.Context,
+ msg RouterGreetingMsg) fn.Result[RouterGreetingResponse] {
+
+ return fn.Ok(RouterGreetingResponse{
+ Greeting: "Salutations, " + msg.Name + "!",
+ HandlerID: actorID2,
+ })
+ },
+ )
+ _, err = routerGreeterKey.Spawn(system, actorID2, greeterBehavior2)
+ if err != nil {
+ fmt.Printf("Failed to spawn actor: %v\n", err)
+ return
+ }
+ fmt.Printf("Actor %s spawned.\n", actorID2)
+
+ // Create a router for the "router-greeter-service".
+ greeterRouter := actor.NewRouter(
+ system.Receptionist(), routerGreeterKey,
+ actor.NewRoundRobinStrategy[RouterGreetingMsg,
+ RouterGreetingResponse](),
+ system.DeadLetters(),
+ )
+ fmt.Printf("Router %s created for service key '%s'.\n",
+ greeterRouter.ID(), "router-greeter-service")
+
+ // Send messages through the router.
+ names := []string{"Alice", "Bob", "Charlie", "David"}
+ for _, name := range names {
+ askCtx, askCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ futureResponse := greeterRouter.Ask(
+ askCtx, RouterGreetingMsg{Name: name},
+ )
+
+ awaitCtx, awaitCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ result := futureResponse.Await(awaitCtx)
+
+ result.WhenErr(func(err error) {
+ fmt.Printf("For %s: Error - %v\n", name, err)
+ })
+ result.WhenOk(func(response RouterGreetingResponse) {
+ fmt.Printf("For %s: Received '%s' from %s\n",
+ name, response.Greeting, response.HandlerID)
+ })
+ awaitCancel()
+ askCancel()
+ }
+
+ // Output:
+ // Actor router-greeter-1 spawned.
+ // Actor router-greeter-2 spawned.
+ // Router router(router-greeter-service) created for service key 'router-greeter-service'.
+ // For Alice: Received 'Greetings, Alice!' from router-greeter-1
+ // For Bob: Received 'Salutations, Bob!' from router-greeter-2
+ // For Charlie: Received 'Greetings, Charlie!' from router-greeter-1
+ // For David: Received 'Salutations, David!' from router-greeter-2
+}
diff --git a/actor/example_struct_actor_test.go b/actor/example_struct_actor_test.go
new file mode 100644
index 0000000..e92e1e1
--- /dev/null
+++ b/actor/example_struct_actor_test.go
@@ -0,0 +1,153 @@
+package actor_test
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// CounterMsg is a message type for the stateful counter actor.
+// It can be used to increment the counter or get its current value.
+type CounterMsg struct {
+ actor.BaseMessage
+ Increment int
+ GetValue bool
+ Who string
+}
+
+// MessageType implements actor.Message.
+func (m CounterMsg) MessageType() string { return "CounterMsg" }
+
+// CounterResponse is a response type for the counter actor.
+type CounterResponse struct {
+ Value int
+ Responder string
+}
+
+// StatefulCounterActor demonstrates an actor that maintains internal state (a
+// counter) and processes messages to modify or query that state.
+type StatefulCounterActor struct {
+ counter int
+ actorID string
+}
+
+// NewStatefulCounterActor creates a new counter actor.
+func NewStatefulCounterActor(id string) *StatefulCounterActor {
+ return &StatefulCounterActor{
+ actorID: id,
+ }
+}
+
+// Receive is the message handler for the StatefulCounterActor.
+// It implements the actor.ActorBehavior interface implicitly when wrapped.
+func (s *StatefulCounterActor) Receive(ctx context.Context,
+ msg CounterMsg) fn.Result[CounterResponse] {
+
+ if msg.Increment > 0 {
+ // For increment, we can just acknowledge or return the new
+ // value. Messages are sent serially, so we don't need to worry
+ // about a mutex here.
+ s.counter += msg.Increment
+
+ return fn.Ok(CounterResponse{
+ Value: s.counter,
+ Responder: s.actorID,
+ })
+ }
+
+ if msg.GetValue {
+ return fn.Ok(CounterResponse{
+ Value: s.counter,
+ Responder: s.actorID,
+ })
+ }
+
+ return fn.Err[CounterResponse](fmt.Errorf("invalid CounterMsg"))
+}
+
+// ExampleActor_stateful demonstrates creating an actor whose behavior is defined
+// by a struct with methods, allowing it to maintain internal state.
+func ExampleActor_stateful() {
+ system := actor.NewActorSystem()
+ defer system.Shutdown()
+
+ counterServiceKey := actor.NewServiceKey[CounterMsg, CounterResponse](
+ "struct-counter-service",
+ )
+
+ // Create an instance of our stateful actor logic.
+ actorID := "counter-actor-1"
+ counterLogic := NewStatefulCounterActor(actorID)
+
+ // Spawn the actor.
+ // The counterLogic instance itself satisfies the ActorBehavior
+ // interface because its Receive method matches the required signature.
+ counterRef, err := counterServiceKey.Spawn(
+ system, actorID, counterLogic,
+ )
+ if err != nil {
+ fmt.Printf("Failed to spawn actor: %v\n", err)
+ return
+ }
+ fmt.Printf("Actor %s spawned.\n", counterRef.ID())
+
+ // Send messages to increment the counter.
+ for i := 1; i <= 3; i++ {
+ askCtx, askCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ futureResp := counterRef.Ask(askCtx,
+ CounterMsg{
+ Increment: i,
+ Who: fmt.Sprintf("Incrementer-%d", i),
+ },
+ )
+ awaitCtx, awaitCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ resp := futureResp.Await(awaitCtx)
+
+ resp.WhenOk(func(r CounterResponse) {
+ fmt.Printf("Incremented by %d, new value: %d "+
+ "(from %s)\n", i, r.Value, r.Responder)
+ })
+ resp.WhenErr(func(e error) {
+ fmt.Printf("Error incrementing: %v\n", e)
+ })
+ awaitCancel()
+ askCancel()
+ }
+
+ // Send a message to get the current value.
+ askCtx, askCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+ futureResp := counterRef.Ask(
+ askCtx, CounterMsg{GetValue: true, Who: "Getter"},
+ )
+
+ awaitCtx, awaitCancel := context.WithTimeout(
+ context.Background(), 1*time.Second,
+ )
+
+ finalValueResp := futureResp.Await(awaitCtx)
+ finalValueResp.WhenOk(func(r CounterResponse) {
+ fmt.Printf("Final counter value: %d (from %s)\n",
+ r.Value, r.Responder)
+ })
+ finalValueResp.WhenErr(func(e error) {
+ fmt.Printf("Error getting value: %v\n", e)
+ })
+ awaitCancel()
+ askCancel()
+
+ // Output:
+ // Actor counter-actor-1 spawned.
+ // Incremented by 1, new value: 1 (from counter-actor-1)
+ // Incremented by 2, new value: 3 (from counter-actor-1)
+ // Incremented by 3, new value: 6 (from counter-actor-1)
+ // Final counter value: 6 (from counter-actor-1)
+}
diff --git a/actor/example_tell_only_test.go b/actor/example_tell_only_test.go
new file mode 100644
index 0000000..a637131
--- /dev/null
+++ b/actor/example_tell_only_test.go
@@ -0,0 +1,137 @@
+package actor_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/lightningnetwork/lnd/actor"
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// LogMsg is a message type for the TellOnly example.
+type LogMsg struct {
+ actor.BaseMessage
+ Text string
+}
+
+// MessageType implements actor.Message.
+func (m LogMsg) MessageType() string { return "LogMsg" }
+
+// LoggerActorBehavior is a simple actor behavior that logs messages. It doesn't
+// produce a meaningful response for Ask, so it's a good candidate for TellOnly
+// interactions.
+type LoggerActorBehavior struct {
+ mu sync.Mutex
+ logs []string
+ actorID string
+}
+
+func NewLoggerActorBehavior(id string) *LoggerActorBehavior {
+ return &LoggerActorBehavior{actorID: id}
+}
+
+// Receive processes LogMsg messages by appending them to an internal log. The
+// response type is 'any' as it's not typically used with Ask.
+func (l *LoggerActorBehavior) Receive(ctx context.Context,
+ msg actor.Message) fn.Result[any] {
+
+ logMessage, ok := msg.(LogMsg)
+ if !ok {
+ return fn.Err[any](fmt.Errorf("unexpected message "+
+ "type: %s", msg.MessageType()))
+ }
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ entry := fmt.Sprintf("[%s from %s]: %s", time.Now().Format("15:04:05"),
+ l.actorID, logMessage.Text)
+ l.logs = append(l.logs, entry)
+
+ // For Tell, the result is often ignored, but we must return something.
+ return fn.Ok[any](nil)
+}
+
+func (l *LoggerActorBehavior) GetLogs() []string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ copiedLogs := make([]string, len(l.logs))
+ copy(copiedLogs, l.logs)
+
+ return copiedLogs
+}
+
+// ExampleTellOnlyRef demonstrates using a TellOnlyRef for fire-and-forget
+// messaging with an actor.
+func ExampleTellOnlyRef() {
+ system := actor.NewActorSystem()
+ defer system.Shutdown()
+
+ // The logger actor doesn't really have a response type for Ask, so we
+ // use 'any'.
+ loggerServiceKey := actor.NewServiceKey[actor.Message, any](
+ "tell-only-logger-service",
+ )
+
+ actorID := "my-logger"
+ loggerLogic := NewLoggerActorBehavior(actorID)
+
+ // Spawn the actor.
+ fullRef, err := loggerServiceKey.Spawn(system, actorID, loggerLogic)
+ if err != nil {
+ fmt.Printf("Failed to spawn actor: %v\n", err)
+ return
+ }
+ fmt.Printf("Actor %s spawned.\n", fullRef.ID())
+
+ // Get a TellOnlyRef for the actor. We can get this from the Actor
+ // instance itself if we had it, or by type assertion if we know the
+ // underlying ref supports it. Since fullRef is ActorRef[actor.Message,
+ // any], it already satisfies TellOnlyRef[actor.Message].
+ //
+ // Or, if we had the *Actor instance: tellOnlyLogger =
+ // actorInstance.TellRef()
+ var tellOnlyLogger actor.TellOnlyRef[actor.Message] = fullRef
+
+ fmt.Printf("Obtained TellOnlyRef for %s.\n", tellOnlyLogger.ID())
+
+ // Send messages using Tell.
+ tellOnlyLogger.Tell(
+ context.Background(), LogMsg{Text: "First log entry."},
+ )
+ tellOnlyLogger.Tell(
+ context.Background(), LogMsg{Text: "Second log entry."},
+ )
+
+ // Allow some time for messages to be processed.
+ time.Sleep(10 * time.Millisecond)
+
+ // Retrieve logs directly from the behavior for verification in this
+ // example. In a real scenario, this might not be possible or desired.
+ logs := loggerLogic.GetLogs()
+ fmt.Println("Logged entries:")
+ for _, entry := range logs {
+ // Strip the timestamp and actor ID for consistent example
+ // output. Example entry: "[15:04:05 from my-logger]: Actual log
+ // text"
+ parts := strings.SplitN(entry, "]: ", 2)
+ if len(parts) == 2 {
+ fmt.Println(parts[1])
+ }
+ }
+
+ // Attempting to Ask using tellOnlyLogger would be a compile-time error:
+ // tellOnlyLogger.Ask(context.Background(), LogMsg{Text: "This would
+ // fail"})
+
+ // Output:
+ // Actor my-logger spawned.
+ // Obtained TellOnlyRef for my-logger.
+ // Logged entries:
+ // First log entry.
+ // Second log entry.
+}
diff --git a/actor/func_actor.go b/actor/func_actor.go
new file mode 100644
index 0000000..f1580f0
--- /dev/null
+++ b/actor/func_actor.go
@@ -0,0 +1,45 @@
+package actor
+
+import (
+ "context"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// ActorFunc is a function type that represents an actor which functions purely
+// based on a simple function processor.
+type ActorFunc[M Message, R any] func(context.Context, M) fn.Result[R]
+
+// FunctionBehavior adapts a function to the ActorBehavior interface.
+type FunctionBehavior[M Message, R any] struct {
+ fn ActorFunc[M, R]
+}
+
+// NewFunctionBehavior creates a behavior from a function.
+func NewFunctionBehavior[M Message, R any](
+ fn ActorFunc[M, R]) *FunctionBehavior[M, R] {
+
+ return &FunctionBehavior[M, R]{fn: fn}
+}
+
+// Receive implements ActorBehavior interface for the function.
+//
+// TODO(roasbeef): just base it off the function direct instead?
+func (b *FunctionBehavior[M, R]) Receive(ctx context.Context,
+ msg M) fn.Result[R] {
+
+ return b.fn(ctx, msg)
+}
+
+// FunctionBehaviorFromSimple adapts a simpler function to the ActorBehavior
+// interface.
+func FunctionBehaviorFromSimple[M Message, R any](
+ sFunc func(M) (R, error)) *FunctionBehavior[M, R] {
+
+ return NewFunctionBehavior(
+ func(ctx context.Context, msg M) fn.Result[R] {
+ val, err := sFunc(msg)
+ return fn.NewResult(val, err)
+ },
+ )
+}
diff --git a/actor/future.go b/actor/future.go
new file mode 100644
index 0000000..8c21169
--- /dev/null
+++ b/actor/future.go
@@ -0,0 +1,158 @@
+package actor
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+)
+
+// promiseImpl is a structure that can be used to complete a Future. It provides
+// methods to set the result of an asynchronous operation and to obtain the
+// Future interface for consumers.
+// The promiseImpl itself is not typically exposed directly to consumers of the
+// future's result; they interact with the Future interface.
+type promiseImpl[T any] struct {
+ fut *futureImpl[T]
+}
+
+// NewPromise creates a new Promise. The associated Future, which consumers can
+// use to await the result, can be obtained via the Future() method. The Future
+// is completed by calling the Complete() method on this Promise.
+func NewPromise[T any]() Promise[T] {
+ return &promiseImpl[T]{
+ fut: &futureImpl[T]{
+ // done is a channel that will be closed when the future
+ // is completed.
+ done: make(chan struct{}),
+ },
+ }
+}
+
+// Future returns the Future interface associated with this Promise. Consumers
+// can use this to Await the result or register callbacks.
+func (p *promiseImpl[T]) Future() Future[T] {
+ return p.fut
+}
+
+// Complete attempts to set the result of the future. It returns true if this
+// call successfully set the result (i.e., it was the first to complete it),
+// and false if the future had already been completed. This ensures that a
+// future can only be completed once. The completion involves storing the result
+// and signaling any goroutines waiting on the future's done channel.
+func (p *promiseImpl[T]) Complete(result fn.Result[T]) bool {
+ var success bool
+ p.fut.completeOnce.Do(func() {
+ p.fut.resultCache.Store(&result)
+ close(p.fut.done)
+
+ success = true
+ })
+
+ return success
+}
+
+// futureImpl is the concrete implementation of the Future interface. It manages
+// the state of an asynchronous computation's result.
+type futureImpl[T any] struct {
+ // resultCache stores the fn.Result[T] after the future is completed.
+ // It's of type atomic.Pointer to allow lock-free reads after completion
+ // with improved type safety over atomic.Value.
+ resultCache atomic.Pointer[fn.Result[T]]
+
+ // done is closed once the future is completed, signaling any waiting
+ // Await calls.
+ done chan struct{}
+
+ // completeOnce ensures that the logic to set the result and close the
+ // done channel is executed only once.
+ completeOnce sync.Once
+}
+
+// Await blocks until the result is available or the passed context is
+// cancelled. If the future is already completed, it returns the result
+// immediately. Otherwise, it waits for either the future's completion or the
+// context's cancellation.
+func (f *futureImpl[T]) Await(ctx context.Context) fn.Result[T] {
+ // First, try a non-blocking load from the cache. If the future is
+ // already completed, this will return the result directly.
+ if resPtr := f.resultCache.Load(); resPtr != nil {
+ return *resPtr
+ }
+
+ // Wait for either the future to be done or the context to be cancelled.
+ select {
+ case <-f.done:
+ // The future has been completed. Load the result from the
+ // cache. It must be present now. Load and dereference.
+ // This load is safe because the 'done' channel is closed only
+ // after the resultCache is written (ensured by completeOnce).
+ resPtr := f.resultCache.Load()
+
+ // resPtr should not be nil here as <-f.done was signaled.
+ return *resPtr
+
+ case <-ctx.Done():
+ // The waiting context was cancelled before the future completed.
+ return fn.Err[T](ctx.Err())
+ }
+}
+
+// ThenApply registers a function to transform the result of a future. The
+// original future is not modified; a new Future instance representing the
+// transformed result is returned. Once the original future completes
+// successfully, the provided transformation function (fApply) is called with
+// the result. The transformation is applied asynchronously in a new goroutine.
+// If the passed context is cancelled while waiting for the
+// original future to complete, the returned future will yield the context's
+// error.
+func (f *futureImpl[T]) ThenApply(ctx context.Context,
+ fApply func(T) T) Future[T] {
+
+ // Create a new promise for the transformed result.
+ transformedPromise := NewPromise[T]()
+
+ go func() {
+ // Await the original future's result, respecting the passed
+ // context for cancellation.
+ originalResult := f.Await(ctx)
+
+ // If the original future completed with an error (or Await was
+ // cancelled by its context), complete the transformed future
+ // with the same error.
+ // This also handles the case where originalResult.Await(ctx)
+ // itself returned ctx.Err().
+ if originalResult.IsErr() {
+ transformedPromise.Complete(originalResult)
+ return
+ }
+
+ // Otherwise, the original future completed successfully. Apply the
+ // transformation function to its result.
+ originalResult.WhenOk(func(res T) {
+ newValue := fApply(res)
+ transformedPromise.Complete(fn.Ok(newValue))
+ })
+ }()
+
+ return transformedPromise.Future()
+}
+
+// OnComplete registers a function to be called when the result is ready. If the
+// passed context is cancelled before the future completes, the callback
+// function (cFunc) will be invoked with the context's error. The callback is
+// executed in a new goroutine, so it does not block the completion path of the
+// original future.
+func (f *futureImpl[T]) OnComplete(ctx context.Context,
+ cFunc func(fn.Result[T])) {
+
+ go func() {
+ // Await the original future's result, respecting the passed
+ // context for cancellation.
+ result := f.Await(ctx)
+
+ // Call the callback function with the result.
+ cFunc(result)
+ }()
+}
diff --git a/actor/future_test.go b/actor/future_test.go
new file mode 100644
index 0000000..3d56d2c
--- /dev/null
+++ b/actor/future_test.go
@@ -0,0 +1,467 @@
+package actor
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
+)
+
+// TestFutureAwaitContextCancellation tests that Await respects context
+// cancellation if the context is cancelled before the future resolves.
+func TestFutureAwaitContextCancellation(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ // Test cancellation when the Await context is cancelled via
+ // context.Cancel. The underlying future will not be completed, allowing
+ // us to test the cancellation path of Await.
+ prom1 := NewPromise[int]()
+ fut1 := prom1.Future()
+ ctx1, cancel1 := context.WithCancel(context.Background())
+
+ // We'll cancel the future immediately after creating it.
+ cancel1()
+
+ result1 := fut1.Await(ctx1)
+
+ require.True(t, result1.IsErr())
+ require.ErrorIs(
+ t, result1.Err(), context.Canceled,
+ "await with immediate cancel",
+ )
+
+ // Test cancellation when the Await context times out. The
+ // underlying future will also not be completed.
+ prom2 := NewPromise[int]()
+ fut2 := prom2.Future()
+
+ // Use a very short timeout that will trigger.
+ ctx2, cancel2 := context.WithTimeout(
+ context.Background(), 1*time.Nanosecond,
+ )
+ defer cancel2()
+
+ // Await the future; it should fall through to the timeout
+ // because the future itself is not completed.
+ result2 := fut2.Await(ctx2)
+
+ require.True(t, result2.IsErr())
+ require.ErrorIs(
+ t, result2.Err(), context.DeadlineExceeded,
+ "await with timeout",
+ )
+ })
+}
+
+// TestFutureAwaitFutureCompletes tests that Await returns the future's
+// result if the context is not cancelled before the future resolves.
+func TestFutureAwaitFutureCompletes(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ valToSet := rapid.Int().Draw(t, "valToSet")
+
+ // With a 50% chance, configure the test to complete the future
+ // with an error instead of a successful value.
+ var errToSet error
+ if rapid.Bool().Draw(t, "have_error") {
+ errToSet = fmt.Errorf("err")
+ }
+
+ promise := NewPromise[int]()
+ fut := promise.Future()
+
+ // Use a background context for Await, as we expect the future
+ // to complete normally.
+ ctx := context.Background()
+
+ // Complete the future in a separate goroutine to simulate an
+ // asynchronous operation.
+ go func() {
+ if errToSet != nil {
+ promise.Complete(fn.Err[int](errToSet))
+ } else {
+ promise.Complete(fn.Ok(valToSet))
+ }
+ }()
+
+ // Now we'll wait for the future to complete, then verify below
+ // that the result (value or error) is as expected.
+ result := fut.Await(ctx)
+
+ if errToSet != nil {
+ // If an error was set, verify that Await returns that
+ // specific error.
+ require.True(t, result.IsErr())
+ require.ErrorIs(
+ t, result.Err(), errToSet,
+ "await with error",
+ )
+ } else {
+ // If no error was set, verify that Await returns the
+ // correct value.
+ require.False(t, result.IsErr(), "await with value")
+
+ result.WhenOk(func(val int) {
+ require.Equal(
+ t, valToSet, val, "await with value",
+ )
+ })
+ }
+ })
+}
+
+// TestFutureThenApplyContextCancellation tests that ThenApply respects its
+// context, yielding a context error if cancelled before the original future
+// completes.
+func TestFutureThenApplyContextCancellation(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ // The original future will not be completed in this test case,
+ // allowing us to specifically test the cancellation behavior of
+ // the context passed to ThenApply.
+ originalPromise := NewPromise[int]()
+ originalFut := originalPromise.Future()
+
+ // Create a context for ThenApply and cancel it immediately.
+ ctxApply, cancelApply := context.WithCancel(
+ context.Background(),
+ )
+ cancelApply()
+
+ var transformCalled atomic.Bool
+ transform := func(i int) int {
+ transformCalled.Store(true)
+ return i * 2
+ }
+
+ // Register the transformation. The ThenApply operation itself
+ // will start a goroutine to await the originalFut.
+ newFut := originalFut.ThenApply(ctxApply, transform)
+
+ // Await the new (transformed) future. Use a background context
+ // for this Await to isolate the test to the cancellation of
+ // ctxApply.
+ result := newFut.Await(context.Background())
+
+ require.True(t, result.IsErr())
+ require.ErrorIs(
+ t, result.Err(), context.Canceled,
+ "ThenApply with cancelled context",
+ )
+ require.False(
+ t, transformCalled.Load(),
+ "ThenApply transform function called despite "+
+ "context cancellation",
+ )
+ })
+}
+
+// TestFutureThenApplyOriginalFutureCompletes tests ThenApply's behavior when
+// the original future completes (with a value or error) before ThenApply's
+// context is cancelled.
+func TestFutureThenApplyOriginalFutureCompletes(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ initialVal := rapid.Int().Draw(t, "initialVal")
+
+ // Configure whether the original future completes with an error
+ // or a successful value.
+ var originalErr error
+ if rapid.Bool().Draw(t, "have_error") {
+ originalErr = fmt.Errorf("original error")
+ }
+
+ originalPromise := NewPromise[int]()
+ originalFut := originalPromise.Future()
+
+ // Create a context for ThenApply that should not cancel before
+ // the original future completes.
+ ctxApply, cancelApply := context.WithTimeout(
+ context.Background(), 50*time.Millisecond,
+ )
+ defer cancelApply()
+
+ var transformCalled atomic.Bool
+ transform := func(i int) int {
+ transformCalled.Store(true)
+ return i * 2
+ }
+
+ newFut := originalFut.ThenApply(ctxApply, transform)
+
+ // Complete the original future in a separate goroutine to
+ // simulate asynchrony.
+ go func() {
+ if originalErr != nil {
+ originalPromise.Complete(
+ fn.Err[int](originalErr),
+ )
+ } else {
+ originalPromise.Complete(fn.Ok(initialVal))
+ }
+ }()
+
+ // Await our new future which transforms the original future's
+ // result. Use a background context for this Await.
+ result := newFut.Await(context.Background())
+
+ if originalErr != nil {
+ // If the original future had an error, the transformed
+ // future should also yield that same error.
+ require.True(t, result.IsErr())
+ require.ErrorIs(
+ t, result.Err(), originalErr,
+ "ThenApply with original error",
+ )
+ require.False(
+ t, transformCalled.Load(),
+ "ThenApply transform function called despite "+
+ "original future having an error",
+ )
+ } else {
+ // If the original future completed successfully, the
+ // transformed future should contain the transformed value.
+ require.False(
+ t, result.IsErr(),
+ "ThenApply with original value",
+ )
+ require.True(
+ t, transformCalled.Load(),
+ "ThenApply transform function not called for "+
+ "successful original future",
+ )
+
+ result.WhenOk(func(val int) {
+ expectedTransformedVal := initialVal * 2
+ require.Equal(
+ t, expectedTransformedVal, val,
+ "ThenApply with original value",
+ )
+ })
+ }
+ })
+}
+
+// TestFutureOnCompleteContextCancellation tests that OnComplete's callback
+// receives a context error if its context is cancelled before the future
+// completes.
+func TestFutureOnCompleteContextCancellation(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ // The original future will not complete in this test, allowing
+ // us to focus on the cancellation of OnComplete's context.
+ originalPromise := NewPromise[int]()
+ originalFut := originalPromise.Future()
+
+ // Create a context for OnComplete and cancel it immediately to
+ // simulate a premature cancellation.
+ ctxComplete, cancelComplete := context.WithCancel(
+ context.Background(),
+ )
+ cancelComplete()
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+ var (
+ callbackInvoked atomic.Bool
+ callbackResultValue fn.Result[int]
+
+ // mu is a mutex to protect callbackResultValue as it's
+ // written by the callback goroutine and read by the
+ // test goroutine.
+ mu sync.Mutex
+ )
+
+ // Register an OnComplete callback. The callback itself runs in
+ // a new goroutine started by OnComplete.
+ originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) {
+ mu.Lock()
+ callbackResultValue = res
+ mu.Unlock()
+
+ callbackInvoked.Store(true)
+ wg.Done()
+ })
+
+ // Use a wait group and a channel to wait for the callback to
+ // be invoked.
+ waitChan := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(waitChan)
+ }()
+
+ select {
+ // The callback should be invoked, even if with a context error.
+ case <-waitChan:
+ case <-time.After(50 * time.Millisecond):
+ require.Fail(
+ t, "OnComplete callback timed out waiting "+
+ "for execution after context cancel",
+ )
+ }
+
+ require.True(
+ t, callbackInvoked.Load(),
+ "OnComplete callback not invoked",
+ )
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ // Verify that the callback received a context.Canceled error
+ // because its context (ctxComplete) was cancelled.
+ require.True(t, callbackResultValue.IsErr())
+ require.ErrorIs(
+ t, callbackResultValue.Err(), context.Canceled,
+ "OnComplete with cancelled context",
+ )
+ })
+}
+
+// TestFutureOnCompleteFutureCompletes tests OnComplete's behavior when the
+// future completes (with value or error) before its context is cancelled.
+func TestFutureOnCompleteFutureCompletes(t *testing.T) {
+ t.Parallel()
+
+ rapid.Check(t, func(t *rapid.T) {
+ valToSet := rapid.Int().Draw(t, "valToSet")
+
+ // Configure whether the original future completes with an error
+ // or a successful value.
+ var originalErr error
+ if rapid.Bool().Draw(t, "have_error") {
+ originalErr = fmt.Errorf("original error")
+ }
+
+ originalPromise := NewPromise[int]()
+ originalFut := originalPromise.Future()
+
+ // Use a background context for OnComplete, as we expect the
+ // future to complete normally.
+ ctxComplete := context.Background()
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+
+ var (
+ callbackInvoked atomic.Bool
+ callbackResultValue fn.Result[int]
+ mu sync.Mutex
+ )
+
+ // Register an OnComplete callback. This callback will execute
+ // once the originalFut completes.
+ originalFut.OnComplete(ctxComplete, func(res fn.Result[int]) {
+ mu.Lock()
+ callbackResultValue = res
+ mu.Unlock()
+
+ callbackInvoked.Store(true)
+
+ wg.Done()
+ })
+
+ // Complete the original future in a separate goroutine to
+ // simulate an asynchronous operation.
+ go func() {
+ if originalErr != nil {
+ originalPromise.Complete(
+ fn.Err[int](originalErr),
+ )
+ } else {
+ originalPromise.Complete(fn.Ok(valToSet))
+ }
+ }()
+
+ // Use a wait group and a channel to wait for the callback's
+ // execution.
+ waitChan := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(waitChan)
+ }()
+
+ select {
+ // The callback should be invoked as the future completes.
+ case <-waitChan:
+ case <-time.After(50 * time.Millisecond):
+ require.Fail(
+ t, "OnComplete callback timed out waiting "+
+ "for execution",
+ )
+ }
+
+ require.True(t, callbackInvoked.Load())
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ // Verify that the callback received the correct result (either
+ // the error or the value from the completed future).
+ if originalErr != nil {
+ require.True(t, callbackResultValue.IsErr())
+ require.ErrorIs(
+ t, callbackResultValue.Err(), originalErr,
+ "OnComplete with error",
+ )
+ } else {
+ require.False(
+ t, callbackResultValue.IsErr(),
+ "OnComplete with value",
+ )
+ callbackResultValue.WhenOk(func(val int) {
+ require.Equal(
+ t, valToSet, val,
+ "OnComplete with value",
+ )
+ })
+ }
+ })
+}
+
+// TestPromiseCompleteIdempotency verifies that calling Complete on a Promise
+// multiple times is safe and only the first completion takes effect. Subsequent
+// calls should return false and not alter the future's result.
+func TestPromiseCompleteIdempotency(t *testing.T) {
+ t.Parallel()
+
+ promise := NewPromise[string]()
+ future := promise.Future()
+
+ // First completion should succeed.
+ firstResult := fn.Ok("first-value")
+ ok := promise.Complete(firstResult)
+ require.True(t, ok, "first Complete should return true")
+
+ // Second completion with a different value should be ignored.
+ secondResult := fn.Ok("second-value")
+ ok = promise.Complete(secondResult)
+ require.False(t, ok, "second Complete should return false")
+
+ // Third completion with an error should also be ignored.
+ thirdResult := fn.Err[string](fmt.Errorf("should be ignored"))
+ ok = promise.Complete(thirdResult)
+ require.False(t, ok, "third Complete should return false")
+
+ // The future should contain the first value.
+ result := future.Await(context.Background())
+ require.False(t, result.IsErr(), "future should not be an error")
+ result.WhenOk(func(val string) {
+ require.Equal(
+ t, "first-value", val,
+ "future should contain the first completion value",
+ )
+ })
+}
diff --git a/actor/go.mod b/actor/go.mod
new file mode 100644
index 0000000..77ffce8
--- /dev/null
+++ b/actor/go.mod
@@ -0,0 +1,19 @@
+module github.com/lightningnetwork/lnd/actor
+
+go 1.25.5
+
+require (
+ github.com/btcsuite/btclog/v2 v2.0.1-0.20250602222548-9967d19bb084
+ github.com/lightningnetwork/lnd/fn/v2 v2.0.8
+ github.com/stretchr/testify v1.8.1
+ pgregory.net/rapid v1.2.0
+)
+
+require (
+ github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c // indirect
+ github.com/davecgh/go-spew v1.1.1 // indWhy this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.