mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
Compare commits
137 commits
master
...
v0.20.2-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c863397a4f | ||
|
|
5e26b56067 | ||
|
|
529bdb1a8b | ||
|
|
f4fa341e02 | ||
|
|
bf8df0a144 | ||
|
|
cf20de9bfb | ||
|
|
bc8463a98f | ||
|
|
7a42b56cbf | ||
|
|
b32e432e0b | ||
|
|
de8ca73610 | ||
|
|
77989b955d | ||
|
|
b9619a28cd | ||
|
|
30ddd606fc | ||
|
|
1c82b7a27d | ||
|
|
141acda471 | ||
|
|
6968e2bdab | ||
|
|
3061190445 | ||
|
|
0b20b0aab7 | ||
|
|
010f72681b | ||
|
|
2c4af78fe1 | ||
|
|
0b04e339a4 | ||
|
|
848b72ce96 | ||
|
|
f9035f74f5 | ||
|
|
a4f375ffdd | ||
|
|
cac164694f | ||
|
|
b6bd8c4fd2 | ||
|
|
f427fee341 | ||
|
|
81d4da7ffd | ||
|
|
d190a01be4 | ||
|
|
fe486e13a9 | ||
|
|
802849f0d5 | ||
|
|
3be1baf9cf | ||
|
|
60bc0f389c | ||
|
|
1495920bb9 | ||
|
|
706ec5e136 | ||
|
|
175933a6ca | ||
|
|
adda56d40b | ||
|
|
a8b00fcb70 | ||
|
|
40d8b475b5 | ||
|
|
82b4345a3e | ||
|
|
8cf08fde1d | ||
|
|
c12c9e7bd2 | ||
|
|
3a6a756e0a | ||
|
|
ab7a002c94 | ||
|
|
4adfa8ec39 | ||
|
|
fadfdecd0e | ||
|
|
25b19461db | ||
|
|
8de352e276 | ||
|
|
19d8bb2a6c | ||
|
|
6f6034dd51 | ||
|
|
a8e37b08e4 | ||
|
|
4d5a14de3d | ||
|
|
99b32c9465 | ||
|
|
65d53ce875 | ||
|
|
a28a09670a | ||
|
|
d9d2bf466e | ||
|
|
bd6fa84d8d | ||
|
|
e5c5011900 | ||
|
|
8a38c86628 | ||
|
|
55807ac4d0 | ||
|
|
0b9e8c33e1 | ||
|
|
c8364afaeb | ||
|
|
8565d12e40 | ||
|
|
a7ca338720 | ||
|
|
d59e98b3f4 | ||
|
|
56829a9c8a | ||
|
|
5cf38edc62 | ||
|
|
a0be1c926b | ||
|
|
b7b73b02e1 | ||
|
|
1827699848 | ||
|
|
8b471eadeb | ||
|
|
2cb9ff24fc | ||
|
|
f5527e1e60 | ||
|
|
57069cf3a1 | ||
|
|
96652812d7 | ||
|
|
580e820b6a | ||
|
|
86cc097fec | ||
|
|
f1332fe4a7 | ||
|
|
10aff6c580 | ||
|
|
6c656a6af6 | ||
|
|
1a83e9a8a0 | ||
|
|
99b136e39e | ||
|
|
677ffabed8 | ||
|
|
eceaadc958 | ||
|
|
07b845207f | ||
|
|
f82c35c777 | ||
|
|
1a543fbfcd | ||
|
|
c6467a6305 | ||
|
|
43091bdd51 | ||
|
|
68c4809913 | ||
|
|
6af171f365 | ||
|
|
f1fc329eb2 | ||
|
|
04ebd363c2 | ||
|
|
246abd1697 | ||
|
|
53994daf13 | ||
|
|
7d695f581f | ||
|
|
c32cbd940a | ||
|
|
189e23af97 | ||
|
|
0d225a1110 | ||
|
|
0c7db1a206 | ||
|
|
c079362c2c | ||
|
|
ad87b492d1 | ||
|
|
439c3ede7d | ||
|
|
a83d1177b8 | ||
|
|
550de8d348 | ||
|
|
540224239b | ||
|
|
ec480f033f | ||
|
|
5bfcdf9957 | ||
|
|
222e038a77 | ||
|
|
76b0701745 | ||
|
|
24de03d766 | ||
|
|
2590593c3f | ||
|
|
c207461aca | ||
|
|
2a9e82b60a | ||
|
|
13c6a3777c | ||
|
|
b60920b1ae | ||
|
|
1fa6f70b0d | ||
|
|
2d477d699d | ||
|
|
4eaf57d71d | ||
|
|
6bdfb1dc4d | ||
|
|
a25659a6e4 | ||
|
|
dd304e94fa | ||
|
|
bd8f49afd6 | ||
|
|
0aa757b022 | ||
|
|
7bf9f30b55 | ||
|
|
63da9b3259 | ||
|
|
f8b5cb0e89 | ||
|
|
07003fc3a0 | ||
|
|
64e2f013e2 | ||
|
|
465013f097 | ||
|
|
856693d70c | ||
|
|
c4d8dc8e00 | ||
|
|
dac47cf698 | ||
|
|
911f7296a8 | ||
|
|
da55e567e9 | ||
|
|
8bc240770a | ||
|
|
f16886041d |
1190 changed files with 39484 additions and 139049 deletions
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
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
|
||||
4
.custom-gcl.yml
Normal file
4
.custom-gcl.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
version: v1.57.0
|
||||
plugins:
|
||||
- module: 'github.com/lightningnetwork/lnd/tools/linters'
|
||||
path: ./tools/linters
|
||||
4
.github/actions/setup-go/action.yml
vendored
4
.github/actions/setup-go/action.yml
vendored
|
|
@ -52,8 +52,8 @@ runs:
|
|||
# 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.26.4-unit-test-`.
|
||||
# It ensures that a job running on Linux with Go 1.26 only looks for
|
||||
# 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.
|
||||
|
|
|
|||
124
.github/workflows/backport.yml
vendored
124
.github/workflows/backport.yml
vendored
|
|
@ -1,124 +0,0 @@
|
|||
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"}'
|
||||
176
.github/workflows/claude-dedupe-issues.yml
vendored
176
.github/workflows/claude-dedupe-issues.yml
vendored
|
|
@ -1,176 +0,0 @@
|
|||
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
|
||||
|
||||
# Default to read-only. The find-duplicates job reads untrusted issue text with
|
||||
# the model, so it must not hold a write token; post-comment takes issues: write
|
||||
# but runs no model and only shells out to scripts/comment-on-duplicates.sh,
|
||||
# which re-validates every issue number it is handed.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize runs for the same issue so an `issues: opened` event and a
|
||||
# workflow_dispatch for the same number can't both read "no prior comment" and
|
||||
# double-post. Mirrors pr-severity.yml.
|
||||
concurrency:
|
||||
group: claude-dedupe-${{ github.event.issue.number || inputs.issue_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
find-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Read-only: the model inspects the issue and searches for duplicates, then
|
||||
# records the candidate issue numbers to a file.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Find duplicate issues with Claude
|
||||
# Pinned to a full commit SHA rather than the mutable @v1 tag: this step
|
||||
# feeds untrusted issue text to the model with CLAUDE_CODE_OAUTH_TOKEN in
|
||||
# process, so a repointed tag would run attacker-controlled action code
|
||||
# with that secret present. Bump deliberately when updating.
|
||||
uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Accept any issue author: this job holds only read scope and merely
|
||||
# records candidate issue numbers to a file; the comment is posted by
|
||||
# a separate, model-free job. "*" is safe ONLY while this job stays
|
||||
# read-only. Before granting this job a write token or a mutating tool
|
||||
# (a write-capable gh subcommand, a Bash mutation), replace "*" with
|
||||
# an explicit allowlist — otherwise any fork author's issue text would
|
||||
# steer a privileged model.
|
||||
allowed_non_write_users: "*"
|
||||
model: claude-haiku-4-5-20251001
|
||||
|
||||
# Read-only gh tools plus Write to record the result. No comment or
|
||||
# edit tools, and no access to the duplicate-comment script.
|
||||
claude_args: >-
|
||||
--allowedTools
|
||||
"Bash(gh issue view:*)"
|
||||
"Bash(gh search:*)"
|
||||
"Bash(gh issue list:*)"
|
||||
"Write"
|
||||
|
||||
prompt: |
|
||||
Find up to 3 likely duplicate issues for issue
|
||||
#${{ github.event.issue.number || inputs.issue_number }} in the
|
||||
${{ github.repository }} repository. Follow these steps precisely:
|
||||
|
||||
1. View the issue and check whether it (a) is closed, (b) does not
|
||||
need deduping (e.g. broad product feedback without a specific
|
||||
solution, or positive feedback), or (c) already has a duplicates
|
||||
comment. If any of these hold, write an empty `duplicates.txt`
|
||||
(create the file with no content) and stop.
|
||||
|
||||
2. Summarize the issue.
|
||||
|
||||
3. Search GitHub for duplicates of this issue using several diverse
|
||||
keyword searches and search approaches, based on the summary.
|
||||
|
||||
4. Filter out false positives that are likely not actually
|
||||
duplicates of the original issue. If no plausible duplicates
|
||||
remain, write an empty `duplicates.txt` and stop.
|
||||
|
||||
5. Otherwise, write the chosen duplicate issue numbers to a file
|
||||
named `duplicates.txt` in the current working directory: digits
|
||||
only, one issue number per line, at most 3 lines. Do not include
|
||||
`#`, URLs, or any other text.
|
||||
|
||||
Notes:
|
||||
- Use `gh` to interact with GitHub, not web fetch.
|
||||
- Do NOT use any tools beyond `gh issue view`, `gh search`,
|
||||
`gh issue list`, and `Write`. You do NOT post comments; a separate
|
||||
step does that from the file you write.
|
||||
- Make a todo list first.
|
||||
|
||||
- name: Upload duplicate candidates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dedupe-result
|
||||
path: duplicates.txt
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
|
||||
post-comment:
|
||||
runs-on: ubuntu-latest
|
||||
needs: find-duplicates
|
||||
timeout-minutes: 5
|
||||
# Write scope lives here, in a job that runs no model. The base issue number
|
||||
# comes from the trusted event payload, and comment-on-duplicates.sh
|
||||
# re-validates every candidate issue number before posting.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download duplicate candidates
|
||||
uses: actions/download-artifact@v4
|
||||
# find-duplicates uploads with if-no-files-found: warn, so when the
|
||||
# model writes no file at all (timeout, refusal) no artifact exists and
|
||||
# download-artifact would otherwise hard-fail the job. Tolerate a
|
||||
# missing artifact so the no-op guard in the next step is reachable.
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: dedupe-result
|
||||
path: result
|
||||
|
||||
- name: Post duplicate comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
BASE_ISSUE: ${{ github.event.issue.number || inputs.issue_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Distinguish a genuine "no duplicates" verdict from a find-duplicates
|
||||
# run that produced no artifact at all (model crash/timeout, or a
|
||||
# tolerated missing-artifact download). The latter gets a warning so a
|
||||
# broken run doesn't read as a healthy no-op, mirroring the pr-severity
|
||||
# apply step; the present-but-empty case stays a silent no-op.
|
||||
if [[ ! -f result/duplicates.txt ]]; then
|
||||
echo "::warning::dedupe find-duplicates produced no result; nothing posted."
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! -s result/duplicates.txt ]]; then
|
||||
echo "No duplicate candidates; nothing to post."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract up to 3 purely-numeric issue ids. comment-on-duplicates.sh
|
||||
# re-validates these and the base issue (numeric, existing, at most 3)
|
||||
# before posting.
|
||||
mapfile -t DUPS < <(grep -oE '^[0-9]+$' result/duplicates.txt | head -n 3)
|
||||
|
||||
if [[ ${#DUPS[@]} -eq 0 ]]; then
|
||||
echo "No valid numeric duplicate ids; nothing to post."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
./scripts/comment-on-duplicates.sh \
|
||||
--base-issue "$BASE_ISSUE" \
|
||||
--potential-duplicates "${DUPS[@]}"
|
||||
62
.github/workflows/claude.yml
vendored
62
.github/workflows/claude.yml
vendored
|
|
@ -1,62 +0,0 @@
|
|||
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:*)'
|
||||
|
||||
66
.github/workflows/gateway.yml
vendored
66
.github/workflows/gateway.yml
vendored
|
|
@ -1,66 +0,0 @@
|
|||
name: gateway
|
||||
|
||||
# Opt-in code-review bot. Triggered by a `/gateway <command>` comment on a PR
|
||||
# (e.g. `/gateway review`); review/approve commands are gated to maintainers.
|
||||
# Comment-commands only — no pull_request triggers — so fork PRs (which receive
|
||||
# no secrets) never spawn failing runs. v0.5.0 added the
|
||||
# pull_request_review_comment trigger: /gateway dismiss, promote, and explain
|
||||
# now also work as replies on a finding's inline thread (finding id inferred
|
||||
# from the thread when omitted). Also a comment event — same fork-PR safety
|
||||
# profile as issue_comment.
|
||||
#
|
||||
# Thin shim: the public lightninglabs/gateway-action mints an App token and
|
||||
# checks out the private gateway runtime at execution time. The runtime stays
|
||||
# private; only this entry point is public.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
# The action mints an App installation token internally; the GITHUB_TOKEN
|
||||
# handed to this shim is unused, so we minimise it.
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# issue_comment fires for all issues and every PR comment. Filter to PR
|
||||
# comments that look like a /gateway command so unrelated comments don't
|
||||
# spin up a no-op runner. `contains` (not `startsWith`) because the runtime
|
||||
# accepts the command at column 0 of any line, including multi-line bodies.
|
||||
if: >-
|
||||
${{
|
||||
(github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '/gateway')) ||
|
||||
(github.event_name == 'pull_request_review_comment'
|
||||
&& contains(github.event.comment.body, '/gateway'))
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GATEWAY_REVIEW_MODE: multi
|
||||
steps:
|
||||
- uses: lightninglabs/gateway-action@334a8455ee316e40668ae3ac85249150c62704ec # v0.6.0
|
||||
with:
|
||||
# Pin the private runtime to an immutable commit (matches the action
|
||||
# SHA-pin above) so runtime upgrades go through an lnd PR, not a moved
|
||||
# tag. Without this, runtime_ref defaults to the v0.6.0 tag.
|
||||
runtime_ref: 75f6e67deac362bdcfc10d10629ddcf69c0e2615 # gateway v0.6.0
|
||||
event_name: ${{ github.event_name }}
|
||||
event_action: ${{ github.event.action }}
|
||||
repo: ${{ github.repository }}
|
||||
pr_number: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
actor: ${{ github.event.sender.login }}
|
||||
comment_body: ${{ github.event.comment.body }}
|
||||
comment_id: ${{ github.event.comment.id }}
|
||||
comment_in_reply_to: ${{ github.event.comment.in_reply_to_id }}
|
||||
# installation_id intentionally omitted: as of gateway v0.4.4 the
|
||||
# runtime resolves the App installation covering this repo from
|
||||
# app_id/private_key, so a hardcoded (and easily wrong-org) id is no
|
||||
# longer needed.
|
||||
app_id: ${{ secrets.GATEWAY_APP_ID }}
|
||||
private_key: ${{ secrets.GATEWAY_PRIVATE_KEY }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
109
.github/workflows/govulncheck.yml
vendored
109
.github/workflows/govulncheck.yml
vendored
|
|
@ -1,109 +0,0 @@
|
|||
name: Vulnerability scan
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run weekly to catch newly published vulnerabilities even when the code
|
||||
# does not change.
|
||||
- cron: "0 9 * * 1"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/govulncheck.yml"
|
||||
- ".github/actions/setup-go/action.yml"
|
||||
- "Makefile"
|
||||
- "make/release_flags.mk"
|
||||
- "**/*.go"
|
||||
- "**/go.mod"
|
||||
- "**/go.sum"
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths:
|
||||
- ".github/workflows/govulncheck.yml"
|
||||
- ".github/actions/setup-go/action.yml"
|
||||
- "Makefile"
|
||||
- "make/release_flags.mk"
|
||||
- "**/*.go"
|
||||
- "**/go.mod"
|
||||
- "**/go.sum"
|
||||
merge_group:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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.26.4
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
name: Scan release binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go ${{ env.GO_VERSION }}
|
||||
uses: ./.github/actions/setup-go
|
||||
with:
|
||||
go-version: '${{ env.GO_VERSION }}'
|
||||
key-prefix: govulncheck
|
||||
use-build-cache: 'no'
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||
|
||||
- name: Build release binaries
|
||||
run: make release-install
|
||||
|
||||
- name: Run govulncheck
|
||||
run: |
|
||||
set +e
|
||||
|
||||
gopath="$(go env GOPATH)"
|
||||
final_exit_code=0
|
||||
advisory_findings=0
|
||||
|
||||
for binary in lnd lncli; do
|
||||
output="govulncheck-${binary}.txt"
|
||||
"${gopath}/bin/govulncheck" \
|
||||
-mode=binary \
|
||||
"${gopath}/bin/${binary}" 2>&1 | tee "${output}"
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
|
||||
{
|
||||
echo "### govulncheck ${binary}"
|
||||
echo
|
||||
echo '```'
|
||||
sed -n '1,200p' "${output}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$exit_code" -eq 3 ]; then
|
||||
advisory_findings=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$exit_code" -ne 0 ] && [ "$final_exit_code" -eq 0 ]; then
|
||||
final_exit_code="$exit_code"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$advisory_findings" -eq 1 ]; then
|
||||
echo "::warning title=govulncheck findings::govulncheck found vulnerabilities; see the job summary for details."
|
||||
{
|
||||
echo
|
||||
echo "> govulncheck exited with code 3 for one or more release binaries. This job is advisory while the existing vulnerability baseline is remediated."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
exit "$final_exit_code"
|
||||
21
.github/workflows/main.yml
vendored
21
.github/workflows/main.yml
vendored
|
|
@ -27,8 +27,7 @@ defaults:
|
|||
shell: bash
|
||||
|
||||
env:
|
||||
# Accepts either a major image tag like "30" or a patch tag like "29.1".
|
||||
BITCOIN_VERSION: "31"
|
||||
BITCOIN_VERSION: "29"
|
||||
|
||||
# TRANCHES defines the number of tranches used in the itests.
|
||||
TRANCHES: 16
|
||||
|
|
@ -41,7 +40,7 @@ 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.26.4
|
||||
GO_VERSION: 1.25.5
|
||||
|
||||
jobs:
|
||||
static-checks:
|
||||
|
|
@ -177,7 +176,7 @@ jobs:
|
|||
- 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-arm64
|
||||
sys: darwin-arm64 freebsd-arm linux-armv6 linux-armv7 linux-arm64 windows-arm
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v5
|
||||
|
|
@ -213,8 +212,6 @@ jobs:
|
|||
- 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:
|
||||
|
|
@ -281,8 +278,6 @@ jobs:
|
|||
args: backend=btcd cover=1
|
||||
- name: bitcoind
|
||||
args: backend=bitcoind cover=1
|
||||
- name: bitcoind-miner
|
||||
args: backend=bitcoind minerbackend=bitcoind cover=1
|
||||
- name: bitcoind-notxindex
|
||||
args: backend="bitcoind notxindex"
|
||||
- name: neutrino
|
||||
|
|
@ -551,7 +546,7 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
pinned_dep:
|
||||
- google.golang.org/grpc v1.79.3
|
||||
- google.golang.org/grpc v1.59.0
|
||||
- github.com/golang/protobuf v1.5.4
|
||||
|
||||
steps:
|
||||
|
|
@ -605,14 +600,6 @@ jobs:
|
|||
- 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
|
||||
#########################################
|
||||
|
|
|
|||
313
.github/workflows/pr-severity.yml
vendored
313
.github/workflows/pr-severity.yml
vendored
|
|
@ -1,313 +0,0 @@
|
|||
name: PR Severity Classification
|
||||
|
||||
on:
|
||||
# Use pull_request_target so the workflow runs on fork PRs with the base
|
||||
# repository's workflow definition. The classify job below reads PR metadata
|
||||
# with a read-only token and never checks out or executes PR code; the write
|
||||
# scope needed to apply the label lives in a separate, model-free job.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
# Default the whole workflow to read-only. Each job opts into exactly the scope
|
||||
# it needs: classify stays read-only (untrusted PR metadata reaches the model,
|
||||
# so it must not hold a write token), apply takes pull-requests: write but runs
|
||||
# no model.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-severity-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
classify:
|
||||
name: Classify PR Severity
|
||||
runs-on: ubuntu-latest
|
||||
# Cap the model run: it fires on every pull_request_target synchronize with
|
||||
# attacker-controllable input, so bound its runner-minute/token cost rather
|
||||
# than inheriting GitHub's 6h default. Mirrors the dedupe workflow.
|
||||
timeout-minutes: 10
|
||||
# Read-only: the classifier only inspects PR metadata via the GitHub API.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# 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
|
||||
# Don't leave the job token in .git/config: nothing here needs a
|
||||
# persisted git credential, and the classifier runs on untrusted
|
||||
# fork-PR input.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Classify PR with Claude
|
||||
# Pinned to a full commit SHA rather than the mutable @v1 tag: this step
|
||||
# runs on pull_request_target with CLAUDE_CODE_OAUTH_TOKEN and
|
||||
# GITHUB_TOKEN in-process and is reachable by any fork author, so a
|
||||
# repointed tag would run attacker-controlled action code with those
|
||||
# secrets present. Bump deliberately when updating.
|
||||
uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1
|
||||
env:
|
||||
# gh (invoked by the Bash(gh pr view) tool) authenticates from
|
||||
# GH_TOKEN; set it explicitly so classification doesn't depend on the
|
||||
# action propagating its github_token input into the tool environment,
|
||||
# matching the dedupe find-duplicates step.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Accept any PR author: this job holds only a read-only token, reads
|
||||
# PR metadata via the API, and writes its verdict to a file. The
|
||||
# privileged label/comment step runs separately without the model.
|
||||
# "*" is safe ONLY while this job stays read-only. Before granting it
|
||||
# a write token or a mutating tool (a write-capable gh subcommand, a
|
||||
# Bash mutation), replace "*" with an explicit allowlist — otherwise
|
||||
# any fork author's PR text would steer a privileged model.
|
||||
allowed_non_write_users: "*"
|
||||
|
||||
# Pin the model so the severity decision (which gates the label the
|
||||
# apply job applies) is reproducible from the workflow file and does
|
||||
# not drift on an action-default change.
|
||||
model: claude-sonnet-5
|
||||
|
||||
# The classifier only needs to read PR data and record its verdict to
|
||||
# the workspace. It has no write-capable gh tools.
|
||||
#
|
||||
# Security note: because this runs on pull_request_target, the OAuth
|
||||
# token above is present in-process while the model reads untrusted
|
||||
# fork-PR text. The read-only github_token bounds what the model can
|
||||
# WRITE via the API, but the in-process token's protection rests on
|
||||
# this allowlist staying minimal — read-only `gh pr view` plus
|
||||
# `Write`, with no network- or shell-mutating tool a prompt injection
|
||||
# could use to exfiltrate it. Keep it that way: do not add `Bash`
|
||||
# verbs beyond `gh pr view`, and never add a tool that can make
|
||||
# outbound requests. (A stricter design would drop Bash entirely and
|
||||
# pre-fetch PR metadata via the API in a separate step.)
|
||||
claude_args: --allowedTools "Bash(gh pr view:*)" "Write"
|
||||
|
||||
prompt: |
|
||||
You are a PR severity classifier for the lnd (Lightning Network Daemon) repository.
|
||||
|
||||
## Tool Constraints
|
||||
|
||||
You ONLY have access to:
|
||||
- `gh pr view` - to read PR metadata
|
||||
- `Write` - to record your verdict to files
|
||||
|
||||
You do NOT have access to `gh api`, `gh label`, `gh pr edit`,
|
||||
`gh pr comment`, or any other command. Do not attempt to use them.
|
||||
You do NOT apply labels or post comments yourself. A separate,
|
||||
deterministic step reads the files you write and applies the label
|
||||
and comment. Your job is only to classify and record the result.
|
||||
|
||||
## Your Task
|
||||
|
||||
Analyze PR #${{ github.event.pull_request.number }} and:
|
||||
1. Determine its severity level based on the files changed
|
||||
2. Record the severity, whether a comment should be posted, and the
|
||||
comment body, to files (see "Output" below).
|
||||
|
||||
## 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. Read the current labels AND comments to detect overrides and prior
|
||||
bot activity:
|
||||
```
|
||||
gh pr view ${{ github.event.pull_request.number }} --json labels,comments
|
||||
```
|
||||
Note which `severity-*` label (if any) is currently applied. This
|
||||
is the "previous severity". Look for the HTML marker
|
||||
`<!-- pr-severity-bot -->` in comment bodies to tell whether the
|
||||
bot has commented before.
|
||||
|
||||
2. If an override label exists (severity-override-*), use that level
|
||||
and skip classification.
|
||||
|
||||
3. Get the list of changed files:
|
||||
```
|
||||
gh pr view ${{ github.event.pull_request.number }} --json files,additions,deletions
|
||||
```
|
||||
|
||||
4. Classify each file and determine the new overall severity.
|
||||
|
||||
5. **Decide whether a comment should be posted.** Set should_comment
|
||||
to "true" only 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.
|
||||
|
||||
Otherwise set should_comment to "false" (the label may still be
|
||||
updated by the apply step, but no new comment is posted).
|
||||
|
||||
## Output
|
||||
|
||||
Record your verdict by writing these files in the current working
|
||||
directory (the repository root). Do NOT apply labels or comment
|
||||
yourself.
|
||||
|
||||
1. `severity.txt` - exactly one lowercase word, one of:
|
||||
`critical`, `high`, `medium`, `low`. Nothing else.
|
||||
|
||||
2. `should_comment.txt` - exactly `true` or `false`.
|
||||
|
||||
3. `comment.md` - only if should_comment is `true`. The full comment
|
||||
markdown, in 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 -->
|
||||
```
|
||||
|
||||
If should_comment is `false`, do not create `comment.md`.
|
||||
|
||||
Keep the comment concise and factual: it is posted verbatim under
|
||||
the bot's identity, and the apply step defangs any `@`-mentions
|
||||
and links, so do not rely on them.
|
||||
|
||||
## Emoji Mapping
|
||||
- critical: 🔴
|
||||
- high: 🟠
|
||||
- medium: 🟡
|
||||
- low: 🟢
|
||||
|
||||
- name: Upload classification result
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-severity-result
|
||||
path: |
|
||||
severity.txt
|
||||
should_comment.txt
|
||||
comment.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
|
||||
apply:
|
||||
name: Apply Severity Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: classify
|
||||
timeout-minutes: 5
|
||||
# Write scope lives here, in a job that runs no model. The only inputs are
|
||||
# the PR number from the trusted event payload and the classifier's files,
|
||||
# which are strictly validated before use.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Needed for scripts/apply-pr-severity.sh; no persisted git credential
|
||||
# is required here.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download classification result
|
||||
uses: actions/download-artifact@v4
|
||||
# classify uploads with if-no-files-found: warn, so if the model writes
|
||||
# no verdict at all (timeout, refusal) no artifact exists and
|
||||
# download-artifact would otherwise hard-fail the job. Tolerate a
|
||||
# missing artifact so the no-op guard in the next step is reachable.
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: pr-severity-result
|
||||
path: result
|
||||
|
||||
- name: Apply label and comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
# Validate the severity, reconcile the label, and sanitize + post the
|
||||
# model-authored comment. The logic lives in a checked-in script so the
|
||||
# untrusted-comment sanitizer is unit-tested
|
||||
# (scripts/apply-pr-severity_test.sh), mirroring how the dedupe workflow
|
||||
# delegates to scripts/comment-on-duplicates.sh.
|
||||
run: ./scripts/apply-pr-severity.sh result
|
||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
|
|
@ -12,7 +12,7 @@ defaults:
|
|||
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.26.4
|
||||
GO_VERSION: 1.25.5
|
||||
|
||||
jobs:
|
||||
########################
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: build release for all architectures
|
||||
run: make release tag=${{ env.RELEASE_VERSION }}
|
||||
run: SKIP_VERSION_CHECK=1 make release tag=${{ env.RELEASE_VERSION }}
|
||||
|
||||
- name: Create Release
|
||||
uses: lightninglabs/gh-actions/action-gh-release@c7149b6a7818d1c39b36b69e727569897b6f2c5a
|
||||
|
|
@ -81,11 +81,10 @@ jobs:
|
|||
|
||||
## Verifying the Release Timestamp
|
||||
|
||||
From this new version onwards, in addition to time-stamping the _git tag_ with [OpenTimestamps](https://opentimestamps.org/), we'll also now timestamp the manifest file along with the `roasbeef` release signature. For final releases, and for release candidates when these optional artifacts are uploaded, timestamp proof files are included along with the rest of our release artifacts: `manifest-${{ env.RELEASE_VERSION }}.txt.ots` and `manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig.ots`.
|
||||
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-${{ env.RELEASE_VERSION }}.txt.ots -f manifest-${{ env.RELEASE_VERSION }}.txt
|
||||
ots verify manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig.ots -f manifest-roasbeef-${{ env.RELEASE_VERSION }}.sig
|
||||
```
|
||||
|
||||
|
|
|
|||
74
.github/workflows/verify-release.yaml
vendored
74
.github/workflows/verify-release.yaml
vendored
|
|
@ -1,74 +0,0 @@
|
|||
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: git checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.version || github.sha }}
|
||||
|
||||
- name: Check final release OpenTimestamps asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version || github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${VERSION}" =~ \.rc[0-9]+$ ]]; then
|
||||
echo "Release candidate ${VERSION}; skipping OpenTimestamps asset check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REQUIRED_MANIFEST_OTS="manifest-${VERSION}.txt.ots"
|
||||
REQUIRED_SIG="manifest-roasbeef-${VERSION}.sig"
|
||||
REQUIRED_SIG_OTS="${REQUIRED_SIG}.ots"
|
||||
|
||||
ASSETS="$(gh release view "${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--json assets \
|
||||
--jq '.assets[].name')"
|
||||
|
||||
for asset in "${REQUIRED_MANIFEST_OTS}" "${REQUIRED_SIG}" "${REQUIRED_SIG_OTS}"; do
|
||||
if ! grep -Fxq "${asset}" <<< "${ASSETS}"; then
|
||||
echo "ERROR: Final release ${VERSION} is missing ${asset}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Found required release timestamp artifacts:"
|
||||
echo " ${REQUIRED_MANIFEST_OTS}"
|
||||
echo " ${REQUIRED_SIG}"
|
||||
echo " ${REQUIRED_SIG_OTS}"
|
||||
|
||||
- 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -40,9 +40,6 @@ itest/btcd-itest
|
|||
itest/.logs-*
|
||||
itest/cover
|
||||
|
||||
# Local lntest miner logs (dev artifacts)
|
||||
lntest/miner/*.log
|
||||
|
||||
cmd/cmd
|
||||
*.key
|
||||
*.hex
|
||||
|
|
@ -83,7 +80,6 @@ coverage.txt
|
|||
# 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-*/
|
||||
/.worktrees/
|
||||
|
||||
.aider*
|
||||
|
||||
|
|
|
|||
410
.golangci.yml
410
.golangci.yml
|
|
@ -1,9 +1,7 @@
|
|||
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.26.4"
|
||||
go: "1.25.5"
|
||||
|
||||
# Abort after 10 minutes.
|
||||
timeout: 10m
|
||||
|
|
@ -23,8 +21,95 @@ run:
|
|||
- kvdb_sqlite
|
||||
- integration
|
||||
|
||||
linters-settings:
|
||||
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
|
||||
# Tab width in spaces.
|
||||
tab-width: 8
|
||||
# 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\\("
|
||||
|
||||
errorlint:
|
||||
# Check for incorrect fmt.Errorf error wrapping.
|
||||
errorf: true
|
||||
|
||||
gofmt:
|
||||
# simplify code: gofmt with `-s` option, true by default
|
||||
simplify: true
|
||||
|
||||
tagliatelle:
|
||||
case:
|
||||
rules:
|
||||
json: snake
|
||||
|
||||
whitespace:
|
||||
multi-func: true
|
||||
multi-if: 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.
|
||||
|
||||
staticcheck:
|
||||
checks: ["-SA1019"]
|
||||
|
||||
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
|
||||
|
||||
dupl:
|
||||
# Tokens count to trigger issue.
|
||||
threshold: 200
|
||||
|
||||
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
|
||||
|
||||
gomnd:
|
||||
# List of numbers to exclude from analysis.
|
||||
# The numbers should be written as string.
|
||||
# Values always ignored: "1", "1.0", "0" and "0.0"
|
||||
# Default: []
|
||||
ignored-numbers:
|
||||
- '0666'
|
||||
- '0755'
|
||||
|
||||
# List of function patterns to exclude from analysis.
|
||||
# Values always ignored: `time.Date`
|
||||
# Default: []
|
||||
ignored-functions:
|
||||
- 'math.*'
|
||||
- 'strconv.ParseInt'
|
||||
- 'errors.Wrap'
|
||||
|
||||
gomoddirectives:
|
||||
replace-local: true
|
||||
replace-allow-list:
|
||||
# See go.mod for the explanation why these are needed.
|
||||
- github.com/ulikunitz/xz
|
||||
- github.com/gogo/protobuf
|
||||
- google.golang.org/protobuf
|
||||
- github.com/lightningnetwork/lnd/sqldb
|
||||
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable-all: true
|
||||
disable:
|
||||
# We instead use our own custom line length linter called `ll` since
|
||||
# then we can ignore log lines.
|
||||
|
|
@ -46,33 +131,25 @@ linters:
|
|||
# Init functions are used by loggers throughout the codebase.
|
||||
- gochecknoinits
|
||||
|
||||
# contextcheck requires threading context.Context through many existing
|
||||
# function signatures (including test harnesses), so we leave it off for
|
||||
# now.
|
||||
# Deprecated linters. See https://golangci-lint.run/usage/linters/.
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
|
||||
# tparallel requires adding t.Parallel() to a large number of existing
|
||||
# subtests, which can surface shared-state races. Disabled until we can
|
||||
# address it carefully.
|
||||
- tparallel
|
||||
|
||||
# unparam has a sizeable backlog of unused parameters to clean up before it
|
||||
# can be enabled.
|
||||
- unparam
|
||||
|
||||
# nilerr is too noisy for our code base: most reports are intentional error
|
||||
# swallowing (documented with comments) or false positives where a boolean
|
||||
# check is mistaken for an error check.
|
||||
- nilerr
|
||||
|
||||
# noctx would only flag a couple of interface methods and a test helper that
|
||||
# have no context to thread through, so it adds little value for now.
|
||||
- noctx
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unparam
|
||||
- wastedassign
|
||||
|
||||
# Disable whitespace linters as it has conflict rules against our
|
||||
# Disable gofumpt as it has weird behavior regarding formatting multiple
|
||||
# lines for a function which is in conflict with our contribution
|
||||
# guidelines. See https://github.com/mvdan/gofumpt/issues/235.
|
||||
- gofumpt
|
||||
|
||||
# Disable whitespace linter as it has conflict rules against our
|
||||
# contribution guidelines.
|
||||
- wsl
|
||||
- wsl_v5
|
||||
|
||||
# Allow using default empty values.
|
||||
- exhaustruct
|
||||
|
|
@ -126,6 +203,7 @@ linters:
|
|||
- testifylint
|
||||
- perfsprint
|
||||
- inamedparam
|
||||
- copyloopvar
|
||||
- tagalign
|
||||
- protogetter
|
||||
- revive
|
||||
|
|
@ -133,216 +211,88 @@ linters:
|
|||
- 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
|
||||
- github.com/lightningnetwork/lightning-onion
|
||||
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
|
||||
# Deprecated linters that have been replaced by newer ones.
|
||||
- tenv
|
||||
|
||||
issues:
|
||||
# Only show newly introduced problems.
|
||||
new-from-rev: 03eab4db64540aa5f789c617793e4459f4ba9e78
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- goimports
|
||||
# Skip autogenerated files for mobile and gRPC as well as copied code for
|
||||
# internal use.
|
||||
skip-files:
|
||||
- "mobile\\/.*generated\\.go"
|
||||
- "\\.pb\\.go$"
|
||||
- "\\.pb\\.gw\\.go$"
|
||||
- "internal\\/musig2v040"
|
||||
|
||||
settings:
|
||||
gofmt:
|
||||
# simplify code: gofmt with `-s` option, true by default
|
||||
simplify: true
|
||||
skip-dirs:
|
||||
- channeldb/migration_01_to_11
|
||||
- channeldb/migration/lnwire21
|
||||
|
||||
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
|
||||
exclude-rules:
|
||||
# Exclude gosec from running for tests so that tests with weak randomness
|
||||
# (math/rand) will pass the linter.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- funlen
|
||||
- revive
|
||||
# Allow duplications in tests so it's easier to follow a single unit
|
||||
# test.
|
||||
- dupl
|
||||
|
||||
- path: mock*
|
||||
linters:
|
||||
- revive
|
||||
# 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: test*
|
||||
linters:
|
||||
- gosec
|
||||
- funlen
|
||||
|
||||
# Allow duplicated code and fmt.Printf() in DB migrations.
|
||||
- path: channeldb/migration*
|
||||
linters:
|
||||
- dupl
|
||||
- forbidigo
|
||||
- godot
|
||||
|
||||
# Allow duplicated code and fmt.Printf() in DB migration tests.
|
||||
- path: channeldb/migtest
|
||||
linters:
|
||||
- dupl
|
||||
- forbidigo
|
||||
- godot
|
||||
|
||||
# Allow fmt.Printf() in commands.
|
||||
- path: cmd/commands/*
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
# Allow fmt.Printf() in config parsing.
|
||||
- path: config\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: lnd\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
- 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*
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.26.4-alpine as builder
|
||||
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.
|
||||
|
|
@ -29,13 +29,13 @@ FROM alpine as final
|
|||
VOLUME /root/.lnd
|
||||
|
||||
# Add utilities for quality of life and SSL-related reasons. We also require
|
||||
# wget and gpg for the signature verification script.
|
||||
# curl and gpg for the signature verification script.
|
||||
RUN apk --no-cache add \
|
||||
bash \
|
||||
jq \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
wget
|
||||
curl
|
||||
|
||||
# Copy the binaries from the builder image.
|
||||
COPY --from=builder /go/bin/lncli /bin/
|
||||
|
|
|
|||
98
Makefile
98
Makefile
|
|
@ -1,20 +1,16 @@
|
|||
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
|
||||
GOIMPORTS_BIN := $(GO_BIN)/gosimports
|
||||
GOMOBILE_BIN := $(GO_BIN)/gomobile
|
||||
|
||||
MOBILE_BUILD_DIR :=${GOPATH}/src/$(MOBILE_PKG)/build
|
||||
|
|
@ -22,10 +18,6 @@ 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
|
||||
# For Android, set max page size to 16KB to support devices using 16KB memory pages.
|
||||
# Reference: https://developer.android.com/guide/practices/page-sizes
|
||||
ANDROID_MAX_PAGE_SIZE := 16384
|
||||
ANDROID_EXTLDFLAGS := -extldflags '-Wl,-z,max-page-size=$(ANDROID_MAX_PAGE_SIZE)'
|
||||
|
||||
COMMIT := $(shell git describe --tags --dirty)
|
||||
|
||||
|
|
@ -36,7 +28,7 @@ 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.26.4
|
||||
GO_VERSION = 1.25.5
|
||||
|
||||
GOBUILD := $(GOCC) build -v
|
||||
GOINSTALL := $(GOCC) install -v
|
||||
|
|
@ -72,41 +64,12 @@ 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 \
|
||||
DOCKER_TOOLS = 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
|
||||
-v $(shell bash -c "$(GOCC) env GOCACHE || (mkdir -p /tmp/go-cache; echo /tmp/go-cache)"):/tmp/build/.cache \
|
||||
-v $(shell bash -c "$(GOCC) env GOMODCACHE || (mkdir -p /tmp/go-modcache; echo /tmp/go-modcache)"):/tmp/build/.modcache \
|
||||
-v $(shell bash -c "mkdir -p /tmp/go-lint-cache; echo /tmp/go-lint-cache"):/root/.cache/golangci-lint \
|
||||
-v $$(pwd):/build lnd-tools
|
||||
|
||||
GREEN := "\\033[0;32m"
|
||||
NC := "\\033[0m"
|
||||
|
|
@ -125,6 +88,10 @@ $(BTCD_BIN):
|
|||
@$(call print, "Installing btcd.")
|
||||
cd $(TOOLS_DIR); $(GOCC) install -trimpath $(BTCD_PKG)
|
||||
|
||||
$(GOIMPORTS_BIN):
|
||||
@$(call print, "Installing goimports.")
|
||||
cd $(TOOLS_DIR); $(GOCC) install -trimpath $(GOIMPORTS_PKG)
|
||||
|
||||
# ============
|
||||
# INSTALLATION
|
||||
# ============
|
||||
|
|
@ -199,11 +166,7 @@ release: clean-mobile
|
|||
./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-cache:
|
||||
$(call check_docker_release_cache,$(DOCKER_RELEASE_GOCACHE))
|
||||
$(call check_docker_release_cache,$(DOCKER_RELEASE_GOMODCACHE))
|
||||
|
||||
docker-release: docker-release-cache
|
||||
docker-release:
|
||||
@$(call print, "Building release helper docker image.")
|
||||
if [ "$(tag)" = "" ]; then echo "Must specify tag=<commit_or_tag>!"; exit 1; fi
|
||||
|
||||
|
|
@ -352,9 +315,9 @@ fuzz:
|
|||
# =========
|
||||
|
||||
#? fmt: Format source code and fix imports
|
||||
fmt:
|
||||
fmt: $(GOIMPORTS_BIN)
|
||||
@$(call print, "Fixing imports.")
|
||||
$(GOTOOL) $(GOIMPORTS_PKG) -w $(GOFILES_NOVENDOR)
|
||||
gosimports -w $(GOFILES_NOVENDOR)
|
||||
@$(call print, "Formatting source.")
|
||||
gofmt -l -w -s $(GOFILES_NOVENDOR)
|
||||
|
||||
|
|
@ -379,28 +342,10 @@ 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
|
||||
$(DOCKER_TOOLS) custom-gcl run -v $(LINT_WORKERS)
|
||||
|
||||
#? lint: Run static code analysis
|
||||
lint: check-go-version lint-config-check lint-source
|
||||
|
||||
#? build-native-linter: Build the custom golangci-lint binary natively
|
||||
build-native-linter:
|
||||
@$(call print, "Building custom linter natively.")
|
||||
cd tools && CGO_ENABLED=0 $(GOCC) tool $(GOLINT_PKG) custom
|
||||
|
||||
#? lint-native: Run static code analysis without Docker (faster on macOS)
|
||||
lint-native: check-go-version lint-config-check build-native-linter
|
||||
@$(call print, "Linting source (native).")
|
||||
GOWORK=off ./tools/custom-gcl run -v $(LINT_WORKERS) \
|
||||
--new-from-rev=$$(git merge-base HEAD master)
|
||||
lint: check-go-version lint-source
|
||||
|
||||
#? protolint: Lint proto files using protolint
|
||||
protolint:
|
||||
|
|
@ -502,7 +447,7 @@ macos: mobile-rpc
|
|||
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) $(ANDROID_EXTLDFLAGS)" -v -o $(ANDROID_BUILD) $(MOBILE_PKG)
|
||||
$(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
|
||||
|
|
@ -520,11 +465,6 @@ clean-mobile:
|
|||
$(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 \
|
||||
|
|
@ -543,7 +483,6 @@ clean-docker-volumes:
|
|||
flake-unit \
|
||||
fmt \
|
||||
lint \
|
||||
lint-native \
|
||||
list \
|
||||
rpc \
|
||||
rpc-format \
|
||||
|
|
@ -554,5 +493,4 @@ clean-docker-volumes:
|
|||
ios \
|
||||
android \
|
||||
mobile \
|
||||
clean \
|
||||
clean-docker-volumes
|
||||
clean
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ 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).
|
||||
[here](https://gist.githubusercontent.com/Roasbeef/6fb5b52886183239e4aa558f83d085d3/raw/5fa96010af201628bcfa61e9309d9b13d23d220f/security@lightning.engineering).
|
||||
|
||||
## Further reading
|
||||
* [Step-by-step send payment guide with docker](https://github.com/lightningnetwork/lnd/tree/master/docker)
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ The last major lnd release is to be considered the current support version. Give
|
|||
|
||||
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).
|
||||
The following key can be used to communicate sensitive information: `91FE 464C D751 01DA 6B6B AB60 555C 6465 E5BC B3AF`.
|
||||
|
|
|
|||
478
actor/README.md
478
actor/README.md
|
|
@ -1,478 +0,0 @@
|
|||
# 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`.
|
||||
294
actor/actor.go
294
actor/actor.go
|
|
@ -1,294 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
)
|
||||
|
||||
// MailboxFactory is a function type that creates a Mailbox implementation.
|
||||
// It receives the actor's context and the desired capacity, allowing custom
|
||||
// mailbox implementations (e.g., BackpressureMailbox) to be injected.
|
||||
type MailboxFactory[M Message, R any] func(ctx context.Context,
|
||||
capacity int) Mailbox[M, R]
|
||||
|
||||
// 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
|
||||
|
||||
// MailboxFactory is an optional factory for creating the actor's
|
||||
// mailbox. If nil, a default ChannelMailbox will be used.
|
||||
MailboxFactory MailboxFactory[M, R]
|
||||
}
|
||||
|
||||
// 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 the mailbox using the factory if provided, otherwise use
|
||||
// the default ChannelMailbox.
|
||||
var mailbox Mailbox[M, R]
|
||||
if cfg.MailboxFactory != nil {
|
||||
mailbox = cfg.MailboxFactory(ctx, mailboxCapacity)
|
||||
} else {
|
||||
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 the message was either dropped by backpressure
|
||||
// (load shedding) or the caller's context was cancelled.
|
||||
// Both are intentionally silent — no DLO routing.
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
switch {
|
||||
case ref.actor.ctx.Err() != nil:
|
||||
promise.Complete(fn.Err[R](ErrActorTerminated))
|
||||
case ctx.Err() != nil:
|
||||
promise.Complete(fn.Err[R](ctx.Err()))
|
||||
default:
|
||||
// Neither context is done — the mailbox's
|
||||
// backpressure mechanism dropped the message.
|
||||
promise.Complete(fn.Err[R](ErrMessageDropped))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/lightningnetwork/lnd/queue"
|
||||
)
|
||||
|
||||
// BackpressureMailbox implements the Mailbox interface using a
|
||||
// queue.BackpressureQueue as its core buffer. The BackpressureQueue's drop
|
||||
// predicate is consulted on every Send/TrySend, allowing RED-style load
|
||||
// shedding before the mailbox is full.
|
||||
type BackpressureMailbox[M Message, R any] struct {
|
||||
// queue is the underlying backpressure-aware buffer.
|
||||
queue *queue.BackpressureQueue[envelope[M, R]]
|
||||
|
||||
// closed tracks whether the mailbox has been closed.
|
||||
closed atomic.Bool
|
||||
|
||||
// mu protects Send/TrySend operations to prevent send-on-closed-channel
|
||||
// panics. Close() acquires write lock, Send/TrySend acquire read lock.
|
||||
mu sync.RWMutex
|
||||
|
||||
// closeOnce ensures Close() executes exactly once.
|
||||
closeOnce sync.Once
|
||||
|
||||
// actorCtx is the actor's context for lifecycle management.
|
||||
actorCtx context.Context
|
||||
}
|
||||
|
||||
// NewBackpressureMailbox creates a new mailbox backed by a BackpressureQueue.
|
||||
// The shouldDrop function is called with the current queue depth on every send
|
||||
// attempt; if it returns true the message is silently dropped.
|
||||
func NewBackpressureMailbox[M Message, R any](
|
||||
actorCtx context.Context,
|
||||
capacity int,
|
||||
shouldDrop queue.DropCheckFunc,
|
||||
) *BackpressureMailbox[M, R] {
|
||||
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
|
||||
pred := queue.AsDropPredicate[envelope[M, R]](shouldDrop)
|
||||
|
||||
return &BackpressureMailbox[M, R]{
|
||||
queue: queue.NewBackpressureQueue(capacity, pred),
|
||||
actorCtx: actorCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// Send attempts to send an envelope to the mailbox. The BackpressureQueue's
|
||||
// drop predicate is consulted first; if it decides to drop, false is returned
|
||||
// immediately. Otherwise the send blocks until the envelope is accepted, the
|
||||
// caller's context is cancelled, or the actor's context is cancelled.
|
||||
func (m *BackpressureMailbox[M, R]) Send(ctx context.Context,
|
||||
env envelope[M, R]) bool {
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.IsClosed() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a context that is cancelled when either the caller's context
|
||||
// or the actor's context is done, so that the blocking Enqueue
|
||||
// respects both.
|
||||
merged, cancel := context.WithCancel(ctx)
|
||||
stop := context.AfterFunc(m.actorCtx, cancel)
|
||||
defer stop()
|
||||
defer cancel()
|
||||
|
||||
err := m.queue.Enqueue(merged, env)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// TrySend attempts a non-blocking send. Returns false if the drop predicate
|
||||
// rejects the message, the queue is at capacity, or the mailbox is closed.
|
||||
func (m *BackpressureMailbox[M, R]) TrySend(env envelope[M, R]) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.IsClosed() {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.queue.TryEnqueue(env)
|
||||
}
|
||||
|
||||
// Receive returns an iterator that yields envelopes from the mailbox until
|
||||
// the mailbox is closed, the provided context is cancelled, or the actor's
|
||||
// context is cancelled.
|
||||
func (m *BackpressureMailbox[M, R]) Receive(
|
||||
ctx context.Context) iter.Seq[envelope[M, R]] {
|
||||
|
||||
return func(yield func(envelope[M, R]) bool) {
|
||||
ch := m.queue.ReceiveChan()
|
||||
for {
|
||||
select {
|
||||
case env, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(env) {
|
||||
return
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-m.actorCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the mailbox, preventing new messages from being sent. Any
|
||||
// remaining messages can still be consumed via Drain.
|
||||
func (m *BackpressureMailbox[M, R]) Close() {
|
||||
m.closeOnce.Do(func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.closed.Store(true)
|
||||
|
||||
m.queue.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// IsClosed returns true if the mailbox has been closed.
|
||||
func (m *BackpressureMailbox[M, R]) IsClosed() bool {
|
||||
return m.closed.Load()
|
||||
}
|
||||
|
||||
// Drain returns an iterator that yields all remaining messages in the mailbox
|
||||
// after it has been closed.
|
||||
func (m *BackpressureMailbox[M, R]) Drain() iter.Seq[envelope[M, R]] {
|
||||
return func(yield func(envelope[M, R]) bool) {
|
||||
if !m.IsClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
ch := m.queue.ReceiveChan()
|
||||
for {
|
||||
select {
|
||||
case env, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(env) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/lightningnetwork/lnd/queue"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Compile-time assertion that BackpressureMailbox satisfies the Mailbox
|
||||
// interface.
|
||||
var _ Mailbox[TestMessage, int] = (*BackpressureMailbox[TestMessage, int])(nil)
|
||||
|
||||
// TestBackpressureMailboxDropsWhenThresholdReached verifies that
|
||||
// BackpressureMailbox drops messages when shouldDrop returns true.
|
||||
func TestBackpressureMailboxDropsWhenThresholdReached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 10
|
||||
const dropThreshold = 5
|
||||
|
||||
shouldDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return queueLen >= dropThreshold
|
||||
})
|
||||
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, shouldDrop,
|
||||
)
|
||||
|
||||
// Fill up to the drop threshold — these should all succeed.
|
||||
for i := range dropThreshold {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
ok := mbox.Send(ctx, env)
|
||||
require.True(t, ok, "message %d should be accepted", i)
|
||||
}
|
||||
|
||||
// Next message should be dropped by the predicate.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 99},
|
||||
}
|
||||
ok := mbox.Send(ctx, env)
|
||||
require.False(t, ok, "message at threshold should be dropped")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxTrySendDrops verifies TrySend also respects the drop
|
||||
// predicate.
|
||||
func TestBackpressureMailboxTrySendDrops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 10
|
||||
const dropThreshold = 3
|
||||
|
||||
shouldDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return queueLen >= dropThreshold
|
||||
})
|
||||
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, shouldDrop,
|
||||
)
|
||||
|
||||
// Fill to threshold.
|
||||
for i := range dropThreshold {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
ok := mbox.TrySend(env)
|
||||
require.True(t, ok, "message %d should be accepted", i)
|
||||
}
|
||||
|
||||
// TrySend should now be rejected.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 99},
|
||||
}
|
||||
ok := mbox.TrySend(env)
|
||||
require.False(t, ok, "TrySend at threshold should be dropped")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxNeverDropPassesThrough verifies that a never-drop
|
||||
// predicate lets all messages through (up to channel capacity).
|
||||
func TestBackpressureMailboxNeverDropPassesThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 5
|
||||
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
// Fill the entire capacity.
|
||||
for i := range capacity {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
ok := mbox.Send(ctx, env)
|
||||
require.True(t, ok, "message %d should be accepted", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxDelegatesReceive verifies that Receive yields messages
|
||||
// from the underlying BackpressureQueue.
|
||||
func TestBackpressureMailboxDelegatesReceive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
const capacity = 5
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
// Send two messages.
|
||||
for i := range 2 {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
mbox.Send(ctx, env)
|
||||
}
|
||||
|
||||
// Close so Receive iterator terminates after draining.
|
||||
mbox.Close()
|
||||
|
||||
var count int
|
||||
for range mbox.Receive(ctx) {
|
||||
count++
|
||||
}
|
||||
|
||||
require.Equal(t, 2, count, "should receive 2 messages")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxDelegatesDrain verifies that Drain yields remaining
|
||||
// messages after close.
|
||||
func TestBackpressureMailboxDelegatesDrain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 5
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
// Send messages and close.
|
||||
for i := range 3 {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
mbox.Send(ctx, env)
|
||||
}
|
||||
mbox.Close()
|
||||
|
||||
require.True(t, mbox.IsClosed())
|
||||
|
||||
var count int
|
||||
for range mbox.Drain() {
|
||||
count++
|
||||
}
|
||||
|
||||
require.Equal(t, 3, count, "should drain 3 messages")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxSendRespectsActorCtx verifies that Send returns false
|
||||
// when the actor context is cancelled.
|
||||
func TestBackpressureMailboxSendRespectsActorCtx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actorCtx, actorCancel := context.WithCancel(context.Background())
|
||||
|
||||
const capacity = 1
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
actorCtx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
// Fill the mailbox to capacity.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 1},
|
||||
}
|
||||
ok := mbox.Send(context.Background(), env)
|
||||
require.True(t, ok)
|
||||
|
||||
// Cancel the actor context. The next blocking send should fail.
|
||||
actorCancel()
|
||||
|
||||
env2 := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 2},
|
||||
}
|
||||
ok = mbox.Send(context.Background(), env2)
|
||||
require.False(t, ok, "send should fail when actor context is cancelled")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxReceiveAfterClose verifies that calling Receive after
|
||||
// Close does not panic and yields no messages (the channel is already drained).
|
||||
func TestBackpressureMailboxReceiveAfterClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 5
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
mbox.Close()
|
||||
|
||||
// First Receive after close should return immediately (closed channel).
|
||||
var count int
|
||||
for range mbox.Receive(ctx) {
|
||||
count++
|
||||
}
|
||||
require.Equal(t, 0, count, "no messages expected")
|
||||
|
||||
// Second Receive must not panic.
|
||||
for range mbox.Receive(ctx) {
|
||||
count++
|
||||
}
|
||||
require.Equal(t, 0, count, "still no messages expected")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxDrainAfterDrain verifies that calling Drain twice
|
||||
// after Close does not panic.
|
||||
func TestBackpressureMailboxDrainAfterDrain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const capacity = 5
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
// Send one message and close.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 1},
|
||||
}
|
||||
mbox.Send(ctx, env)
|
||||
mbox.Close()
|
||||
|
||||
// First drain should yield the message.
|
||||
var count int
|
||||
for range mbox.Drain() {
|
||||
count++
|
||||
}
|
||||
require.Equal(t, 1, count, "should drain 1 message")
|
||||
|
||||
// Second drain must not panic and should yield nothing.
|
||||
count = 0
|
||||
for range mbox.Drain() {
|
||||
count++
|
||||
}
|
||||
require.Equal(t, 0, count, "second drain should yield nothing")
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxConcurrentSendClose tests concurrent Send/TrySend and
|
||||
// Close operations to ensure no race conditions or panics occur.
|
||||
func TestBackpressureMailboxConcurrentSendClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
numSenders = 50
|
||||
capacity = 20
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, capacity, neverDrop,
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Launch many goroutines that continuously call Send/TrySend.
|
||||
for i := range numSenders {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for j := range 100 {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{
|
||||
Value: i*100 + j,
|
||||
},
|
||||
}
|
||||
// Send must not panic regardless of
|
||||
// whether Close has been called.
|
||||
mbox.Send(ctx, env)
|
||||
}
|
||||
}()
|
||||
|
||||
// Launch a goroutine that also calls TrySend
|
||||
// concurrently.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for j := range 500 {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: j},
|
||||
}
|
||||
mbox.TrySend(env)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Drain messages concurrently to free buffer space so Send
|
||||
// goroutines make progress and don't all block.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
ch := mbox.queue.ReceiveChan()
|
||||
for range ch {
|
||||
}
|
||||
}()
|
||||
|
||||
// Close the mailbox while senders are still active.
|
||||
mbox.Close()
|
||||
|
||||
// Wait for all goroutines to finish. If the RWMutex protocol
|
||||
// is broken, this test will panic with "send on closed channel"
|
||||
// or the race detector will flag a data race.
|
||||
wg.Wait()
|
||||
|
||||
require.True(t, mbox.IsClosed())
|
||||
|
||||
// After Close, all subsequent sends must return false.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: -1},
|
||||
}
|
||||
require.False(t, mbox.Send(ctx, env))
|
||||
require.False(t, mbox.TrySend(env))
|
||||
}
|
||||
|
||||
// TestBackpressureMailboxConcurrentMultiClose verifies that calling Close
|
||||
// from multiple goroutines simultaneously does not panic.
|
||||
func TestBackpressureMailboxConcurrentMultiClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
neverDrop := queue.DropCheckFunc(func(queueLen int) bool {
|
||||
return false
|
||||
})
|
||||
|
||||
mbox := NewBackpressureMailbox[TestMessage, int](
|
||||
ctx, 10, neverDrop,
|
||||
)
|
||||
|
||||
// Send a few messages first.
|
||||
for i := range 5 {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
}
|
||||
mbox.Send(ctx, env)
|
||||
}
|
||||
|
||||
// Close from many goroutines simultaneously.
|
||||
var wg sync.WaitGroup
|
||||
for range 20 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mbox.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.True(t, mbox.IsClosed())
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
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.
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
174
actor/future.go
174
actor/future.go
|
|
@ -1,174 +0,0 @@
|
|||
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]
|
||||
}
|
||||
|
||||
// CompleteWith completes a promise with the given value, wrapping it as a
|
||||
// successful result. This is a convenience wrapper over
|
||||
// promise.Complete(fn.Ok(val)). Safe to call multiple times; only the first
|
||||
// call takes effect.
|
||||
func CompleteWith[T any](p Promise[T], val T) {
|
||||
p.Complete(fn.Ok(val))
|
||||
}
|
||||
|
||||
// AwaitFuture blocks until the future resolves or the context is cancelled.
|
||||
// On success, it returns the resolved value and a nil error. If the context
|
||||
// is cancelled before the future resolves, it returns the zero value of T and
|
||||
// the context cancellation error.
|
||||
func AwaitFuture[T any](ctx context.Context, f Future[T]) (T, error) {
|
||||
return f.Await(ctx).Unpack()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}()
|
||||
}
|
||||
|
|
@ -1,525 +0,0 @@
|
|||
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",
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompleteWith verifies that CompleteWith resolves a promise with the
|
||||
// supplied value, that the resolution is immediately visible on the Future, and
|
||||
// that a second call is a safe no-op (idempotency inherited from Complete).
|
||||
func TestCompleteWith(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Normal completion — value should be visible on the future.
|
||||
promise := NewPromise[int]()
|
||||
CompleteWith(promise, 42)
|
||||
|
||||
result := promise.Future().Await(context.Background())
|
||||
require.False(t, result.IsErr())
|
||||
result.WhenOk(func(v int) {
|
||||
require.Equal(t, 42, v)
|
||||
})
|
||||
|
||||
// Second call must be a no-op; the future must still hold 42.
|
||||
CompleteWith(promise, 99)
|
||||
|
||||
result2 := promise.Future().Await(context.Background())
|
||||
require.False(t, result2.IsErr())
|
||||
result2.WhenOk(func(v int) {
|
||||
require.Equal(t, 42, v, "second CompleteWith must not overwrite")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAwaitFuture verifies that AwaitFuture unpacks a resolved future into a
|
||||
// (value, nil) pair, that a future completed with fn.Err is reported as a
|
||||
// (zero, err) pair, and that context cancellation before resolution is
|
||||
// reported as a (zero, ctx.Err()) pair.
|
||||
func TestAwaitFuture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Resolved future — should return the value with a nil error.
|
||||
promise := NewPromise[string]()
|
||||
CompleteWith(promise, "hello")
|
||||
|
||||
val, err := AwaitFuture(context.Background(), promise.Future())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", val)
|
||||
|
||||
// Future completed with fn.Err — should surface the error as the
|
||||
// second return value with the zero string value.
|
||||
sentinel := fmt.Errorf("result-level error")
|
||||
errPromise := NewPromise[string]()
|
||||
errPromise.Complete(fn.Err[string](sentinel))
|
||||
|
||||
val3, err3 := AwaitFuture(context.Background(), errPromise.Future())
|
||||
require.ErrorIs(t, err3, sentinel)
|
||||
require.Equal(t, "", val3, "zero value expected on fn.Err result")
|
||||
|
||||
// Cancelled context — should return the zero value and ctx.Err().
|
||||
unresolved := NewPromise[string]()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
val2, err2 := AwaitFuture(ctx, unresolved.Future())
|
||||
require.ErrorIs(t, err2, context.Canceled)
|
||||
require.Equal(t, "", val2, "zero value expected on cancellation")
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
})
|
||||
}
|
||||
27
actor/go.mod
27
actor/go.mod
|
|
@ -1,27 +0,0 @@
|
|||
module github.com/lightningnetwork/lnd/actor
|
||||
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btclog/v2 v2.0.1-0.20250602222548-9967d19bb084
|
||||
github.com/lightningnetwork/lnd/fn/v2 v2.0.8
|
||||
github.com/lightningnetwork/lnd/queue v1.1.1
|
||||
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 // indirect
|
||||
github.com/lightningnetwork/lnd/ticker v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/lightningnetwork/lnd/queue => ../queue
|
||||
|
||||
replace github.com/lightningnetwork/lnd/ticker => ../ticker
|
||||
|
||||
replace github.com/lightningnetwork/lnd/fn/v2 => ../fn
|
||||
27
actor/go.sum
27
actor/go.sum
|
|
@ -1,27 +0,0 @@
|
|||
github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c h1:4HxD1lBUGUddhzgaNgrCPsFWd7cGYNpeFUgd9ZIgyM0=
|
||||
github.com/btcsuite/btclog v0.0.0-20241003133417-09c4e92e319c/go.mod h1:w7xnGOhwT3lmrS4H3b/D1XAXxvh+tbhUm8xeHN2y3TQ=
|
||||
github.com/btcsuite/btclog/v2 v2.0.1-0.20250602222548-9967d19bb084 h1:y3bvkt8ki0KX35eUEU8XShRHusz1S+55QwXUTmxn888=
|
||||
github.com/btcsuite/btclog/v2 v2.0.1-0.20250602222548-9967d19bb084/go.mod h1:XItGUfVOxotJL8kkuk2Hj3EVow5KCugXl3wWfQ6K0AE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
|
||||
golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
)
|
||||
|
||||
// ErrActorTerminated indicates that an operation failed because the target
|
||||
// actor was terminated or in the process of shutting down.
|
||||
var ErrActorTerminated = fmt.Errorf("actor terminated")
|
||||
|
||||
// ErrMessageDropped indicates that a message was dropped by the mailbox's
|
||||
// backpressure mechanism (e.g., RED-style load shedding).
|
||||
var ErrMessageDropped = errors.New("message dropped by backpressure")
|
||||
|
||||
// ErrEmptyActorID is returned when an actor is created with an empty ID.
|
||||
var ErrEmptyActorID = fmt.Errorf("actor ID must not be empty")
|
||||
|
||||
// ErrNilBehavior is returned when an actor is created with a nil behavior.
|
||||
var ErrNilBehavior = fmt.Errorf("actor behavior must not be nil")
|
||||
|
||||
// ErrDuplicateActorID is returned when attempting to register an actor with an
|
||||
// ID that is already in use within the actor system.
|
||||
var ErrDuplicateActorID = fmt.Errorf("actor ID already registered")
|
||||
|
||||
// BaseMessage is a helper struct that can be embedded in message types defined
|
||||
// outside the actor package to satisfy the Message interface's unexported
|
||||
// messageMarker method.
|
||||
type BaseMessage struct{}
|
||||
|
||||
// messageMarker implements the unexported method for the Message interface,
|
||||
// allowing types that embed BaseMessage to satisfy the Message interface.
|
||||
func (BaseMessage) messageMarker() {}
|
||||
|
||||
// Message is a sealed interface for actor messages. Actors will receive
|
||||
// messages conforming to this interface. The interface is "sealed" by the
|
||||
// unexported messageMarker method, meaning only types that can satisfy it
|
||||
// (e.g., by embedding BaseMessage or being in the same package) can be
|
||||
// Messages.
|
||||
type Message interface {
|
||||
// messageMarker is a private method that makes this a sealed interface
|
||||
// (see BaseMessage for embedding).
|
||||
messageMarker()
|
||||
|
||||
// MessageType returns the type name of the message for
|
||||
// routing/filtering.
|
||||
MessageType() string
|
||||
}
|
||||
|
||||
// Future represents the result of an asynchronous computation. It allows
|
||||
// consumers to wait for the result (Await), apply transformations upon
|
||||
// completion (ThenApply), or register a callback to be executed when the
|
||||
// result is available (OnComplete).
|
||||
type Future[T any] interface {
|
||||
// Await blocks until the result is available or the context is
|
||||
// cancelled, then returns it.
|
||||
Await(ctx context.Context) fn.Result[T]
|
||||
|
||||
// ThenApply registers a function to transform the result of a future.
|
||||
// The original future is not modified, a new instance of the future is
|
||||
// returned. If the passed context is cancelled while waiting for the
|
||||
// original future to complete, the new future will complete with the
|
||||
// context's error.
|
||||
ThenApply(ctx context.Context, fn func(T) T) Future[T]
|
||||
|
||||
// OnComplete registers a function to be called when the result of the
|
||||
// future is ready. If the passed context is cancelled before the future
|
||||
// completes, the callback function will be invoked with the context's
|
||||
// error.
|
||||
OnComplete(ctx context.Context, fn func(fn.Result[T]))
|
||||
}
|
||||
|
||||
// Promise is an interface that allows for the completion of an associated
|
||||
// Future. It provides a way to set the result of an asynchronous operation.
|
||||
// The producer of an asynchronous result uses a Promise to set the outcome,
|
||||
// while consumers use the associated Future to retrieve it.
|
||||
type Promise[T any] interface {
|
||||
// Future returns the Future interface associated with this Promise.
|
||||
// Consumers can use this to Await the result or register callbacks.
|
||||
Future() Future[T]
|
||||
|
||||
// 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.
|
||||
Complete(result fn.Result[T]) bool
|
||||
}
|
||||
|
||||
// TellOnlyRef is a reference to an actor that only supports "tell" operations.
|
||||
// This is useful for scenarios where only fire-and-forget message passing is
|
||||
// needed, or to restrict capabilities.
|
||||
type TellOnlyRef[M Message] interface {
|
||||
// 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.
|
||||
Tell(ctx context.Context, msg M)
|
||||
|
||||
// ID returns the unique identifier for this actor.
|
||||
ID() string
|
||||
}
|
||||
|
||||
// ActorRef is a reference to an actor that supports both "tell" and "ask"
|
||||
// operations. It embeds TellOnlyRef and adds the Ask method for
|
||||
// request-response interactions.
|
||||
type ActorRef[M Message, R any] interface {
|
||||
TellOnlyRef[M]
|
||||
|
||||
// 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).
|
||||
Ask(ctx context.Context, msg M) Future[R]
|
||||
}
|
||||
|
||||
// ActorBehavior defines the logic for how an actor processes incoming messages.
|
||||
// It is a strategy interface that encapsulates the actor's reaction to
|
||||
// messages.
|
||||
type ActorBehavior[M Message, R any] interface {
|
||||
// Receive processes a message and returns a Result. The provided
|
||||
// context is the actor's internal context, which can be used to
|
||||
// detect actor shutdown requests.
|
||||
Receive(actorCtx context.Context, msg M) fn.Result[R]
|
||||
}
|
||||
12
actor/log.go
12
actor/log.go
|
|
@ -1,12 +0,0 @@
|
|||
package actor
|
||||
|
||||
import "github.com/btcsuite/btclog/v2"
|
||||
|
||||
// log is a logger that is initialized as disabled. This means the package will
|
||||
// not perform any logging by default until a logger is set.
|
||||
var log = btclog.Disabled
|
||||
|
||||
// UseLogger uses a specified Logger to output package logging info.
|
||||
func UseLogger(logger btclog.Logger) {
|
||||
log = logger
|
||||
}
|
||||
176
actor/mailbox.go
176
actor/mailbox.go
|
|
@ -1,176 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Mailbox represents the message queue for an actor. It provides methods for
|
||||
// sending messages and receiving them via an iterator pattern.
|
||||
type Mailbox[M Message, R any] interface {
|
||||
// Send attempts to send an envelope to the mailbox with context-based
|
||||
// cancellation. Returns true if sent successfully, false if the
|
||||
// context was cancelled or the mailbox is closed.
|
||||
Send(ctx context.Context, env envelope[M, R]) bool
|
||||
|
||||
// TrySend attempts to send without blocking. Returns true if the
|
||||
// envelope was sent, false if the mailbox is full or closed.
|
||||
TrySend(env envelope[M, R]) bool
|
||||
|
||||
// Receive returns an iterator for consuming messages from the mailbox.
|
||||
// The iterator will yield messages until the mailbox is closed or the
|
||||
// context is cancelled.
|
||||
Receive(ctx context.Context) iter.Seq[envelope[M, R]]
|
||||
|
||||
// Close closes the mailbox, preventing new messages from being sent.
|
||||
// Any remaining messages can still be consumed via Receive.
|
||||
Close()
|
||||
|
||||
// IsClosed returns true if the mailbox has been closed.
|
||||
IsClosed() bool
|
||||
|
||||
// Drain returns an iterator that yields all remaining messages in the
|
||||
// mailbox after it has been closed. This is useful for cleanup.
|
||||
Drain() iter.Seq[envelope[M, R]]
|
||||
}
|
||||
|
||||
// ChannelMailbox is a channel-based implementation of the Mailbox interface.
|
||||
type ChannelMailbox[M Message, R any] struct {
|
||||
ch chan envelope[M, R]
|
||||
closed atomic.Bool
|
||||
|
||||
// mu protects Send/TrySend operations to prevent send-on-closed-channel
|
||||
// panics. Close() acquires write lock, Send/TrySend acquire read lock.
|
||||
mu sync.RWMutex
|
||||
|
||||
// closeOnce ensures Close() executes exactly once.
|
||||
closeOnce sync.Once
|
||||
|
||||
// actorCtx is the actor's context for lifecycle management.
|
||||
actorCtx context.Context
|
||||
}
|
||||
|
||||
// NewChannelMailbox creates a new channel-based mailbox with the specified
|
||||
// buffer capacity and actor context.
|
||||
func NewChannelMailbox[M Message, R any](actorCtx context.Context,
|
||||
capacity int) *ChannelMailbox[M, R] {
|
||||
|
||||
if capacity <= 0 {
|
||||
capacity = 1
|
||||
}
|
||||
return &ChannelMailbox[M, R]{
|
||||
ch: make(chan envelope[M, R], capacity),
|
||||
actorCtx: actorCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// Send implements Mailbox.Send with context-aware blocking send.
|
||||
func (m *ChannelMailbox[M, R]) Send(ctx context.Context,
|
||||
env envelope[M, R]) bool {
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.IsClosed() {
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case m.ch <- env:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-m.actorCtx.Done():
|
||||
// Actor is shutting down.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TrySend implements Mailbox.TrySend with non-blocking send.
|
||||
func (m *ChannelMailbox[M, R]) TrySend(env envelope[M, R]) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.IsClosed() {
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case m.ch <- env:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Receive implements Mailbox.Receive using iter.Seq pattern.
|
||||
func (m *ChannelMailbox[M, R]) Receive(
|
||||
ctx context.Context) iter.Seq[envelope[M, R]] {
|
||||
return func(yield func(envelope[M, R]) bool) {
|
||||
for {
|
||||
select {
|
||||
case env, ok := <-m.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(env) {
|
||||
return
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-m.actorCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close implements Mailbox.Close.
|
||||
func (m *ChannelMailbox[M, R]) Close() {
|
||||
m.closeOnce.Do(func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.closed.Store(true)
|
||||
|
||||
close(m.ch)
|
||||
})
|
||||
}
|
||||
|
||||
// IsClosed implements Mailbox.IsClosed.
|
||||
func (m *ChannelMailbox[M, R]) IsClosed() bool {
|
||||
return m.closed.Load()
|
||||
}
|
||||
|
||||
// Drain implements Mailbox.Drain for cleanup after close.
|
||||
func (m *ChannelMailbox[M, R]) Drain() iter.Seq[envelope[M, R]] {
|
||||
return func(yield func(envelope[M, R]) bool) {
|
||||
// Only drain if closed.
|
||||
if !m.IsClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
// Drain all remaining messages from the channel.
|
||||
for {
|
||||
select {
|
||||
case env, ok := <-m.ch:
|
||||
// Channel closed, nothing left to drain.
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(env) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Channel empty, done draining.
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,593 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMessage is a test message type that embeds BaseMessage.
|
||||
type TestMessage struct {
|
||||
BaseMessage
|
||||
Value int
|
||||
}
|
||||
|
||||
// MessageType returns the type name of the message for routing/filtering.
|
||||
func (tm TestMessage) MessageType() string {
|
||||
return "TestMessage"
|
||||
}
|
||||
|
||||
// TestChannelMailboxSend tests the Send method of ChannelMailbox.
|
||||
func TestChannelMailboxSend(t *testing.T) {
|
||||
t.Run("successful send", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
ctx := context.Background()
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.Send(ctx, env)
|
||||
require.True(t, sent, "Send should succeed")
|
||||
})
|
||||
|
||||
t.Run("send with cancelled context", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 1)
|
||||
// Fill the mailbox first.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.TrySend(env)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel immediately.
|
||||
cancel()
|
||||
|
||||
env2 := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 43},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.Send(ctx, env2)
|
||||
require.False(t, sent, "Send should fail with cancelled context")
|
||||
})
|
||||
|
||||
t.Run("send to closed mailbox", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
mailbox.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.Send(ctx, env)
|
||||
require.False(t, sent, "Send should fail on closed mailbox")
|
||||
})
|
||||
}
|
||||
|
||||
// TestChannelMailboxTrySend tests the TrySend method of ChannelMailbox.
|
||||
func TestChannelMailboxTrySend(t *testing.T) {
|
||||
t.Run("successful try send", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.TrySend(env)
|
||||
require.True(t, sent, "TrySend should succeed")
|
||||
})
|
||||
|
||||
t.Run("try send to full mailbox", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 1)
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
// Fill the mailbox.
|
||||
sent := mailbox.TrySend(env)
|
||||
require.True(t, sent, "First TrySend should succeed")
|
||||
|
||||
// Try to send again - should fail.
|
||||
sent = mailbox.TrySend(env)
|
||||
require.False(t, sent, "TrySend should fail on full mailbox")
|
||||
})
|
||||
|
||||
t.Run("try send to closed mailbox", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
mailbox.Close()
|
||||
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.TrySend(env)
|
||||
require.False(t, sent, "TrySend should fail on closed mailbox")
|
||||
})
|
||||
}
|
||||
|
||||
// TestChannelMailboxReceive tests the Receive method of ChannelMailbox.
|
||||
func TestChannelMailboxReceive(t *testing.T) {
|
||||
t.Run("receive messages", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
ctx := context.Background()
|
||||
|
||||
// Send some messages.
|
||||
for i := 0; i < 3; i++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
|
||||
// Start receiving in a goroutine.
|
||||
var received []int
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for env := range mailbox.Receive(ctx) {
|
||||
received = append(received, env.message.Value)
|
||||
}
|
||||
}()
|
||||
|
||||
// Close the mailbox after sending all messages.
|
||||
mailbox.Close()
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, received, 3, "Should receive 3 messages")
|
||||
require.Equal(t, []int{0, 1, 2}, received, "Should receive messages in order")
|
||||
})
|
||||
|
||||
t.Run("receive with cancelled context", func(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Send a message.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(context.Background(), env)
|
||||
|
||||
// Start receiving.
|
||||
var received int
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for env := range mailbox.Receive(ctx) {
|
||||
received++
|
||||
_ = env
|
||||
}
|
||||
}()
|
||||
|
||||
// Cancel the context.
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
// Might receive 0 or 1 message depending on timing.
|
||||
require.LessOrEqual(t, received, 1,
|
||||
"Should stop receiving after context cancel")
|
||||
})
|
||||
}
|
||||
|
||||
// TestChannelMailboxClose tests the Close and IsClosed methods.
|
||||
func TestChannelMailboxClose(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
|
||||
require.False(t, mailbox.IsClosed(), "Mailbox should not be closed initially")
|
||||
|
||||
mailbox.Close()
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed after Close()")
|
||||
|
||||
// Closing again should be safe.
|
||||
mailbox.Close()
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should remain closed")
|
||||
}
|
||||
|
||||
// TestChannelMailboxDrain tests the Drain method of ChannelMailbox.
|
||||
func TestChannelMailboxDrain(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
ctx := context.Background()
|
||||
|
||||
// Send some messages.
|
||||
for i := 0; i < 3; i++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: i},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
|
||||
// Close the mailbox.
|
||||
mailbox.Close()
|
||||
|
||||
// Drain messages.
|
||||
var drained []int
|
||||
for env := range mailbox.Drain() {
|
||||
drained = append(drained, env.message.Value)
|
||||
}
|
||||
|
||||
require.Len(t, drained, 3, "Should drain 3 messages")
|
||||
require.Equal(t, []int{0, 1, 2}, drained, "Should drain messages in order")
|
||||
}
|
||||
|
||||
// TestChannelMailboxConcurrent tests concurrent operations on ChannelMailbox.
|
||||
func TestChannelMailboxConcurrent(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 100)
|
||||
ctx := context.Background()
|
||||
|
||||
const numSenders = 10
|
||||
const messagesPerSender = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start multiple senders.
|
||||
for i := 0; i < numSenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(senderID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < messagesPerSender; j++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: senderID*1000 + j},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start receiver.
|
||||
received := make([]int, 0, numSenders*messagesPerSender)
|
||||
var receiverWg sync.WaitGroup
|
||||
receiverWg.Add(1)
|
||||
go func() {
|
||||
defer receiverWg.Done()
|
||||
for env := range mailbox.Receive(ctx) {
|
||||
received = append(received, env.message.Value)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for all senders to complete.
|
||||
wg.Wait()
|
||||
|
||||
// Close the mailbox now that all sends are complete.
|
||||
mailbox.Close()
|
||||
receiverWg.Wait()
|
||||
|
||||
require.Len(t, received, numSenders*messagesPerSender,
|
||||
"Should receive all messages")
|
||||
}
|
||||
|
||||
// TestChannelMailboxZeroCapacity tests that zero capacity defaults to 1.
|
||||
func TestChannelMailboxZeroCapacity(t *testing.T) {
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 0)
|
||||
|
||||
// Should default to capacity of 1.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.TrySend(env)
|
||||
require.True(t, sent, "Should be able to send one message")
|
||||
|
||||
// Second send should fail (mailbox full).
|
||||
sent = mailbox.TrySend(env)
|
||||
require.False(t, sent, "Second send should fail on full mailbox")
|
||||
}
|
||||
|
||||
// TestChannelMailboxActorContext tests that the mailbox respects the actor's
|
||||
// context for cancellation.
|
||||
func TestChannelMailboxActorContext(t *testing.T) {
|
||||
t.Run("send respects actor context", func(t *testing.T) {
|
||||
actorCtx, actorCancel := context.WithCancel(context.Background())
|
||||
mailbox := NewChannelMailbox[TestMessage, int](actorCtx, 1)
|
||||
|
||||
// Fill the mailbox.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.TrySend(env)
|
||||
|
||||
// Cancel the actor context.
|
||||
actorCancel()
|
||||
|
||||
// Try to send with a fresh caller context - should fail due to
|
||||
// actor context cancellation.
|
||||
callerCtx := context.Background()
|
||||
env2 := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 43},
|
||||
promise: nil,
|
||||
}
|
||||
|
||||
sent := mailbox.Send(callerCtx, env2)
|
||||
require.False(t, sent, "Send should fail when actor context is cancelled")
|
||||
})
|
||||
|
||||
t.Run("receive respects actor context", func(t *testing.T) {
|
||||
actorCtx, actorCancel := context.WithCancel(context.Background())
|
||||
mailbox := NewChannelMailbox[TestMessage, int](actorCtx, 10)
|
||||
|
||||
// Send a message.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 42},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(context.Background(), env)
|
||||
|
||||
// Start receiving with a fresh context.
|
||||
callerCtx := context.Background()
|
||||
var received int
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for env := range mailbox.Receive(callerCtx) {
|
||||
received++
|
||||
_ = env
|
||||
}
|
||||
}()
|
||||
|
||||
// Cancel the actor context.
|
||||
actorCancel()
|
||||
wg.Wait()
|
||||
|
||||
// Should have stopped receiving due to actor context cancellation.
|
||||
require.LessOrEqual(t, received, 1,
|
||||
"Should stop receiving when actor context is cancelled")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailboxConcurrentSendAndClose tests concurrent Send and Close operations
|
||||
// to ensure no race conditions or panics occur.
|
||||
func TestMailboxConcurrentSendAndClose(t *testing.T) {
|
||||
const numSenders = 20
|
||||
const sendsPerSender = 100
|
||||
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 100)
|
||||
ctx := context.Background()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start receiver to drain messages.
|
||||
var recvWg sync.WaitGroup
|
||||
recvWg.Add(1)
|
||||
go func() {
|
||||
defer recvWg.Done()
|
||||
for range mailbox.Receive(ctx) {
|
||||
// Just drain.
|
||||
}
|
||||
}()
|
||||
|
||||
// Start multiple senders.
|
||||
for i := 0; i < numSenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(senderID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < sendsPerSender; j++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: senderID*1000 + j},
|
||||
promise: nil,
|
||||
}
|
||||
// Send may fail if mailbox closes, that's ok.
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrently close the mailbox multiple times from different
|
||||
// goroutines.
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mailbox.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
recvWg.Wait()
|
||||
|
||||
// Mailbox should be closed.
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed")
|
||||
|
||||
// Further sends should fail without panic.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 999},
|
||||
promise: nil,
|
||||
}
|
||||
sent := mailbox.Send(ctx, env)
|
||||
require.False(t, sent, "Send should fail on closed mailbox")
|
||||
}
|
||||
|
||||
// TestMailboxConcurrentTrySendAndClose tests concurrent TrySend and Close
|
||||
// operations to ensure no race conditions or panics occur.
|
||||
func TestMailboxConcurrentTrySendAndClose(t *testing.T) {
|
||||
const numSenders = 20
|
||||
const sendsPerSender = 100
|
||||
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start multiple senders using TrySend.
|
||||
for i := 0; i < numSenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(senderID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < sendsPerSender; j++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: senderID*1000 + j},
|
||||
promise: nil,
|
||||
}
|
||||
// TrySend may fail if mailbox is full or closed.
|
||||
mailbox.TrySend(env)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrently close the mailbox.
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mailbox.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Mailbox should be closed.
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed")
|
||||
|
||||
// Further sends should fail without panic.
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: 999},
|
||||
promise: nil,
|
||||
}
|
||||
sent := mailbox.TrySend(env)
|
||||
require.False(t, sent, "TrySend should fail on closed mailbox")
|
||||
}
|
||||
|
||||
// TestMailboxMultipleCloseCallers tests that multiple goroutines calling
|
||||
// Close() simultaneously don't cause panics or issues.
|
||||
func TestMailboxMultipleCloseCallers(t *testing.T) {
|
||||
const numClosers = 100
|
||||
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 10)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start many goroutines all trying to close the mailbox.
|
||||
for i := 0; i < numClosers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mailbox.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Mailbox should be closed exactly once.
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed")
|
||||
|
||||
// Calling Close again should be safe.
|
||||
mailbox.Close()
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should remain closed")
|
||||
}
|
||||
|
||||
// TestMailboxCloseWhileSending tests closing the mailbox while multiple
|
||||
// senders are actively sending messages.
|
||||
func TestMailboxCloseWhileSending(t *testing.T) {
|
||||
const numSenders = 10
|
||||
const sendsPerSender = 1000
|
||||
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 100)
|
||||
ctx := context.Background()
|
||||
|
||||
var sendWg sync.WaitGroup
|
||||
|
||||
// Start multiple senders.
|
||||
for i := 0; i < numSenders; i++ {
|
||||
sendWg.Add(1)
|
||||
go func(senderID int) {
|
||||
defer sendWg.Done()
|
||||
for j := 0; j < sendsPerSender; j++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: senderID*1000 + j},
|
||||
promise: nil,
|
||||
}
|
||||
// Send may fail after close, that's expected.
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start receiver to drain messages.
|
||||
var recvWg sync.WaitGroup
|
||||
recvWg.Add(1)
|
||||
receivedCount := 0
|
||||
go func() {
|
||||
defer recvWg.Done()
|
||||
for range mailbox.Receive(ctx) {
|
||||
receivedCount++
|
||||
}
|
||||
}()
|
||||
|
||||
// Close mailbox while sends are happening.
|
||||
mailbox.Close()
|
||||
|
||||
sendWg.Wait()
|
||||
recvWg.Wait()
|
||||
|
||||
// Should have received at least some messages (exact count depends on
|
||||
// timing).
|
||||
t.Logf("Received %d messages before close", receivedCount)
|
||||
|
||||
// Mailbox should be closed.
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed")
|
||||
}
|
||||
|
||||
// TestMailboxStressTest performs a high-concurrency stress test with multiple
|
||||
// senders, receivers, and close operations.
|
||||
func TestMailboxStressTest(t *testing.T) {
|
||||
const numSenders = 50
|
||||
const numReceivers = 5
|
||||
const sendsPerSender = 200
|
||||
|
||||
mailbox := NewChannelMailbox[TestMessage, int](context.Background(), 200)
|
||||
ctx := context.Background()
|
||||
|
||||
var sendWg sync.WaitGroup
|
||||
|
||||
// Start multiple senders.
|
||||
for i := 0; i < numSenders; i++ {
|
||||
sendWg.Add(1)
|
||||
go func(senderID int) {
|
||||
defer sendWg.Done()
|
||||
for j := 0; j < sendsPerSender; j++ {
|
||||
env := envelope[TestMessage, int]{
|
||||
message: TestMessage{Value: senderID*1000 + j},
|
||||
promise: nil,
|
||||
}
|
||||
mailbox.Send(ctx, env)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start multiple receivers.
|
||||
var recvWg sync.WaitGroup
|
||||
for i := 0; i < numReceivers; i++ {
|
||||
recvWg.Add(1)
|
||||
go func() {
|
||||
defer recvWg.Done()
|
||||
for range mailbox.Receive(ctx) {
|
||||
// Just drain messages.
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all sends to complete.
|
||||
sendWg.Wait()
|
||||
|
||||
// Close mailbox.
|
||||
mailbox.Close()
|
||||
|
||||
// Wait for all receivers to finish.
|
||||
recvWg.Wait()
|
||||
|
||||
// Mailbox should be closed.
|
||||
require.True(t, mailbox.IsClosed(), "Mailbox should be closed")
|
||||
}
|
||||
154
actor/router.go
154
actor/router.go
|
|
@ -1,154 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
)
|
||||
|
||||
// ErrNoActorsAvailable is returned when a router cannot find any actors
|
||||
// registered for its service key to forward a message to.
|
||||
var ErrNoActorsAvailable = errors.New("no actors available for service key")
|
||||
|
||||
// Compile-time assertion that Router satisfies the ActorRef interface.
|
||||
var _ ActorRef[Message, any] = (*Router[Message, any])(nil)
|
||||
|
||||
// RoutingStrategy defines the interface for selecting an actor from a list of
|
||||
// available actors.
|
||||
// The M (Message) and R (Response) type parameters ensure that the strategy
|
||||
// is compatible with the types of actors it will be selecting.
|
||||
type RoutingStrategy[M Message, R any] interface {
|
||||
// Select chooses an ActorRef from the provided slice. It returns the
|
||||
// selected actor or an error if no actor can be selected (e.g., if the
|
||||
// list is empty or another strategy-specific issue occurs).
|
||||
Select(refs []ActorRef[M, R]) (ActorRef[M, R], error)
|
||||
}
|
||||
|
||||
// RoundRobinStrategy implements a round-robin selection strategy. It is generic
|
||||
// over M and R to match the RoutingStrategy interface, though its logic doesn't
|
||||
// depend on these types directly for the selection mechanism itself.
|
||||
type RoundRobinStrategy[M Message, R any] struct {
|
||||
// index is used to pick the next actor in a round-robin fashion. It
|
||||
// must be accessed atomically to ensure thread-safety if multiple
|
||||
// goroutines use the same strategy instance (which they will via the
|
||||
// router).
|
||||
index uint64
|
||||
}
|
||||
|
||||
// NewRoundRobinStrategy creates a new RoundRobinStrategy, initialized for
|
||||
// round-robin selection.
|
||||
func NewRoundRobinStrategy[M Message, R any]() *RoundRobinStrategy[M, R] {
|
||||
return &RoundRobinStrategy[M, R]{}
|
||||
}
|
||||
|
||||
// Select picks an actor from the list using a round-robin algorithm.
|
||||
func (s *RoundRobinStrategy[M, R]) Select(
|
||||
refs []ActorRef[M, R],
|
||||
) (ActorRef[M, R], error) {
|
||||
if len(refs) == 0 {
|
||||
return nil, ErrNoActorsAvailable
|
||||
}
|
||||
|
||||
// Atomically increment and get the current index for selection.
|
||||
// We subtract 1 because AddUint64 returns the new value (which is
|
||||
// 1-based for the first call after initialization to 0), and slice
|
||||
// indexing is 0-based.
|
||||
idx := atomic.AddUint64(&s.index, 1) - 1
|
||||
selectedRef := refs[idx%uint64(len(refs))]
|
||||
|
||||
return selectedRef, nil
|
||||
}
|
||||
|
||||
// Router is a message-dispatching component that fronts multiple actors
|
||||
// registered under a specific ServiceKey. It uses a RoutingStrategy to
|
||||
// distribute messages to one of the available actors. It is generic over M
|
||||
// (Message type) and R (Response type) to match the actors it routes to.
|
||||
type Router[M Message, R any] struct {
|
||||
receptionist *Receptionist
|
||||
serviceKey ServiceKey[M, R]
|
||||
strategy RoutingStrategy[M, R]
|
||||
dlo ActorRef[Message, any] // Dead Letter Office reference.
|
||||
}
|
||||
|
||||
// NewRouter creates a new Router for a given service key and strategy. The
|
||||
// receptionist is used to discover actors registered with the service key.
|
||||
// The router itself is not an actor but a message dispatcher that behaves like
|
||||
// an ActorRef from the sender's perspective.
|
||||
func NewRouter[M Message, R any](receptionist *Receptionist,
|
||||
key ServiceKey[M, R], strategy RoutingStrategy[M, R],
|
||||
dlo ActorRef[Message, any]) *Router[M, R] {
|
||||
|
||||
return &Router[M, R]{
|
||||
receptionist: receptionist,
|
||||
serviceKey: key,
|
||||
strategy: strategy,
|
||||
dlo: dlo,
|
||||
}
|
||||
}
|
||||
|
||||
// getActor dynamically finds available actors for the service key and selects
|
||||
// one using the configured strategy. This method is called internally by Tell
|
||||
// and Ask on each invocation to ensure up-to-date actor discovery.
|
||||
func (r *Router[M, R]) getActor() (ActorRef[M, R], error) {
|
||||
// Discover available actors from the receptionist.
|
||||
availableActors := FindInReceptionist(r.receptionist, r.serviceKey)
|
||||
if len(availableActors) == 0 {
|
||||
return nil, ErrNoActorsAvailable
|
||||
}
|
||||
|
||||
// Select one actor using the strategy.
|
||||
return r.strategy.Select(availableActors)
|
||||
}
|
||||
|
||||
// Tell sends a message to one of the actors managed by the router, selected by
|
||||
// the routing strategy. If no actors are available or the send context is
|
||||
// cancelled before the message can be enqueued in the target actor's mailbox,
|
||||
// the message may be dropped. Errors during actor selection (e.g.,
|
||||
// ErrNoActorsAvailable) are currently not propagated from Tell, aligning with
|
||||
// its fire-and-forget nature. Such errors could be logged internally if needed.
|
||||
func (r *Router[M, R]) Tell(ctx context.Context, msg M) {
|
||||
selectedActor, err := r.getActor()
|
||||
if err != nil {
|
||||
// If no actors are available for the service, and a DLO is
|
||||
// configured, forward the message there.
|
||||
if errors.Is(err, ErrNoActorsAvailable) && r.dlo != nil {
|
||||
r.dlo.Tell(context.Background(), msg)
|
||||
} else {
|
||||
log.Warnf("Router(%s): message %s dropped "+
|
||||
"(no actors available, no DLO configured)",
|
||||
r.serviceKey.name, msg.MessageType())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
selectedActor.Tell(ctx, msg)
|
||||
}
|
||||
|
||||
// Ask sends a message to one of the actors managed by the router, selected by
|
||||
// the routing strategy, and returns a Future for the response. If no actors are
|
||||
// available (ErrNoActorsAvailable), the Future will be completed with this
|
||||
// error. If the send context is cancelled before the message can be enqueued in
|
||||
// the chosen actor's mailbox, the Future will be completed with the context's
|
||||
// error.
|
||||
func (r *Router[M, R]) Ask(ctx context.Context, msg M) Future[R] {
|
||||
selectedActor, err := r.getActor()
|
||||
if err != nil {
|
||||
// If no actor could be selected (e.g., none available),
|
||||
// complete the promise immediately with the selection error.
|
||||
promise := NewPromise[R]()
|
||||
promise.Complete(fn.Err[R](err))
|
||||
return promise.Future()
|
||||
}
|
||||
|
||||
return selectedActor.Ask(ctx, msg)
|
||||
}
|
||||
|
||||
// ID provides an identifier for the router. Since a router isn't an actor
|
||||
// itself but a dispatcher for a service, its ID can be based on the service
|
||||
// key.
|
||||
func (r *Router[M, R]) ID() string {
|
||||
return "router(" + r.serviceKey.name + ")"
|
||||
}
|
||||
447
actor/system.go
447
actor/system.go
|
|
@ -1,447 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
)
|
||||
|
||||
// stoppable defines an interface for components that can be stopped.
|
||||
// This is unexported as it's an internal detail of ActorSystem for managing
|
||||
// actors that need to be shut down.
|
||||
type stoppable interface {
|
||||
Stop()
|
||||
}
|
||||
|
||||
// SystemConfig holds configuration parameters for the ActorSystem.
|
||||
type SystemConfig struct {
|
||||
// MailboxCapacity is the default capacity for actor mailboxes.
|
||||
MailboxCapacity int
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default configuration for the ActorSystem.
|
||||
// The default mailbox capacity of 100 means each actor can buffer up to 100
|
||||
// pending messages (envelopes). Each envelope holds a message and an optional
|
||||
// promise pointer, so the memory overhead per actor is roughly proportional to
|
||||
// the size of the messages being sent multiplied by this capacity.
|
||||
func DefaultConfig() SystemConfig {
|
||||
return SystemConfig{
|
||||
MailboxCapacity: 100,
|
||||
}
|
||||
}
|
||||
|
||||
// ActorSystem manages the lifecycle of actors and provides coordination
|
||||
// services such as a receptionist for actor discovery and a dead letter office
|
||||
// for undeliverable messages. It also handles the graceful shutdown of all
|
||||
// managed actors.
|
||||
type ActorSystem struct {
|
||||
// receptionist is used for actor discovery.
|
||||
receptionist *Receptionist
|
||||
|
||||
// actors stores all actors managed by the system, keyed by their ID.
|
||||
// This includes the deadLetterActor.
|
||||
actors map[string]stoppable
|
||||
|
||||
// deadLetterActor handles undeliverable messages.
|
||||
deadLetterActor ActorRef[Message, any]
|
||||
|
||||
// config holds the system-wide configuration.
|
||||
config SystemConfig
|
||||
|
||||
// mu protects the 'actors' map.
|
||||
mu sync.RWMutex
|
||||
|
||||
// ctx is the main context for the actor system.
|
||||
ctx context.Context
|
||||
|
||||
// cancel cancels the main system context.
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewActorSystem creates a new actor system using the default configuration.
|
||||
func NewActorSystem() *ActorSystem {
|
||||
return NewActorSystemWithConfig(DefaultConfig())
|
||||
}
|
||||
|
||||
// NewActorSystemWithConfig creates a new actor system with custom configuration
|
||||
func NewActorSystemWithConfig(config SystemConfig) *ActorSystem {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Initialize the core ActorSystem components.
|
||||
system := &ActorSystem{
|
||||
receptionist: newReceptionist(),
|
||||
config: config,
|
||||
actors: make(map[string]stoppable),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Define the behavior for the dead letter actor. It logs undeliverable
|
||||
// messages and returns an error.
|
||||
deadLetterBehavior := NewFunctionBehavior(
|
||||
func(ctx context.Context, msg Message) fn.Result[any] {
|
||||
log.Warnf("Dead letter received: message type=%s",
|
||||
msg.MessageType())
|
||||
|
||||
return fn.Err[any](errors.New(
|
||||
"message undeliverable: " + msg.MessageType(),
|
||||
))
|
||||
},
|
||||
)
|
||||
|
||||
// Create the raw dead letter actor (*Actor instance). The DLO's own DLO
|
||||
// reference is nil to prevent loops if messages to the DLO itself fail.
|
||||
deadLetterActorCfg := ActorConfig[Message, any]{
|
||||
ID: "dead-letters",
|
||||
Behavior: deadLetterBehavior,
|
||||
DLO: nil,
|
||||
MailboxSize: config.MailboxCapacity,
|
||||
}
|
||||
deadLetterRawActor, err := NewActor[Message, any](deadLetterActorCfg)
|
||||
if err != nil {
|
||||
// This should never happen since we control the DLO config.
|
||||
panic("failed to create dead letter actor: " + err.Error())
|
||||
}
|
||||
deadLetterRawActor.Start()
|
||||
system.deadLetterActor = deadLetterRawActor.Ref()
|
||||
|
||||
// Add the raw actor to the map of stoppable actors. No lock needed here
|
||||
// as 'system' is not yet accessible concurrently.
|
||||
system.actors[deadLetterRawActor.id] = deadLetterRawActor
|
||||
|
||||
// The system is now fully initialized and ready.
|
||||
return system
|
||||
}
|
||||
|
||||
// ActorOption is a functional option for customizing actor creation.
|
||||
type ActorOption[M Message, R any] func(*ActorConfig[M, R])
|
||||
|
||||
// WithMailboxFactory returns an ActorOption that sets a custom mailbox factory.
|
||||
func WithMailboxFactory[M Message, R any](
|
||||
f MailboxFactory[M, R]) ActorOption[M, R] {
|
||||
|
||||
return func(cfg *ActorConfig[M, R]) {
|
||||
cfg.MailboxFactory = f
|
||||
}
|
||||
}
|
||||
|
||||
// WithMailboxSize returns an ActorOption that overrides the default mailbox
|
||||
// size.
|
||||
func WithMailboxSize[M Message, R any](size int) ActorOption[M, R] {
|
||||
return func(cfg *ActorConfig[M, R]) {
|
||||
cfg.MailboxSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWithSystem creates an actor with the given ID, service key, and
|
||||
// behavior within the specified ActorSystem. It starts the actor, adds it to
|
||||
// the system's management, registers it with the receptionist using the
|
||||
// provided key, and returns its ActorRef.
|
||||
func RegisterWithSystem[M Message, R any](as *ActorSystem, id string,
|
||||
key ServiceKey[M, R],
|
||||
behavior ActorBehavior[M, R],
|
||||
opts ...ActorOption[M, R]) (ActorRef[M, R], error) {
|
||||
|
||||
actorCfg := ActorConfig[M, R]{
|
||||
ID: id,
|
||||
Behavior: behavior,
|
||||
DLO: as.deadLetterActor,
|
||||
MailboxSize: as.config.MailboxCapacity,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&actorCfg)
|
||||
}
|
||||
// Check for duplicate actor ID before creating the actor.
|
||||
as.mu.Lock()
|
||||
if _, exists := as.actors[id]; exists {
|
||||
as.mu.Unlock()
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrDuplicateActorID, id)
|
||||
}
|
||||
|
||||
actorInstance, err := NewActor(actorCfg)
|
||||
if err != nil {
|
||||
as.mu.Unlock()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
actorInstance.Start()
|
||||
|
||||
// Add the actor instance to the system's list of stoppable actors.
|
||||
as.actors[actorInstance.id] = actorInstance
|
||||
as.mu.Unlock()
|
||||
|
||||
log.Infof("ActorSystem: registered actor %s with service key %s",
|
||||
id, key.name)
|
||||
|
||||
// Register the actor's reference with the receptionist under the given
|
||||
// service key, making it discoverable by other parts of the system.
|
||||
RegisterWithReceptionist(as.receptionist, key, actorInstance.Ref())
|
||||
|
||||
return actorInstance.Ref(), nil
|
||||
}
|
||||
|
||||
// Receptionist returns the system's receptionist, which can be used for
|
||||
// actor service discovery (finding actors by ServiceKey).
|
||||
func (as *ActorSystem) Receptionist() *Receptionist {
|
||||
return as.receptionist
|
||||
}
|
||||
|
||||
// DeadLetters returns a reference to the system's dead letter actor. Messages
|
||||
// that cannot be delivered to their intended recipient (e.g., if an Ask
|
||||
// context is cancelled before enqueuing) may be routed here if not otherwise
|
||||
// handled.
|
||||
func (as *ActorSystem) DeadLetters() ActorRef[Message, any] {
|
||||
return as.deadLetterActor
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the actor system. It iterates through all managed
|
||||
// actors, including the dead letter actor, and calls their Stop method.
|
||||
// After initiating the stop for all actors, it cancels the main system context.
|
||||
// This method is safe for concurrent use.
|
||||
func (as *ActorSystem) Shutdown() error {
|
||||
log.Infof("ActorSystem: initiating shutdown")
|
||||
|
||||
// Create a slice of actors to stop. This avoids holding the lock while
|
||||
// calling Stop() on each actor, and includes the dead letter actor.
|
||||
var actorsToStop []stoppable
|
||||
as.mu.RLock()
|
||||
for _, actor := range as.actors {
|
||||
actorsToStop = append(actorsToStop, actor)
|
||||
}
|
||||
as.mu.RUnlock()
|
||||
|
||||
// Notify all managed actors to stop. Actor.Stop() is non-blocking.
|
||||
// Each actor's Stop method will cancel its internal context, leading
|
||||
// to the termination of its processing goroutine.
|
||||
for _, actor := range actorsToStop {
|
||||
actor.Stop()
|
||||
}
|
||||
|
||||
// Clear the actors map after initiating their shutdown.
|
||||
as.mu.Lock()
|
||||
as.actors = nil
|
||||
as.mu.Unlock()
|
||||
|
||||
// Finally cancel the main context
|
||||
// This signals to any other components observing the system's context
|
||||
// that shutdown has been initiated.
|
||||
as.cancel()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAndRemoveActor stops a specific actor by its ID and removes it from the
|
||||
// ActorSystem's management. It returns true if the actor was found and stopped,
|
||||
// false otherwise.
|
||||
func (as *ActorSystem) StopAndRemoveActor(id string) bool {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
|
||||
actorToStop, exists := as.actors[id]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Stop the actor. This is non-blocking.
|
||||
actorToStop.Stop()
|
||||
|
||||
// Remove from the system's management.
|
||||
delete(as.actors, id)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// UnregisterFromReceptionist removes an actor reference from a service key in
|
||||
// the given receptionist. It returns true if the reference was found and
|
||||
// removed, and false otherwise. This is a package-level generic function
|
||||
// because methods cannot have their own type parameters in Go.
|
||||
func UnregisterFromReceptionist[M Message, R any](r *Receptionist,
|
||||
key ServiceKey[M, R], refToRemove ActorRef[M, R]) bool {
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
refs, exists := r.registrations[key.name]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
// Build a new slice containing only the references that are not the one
|
||||
// to be removed.
|
||||
newRefs := make([]any, 0, max(0, len(refs)-1))
|
||||
for _, itemInSlice := range refs {
|
||||
// Try to assert the item from the slice to the specific
|
||||
// ActorRef[M,R] type we are trying to remove.
|
||||
if specificActorRef, ok := itemInSlice.(ActorRef[M, R]); ok {
|
||||
// If the type assertion is successful and it's the one
|
||||
// we want to remove, mark as found and skip adding it
|
||||
// to newRefs.
|
||||
if specificActorRef == refToRemove {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
newRefs = append(newRefs, itemInSlice)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the new list of references is empty, remove the key from the map.
|
||||
// Otherwise, update the map with the new slice.
|
||||
if len(newRefs) == 0 {
|
||||
delete(r.registrations, key.name)
|
||||
} else {
|
||||
r.registrations[key.name] = newRefs
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ServiceKey is a type-safe identifier used for registering and discovering
|
||||
// actors via the Receptionist. The generic type parameters M (Message) and R
|
||||
// (Response) ensure that only actors handling compatible message/response types
|
||||
// are associated with and retrieved for this key.
|
||||
type ServiceKey[M Message, R any] struct {
|
||||
name string
|
||||
}
|
||||
|
||||
// NewServiceKey creates a new service key with the given name. The name is used
|
||||
// as the lookup key within the Receptionist.
|
||||
func NewServiceKey[M Message, R any](name string) ServiceKey[M, R] {
|
||||
return ServiceKey[M, R]{name: name}
|
||||
}
|
||||
|
||||
// Spawn registers an actor for this service key within the given ActorSystem.
|
||||
// It's a convenience method that calls RegisterWithSystem, starting the actor
|
||||
// and registering it with the receptionist.
|
||||
func (sk ServiceKey[M, R]) Spawn(as *ActorSystem, id string,
|
||||
behavior ActorBehavior[M, R],
|
||||
opts ...ActorOption[M, R]) (ActorRef[M, R], error) {
|
||||
|
||||
return RegisterWithSystem(as, id, sk, behavior, opts...)
|
||||
}
|
||||
|
||||
// Unregister removes an actor reference associated with this service key from
|
||||
// the ActorSystem's receptionist and also stops the actor.
|
||||
// It returns true if the actor was successfully unregistered from the
|
||||
// receptionist AND successfully stopped and removed from the system's
|
||||
// management. Otherwise, it returns false.
|
||||
func (sk ServiceKey[M, R]) Unregister(as *ActorSystem,
|
||||
refToRemove ActorRef[M, R]) bool {
|
||||
|
||||
unregisteredFromReceptionist := UnregisterFromReceptionist(
|
||||
as.Receptionist(), sk, refToRemove,
|
||||
)
|
||||
|
||||
// If not found in receptionist, no need to try stopping.
|
||||
if !unregisteredFromReceptionist {
|
||||
return false
|
||||
}
|
||||
|
||||
// Attempt to stop and remove the actor from the system.
|
||||
stoppedAndRemoved := as.StopAndRemoveActor(refToRemove.ID())
|
||||
|
||||
return unregisteredFromReceptionist && stoppedAndRemoved
|
||||
}
|
||||
|
||||
// UnregisterAll finds all actor references associated with this service key in
|
||||
// the ActorSystem's receptionist. For each found actor, it attempts to stop it
|
||||
// and remove it from system management, and also unregisters it from the
|
||||
// receptionist.
|
||||
func (sk ServiceKey[M, R]) UnregisterAll(as *ActorSystem) int {
|
||||
// First find all the refs that match this service key.
|
||||
refsFound := FindInReceptionist(as.Receptionist(), sk)
|
||||
|
||||
actorsStoppedCount := 0
|
||||
for _, ref := range refsFound {
|
||||
// Attempt to stop and remove the actor from the system's active
|
||||
// management. This is the primary action to deactivate the
|
||||
// actor. If StopAndRemoveActor returns true, it means an active
|
||||
// actor was found in the system's `actors` map and was stopped.
|
||||
if as.StopAndRemoveActor(ref.ID()) {
|
||||
actorsStoppedCount++
|
||||
}
|
||||
|
||||
// Regardless of whether the actor was actively managed by the
|
||||
// system (i.e., found in as.actors), attempt to unregister its
|
||||
// reference from the receptionist. This helps clean up any
|
||||
// potentially stale entries in the receptionist if an actor was
|
||||
// removed from the system's management without also being
|
||||
// unregistered from the receptionist.
|
||||
UnregisterFromReceptionist(as.Receptionist(), sk, ref)
|
||||
}
|
||||
|
||||
return actorsStoppedCount
|
||||
}
|
||||
|
||||
// Receptionist provides service discovery for actors. Actors can be registered
|
||||
// under a ServiceKey and later discovered by other actors or system components.
|
||||
type Receptionist struct {
|
||||
// registrations stores ActorRef instances, keyed by ServiceKey.name.
|
||||
registrations map[string][]any
|
||||
|
||||
// mu protects access to registrations.
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// newReceptionist creates a new Receptionist instance.
|
||||
func newReceptionist() *Receptionist {
|
||||
return &Receptionist{
|
||||
registrations: make(map[string][]any),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWithReceptionist registers an actor with a service key in the given
|
||||
// receptionist. This is a package-level generic function because methods
|
||||
// cannot have their own type parameters in Go (as of the current version).
|
||||
// It appends the actor reference to the list associated with the key's name.
|
||||
func RegisterWithReceptionist[M Message, R any](r *Receptionist,
|
||||
key ServiceKey[M, R], ref ActorRef[M, R]) {
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Initialize the slice for this key if it's the first registration.
|
||||
if _, exists := r.registrations[key.name]; !exists {
|
||||
r.registrations[key.name] = make([]any, 0)
|
||||
}
|
||||
|
||||
r.registrations[key.name] = append(r.registrations[key.name], ref)
|
||||
}
|
||||
|
||||
// FindInReceptionist returns all actors registered with a service key in the
|
||||
// given receptionist. This is a package-level generic function because methods
|
||||
// cannot have their own type parameters. It performs a type assertion to ensure
|
||||
// that only ActorRefs matching the ServiceKey's generic types (M, R) are
|
||||
// returned, providing type safety.
|
||||
func FindInReceptionist[M Message, R any](r *Receptionist,
|
||||
key ServiceKey[M, R]) []ActorRef[M, R] {
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if refs, exists := r.registrations[key.name]; exists {
|
||||
typedRefs := make([]ActorRef[M, R], 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
// Make sure that the reference is of the correct type.
|
||||
// This type assertion is crucial for type safety, ensuring
|
||||
// that the returned ActorRefs match the expected M and R.
|
||||
if typedRef, ok := ref.(ActorRef[M, R]); ok {
|
||||
typedRefs = append(typedRefs, typedRef)
|
||||
}
|
||||
}
|
||||
|
||||
return typedRefs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,958 +0,0 @@
|
|||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestActorSystemNewActorSystem verifies the basic initialization of an
|
||||
// ActorSystem, including its default DLO.
|
||||
func TestActorSystemNewActorSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
require.NotNil(t, as, "newActorSystem should not return nil")
|
||||
require.NotNil(t, as.Receptionist(), "receptionist should not be nil")
|
||||
require.NotNil(t, as.DeadLetters(), "deadLetters should not be nil")
|
||||
require.Equal(t, "dead-letters", as.DeadLetters().ID(), "dLO ID mismatch")
|
||||
|
||||
// Test the DLO's behavior (it should return an error for Ask).
|
||||
testDLOMsg := newTestMsg("to-dlo")
|
||||
future := as.DeadLetters().Ask(context.Background(), testDLOMsg)
|
||||
result := future.Await(context.Background())
|
||||
|
||||
// We should get back an error for asks.
|
||||
require.True(
|
||||
t, result.IsErr(), "system DLO should return an error on Ask",
|
||||
)
|
||||
expectedErrStr := "message undeliverable: " + testDLOMsg.MessageType()
|
||||
require.EqualError(
|
||||
t, result.Err(), expectedErrStr, "dLO error message mismatch",
|
||||
)
|
||||
|
||||
// Shutdown the system to clean up resources.
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err, "actorSystem shutdown failed")
|
||||
}
|
||||
|
||||
// TestActorSystemRegisterWithSystem verifies actor registration, lifecycle
|
||||
// management within the system.
|
||||
func TestActorSystemRegisterWithSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
defer func() {
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
actorID := "test-actor-sys-reg"
|
||||
serviceKey := NewServiceKey[*testMsg, string]("test-service")
|
||||
|
||||
// Using echoBehavior from actor_test.go (implicitly available)
|
||||
beh := newEchoBehavior(t, 0)
|
||||
|
||||
// We'll start off by registering the actor.
|
||||
actorRef, err := RegisterWithSystem(as, actorID, serviceKey, beh)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, actorRef, "registerWithSystem should return a valid ActorRef")
|
||||
require.Equal(t, actorID, actorRef.ID(), "registered actor ID mismatch")
|
||||
|
||||
// The actor should be found in the receptionist.
|
||||
foundActors := FindInReceptionist(as.Receptionist(), serviceKey)
|
||||
require.Len(t, foundActors, 1, "actor not found in receptionist")
|
||||
require.Equal(t, actorRef, foundActors[0], "incorrect actor in receptionist")
|
||||
|
||||
// Next, we'll send out a simple tell, using our reply channel to make
|
||||
// sure it's actually processed.
|
||||
msgData := "hello-system-actor"
|
||||
replyChan := make(chan string, 1)
|
||||
actorRef.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")
|
||||
|
||||
// Stop the actor through the system.
|
||||
stopped := as.StopAndRemoveActor(actorID)
|
||||
require.True(t, stopped, "StopAndRemoveActor failed")
|
||||
|
||||
// Wait for actor to fully stop.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Send a message to the now-stopped actor's ref. This should go to the
|
||||
// system's DLO.
|
||||
afterStopMsg := newTestMsg("after-stop-to-dlo")
|
||||
require.NotPanics(t, func() {
|
||||
actorRef.Tell(context.Background(), afterStopMsg)
|
||||
}, "tell to stopped actor should not panic")
|
||||
}
|
||||
|
||||
// TestActorSystemShutdown verifies that all actors are stopped and the system
|
||||
// context is cancelled upon shutdown.
|
||||
func TestActorSystemShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
|
||||
// We'll start by making 3 new actors, each with a unique ID.
|
||||
numActors := 3
|
||||
actorRefs := make([]ActorRef[*testMsg, string], numActors)
|
||||
for i := 0; i < numActors; i++ {
|
||||
actorID := fmt.Sprintf("shutdown-test-actor-%d", i)
|
||||
key := NewServiceKey[*testMsg, string](
|
||||
fmt.Sprintf("service-%d", i),
|
||||
)
|
||||
beh := newEchoBehavior(t, 0)
|
||||
ref, regErr := RegisterWithSystem(as, actorID, key, beh)
|
||||
require.NoError(t, regErr)
|
||||
actorRefs[i] = ref
|
||||
}
|
||||
|
||||
// We'll now send a message to each actor to ensure that they're
|
||||
// running.
|
||||
for i, ref := range actorRefs {
|
||||
future := ref.Ask(
|
||||
context.Background(),
|
||||
newTestMsg(fmt.Sprintf("ping-%d", i)),
|
||||
)
|
||||
ctxAwait, cancelAwait := context.WithTimeout(
|
||||
context.Background(), time.Second,
|
||||
)
|
||||
res := future.Await(ctxAwait)
|
||||
cancelAwait()
|
||||
require.False(
|
||||
t, res.IsErr(),
|
||||
"actor %d failed to respond before shutdown: %v",
|
||||
i, res.Err(),
|
||||
)
|
||||
}
|
||||
|
||||
// Next, trigger a shutdown, and assert that the done channel gets
|
||||
// closed.
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err, "actorSystem shutdown failed")
|
||||
|
||||
// Check if the system context is done using RecvOrTimeout with a zero
|
||||
// timeout for a non-blocking check.
|
||||
_, err = fn.RecvOrTimeout(as.ctx.Done(), time.Millisecond*100)
|
||||
require.NoError(t, err, "actorSystem context not cancelled after shutdown")
|
||||
|
||||
// We'll now try to send a message to each of the actors, this should
|
||||
// result in an error.
|
||||
for i, ref := range actorRefs {
|
||||
future := ref.Ask(
|
||||
context.Background(),
|
||||
newTestMsg(fmt.Sprintf("ping-after-shutdown-%d", i)),
|
||||
)
|
||||
res := future.Await(context.Background())
|
||||
require.True(
|
||||
t, res.IsErr(),
|
||||
"actor %d Ask should fail after shutdown", i,
|
||||
)
|
||||
require.ErrorIs(t, res.Err(), ErrActorTerminated)
|
||||
}
|
||||
|
||||
as.mu.RLock()
|
||||
require.Nil(t, as.actors, "actors map should be nil after shutdown")
|
||||
as.mu.RUnlock()
|
||||
|
||||
// Once shutdown, we shouldn't be able to send to the DLO either.
|
||||
dloRef := as.DeadLetters()
|
||||
futureDLO := dloRef.Ask(
|
||||
context.Background(), newTestMsg("ping-dlo-after-shutdown"),
|
||||
)
|
||||
resDLO := futureDLO.Await(context.Background())
|
||||
require.True(
|
||||
t, resDLO.IsErr(), "DLO Ask should fail after system shutdown",
|
||||
)
|
||||
require.ErrorIs(
|
||||
t, resDLO.Err(), ErrActorTerminated,
|
||||
)
|
||||
}
|
||||
|
||||
// TestActorSystemStopAndRemoveActor verifies specific actor stopping and
|
||||
// removal.
|
||||
func TestActorSystemStopAndRemoveActor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
defer func() {
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Make some actor IDs, then unique service keys, then use that to
|
||||
// register two actors.
|
||||
actor1ID := "actor-to-stop"
|
||||
actor2ID := "actor-to-keep"
|
||||
key1 := NewServiceKey[*testMsg, string]("service1")
|
||||
key2 := NewServiceKey[*testMsg, string]("service2")
|
||||
beh := newEchoBehavior(t, 0)
|
||||
|
||||
ref1, err := RegisterWithSystem(as, actor1ID, key1, beh)
|
||||
require.NoError(t, err)
|
||||
ref2, err := RegisterWithSystem(as, actor2ID, key2, beh)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If we remove one actor, then try to send to it, we should get an
|
||||
// error.
|
||||
stopped := as.StopAndRemoveActor(actor1ID)
|
||||
require.True(t, stopped, "failed to stop and remove actor1")
|
||||
|
||||
future1 := ref1.Ask(context.Background(), newTestMsg("ping-actor1"))
|
||||
res1 := future1.Await(context.Background())
|
||||
require.True(t, res1.IsErr(), "actor1 should be stopped")
|
||||
require.ErrorIs(t, res1.Err(), ErrActorTerminated)
|
||||
|
||||
as.mu.RLock()
|
||||
_, exists := as.actors[actor1ID]
|
||||
as.mu.RUnlock()
|
||||
|
||||
// The actor should no longer be found.
|
||||
require.False(t, exists, "actor1 still in system's actor map")
|
||||
|
||||
// Make sure that we can still send messages to the existing actor.
|
||||
future2 := ref2.Ask(
|
||||
context.Background(), newTestMsg("ping-actor2"),
|
||||
)
|
||||
|
||||
ctxAwait2, cancelAwait2 := context.WithTimeout(
|
||||
context.Background(), time.Second,
|
||||
)
|
||||
res2 := future2.Await(ctxAwait2)
|
||||
cancelAwait2()
|
||||
|
||||
require.False(
|
||||
t, res2.IsErr(), "actor2 should still be running: %v",
|
||||
res2.Err(),
|
||||
)
|
||||
res2.WhenOk(func(s string) {
|
||||
require.Equal(t, "echo: ping-actor2", s)
|
||||
})
|
||||
|
||||
stoppedNonExistent := as.StopAndRemoveActor("non-existent-actor")
|
||||
require.False(
|
||||
t, stoppedNonExistent, "stopping non-existent actor should "+
|
||||
"return false",
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceptionist covers basic registration, finding, and unregistration.
|
||||
func TestReceptionist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
defer func() {
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
receptionist := as.Receptionist()
|
||||
|
||||
key1 := NewServiceKey[*testMsg, string]("key1")
|
||||
key2 := NewServiceKey[*testMsg, string]("key2")
|
||||
key1Again := NewServiceKey[*testMsg, string]("key1")
|
||||
|
||||
// Register 3 actor instance using the service keys we created above.
|
||||
beh := newEchoBehavior(t, 0)
|
||||
actor1Ref, err := RegisterWithSystem(as, "actor1-rec", key1, beh)
|
||||
require.NoError(t, err)
|
||||
actor2Ref, err := RegisterWithSystem(as, "actor2-rec", key1, beh)
|
||||
require.NoError(t, err)
|
||||
actor3Ref, err := RegisterWithSystem(as, "actor3-rec", key2, beh)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We should be able to find the actors we registered.
|
||||
foundForKey1 := FindInReceptionist(receptionist, key1)
|
||||
require.Len(t, foundForKey1, 2, "should find 2 actors for key1")
|
||||
require.Contains(t, foundForKey1, actor1Ref)
|
||||
require.Contains(t, foundForKey1, actor2Ref)
|
||||
|
||||
foundForKey1Again := FindInReceptionist(receptionist, key1Again)
|
||||
require.ElementsMatch(t, foundForKey1, foundForKey1Again)
|
||||
|
||||
// Same goes for the second key we added.
|
||||
foundForKey2 := FindInReceptionist(receptionist, key2)
|
||||
require.Len(t, foundForKey2, 1, "should find 1 actor for key2")
|
||||
require.Equal(t, actor3Ref, foundForKey2[0])
|
||||
|
||||
// We shouldn't be able to find a key we didn't add.
|
||||
nonExistentKey := NewServiceKey[*testMsg, string]("non-existent")
|
||||
foundForNonExistent := FindInReceptionist(receptionist, nonExistentKey)
|
||||
require.Empty(t, foundForNonExistent)
|
||||
|
||||
// We should be able to unregister the actors we added.
|
||||
unregistered := UnregisterFromReceptionist(
|
||||
receptionist, key1, actor1Ref,
|
||||
)
|
||||
require.True(t, unregistered, "failed to unregister actor1Ref")
|
||||
|
||||
foundForKey1AfterUnreg := FindInReceptionist(receptionist, key1)
|
||||
require.Len(t, foundForKey1AfterUnreg, 1)
|
||||
require.Equal(t, actor2Ref, foundForKey1AfterUnreg[0])
|
||||
|
||||
// If we try to unregister the same actor again, it should fail.
|
||||
unregisteredAgain := UnregisterFromReceptionist(receptionist, key1, actor1Ref)
|
||||
require.False(t, unregisteredAgain)
|
||||
|
||||
unregisteredLast := UnregisterFromReceptionist(receptionist, key1, actor2Ref)
|
||||
require.True(t, unregisteredLast)
|
||||
foundForKey1AfterAllUnreg := FindInReceptionist(receptionist, key1)
|
||||
require.Empty(t, foundForKey1AfterAllUnreg)
|
||||
|
||||
receptionist.mu.RLock()
|
||||
_, exists := receptionist.registrations[key1.name]
|
||||
receptionist.mu.RUnlock()
|
||||
require.False(t, exists, "key1 should be removed from registrations map")
|
||||
|
||||
// Finally, if we use the wrong key, or one that doesn't exist, that
|
||||
// should also fail.
|
||||
unregisteredWrongKey := UnregisterFromReceptionist(receptionist, key1, actor3Ref)
|
||||
require.False(t, unregisteredWrongKey)
|
||||
unregisteredNonExistentKey := UnregisterFromReceptionist(receptionist, nonExistentKey, actor1Ref)
|
||||
require.False(t, unregisteredNonExistentKey)
|
||||
}
|
||||
|
||||
// TestServiceKeyMethods tests Spawn and Unregister methods on ServiceKey.
|
||||
func TestServiceKeyMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
as := NewActorSystem()
|
||||
defer func() {
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
key := NewServiceKey[*testMsg, string]("sk-service")
|
||||
beh := newEchoBehavior(t, 0)
|
||||
|
||||
// Attempt to spawn a new actor using the service key and desired
|
||||
// behavior.
|
||||
actorRef, err := key.Spawn(as, "actor-sk-spawn", beh)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, actorRef)
|
||||
require.Equal(t, "actor-sk-spawn", actorRef.ID())
|
||||
|
||||
// We should be able to find the actor in the receptionist.
|
||||
found := FindInReceptionist(as.Receptionist(), key)
|
||||
require.Len(t, found, 1)
|
||||
require.Equal(t, actorRef, found[0])
|
||||
|
||||
as.mu.RLock()
|
||||
_, sysExists := as.actors[actorRef.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.True(t, sysExists)
|
||||
|
||||
// Next, try to unregister the actor using the service key.
|
||||
success := key.Unregister(as, actorRef)
|
||||
require.True(t, success, "serviceKey.Unregister failed")
|
||||
|
||||
// The actor should no longer be found in the receptionist.
|
||||
foundAfter := FindInReceptionist(as.Receptionist(), key)
|
||||
require.Empty(t, foundAfter)
|
||||
|
||||
as.mu.RLock()
|
||||
_, sysExistsAfter := as.actors[actorRef.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.False(t, sysExistsAfter)
|
||||
|
||||
// If we try to send a message to the actor after unregistering it, then
|
||||
// we should get an error.
|
||||
future := actorRef.Ask(context.Background(), newTestMsg("ping"))
|
||||
res := future.Await(context.Background())
|
||||
require.True(t, res.IsErr() && errors.Is(res.Err(), ErrActorTerminated))
|
||||
|
||||
successAgain := key.Unregister(as, actorRef)
|
||||
require.False(t, successAgain)
|
||||
|
||||
otherSys := NewActorSystem() // Create a different actor system
|
||||
defer func() {
|
||||
err := otherSys.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Create a dummy actor in otherSys of the correct generic type for the
|
||||
// key. This actor won't be found in 'as', so Unregister should fail.
|
||||
dummyBehOther := newEchoBehavior(t, 0)
|
||||
dummyKeyOther := NewServiceKey[*testMsg, string]("dummy-other")
|
||||
dummyActorRefOtherSys, err := RegisterWithSystem(
|
||||
otherSys, "dummy-other-actor", dummyKeyOther, dummyBehOther,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
successNonMember := key.Unregister(as, dummyActorRefOtherSys)
|
||||
require.False(t, successNonMember)
|
||||
}
|
||||
|
||||
// TestServiceKeyUnregisterAll tests the UnregisterAll method on ServiceKey.
|
||||
// It covers scenarios including basic unregistration of multiple actors,
|
||||
// attempting to unregister with no actors present, unregistering actors for
|
||||
// one key while leaving others intact, and the idempotency of the operation.
|
||||
func TestServiceKeyUnregisterAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Common setup for all sub-tests.
|
||||
as := NewActorSystem()
|
||||
defer func() {
|
||||
err := as.Shutdown()
|
||||
require.NoError(t, err, "ActorSystem shutdown failed.")
|
||||
}()
|
||||
|
||||
// Common behavior for test actors used across sub-tests.
|
||||
beh := newEchoBehavior(t, 0)
|
||||
|
||||
t.Run("unregister all multiple actors", func(st *testing.T) {
|
||||
key1 := NewServiceKey[*testMsg, string]("sk-ua-key1")
|
||||
actor1Key1, err := key1.Spawn(as, "actor1-k1-ua", beh)
|
||||
require.NoError(st, err)
|
||||
actor2Key1, err := key1.Spawn(as, "actor2-k1-ua", beh)
|
||||
require.NoError(st, err)
|
||||
|
||||
// Verify they are registered in the receptionist.
|
||||
foundActorsForKey1 := FindInReceptionist(
|
||||
as.Receptionist(), key1,
|
||||
)
|
||||
require.Len(
|
||||
st, foundActorsForKey1, 2,
|
||||
"actors for key1 not in receptionist initially.",
|
||||
)
|
||||
|
||||
// Verify they are in the system's actor map.
|
||||
as.mu.RLock()
|
||||
_, actor1Key1Exists := as.actors[actor1Key1.ID()]
|
||||
_, actor2Key1Exists := as.actors[actor2Key1.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.True(
|
||||
st, actor1Key1Exists,
|
||||
"actor1 for key1 not in system actors map initially.",
|
||||
)
|
||||
require.True(
|
||||
st, actor2Key1Exists,
|
||||
"actor2 for key1 not in system actors map initially.",
|
||||
)
|
||||
|
||||
// Unregister all for key1.
|
||||
stoppedCountKey1 := key1.UnregisterAll(as)
|
||||
require.Equal(
|
||||
st, 2, stoppedCountKey1,
|
||||
"UnregisterAll for key1 returned incorrect count.",
|
||||
)
|
||||
|
||||
// Verify they are unregistered from the receptionist.
|
||||
foundActorsForKey1After := FindInReceptionist(
|
||||
as.Receptionist(), key1,
|
||||
)
|
||||
require.Empty(
|
||||
st, foundActorsForKey1After,
|
||||
"actors for key1 still in receptionist after "+
|
||||
"UnregisterAll.",
|
||||
)
|
||||
|
||||
// Verify they are removed from system actors map.
|
||||
as.mu.RLock()
|
||||
_, actor1Key1ExistsAfter := as.actors[actor1Key1.ID()]
|
||||
_, actor2Key1ExistsAfter := as.actors[actor2Key1.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.False(
|
||||
st, actor1Key1ExistsAfter,
|
||||
"Actor1 for key1 still in system actors "+
|
||||
"map after UnregisterAll.",
|
||||
)
|
||||
require.False(
|
||||
st, actor2Key1ExistsAfter,
|
||||
"Actor2 for key1 still in system actors "+
|
||||
"map after UnregisterAll.",
|
||||
)
|
||||
|
||||
// Verify actors are stopped.
|
||||
resultActor1Key1 := actor1Key1.Ask(
|
||||
context.Background(), newTestMsg("ping-k1-a1"),
|
||||
).Await(context.Background())
|
||||
require.True(
|
||||
st, resultActor1Key1.IsErr(),
|
||||
"Actor1 key1 Ask should fail after UnregisterAll.",
|
||||
)
|
||||
require.ErrorIs(
|
||||
st, resultActor1Key1.Err(), ErrActorTerminated,
|
||||
"Actor1 key1 not terminated with correct error.",
|
||||
)
|
||||
|
||||
resultActor2Key1 := actor2Key1.Ask(
|
||||
context.Background(), newTestMsg("ping-k1-a2"),
|
||||
).Await(context.Background())
|
||||
require.True(
|
||||
st, resultActor2Key1.IsErr(),
|
||||
"Actor2 key1 Ask should fail after UnregisterAll.",
|
||||
)
|
||||
require.ErrorIs(
|
||||
st, resultActor2Key1.Err(), ErrActorTerminated,
|
||||
"Actor2 key1 not terminated with correct error.",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("unregister all with no actors for the key", func(st *testing.T) {
|
||||
keyEmpty := NewServiceKey[*testMsg, string]("sk-ua-key-empty")
|
||||
stoppedCountEmptyKey := keyEmpty.UnregisterAll(as)
|
||||
require.Equal(
|
||||
st, 0, stoppedCountEmptyKey,
|
||||
"UnregisterAll for empty key returned non-zero count.",
|
||||
)
|
||||
|
||||
foundActorsForKeyEmpty := FindInReceptionist(
|
||||
as.Receptionist(), keyEmpty,
|
||||
)
|
||||
require.Empty(
|
||||
st, foundActorsForKeyEmpty,
|
||||
"Receptionist not empty for keyEmpty "+
|
||||
"after UnregisterAll.",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("unregister all with mixed keys", func(st *testing.T) {
|
||||
keyA := NewServiceKey[*testMsg, string]("sk-ua-keyA")
|
||||
keyB := NewServiceKey[*testMsg, string]("sk-ua-keyB")
|
||||
|
||||
// Spawn 3 actors, two of them will share the same service key.
|
||||
actorA1, err := keyA.Spawn(as, "actorA1-ua-mixed", beh)
|
||||
require.NoError(st, err)
|
||||
actorA2, err := keyA.Spawn(as, "actorA2-ua-mixed", beh)
|
||||
require.NoError(st, err)
|
||||
actorB1, err := keyB.Spawn(as, "actorB1-ua-mixed", beh)
|
||||
require.NoError(st, err)
|
||||
|
||||
// Make sure we're able to find them in the receptionist.
|
||||
require.Len(
|
||||
st, FindInReceptionist(as.Receptionist(), keyA), 2,
|
||||
"KeyA initial registration count mismatch.",
|
||||
)
|
||||
require.Len(
|
||||
st, FindInReceptionist(as.Receptionist(), keyB), 1,
|
||||
"KeyB initial registration count mismatch.",
|
||||
)
|
||||
|
||||
// We'll start by unregistering all actors for keyA.
|
||||
stoppedCountKeyA := keyA.UnregisterAll(as)
|
||||
require.Equal(
|
||||
st, 2, stoppedCountKeyA,
|
||||
"UnregisterAll for keyA returned incorrect count.",
|
||||
)
|
||||
|
||||
// Verify keyA actors are gone from receptionist, keyB actor
|
||||
// remains.
|
||||
require.Empty(
|
||||
st, FindInReceptionist(as.Receptionist(), keyA),
|
||||
"actors for keyA still in receptionist after "+
|
||||
"UnregisterAll.",
|
||||
)
|
||||
foundActorsForKeyBAfterA := FindInReceptionist(
|
||||
as.Receptionist(), keyB,
|
||||
)
|
||||
require.Len(
|
||||
st, foundActorsForKeyBAfterA, 1,
|
||||
"Actor for keyB affected by UnregisterAll on keyA.",
|
||||
)
|
||||
require.Equal(
|
||||
st, actorB1, foundActorsForKeyBAfterA[0],
|
||||
"Wrong actor found for keyB.",
|
||||
)
|
||||
|
||||
// Verify keyA actors are removed from system map, keyB actor
|
||||
// remains.
|
||||
as.mu.RLock()
|
||||
_, actorA1ExistsAfterMixed := as.actors[actorA1.ID()]
|
||||
_, actorA2ExistsAfterMixed := as.actors[actorA2.ID()]
|
||||
_, actorB1ExistsAfterMixed := as.actors[actorB1.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.False(
|
||||
st, actorA1ExistsAfterMixed,
|
||||
"ActorA1 still in system actors map after "+
|
||||
"mixed UnregisterAll.",
|
||||
)
|
||||
require.False(
|
||||
st, actorA2ExistsAfterMixed,
|
||||
"ActorA2 still in system actors map after "+
|
||||
"mixed UnregisterAll.",
|
||||
)
|
||||
require.True(
|
||||
st, actorB1ExistsAfterMixed,
|
||||
"ActorB1 removed from system actors map incorrectly.",
|
||||
)
|
||||
|
||||
// Verify keyA actors are stopped, keyB actor is running.
|
||||
resultActorA1Mixed := actorA1.Ask(
|
||||
context.Background(), newTestMsg("ping-kA-a1"),
|
||||
).Await(context.Background())
|
||||
require.True(st, resultActorA1Mixed.IsErr())
|
||||
require.ErrorIs(
|
||||
st, resultActorA1Mixed.Err(), ErrActorTerminated,
|
||||
)
|
||||
|
||||
resultActorB1Mixed := actorB1.Ask(
|
||||
context.Background(), newTestMsg("ping-kB-a1"),
|
||||
).Await(context.Background())
|
||||
require.False(
|
||||
st, resultActorB1Mixed.IsErr(),
|
||||
"ActorB1 terminated incorrectly (mixed test): %v",
|
||||
resultActorB1Mixed.Err(),
|
||||
)
|
||||
resultActorB1Mixed.WhenOk(func(s string) {
|
||||
require.Equal(st, "echo: ping-kB-a1", s)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("idempotency of UnregisterAll", func(st *testing.T) {
|
||||
keyIdempotent := NewServiceKey[*testMsg, string](
|
||||
"sk-ua-key-idem",
|
||||
)
|
||||
actorIdem, err := keyIdempotent.Spawn(as, "actor-idem-ua", beh)
|
||||
require.NoError(st, err)
|
||||
|
||||
// First call should unregister and stop.
|
||||
stoppedCountFirstCall := keyIdempotent.UnregisterAll(as)
|
||||
require.Equal(
|
||||
st, 1, stoppedCountFirstCall,
|
||||
"UnregisterAll (first call) incorrect count.",
|
||||
)
|
||||
|
||||
// Second call should do nothing and return 0.
|
||||
stoppedCountSecondCall := keyIdempotent.UnregisterAll(as)
|
||||
require.Equal(
|
||||
st, 0, stoppedCountSecondCall,
|
||||
"UnregisterAll (second call) incorrect count, not "+
|
||||
"idempotent.",
|
||||
)
|
||||
|
||||
// Verify actor is gone from receptionist and system map, and is
|
||||
// stopped.
|
||||
require.Empty(
|
||||
st, FindInReceptionist(as.Receptionist(), keyIdempotent),
|
||||
"Actors for keyIdempotent still in receptionist "+
|
||||
"after calls.",
|
||||
)
|
||||
|
||||
as.mu.RLock()
|
||||
_, actorIdemExistsAfter := as.actors[actorIdem.ID()]
|
||||
as.mu.RUnlock()
|
||||
require.False(
|
||||
st, actorIdemExistsAfter,
|
||||
"ActorIdem still in system actors map after calls.",
|
||||
)
|
||||
|
||||
resultActorIdem := actorIdem.Ask(
|
||||
context.Background(), newTestMsg("ping-kidem-a1"),
|
||||
).Await(context.Background())
|
||||
require.True(st, resultActorIdem.IsErr())
|
||||
require.ErrorIs(st, resultActorIdem.Err(), ErrActorTerminated)
|
||||
})
|
||||
}
|
||||
|
||||
// routerTestHarness helps set up routers and their associated actors for testing.
|
||||
// It uses an actorTestHarness internally for DLO observation for the router.
|
||||
type routerTestHarness struct {
|
||||
*actorTestHarness
|
||||
as *ActorSystem
|
||||
receptionist *Receptionist
|
||||
}
|
||||
|
||||
// newRouterTestHarness sets up a new harness for router testing.
|
||||
// It creates an ActorSystem for actors that the router will route to,
|
||||
// and uses the embedded actorTestHarness for the router's own DLO.
|
||||
func newRouterTestHarness(t *testing.T) *routerTestHarness {
|
||||
t.Helper()
|
||||
system := NewActorSystem()
|
||||
t.Cleanup(func() {
|
||||
err := system.Shutdown()
|
||||
require.NoError(t, err, "router test actor system shutdown failed")
|
||||
})
|
||||
|
||||
// The DLO for the router itself will come from actorTestHarness.
|
||||
// Actors managed by `system` (router targets) will use `system.DeadLetters()`.
|
||||
return &routerTestHarness{
|
||||
actorTestHarness: newActorTestHarness(t),
|
||||
as: system,
|
||||
receptionist: system.Receptionist(),
|
||||
}
|
||||
}
|
||||
|
||||
// newRouterTargetActor creates an actor, registers it with the harness's
|
||||
// ActorSystem (h.as) and Receptionist under the given service key. This actor
|
||||
// is intended to be a target for the router.
|
||||
func (h *routerTestHarness) newRouterTargetActor(id string,
|
||||
key ServiceKey[*testMsg, string],
|
||||
beh ActorBehavior[*testMsg, string]) ActorRef[*testMsg, string] {
|
||||
|
||||
h.t.Helper()
|
||||
|
||||
ref, err := RegisterWithSystem(h.as, id, key, beh)
|
||||
require.NoError(h.t, err)
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
// TestRouterNewRouter verifies that a new router can be created as expected.
|
||||
func TestRouterNewRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newRouterTestHarness(t)
|
||||
|
||||
key := NewServiceKey[*testMsg, string]("router-service")
|
||||
strategy := NewRoundRobinStrategy[*testMsg, string]()
|
||||
|
||||
router := NewRouter(h.receptionist, key, strategy, h.dlo.Ref())
|
||||
require.NotNil(t, router, "newRouter should not return nil")
|
||||
require.Equal(t, "router(router-service)", router.ID(), "router ID mismatch")
|
||||
}
|
||||
|
||||
// countingEchoBehavior is an echo behavior that also counts how many messages
|
||||
// it has processed.
|
||||
type countingEchoBehavior struct {
|
||||
*echoBehavior
|
||||
id string
|
||||
processedMsgs int64
|
||||
}
|
||||
|
||||
func newCountingEchoBehavior(t *testing.T, id string) *countingEchoBehavior {
|
||||
return &countingEchoBehavior{
|
||||
echoBehavior: newEchoBehavior(t, 0),
|
||||
id: id,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *countingEchoBehavior) Receive(ctx context.Context,
|
||||
msg *testMsg) fn.Result[string] {
|
||||
|
||||
atomic.AddInt64(&b.processedMsgs, 1)
|
||||
|
||||
// Include actor ID in reply for easier verification.
|
||||
res := b.echoBehavior.Receive(ctx, msg)
|
||||
val, err := res.Unpack()
|
||||
if err == nil {
|
||||
return fn.Ok(fmt.Sprintf("%s:%s", b.id, val))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestRouterTellAndAskRoundRobin verifies that the router distributes messages
|
||||
// in a round robin properly.
|
||||
func TestRouterTellAndAskRoundRobin(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newRouterTestHarness(t)
|
||||
|
||||
// Make a new router for the given service key and round robin strategy.
|
||||
serviceKey := NewServiceKey[*testMsg, string]("rr-service")
|
||||
strategy := NewRoundRobinStrategy[*testMsg, string]()
|
||||
router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref())
|
||||
|
||||
// We'll now register two actors with the router, each with a different
|
||||
// service key.
|
||||
actor1Beh := newCountingEchoBehavior(t, "actor1")
|
||||
actor2Beh := newCountingEchoBehavior(t, "actor2")
|
||||
_ = h.newRouterTargetActor("actor1-rr", serviceKey, actor1Beh)
|
||||
_ = h.newRouterTargetActor("actor2-rr", serviceKey, actor2Beh)
|
||||
|
||||
// Nxet, we'll send a mix of Tell and Ask messages to the router.
|
||||
numMessages := 6
|
||||
for i := 0; i < numMessages; i++ {
|
||||
msgData := fmt.Sprintf("message-%d", i)
|
||||
if i%2 == 0 {
|
||||
router.Tell(context.Background(), newTestMsg(msgData))
|
||||
} else {
|
||||
future := router.Ask(
|
||||
context.Background(), newTestMsg(msgData),
|
||||
)
|
||||
ctxAwait, cancelAwait := context.WithTimeout(
|
||||
context.Background(), time.Second,
|
||||
)
|
||||
|
||||
result := future.Await(ctxAwait)
|
||||
cancelAwait()
|
||||
require.False(
|
||||
t, result.IsErr(), "ask failed: %v", result.Err(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit for Tell messages to be processed.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Each actor should have processed numMessages / 2 messages.
|
||||
require.EqualValues(
|
||||
t, numMessages/2, atomic.LoadInt64(&actor1Beh.processedMsgs),
|
||||
"actor1 processed message count mismatch",
|
||||
)
|
||||
require.EqualValues(
|
||||
t, numMessages/2, atomic.LoadInt64(&actor2Beh.processedMsgs),
|
||||
"actor2 processed message count mismatch",
|
||||
)
|
||||
|
||||
// Router's DLO should be empty.
|
||||
h.assertNoDLOMessages()
|
||||
}
|
||||
|
||||
// TestRouterNoActorsAvailable verifies that if no actors are available for the
|
||||
// message, then an error is returned.
|
||||
func TestRouterNoActorsAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newRouterTestHarness(t)
|
||||
|
||||
serviceKey := NewServiceKey[*testMsg, string]("no-actor-service")
|
||||
strategy := NewRoundRobinStrategy[*testMsg, string]()
|
||||
router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref())
|
||||
|
||||
// We'll send a message, then assert that it goes to the DLO.
|
||||
tellMsg := newTestMsg("tell-no-actor")
|
||||
router.Tell(context.Background(), tellMsg)
|
||||
h.assertDLOMessage(tellMsg)
|
||||
|
||||
// If we use an ask instead, then we should get an error.
|
||||
askMsg := newTestMsg("ask-no-actor")
|
||||
future := router.Ask(context.Background(), askMsg)
|
||||
result := future.Await(context.Background())
|
||||
|
||||
require.True(
|
||||
t, result.IsErr(), "ask should fail when no actors are available",
|
||||
)
|
||||
require.ErrorIs(t, result.Err(), ErrNoActorsAvailable, "error mismatch")
|
||||
}
|
||||
|
||||
// TestRouterTellAskContextCancellation verifies that if the context is
|
||||
// canceled, then sending aborts.
|
||||
func TestRouterTellAskContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newRouterTestHarness(t)
|
||||
|
||||
serviceKey := NewServiceKey[*testMsg, string]("ctx-cancel-service")
|
||||
strategy := NewRoundRobinStrategy[*testMsg, string]()
|
||||
router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref())
|
||||
|
||||
// Use a regular echo actor, but we'll control context for Tell/Ask.
|
||||
targetActorBeh := newEchoBehavior(t, 50*time.Millisecond)
|
||||
_ = h.newRouterTargetActor("target-ctx", serviceKey, targetActorBeh)
|
||||
|
||||
// Next, we'll send a Tell message with a context that will be cancelled
|
||||
// before we even send.
|
||||
ctxTell, cancelTell := context.WithCancel(context.Background())
|
||||
cancelTell()
|
||||
router.Tell(ctxTell, newTestMsg("tell-ctx-cancelled"))
|
||||
|
||||
// The Message should be dropped by actorRefImpl.Tell if ctx is
|
||||
// cancelled. Router's DLO should not receive it from this path.
|
||||
h.assertNoDLOMessages()
|
||||
|
||||
// Next, we'll do the same for Ask. This time, we should get an error.
|
||||
ctxAsk, cancelAsk := context.WithCancel(context.Background())
|
||||
cancelAsk()
|
||||
futureAsk := router.Ask(ctxAsk, newTestMsg("ask-ctx-cancelled"))
|
||||
resultAsk := futureAsk.Await(context.Background())
|
||||
|
||||
require.True(
|
||||
t, resultAsk.IsErr(), "ask with cancelled context should fail",
|
||||
)
|
||||
require.ErrorIs(
|
||||
t, resultAsk.Err(), context.Canceled,
|
||||
"error should be context.Canceled",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRouterDynamicActorRegistration tests that we're able to dynamically add
|
||||
// and remove actors from the router.
|
||||
func TestRouterDynamicActorRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newRouterTestHarness(t)
|
||||
|
||||
serviceKey := NewServiceKey[*testMsg, string]("dynamic-service")
|
||||
strategy := NewRoundRobinStrategy[*testMsg, string]()
|
||||
router := NewRouter(h.receptionist, serviceKey, strategy, h.dlo.Ref())
|
||||
|
||||
// If we try to send a mesasge to the router before any actors are
|
||||
// added, we should get an error.
|
||||
futureNoActor := router.Ask(context.Background(), newTestMsg("ping-no-actors"))
|
||||
resNoActor := futureNoActor.Await(context.Background())
|
||||
require.ErrorIs(t, resNoActor.Err(), ErrNoActorsAvailable)
|
||||
|
||||
actor1Beh := newCountingEchoBehavior(t, "actor1")
|
||||
actor1Ref := h.newRouterTargetActor("actor1-dynamic", serviceKey, actor1Beh)
|
||||
|
||||
// At this point, we have a new actor added, but we'll try to send a
|
||||
// message to a different actor ID. This should go to the router's DLO.
|
||||
futureActor1 := router.Ask(context.Background(), newTestMsg("ping-actor1"))
|
||||
ctxAwaitA1, cancelAwaitA1 := context.WithTimeout(context.Background(), time.Second)
|
||||
resActor1 := futureActor1.Await(ctxAwaitA1)
|
||||
cancelAwaitA1()
|
||||
require.False(t, resActor1.IsErr(), "ask to actor1 failed: %v", resActor1.Err())
|
||||
resActor1.WhenOk(func(s string) {
|
||||
require.Equal(t, "actor1:echo: ping-actor1", s)
|
||||
})
|
||||
|
||||
actor2Beh := newCountingEchoBehavior(t, "actor2")
|
||||
actor2Ref := h.newRouterTargetActor(
|
||||
"actor2-dynamic", serviceKey, actor2Beh,
|
||||
)
|
||||
|
||||
// Now that we've added two actors above, we should round robin between
|
||||
// them when sending.
|
||||
ctxAwaitDA1, cancelAwaitDA1 := context.WithTimeout(
|
||||
context.Background(), time.Second,
|
||||
)
|
||||
router.Ask(context.Background(), newTestMsg("dynamic-ask1")).Await(
|
||||
ctxAwaitDA1,
|
||||
)
|
||||
cancelAwaitDA1()
|
||||
|
||||
ctxAwaitDA2, cancelAwaitDA2 := context.WithTimeout(context.Background(), time.Second)
|
||||
router.Ask(context.Background(), newTestMsg("dynamic-ask2")).Await(ctxAwaitDA2)
|
||||
cancelAwaitDA2()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// actor1 should have processed 2 messages (ping-actor1, dynamic-ask1),
|
||||
require.EqualValues(t, 2, atomic.LoadInt64(&actor1Beh.processedMsgs))
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&actor2Beh.processedMsgs))
|
||||
|
||||
// Next, we'll unregister the first actor ref.
|
||||
unregistered := UnregisterFromReceptionist(
|
||||
h.receptionist, serviceKey, actor1Ref,
|
||||
)
|
||||
require.True(t, unregistered)
|
||||
|
||||
// All the messages should now go to the second actor.
|
||||
for i := 0; i < 2; i++ {
|
||||
msgData := fmt.Sprintf("to-actor2-%d", i)
|
||||
future := router.Ask(context.Background(), newTestMsg(msgData))
|
||||
ctxAwaitLoop, cancelAwaitLoop := context.WithTimeout(
|
||||
context.Background(), time.Second,
|
||||
)
|
||||
|
||||
res := future.Await(ctxAwaitLoop)
|
||||
cancelAwaitLoop()
|
||||
|
||||
require.False(
|
||||
t, res.IsErr(), "ask to actor2 failed: %v", res.Err(),
|
||||
)
|
||||
res.WhenOk(func(s string) {
|
||||
require.Equal(t, "actor2:echo: "+msgData, s)
|
||||
})
|
||||
}
|
||||
|
||||
// Actor 1 shouldn't have got any of the messages, they should go to
|
||||
// actor 2.
|
||||
require.EqualValues(t, 2, atomic.LoadInt64(&actor1Beh.processedMsgs))
|
||||
require.EqualValues(t, 1+2, atomic.LoadInt64(&actor2Beh.processedMsgs))
|
||||
|
||||
// Next, we'll unregister the second actor ref.
|
||||
unregistered2 := UnregisterFromReceptionist(
|
||||
h.receptionist, serviceKey, actor2Ref,
|
||||
)
|
||||
require.True(t, unregistered2)
|
||||
|
||||
// If we try to send another message, it should go to the DL.
|
||||
tellMsg := newTestMsg("dynamic-tell-no-actors")
|
||||
router.Tell(context.Background(), tellMsg)
|
||||
h.assertDLOMessage(tellMsg)
|
||||
}
|
||||
|
|
@ -259,6 +259,7 @@ func TestGetNextScid(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
nextScid := getNextScid(test.current)
|
||||
require.Equal(t, test.expected, nextScid)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ var sharerTests = []sharerTest{
|
|||
// receiver, produce identical child hashes and preimages as the sender.
|
||||
func TestSharer(t *testing.T) {
|
||||
for _, test := range sharerTests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
"github.com/lightningnetwork/lnd/lnutils"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package autopilot
|
||||
|
||||
import (
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// AgentConstraints is an interface the agent will query to determine what
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func TestBetweennessCentralityEmptyGraph(t *testing.T) {
|
|||
)
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
||||
|
|
@ -82,6 +83,7 @@ func TestBetweennessCentralityWithNonEmptyGraph(t *testing.T) {
|
|||
|
||||
for _, numWorkers := range workers {
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
numWorkers := numWorkers
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
|
@ -108,6 +110,7 @@ func TestBetweennessCentralityWithNonEmptyGraph(t *testing.T) {
|
|||
require.NoError(t1, err)
|
||||
|
||||
for _, expected := range tests {
|
||||
expected := expected
|
||||
centrality := metric.GetMetric(
|
||||
expected.normalize,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// WeightedHeuristic is a tuple that associates a weight to an
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// ExternalScoreAttachment is an implementation of the AttachmentHeuristic
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/autopilot"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
||||
"github.com/lightningnetwork/lnd/graph/db/models"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
@ -101,23 +101,25 @@ func (d *databaseChannelGraph) ForEachNode(ctx context.Context,
|
|||
}, reset)
|
||||
}
|
||||
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each
|
||||
// node, all the channels that connect to it. The passed callback will be
|
||||
// called with the context, the node's pubkey, and a slice of ChannelEdge
|
||||
// that connect to the node.
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each node,
|
||||
// all the channels that connect to it. The passed callback will be called with
|
||||
// the context, the Node itself, and a slice of ChannelEdge that connect to the
|
||||
// node.
|
||||
//
|
||||
// NOTE: Part of the autopilot.ChannelGraph interface.
|
||||
func (d *databaseChannelGraph) ForEachNodesChannels(ctx context.Context,
|
||||
cb func(context.Context, NodeID, []*ChannelEdge) error,
|
||||
cb func(context.Context, Node, []*ChannelEdge) error,
|
||||
reset func()) error {
|
||||
|
||||
// The channel-scoring callers only need topology data here. Address
|
||||
// filtering happens through ForEachNode before connecting to peers.
|
||||
return d.db.ForEachNodeCached(
|
||||
ctx, func(ctx context.Context, node route.Vertex,
|
||||
ctx, true, func(ctx context.Context, node route.Vertex,
|
||||
addrs []net.Addr,
|
||||
chans map[uint64]*graphdb.DirectedChannel) error {
|
||||
|
||||
if len(chans) == 0 {
|
||||
// We'll skip over any node that doesn't have any
|
||||
// advertised addresses. As we won't be able to reach
|
||||
// them to actually open any channels.
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +134,10 @@ func (d *databaseChannelGraph) ForEachNodesChannels(ctx context.Context,
|
|||
})
|
||||
}
|
||||
|
||||
return cb(ctx, NodeID(node), edges)
|
||||
return cb(ctx, &dbNode{
|
||||
pub: node,
|
||||
addrs: addrs,
|
||||
}, edges)
|
||||
}, reset,
|
||||
)
|
||||
}
|
||||
|
|
@ -191,8 +196,8 @@ func (nc dbNodeCached) Addrs() []net.Addr {
|
|||
func (dc *databaseChannelGraphCached) ForEachNode(ctx context.Context,
|
||||
cb func(context.Context, Node) error, reset func()) error {
|
||||
|
||||
return dc.db.ForEachNodeCached(ctx, func(ctx context.Context,
|
||||
n route.Vertex,
|
||||
return dc.db.ForEachNodeCached(ctx, false, func(ctx context.Context,
|
||||
n route.Vertex, _ []net.Addr,
|
||||
channels map[uint64]*graphdb.DirectedChannel) error {
|
||||
|
||||
if len(channels) > 0 {
|
||||
|
|
@ -208,24 +213,20 @@ func (dc *databaseChannelGraphCached) ForEachNode(ctx context.Context,
|
|||
}, reset)
|
||||
}
|
||||
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each
|
||||
// node, all the channels that connect to it. The passed callback will be
|
||||
// called with the context, the node's pubkey, and a slice of ChannelEdge
|
||||
// that connect to the node.
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each node,
|
||||
// all the channels that connect to it. The passed callback will be called with
|
||||
// the context, the Node itself, and a slice of ChannelEdge that connect to the
|
||||
// node.
|
||||
//
|
||||
// NOTE: Part of the autopilot.ChannelGraph interface.
|
||||
func (dc *databaseChannelGraphCached) ForEachNodesChannels(ctx context.Context,
|
||||
cb func(context.Context, NodeID, []*ChannelEdge) error,
|
||||
cb func(context.Context, Node, []*ChannelEdge) error,
|
||||
reset func()) error {
|
||||
|
||||
return dc.db.ForEachNodeCached(ctx, func(ctx context.Context,
|
||||
n route.Vertex,
|
||||
return dc.db.ForEachNodeCached(ctx, false, func(ctx context.Context,
|
||||
n route.Vertex, _ []net.Addr,
|
||||
channels map[uint64]*graphdb.DirectedChannel) error {
|
||||
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
edges := make([]*ChannelEdge, 0, len(channels))
|
||||
for cid, channel := range channels {
|
||||
edges = append(edges, &ChannelEdge{
|
||||
|
|
@ -235,7 +236,18 @@ func (dc *databaseChannelGraphCached) ForEachNodesChannels(ctx context.Context,
|
|||
})
|
||||
}
|
||||
|
||||
return cb(ctx, NodeID(n), edges)
|
||||
if len(channels) > 0 {
|
||||
node := dbNodeCached{
|
||||
node: n,
|
||||
channels: channels,
|
||||
}
|
||||
|
||||
if err := cb(ctx, node, edges); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, reset)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package autopilot_test
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/autopilot"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import (
|
|||
"net"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
||||
"github.com/lightningnetwork/lnd/graph/db/models"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
@ -84,10 +84,10 @@ type ChannelGraph interface {
|
|||
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for
|
||||
// each node, all the channels that connect to it. The passed callback
|
||||
// will be called with the context, the node's pubkey, and a slice of
|
||||
// will be called with the context, the Node itself, and a slice of
|
||||
// ChannelEdge that connect to the node.
|
||||
ForEachNodesChannels(ctx context.Context,
|
||||
cb func(context.Context, NodeID, []*ChannelEdge) error,
|
||||
cb func(context.Context, Node, []*ChannelEdge) error,
|
||||
reset func()) error
|
||||
}
|
||||
|
||||
|
|
@ -237,8 +237,9 @@ type GraphSource interface {
|
|||
// channel graph cache if one is available. It is less consistent than
|
||||
// ForEachNode since any further calls are made across multiple
|
||||
// transactions.
|
||||
ForEachNodeCached(ctx context.Context,
|
||||
ForEachNodeCached(ctx context.Context, withAddrs bool,
|
||||
cb func(ctx context.Context, node route.Vertex,
|
||||
addrs []net.Addr,
|
||||
chans map[uint64]*graphdb.DirectedChannel) error,
|
||||
reset func()) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// minMedianChanSizeFraction determines the minimum size a channel must have to
|
||||
|
|
@ -90,7 +90,7 @@ func (p *PrefAttachment) NodeScores(ctx context.Context, g ChannelGraph,
|
|||
seenChans = make(map[uint64]struct{})
|
||||
)
|
||||
err := g.ForEachNodesChannels(
|
||||
ctx, func(_ context.Context, node NodeID,
|
||||
ctx, func(_ context.Context, node Node,
|
||||
channels []*ChannelEdge) error {
|
||||
|
||||
for _, e := range channels {
|
||||
|
|
@ -121,7 +121,7 @@ func (p *PrefAttachment) NodeScores(ctx context.Context, g ChannelGraph,
|
|||
var maxChans int
|
||||
nodeChanNum := make(map[NodeID]int)
|
||||
err = g.ForEachNodesChannels(
|
||||
ctx, func(ctx context.Context, node NodeID,
|
||||
ctx, func(ctx context.Context, node Node,
|
||||
edges []*ChannelEdge) error {
|
||||
|
||||
var nodeChans int
|
||||
|
|
@ -154,16 +154,17 @@ func (p *PrefAttachment) NodeScores(ctx context.Context, g ChannelGraph,
|
|||
|
||||
// If this node is not among our nodes to score, we can
|
||||
// return early.
|
||||
if _, ok := nodes[node]; !ok {
|
||||
nID := NodeID(node.PubKey())
|
||||
if _, ok := nodes[nID]; !ok {
|
||||
log.Tracef("Node %x not among nodes to score, "+
|
||||
"ignoring", node[:])
|
||||
"ignoring", nID[:])
|
||||
return nil
|
||||
}
|
||||
|
||||
// Otherwise we'll record the number of channels.
|
||||
nodeChanNum[node] = nodeChans
|
||||
nodeChanNum[nID] = nodeChans
|
||||
log.Tracef("Counted %v channels for node %x", nodeChans,
|
||||
node[:])
|
||||
nID[:])
|
||||
|
||||
return nil
|
||||
}, func() {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
||||
"github.com/lightningnetwork/lnd/graph/db/models"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
@ -32,14 +31,12 @@ type testGraph interface {
|
|||
}
|
||||
|
||||
type testDBGraph struct {
|
||||
db *graphdb.VersionedGraph
|
||||
db *graphdb.ChannelGraph
|
||||
databaseChannelGraph
|
||||
}
|
||||
|
||||
func newDiskChanGraph(t *testing.T) (testGraph, error) {
|
||||
graphDB := graphdb.NewVersionedGraph(
|
||||
graphdb.MakeTestGraph(t), lnwire.GossipVersion1,
|
||||
)
|
||||
graphDB := graphdb.MakeTestGraph(t)
|
||||
require.NoError(t, graphDB.Start())
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, graphDB.Stop())
|
||||
|
|
@ -91,6 +88,7 @@ func TestPrefAttachmentSelectEmptyGraph(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
||||
|
|
@ -127,6 +125,7 @@ func TestPrefAttachmentSelectTwoVertexes(t *testing.T) {
|
|||
)
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
||||
|
|
@ -213,6 +212,7 @@ func TestPrefAttachmentSelectGreedyAllocation(t *testing.T) {
|
|||
)
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
||||
|
|
@ -243,11 +243,11 @@ func TestPrefAttachmentSelectGreedyAllocation(t *testing.T) {
|
|||
twoChans := false
|
||||
nodes := make(map[NodeID]struct{})
|
||||
err = graph.ForEachNodesChannels(
|
||||
ctx, func(_ context.Context, node NodeID,
|
||||
ctx, func(_ context.Context, node Node,
|
||||
edges []*ChannelEdge) error {
|
||||
|
||||
numNodes++
|
||||
nodes[node] = struct{}{}
|
||||
nodes[node.PubKey()] = struct{}{}
|
||||
numChans := 0
|
||||
|
||||
for range edges {
|
||||
|
|
@ -325,6 +325,7 @@ func TestPrefAttachmentSelectSkipNodes(t *testing.T) {
|
|||
)
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
graph, err := chanGraph.genFunc(t)
|
||||
require.NoError(t, err, "unable to create graph")
|
||||
|
||||
|
|
@ -416,21 +417,19 @@ func (d *testDBGraph) addRandChannel(node1, node2 *btcec.PublicKey,
|
|||
case errors.Is(err, graphdb.ErrGraphNodeNotFound):
|
||||
fallthrough
|
||||
case errors.Is(err, graphdb.ErrGraphNotFound):
|
||||
//nolint:ll
|
||||
graphNode := models.NewV1Node(
|
||||
route.NewVertex(pub),
|
||||
&models.NodeV1Fields{
|
||||
Addresses: []net.Addr{&net.TCPAddr{
|
||||
IP: bytes.Repeat(
|
||||
[]byte("a"), 16,
|
||||
),
|
||||
}},
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
).RawFeatureVector,
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
},
|
||||
)
|
||||
graphNode := &models.Node{
|
||||
HaveNodeAnnouncement: true,
|
||||
Addresses: []net.Addr{&net.TCPAddr{
|
||||
IP: bytes.Repeat(
|
||||
[]byte("a"), 16,
|
||||
),
|
||||
}},
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
),
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
}
|
||||
graphNode.AddPubKey(pub)
|
||||
err := d.db.AddNode(
|
||||
context.Background(), graphNode,
|
||||
)
|
||||
|
|
@ -448,18 +447,19 @@ func (d *testDBGraph) addRandChannel(node1, node2 *btcec.PublicKey,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbNode := models.NewV1Node(
|
||||
route.NewVertex(nodeKey), &models.NodeV1Fields{
|
||||
Addresses: []net.Addr{&net.TCPAddr{
|
||||
dbNode := &models.Node{
|
||||
HaveNodeAnnouncement: true,
|
||||
Addresses: []net.Addr{
|
||||
&net.TCPAddr{
|
||||
IP: bytes.Repeat([]byte("a"), 16),
|
||||
}},
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
).RawFeatureVector,
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
},
|
||||
},
|
||||
)
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
),
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
}
|
||||
dbNode.AddPubKey(nodeKey)
|
||||
if err := d.db.AddNode(
|
||||
context.Background(), dbNode,
|
||||
); err != nil {
|
||||
|
|
@ -489,26 +489,16 @@ func (d *testDBGraph) addRandChannel(node1, node2 *btcec.PublicKey,
|
|||
}
|
||||
|
||||
chanID := randChanID()
|
||||
nodeKey1 := route.NewVertex(lnNode1)
|
||||
nodeKey2 := route.NewVertex(lnNode2)
|
||||
btcKey1 := route.NewVertex(lnNode1)
|
||||
btcKey2 := route.NewVertex(lnNode2)
|
||||
edge, err := models.NewV1Channel(
|
||||
chanID.ToUint64(), chainhash.Hash{}, nodeKey1, nodeKey2,
|
||||
&models.ChannelV1Fields{
|
||||
BitcoinKey1Bytes: btcKey1,
|
||||
BitcoinKey2Bytes: btcKey2,
|
||||
}, models.WithCapacity(capacity),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
edge := &models.ChannelEdgeInfo{
|
||||
ChannelID: chanID.ToUint64(),
|
||||
Capacity: capacity,
|
||||
Features: lnwire.EmptyFeatureVector(),
|
||||
}
|
||||
|
||||
edge.AddNodeKeys(lnNode1, lnNode2, lnNode1, lnNode2)
|
||||
if err := d.db.AddChannelEdge(ctx, edge); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
edgePolicy := &models.ChannelEdgePolicy{
|
||||
Version: lnwire.GossipVersion1,
|
||||
SigBytes: testSig.Serialize(),
|
||||
ChannelID: chanID.ToUint64(),
|
||||
LastUpdate: time.Now(),
|
||||
|
|
@ -525,7 +515,6 @@ func (d *testDBGraph) addRandChannel(node1, node2 *btcec.PublicKey,
|
|||
return nil, nil, err
|
||||
}
|
||||
edgePolicy = &models.ChannelEdgePolicy{
|
||||
Version: lnwire.GossipVersion1,
|
||||
SigBytes: testSig.Serialize(),
|
||||
ChannelID: chanID.ToUint64(),
|
||||
LastUpdate: time.Now(),
|
||||
|
|
@ -559,19 +548,19 @@ func (d *testDBGraph) addRandNode() (*btcec.PublicKey, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbNode := models.NewV1Node(
|
||||
route.NewVertex(nodeKey), &models.NodeV1Fields{
|
||||
Addresses: []net.Addr{
|
||||
&net.TCPAddr{
|
||||
IP: bytes.Repeat([]byte("a"), 16),
|
||||
},
|
||||
dbNode := &models.Node{
|
||||
HaveNodeAnnouncement: true,
|
||||
Addresses: []net.Addr{
|
||||
&net.TCPAddr{
|
||||
IP: bytes.Repeat([]byte("a"), 16),
|
||||
},
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
).RawFeatureVector,
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
},
|
||||
)
|
||||
Features: lnwire.NewFeatureVector(
|
||||
nil, lnwire.Features,
|
||||
),
|
||||
AuthSigBytes: testSig.Serialize(),
|
||||
}
|
||||
dbNode.AddPubKey(nodeKey)
|
||||
err = d.db.AddNode(context.Background(), dbNode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -615,15 +604,14 @@ func (m *memChannelGraph) ForEachNode(ctx context.Context,
|
|||
return nil
|
||||
}
|
||||
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each
|
||||
// node, all the channels that connect to it. The passed callback will be
|
||||
// called with the context, the node's pubkey, and a slice of ChannelEdge
|
||||
// that connect to the node.
|
||||
// ForEachNodesChannels iterates through all connected nodes, and for each node,
|
||||
// all the channels that connect to it. The passed callback will be called with
|
||||
// the context, the Node itself, and a slice of ChannelEdge that connect to the
|
||||
// node.
|
||||
//
|
||||
// NOTE: Part of the autopilot.ChannelGraph interface.
|
||||
func (m *memChannelGraph) ForEachNodesChannels(ctx context.Context,
|
||||
cb func(context.Context, NodeID, []*ChannelEdge) error,
|
||||
_ func()) error {
|
||||
cb func(context.Context, Node, []*ChannelEdge) error, _ func()) error {
|
||||
|
||||
for _, node := range m.graph {
|
||||
edges := make([]*ChannelEdge, 0, len(node.chans))
|
||||
|
|
@ -631,7 +619,7 @@ func (m *memChannelGraph) ForEachNodesChannels(ctx context.Context,
|
|||
edges = append(edges, &node.chans[i])
|
||||
}
|
||||
|
||||
if err := cb(ctx, NewNodeID(node.pub), edges); err != nil {
|
||||
if err := cb(ctx, node, edges); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package autopilot
|
|||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
// diameterCutoff is used to discard nodes in the diameter calculation.
|
||||
|
|
@ -33,11 +35,12 @@ func NewSimpleGraph(ctx context.Context, g ChannelGraph) (*SimpleGraph, error) {
|
|||
// The returned index is then used to create a simplified adjacency list
|
||||
// where each node is identified by its index instead of its pubkey, and
|
||||
// also to create a mapping from node index to node pubkey.
|
||||
getNodeIndex := func(node NodeID) int {
|
||||
nodeIndex, ok := nodes[node]
|
||||
getNodeIndex := func(node route.Vertex) int {
|
||||
key := NodeID(node)
|
||||
nodeIndex, ok := nodes[key]
|
||||
|
||||
if !ok {
|
||||
nodes[node] = nextIndex
|
||||
nodes[key] = nextIndex
|
||||
nodeIndex = nextIndex
|
||||
nextIndex++
|
||||
}
|
||||
|
|
@ -48,12 +51,12 @@ func NewSimpleGraph(ctx context.Context, g ChannelGraph) (*SimpleGraph, error) {
|
|||
// Iterate over each node and each channel and update the adj and the
|
||||
// node index.
|
||||
err := g.ForEachNodesChannels(ctx, func(_ context.Context,
|
||||
node NodeID, channels []*ChannelEdge) error {
|
||||
node Node, channels []*ChannelEdge) error {
|
||||
|
||||
u := getNodeIndex(node)
|
||||
u := getNodeIndex(node.PubKey())
|
||||
|
||||
for _, edge := range channels {
|
||||
v := getNodeIndex(NodeID(edge.Peer))
|
||||
v := getNodeIndex(edge.Peer)
|
||||
adj[u] = append(adj[u], v)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"runtime"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
)
|
||||
|
||||
// TopCentrality is a simple greedy technique to create connections to nodes
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
@ -83,6 +83,7 @@ func TestTopCentrality(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, chanGraph := range chanGraphs {
|
||||
chanGraph := chanGraph
|
||||
|
||||
success := t.Run(chanGraph.name, func(t1 *testing.T) {
|
||||
t1.Parallel()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package blockcache
|
||||
|
||||
import (
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/neutrino"
|
||||
"github.com/lightninglabs/neutrino/cache"
|
||||
"github.com/lightninglabs/neutrino/cache/lru"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import (
|
|||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/neutrino"
|
||||
"github.com/lightninglabs/neutrino/cache"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// decodeStream runs a single typed-stream pass over data and returns the
|
||||
// canonical TypeMap. Records may be passed in any order; NewStream requires
|
||||
// them sorted, so SortRecords runs first.
|
||||
func decodeStream(data []byte, records ...tlv.Record) (tlv.TypeMap, error) {
|
||||
tlv.SortRecords(records)
|
||||
|
||||
stream, err := tlv.NewStream(records...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create stream: %w", err)
|
||||
}
|
||||
|
||||
typeMap, err := stream.DecodeWithParsedTypesP2P(
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode stream: %w", err)
|
||||
}
|
||||
|
||||
return typeMap, nil
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// Package bolt12 implements encoding, decoding, and validation for BOLT 12
|
||||
// Offers, Invoice Requests, and Invoices. It provides a pure codec library
|
||||
// with no LND daemon dependencies.
|
||||
//
|
||||
// BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant
|
||||
// and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields.
|
||||
//
|
||||
// Human-readable prefixes:
|
||||
// - lno: Offer
|
||||
// - lnr: Invoice Request
|
||||
// - lni: Invoice
|
||||
//
|
||||
// # Codec Contract
|
||||
//
|
||||
// Encode validates before serialising and refuses to emit bytes that would fail
|
||||
// the writer requirements, invalid bytes are unrepresentable on the wire.
|
||||
// Low-level decoders stay permissive so diagnostic and fuzz harnesses can
|
||||
// inspect malformed input.
|
||||
package bolt12
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
)
|
||||
|
||||
// bobKey returns the deterministic spec test key for Bob, whose 32-byte scalar
|
||||
// is 0x42 repeated. Used across signature and round-trip tests so the same key
|
||||
// is not reconstructed in every callsite.
|
||||
func bobKey() (*btcec.PrivateKey, *btcec.PublicKey) {
|
||||
priv, pub := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x42}, 32))
|
||||
|
||||
return priv, pub
|
||||
}
|
||||
|
||||
// aliceKey returns the deterministic spec test key for Alice, whose 32-byte
|
||||
// scalar is 0x41 repeated.
|
||||
func aliceKey() (*btcec.PrivateKey, *btcec.PublicKey) {
|
||||
priv, pub := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x41}, 32))
|
||||
|
||||
return priv, pub
|
||||
}
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// Invoice represents a BOLT 12 invoice message. It mirrors all non-signature
|
||||
// invoice_request fields (types 0-91) and adds invoice-specific fields (types
|
||||
// 160-176) plus a Schnorr signature (type 240).
|
||||
//
|
||||
// An invoice in response to a request should be constructed from that request
|
||||
// (e.g., using NewInvoiceFromRequest) to mirror its fields. The caller then
|
||||
// populates the invoice-specific fields and signs it.
|
||||
type Invoice struct {
|
||||
// Fields in the 0-91 range are mirrored verbatim from the
|
||||
// invoice_request (which carries the offer's fields); the byte-for-byte
|
||||
// match is enforced by ValidateInvoiceAgainstRequest.
|
||||
|
||||
// InvreqMetadata is the payer metadata.
|
||||
InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob]
|
||||
|
||||
// OfferChains are the chains the offer is valid for.
|
||||
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
|
||||
|
||||
// OfferMetadata is the offer metadata.
|
||||
OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]
|
||||
|
||||
// OfferCurrency is the offer currency.
|
||||
OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]
|
||||
|
||||
// OfferAmount is the offer amount.
|
||||
OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]
|
||||
|
||||
// OfferDescription is the offer description.
|
||||
OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]
|
||||
|
||||
// OfferFeatures are the offer features.
|
||||
OfferFeatures tlv.OptionalRecordT[
|
||||
tlv.TlvType12, lnwire.RawFeatureVector,
|
||||
]
|
||||
|
||||
// OfferAbsoluteExpiry is the offer's absolute expiry.
|
||||
OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]
|
||||
|
||||
// OfferPaths are the offer's blinded paths.
|
||||
OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]
|
||||
|
||||
// OfferIssuer is the offer issuer name.
|
||||
OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]
|
||||
|
||||
// OfferQuantityMax is the offer's maximum quantity.
|
||||
OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]
|
||||
|
||||
// OfferIssuerID is the offer issuer's public key.
|
||||
OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]
|
||||
|
||||
// InvreqChain is the requested chain.
|
||||
InvreqChain tlv.OptionalRecordT[tlv.TlvType80, [32]byte]
|
||||
|
||||
// InvreqAmount is the amount the payer offered.
|
||||
InvreqAmount tlv.OptionalRecordT[tlv.TlvType82, TUint64]
|
||||
|
||||
// InvreqFeatures are the payer's features.
|
||||
InvreqFeatures tlv.OptionalRecordT[
|
||||
tlv.TlvType84, lnwire.RawFeatureVector,
|
||||
]
|
||||
|
||||
// InvreqQuantity is the requested quantity.
|
||||
InvreqQuantity tlv.OptionalRecordT[tlv.TlvType86, TUint64]
|
||||
|
||||
// InvreqPayerID is the payer's signing public key.
|
||||
InvreqPayerID tlv.OptionalRecordT[tlv.TlvType88, *btcec.PublicKey]
|
||||
|
||||
// InvreqPayerNote is an optional payer note.
|
||||
InvreqPayerNote tlv.OptionalRecordT[tlv.TlvType89, tlv.Blob]
|
||||
|
||||
// InvreqPaths are the payer's blinded paths to send the invoice to.
|
||||
InvreqPaths tlv.OptionalRecordT[tlv.TlvType90, lnwire.BlindedPaths]
|
||||
|
||||
// InvreqBip353Name is the payer's BIP 353 name.
|
||||
InvreqBip353Name tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]
|
||||
|
||||
// Fields from type 160 on are invoice-specific.
|
||||
|
||||
// InvoicePaths are the blinded paths to the recipient node.
|
||||
InvoicePaths tlv.OptionalRecordT[tlv.TlvType160, lnwire.BlindedPaths]
|
||||
|
||||
// InvoiceBlindedPay carries one blinded_payinfo per invoice_paths
|
||||
// entry, in order.
|
||||
InvoiceBlindedPay tlv.OptionalRecordT[tlv.TlvType162, BlindedPayInfos]
|
||||
|
||||
// InvoiceCreatedAt is the creation time in seconds since the Unix
|
||||
// epoch.
|
||||
InvoiceCreatedAt tlv.OptionalRecordT[tlv.TlvType164, TUint64]
|
||||
|
||||
// InvoiceRelativeExp is the expiry in seconds after creation. When
|
||||
// absent the spec default of 7200 seconds applies.
|
||||
InvoiceRelativeExp tlv.OptionalRecordT[tlv.TlvType166, TUint32]
|
||||
|
||||
// InvoicePaymentHash is the SHA256 hash of the payment preimage.
|
||||
InvoicePaymentHash tlv.OptionalRecordT[tlv.TlvType168, [32]byte]
|
||||
|
||||
// InvoiceAmount is the minimum amount the payee will accept, in the
|
||||
// minimal payable unit of invreq_chain.
|
||||
InvoiceAmount tlv.OptionalRecordT[tlv.TlvType170, TUint64]
|
||||
|
||||
// InvoiceFallbacks are optional on-chain fallback addresses.
|
||||
InvoiceFallbacks tlv.OptionalRecordT[
|
||||
tlv.TlvType172, FallbackAddresses,
|
||||
]
|
||||
|
||||
// InvoiceFeatures are the features of the invoice.
|
||||
InvoiceFeatures tlv.OptionalRecordT[
|
||||
tlv.TlvType174, lnwire.RawFeatureVector,
|
||||
]
|
||||
|
||||
// InvoiceNodeID is the public key of the recipient node, used to verify
|
||||
// the signature.
|
||||
InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, *btcec.PublicKey]
|
||||
|
||||
// Signature is a BIP-340 Schnorr signature covering all fields.
|
||||
Signature tlv.OptionalRecordT[tlv.TlvType240, [64]byte]
|
||||
|
||||
// decodedTLVs is the canonical TypeMap produced by the typed-stream
|
||||
// pass that decoded this invoice. See Offer.decodedTLVs for the design
|
||||
// rationale.
|
||||
decodedTLVs tlv.TypeMap
|
||||
}
|
||||
|
||||
// AllRecords returns the canonical sorted record list for this invoice, merging
|
||||
// the typed records with any extra signed-range fields that the decoder
|
||||
// preserved.
|
||||
//
|
||||
// NOTE: this is part of the tlv.PureTLVMessage interface.
|
||||
func (inv *Invoice) AllRecords() []tlv.Record {
|
||||
return allRecordsFromTypeMap(
|
||||
inv.allRecordProducers(), inv.decodedTLVs,
|
||||
)
|
||||
}
|
||||
|
||||
var _ lnwire.PureTLVMessage = (*Invoice)(nil)
|
||||
|
||||
const (
|
||||
// maxWitnessVersion is the highest segwit witness version a usable
|
||||
// fallback address may carry; the BOLT 12 reader ignores anything
|
||||
// above it.
|
||||
maxWitnessVersion = 16
|
||||
|
||||
// minWitnessProgramLen and maxWitnessProgramLen bound the witness
|
||||
// program length, in bytes, of a usable fallback address.
|
||||
minWitnessProgramLen = 2
|
||||
maxWitnessProgramLen = 40
|
||||
)
|
||||
|
||||
// UsableFallbackAddresses returns the invoice_fallbacks entries a payer may use
|
||||
// after applying the BOLT 12 reader's MUST-ignore rules for the bitcoin chain.
|
||||
func (inv *Invoice) UsableFallbackAddresses() []FallbackAddress {
|
||||
// Unwrap the optional up front so the filtering loop stays flat; a nil
|
||||
// Addrs slice ranges as empty.
|
||||
fallbacks := inv.InvoiceFallbacks.ValOpt().UnwrapOr(FallbackAddresses{})
|
||||
|
||||
var addrs []FallbackAddress
|
||||
for _, a := range fallbacks.Addrs {
|
||||
// MUST ignore any fallback_address for which version is greater
|
||||
// than 16.
|
||||
if a.Version > maxWitnessVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
// MUST ignore any fallback_address for which address is less
|
||||
// than 2 or greater than 40 bytes.
|
||||
if len(a.Address) < minWitnessProgramLen ||
|
||||
len(a.Address) > maxWitnessProgramLen {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// MUST ignore any fallback_address for which address does not
|
||||
// meet known requirements for the given version. NOT enforced
|
||||
// here: the per-version witness-program check needs on-chain
|
||||
// address rules above this codec, so a caller dispatching
|
||||
// on-chain MUST apply it.
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// UsablePath pairs a blinded path with its payment parameters, as returned by
|
||||
// UsablePaths after the BOLT 12 reader's feature filter has been applied.
|
||||
type UsablePath struct {
|
||||
// Path is the blinded path to the recipient.
|
||||
Path lnwire.BlindedPath
|
||||
|
||||
// PayInfo is the blinded_payinfo for Path.
|
||||
PayInfo BlindedPayInfo
|
||||
}
|
||||
|
||||
// UsablePaths returns the invoice_paths entries a payer may use, each paired
|
||||
// with its blinded_payinfo, after applying the BOLT 12 reader rule that a path
|
||||
// MUST NOT be used when its payinfo.features has unknown required (even) bits
|
||||
// set. knownBlindedFeatures names the feature bits the reader understands.
|
||||
//
|
||||
// The result is empty when invoice_paths or invoice_blindedpay is absent, or
|
||||
// when the two lists differ in length; ValidateInvoiceRead rejects those cases
|
||||
// separately, so a caller that validates first can treat an empty result as
|
||||
// "no usable paths".
|
||||
func (inv *Invoice) UsablePaths(
|
||||
knownBlindedFeatures map[lnwire.FeatureBit]string) []UsablePath {
|
||||
|
||||
paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{})
|
||||
bp := inv.InvoiceBlindedPay.ValOpt().UnwrapOr(BlindedPayInfos{})
|
||||
|
||||
// Entries pair by index; a length mismatch is rejected upstream by
|
||||
// ValidateInvoiceRead, so guard here to stay in bounds.
|
||||
if len(paths.Paths) != len(bp.Infos) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var usable []UsablePath
|
||||
for i := range bp.Infos {
|
||||
// MUST NOT use the path if payinfo.features has any unknown
|
||||
// even bits set.
|
||||
fv := bp.Infos[i].Features
|
||||
wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures)
|
||||
if len(wrapped.UnknownRequiredFeatures()) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
usable = append(usable, UsablePath{
|
||||
Path: paths.Paths[i],
|
||||
PayInfo: bp.Infos[i],
|
||||
})
|
||||
}
|
||||
|
||||
return usable
|
||||
}
|
||||
|
||||
// allRecordProducers returns record producers for all set fields.
|
||||
func (inv *Invoice) allRecordProducers() []tlv.RecordProducer {
|
||||
var p []tlv.RecordProducer
|
||||
|
||||
// Invreq mirrored fields.
|
||||
lnwire.AddOpt(&p, inv.InvreqMetadata)
|
||||
lnwire.AddOpt(&p, inv.OfferChains)
|
||||
lnwire.AddOpt(&p, inv.OfferMetadata)
|
||||
lnwire.AddOpt(&p, inv.OfferCurrency)
|
||||
lnwire.AddOpt(&p, inv.OfferAmount)
|
||||
lnwire.AddOpt(&p, inv.OfferDescription)
|
||||
lnwire.AddOpt(&p, inv.OfferFeatures)
|
||||
lnwire.AddOpt(&p, inv.OfferAbsoluteExpiry)
|
||||
lnwire.AddOpt(&p, inv.OfferPaths)
|
||||
lnwire.AddOpt(&p, inv.OfferIssuer)
|
||||
lnwire.AddOpt(&p, inv.OfferQuantityMax)
|
||||
lnwire.AddOpt(&p, inv.OfferIssuerID)
|
||||
lnwire.AddOpt(&p, inv.InvreqChain)
|
||||
lnwire.AddOpt(&p, inv.InvreqAmount)
|
||||
lnwire.AddOpt(&p, inv.InvreqFeatures)
|
||||
lnwire.AddOpt(&p, inv.InvreqQuantity)
|
||||
lnwire.AddOpt(&p, inv.InvreqPayerID)
|
||||
lnwire.AddOpt(&p, inv.InvreqPayerNote)
|
||||
lnwire.AddOpt(&p, inv.InvreqPaths)
|
||||
lnwire.AddOpt(&p, inv.InvreqBip353Name)
|
||||
|
||||
// Invoice-specific fields.
|
||||
lnwire.AddOpt(&p, inv.InvoicePaths)
|
||||
lnwire.AddOpt(&p, inv.InvoiceBlindedPay)
|
||||
lnwire.AddOpt(&p, inv.InvoiceCreatedAt)
|
||||
lnwire.AddOpt(&p, inv.InvoiceRelativeExp)
|
||||
lnwire.AddOpt(&p, inv.InvoicePaymentHash)
|
||||
lnwire.AddOpt(&p, inv.InvoiceAmount)
|
||||
lnwire.AddOpt(&p, inv.InvoiceFallbacks)
|
||||
lnwire.AddOpt(&p, inv.InvoiceFeatures)
|
||||
lnwire.AddOpt(&p, inv.InvoiceNodeID)
|
||||
lnwire.AddOpt(&p, inv.Signature)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Encode validates the invoice per writer requirements and serialises it via
|
||||
// the PureTLVMessage shape.
|
||||
func (inv *Invoice) Encode() ([]byte, error) {
|
||||
if err := ValidateInvoiceWrite(inv); err != nil {
|
||||
return nil, fmt.Errorf("validate invoice: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := lnwire.EncodePureTLVMessage(inv, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecodeInvoice deserializes an invoice from a TLV byte stream. Decoding is
|
||||
// permissive: callers that need spec compliance must run ValidateInvoiceRead.
|
||||
func DecodeInvoice(data []byte) (*Invoice, error) {
|
||||
var inv Invoice
|
||||
|
||||
invreqMetadata := tlv.ZeroRecordT[tlv.TlvType0, tlv.Blob]()
|
||||
chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]()
|
||||
offerMeta := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]()
|
||||
currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]()
|
||||
offerAmt := tlv.ZeroRecordT[tlv.TlvType8, TUint64]()
|
||||
desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]()
|
||||
offerFeat := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]()
|
||||
expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]()
|
||||
offerPaths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]()
|
||||
issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]()
|
||||
qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]()
|
||||
issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]()
|
||||
invreqChain := tlv.ZeroRecordT[tlv.TlvType80, [32]byte]()
|
||||
invreqAmt := tlv.ZeroRecordT[tlv.TlvType82, TUint64]()
|
||||
invreqFeat := tlv.ZeroRecordT[tlv.TlvType84, lnwire.RawFeatureVector]()
|
||||
invreqQty := tlv.ZeroRecordT[tlv.TlvType86, TUint64]()
|
||||
payerID := tlv.ZeroRecordT[tlv.TlvType88, *btcec.PublicKey]()
|
||||
payerNote := tlv.ZeroRecordT[tlv.TlvType89, tlv.Blob]()
|
||||
invreqPaths := tlv.ZeroRecordT[tlv.TlvType90, lnwire.BlindedPaths]()
|
||||
bip353 := tlv.ZeroRecordT[tlv.TlvType91, tlv.Blob]()
|
||||
invPaths := tlv.ZeroRecordT[tlv.TlvType160, lnwire.BlindedPaths]()
|
||||
blindedPay := tlv.ZeroRecordT[tlv.TlvType162, BlindedPayInfos]()
|
||||
createdAt := tlv.ZeroRecordT[tlv.TlvType164, TUint64]()
|
||||
relExp := tlv.ZeroRecordT[tlv.TlvType166, TUint32]()
|
||||
payHash := tlv.ZeroRecordT[tlv.TlvType168, [32]byte]()
|
||||
invAmt := tlv.ZeroRecordT[tlv.TlvType170, TUint64]()
|
||||
fallbacks := tlv.ZeroRecordT[tlv.TlvType172, FallbackAddresses]()
|
||||
invFeat := tlv.ZeroRecordT[tlv.TlvType174, lnwire.RawFeatureVector]()
|
||||
nodeID := tlv.ZeroRecordT[tlv.TlvType176, *btcec.PublicKey]()
|
||||
sig := tlv.ZeroRecordT[tlv.TlvType240, [64]byte]()
|
||||
|
||||
tm, err := decodeStream(
|
||||
data,
|
||||
invreqMetadata.Record(), chains.Record(), offerMeta.Record(),
|
||||
currency.Record(), offerAmt.Record(), desc.Record(),
|
||||
offerFeat.Record(), expiry.Record(), offerPaths.Record(),
|
||||
issuer.Record(), qtyMax.Record(), issuerID.Record(),
|
||||
invreqChain.Record(), invreqAmt.Record(), invreqFeat.Record(),
|
||||
invreqQty.Record(), payerID.Record(), payerNote.Record(),
|
||||
invreqPaths.Record(), bip353.Record(), invPaths.Record(),
|
||||
blindedPay.Record(), createdAt.Record(), relExp.Record(),
|
||||
payHash.Record(), invAmt.Record(), fallbacks.Record(),
|
||||
invFeat.Record(), nodeID.Record(), sig.Record(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode invoice: %w", err)
|
||||
}
|
||||
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqMetadata, invreqMetadata)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferChains, chains)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferMetadata, offerMeta)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferCurrency, currency)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferAmount, offerAmt)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferDescription, desc)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferFeatures, offerFeat)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferAbsoluteExpiry, expiry)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferPaths, offerPaths)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferIssuer, issuer)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferQuantityMax, qtyMax)
|
||||
lnwire.SetOptFromMap(tm, &inv.OfferIssuerID, issuerID)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqChain, invreqChain)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqAmount, invreqAmt)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqFeatures, invreqFeat)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqQuantity, invreqQty)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqPayerID, payerID)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqPayerNote, payerNote)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqPaths, invreqPaths)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvreqBip353Name, bip353)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoicePaths, invPaths)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceBlindedPay, blindedPay)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceCreatedAt, createdAt)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceRelativeExp, relExp)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoicePaymentHash, payHash)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceAmount, invAmt)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceFallbacks, fallbacks)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceFeatures, invFeat)
|
||||
lnwire.SetOptFromMap(tm, &inv.InvoiceNodeID, nodeID)
|
||||
lnwire.SetOptFromMap(tm, &inv.Signature, sig)
|
||||
|
||||
inv.decodedTLVs = tm
|
||||
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all
|
||||
// non-signature fields from the provided InvoiceRequest. When invreq_amount is
|
||||
// present it is mirrored into invoice_amount per the writer requirement. The
|
||||
// caller is responsible for populating the remaining invoice-specific fields
|
||||
// (invoice_created_at, invoice_payment_hash, invoice_node_id, invoice_paths,
|
||||
// invoice_blindedpay, ...) and signing the invoice.
|
||||
func NewInvoiceFromRequest(req *InvoiceRequest) *Invoice {
|
||||
inv := &Invoice{
|
||||
InvreqMetadata: req.InvreqMetadata,
|
||||
OfferChains: req.OfferChains,
|
||||
OfferMetadata: req.OfferMetadata,
|
||||
OfferCurrency: req.OfferCurrency,
|
||||
OfferAmount: req.OfferAmount,
|
||||
OfferDescription: req.OfferDescription,
|
||||
OfferFeatures: req.OfferFeatures,
|
||||
OfferAbsoluteExpiry: req.OfferAbsoluteExpiry,
|
||||
OfferPaths: req.OfferPaths,
|
||||
OfferIssuer: req.OfferIssuer,
|
||||
OfferQuantityMax: req.OfferQuantityMax,
|
||||
OfferIssuerID: req.OfferIssuerID,
|
||||
InvreqChain: req.InvreqChain,
|
||||
InvreqAmount: req.InvreqAmount,
|
||||
InvreqFeatures: req.InvreqFeatures,
|
||||
InvreqQuantity: req.InvreqQuantity,
|
||||
InvreqPayerID: req.InvreqPayerID,
|
||||
InvreqPayerNote: req.InvreqPayerNote,
|
||||
InvreqPaths: req.InvreqPaths,
|
||||
InvreqBip353Name: req.InvreqBip353Name,
|
||||
|
||||
// Carry the request's unknown signed-range TLVs. Known invreq
|
||||
// types appear in the map with nil values and are skipped when
|
||||
// the sidecar is merged, so this re-emits only the unknowns and
|
||||
// never duplicates the typed fields copied above. Any
|
||||
// signature-range entries (240-1000) cloned here are inert:
|
||||
// allRecordsFromTypeMap drops them via bolt12InUnsignedRange,
|
||||
// so the request's signature never leaks into the invoice.
|
||||
decodedTLVs: maps.Clone(req.decodedTLVs),
|
||||
}
|
||||
|
||||
// Writer rule: if invreq_amount is present, invoice_amount MUST be set
|
||||
// to it. When absent, the caller sets the expected amount.
|
||||
req.InvreqAmount.WhenSome(
|
||||
func(r tlv.RecordT[tlv.TlvType82, TUint64]) {
|
||||
inv.InvoiceAmount = tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType170, TUint64](r.Val),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return inv
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// InvoiceError represents a BOLT 12 invoice_error message, the negative reply a
|
||||
// node sends when it rejects an invoice_request or a returned invoice.
|
||||
type InvoiceError struct {
|
||||
// ErroneousField names the TLV type in the rejected message that caused
|
||||
// the failure, letting the recipient pinpoint what to change.
|
||||
ErroneousField tlv.OptionalRecordT[tlv.TlvType1, TUint64]
|
||||
|
||||
// SuggestedValue provides a valid replacement for the erroneous field.
|
||||
// MUST NOT be set if ErroneousField is absent.
|
||||
SuggestedValue tlv.OptionalRecordT[tlv.TlvType3, tlv.Blob]
|
||||
|
||||
// Error is a UTF-8 string explaining the rejection. Required by the
|
||||
// spec.
|
||||
Error tlv.OptionalRecordT[tlv.TlvType5, tlv.Blob]
|
||||
|
||||
// decodedTLVs holds every wire TLV type, including unknown ones, so
|
||||
// ValidateInvoiceErrorRead can apply the must-understand rule.
|
||||
decodedTLVs tlv.TypeMap
|
||||
}
|
||||
|
||||
// allRecordProducers returns record producers for every set optional field, in
|
||||
// declaration order.
|
||||
func (ie *InvoiceError) allRecordProducers() []tlv.RecordProducer {
|
||||
var p []tlv.RecordProducer
|
||||
|
||||
lnwire.AddOpt(&p, ie.ErroneousField)
|
||||
lnwire.AddOpt(&p, ie.SuggestedValue)
|
||||
lnwire.AddOpt(&p, ie.Error)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Encode validates the invoice error per writer requirements and serialises it
|
||||
// into a TLV byte stream suitable for embedding in an onion message payload at
|
||||
// type 68. Note that Encode intentionally drops any unknown TLVs. Since
|
||||
// invoice_error does not carry a cryptographic signature, there is no
|
||||
// signature to invalidate by dropping unrecognized TLVs (unlike signed
|
||||
// messages such as invoices, where unknown TLVs must be preserved to keep
|
||||
// signatures valid).
|
||||
func (ie *InvoiceError) Encode() ([]byte, error) {
|
||||
if err := ValidateInvoiceErrorWrite(ie); err != nil {
|
||||
return nil, fmt.Errorf("validate invoice error: %w", err)
|
||||
}
|
||||
|
||||
records := lnwire.ProduceRecordsSorted(ie.allRecordProducers()...)
|
||||
|
||||
return lnwire.EncodeRecords(records)
|
||||
}
|
||||
|
||||
// DecodeInvoiceError deserializes an invoice error from a TLV byte stream (the
|
||||
// raw value of onion message payload type 68). Decoding is permissive. Run
|
||||
// ValidateInvoiceErrorRead for the BOLT 1 must-understand check.
|
||||
func DecodeInvoiceError(data []byte) (*InvoiceError, error) {
|
||||
var ie InvoiceError
|
||||
|
||||
errField := tlv.ZeroRecordT[tlv.TlvType1, TUint64]()
|
||||
sugVal := tlv.ZeroRecordT[tlv.TlvType3, tlv.Blob]()
|
||||
errMsg := tlv.ZeroRecordT[tlv.TlvType5, tlv.Blob]()
|
||||
|
||||
tm, err := decodeStream(
|
||||
data,
|
||||
errField.Record(),
|
||||
sugVal.Record(),
|
||||
errMsg.Record(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode invoice error: %w", err)
|
||||
}
|
||||
|
||||
lnwire.SetOptFromMap(tm, &ie.ErroneousField, errField)
|
||||
lnwire.SetOptFromMap(tm, &ie.SuggestedValue, sugVal)
|
||||
lnwire.SetOptFromMap(tm, &ie.Error, errMsg)
|
||||
ie.decodedTLVs = tm
|
||||
|
||||
return &ie, nil
|
||||
}
|
||||
|
||||
// ErrorMessage returns the decoded error string, or empty if not set. The bytes
|
||||
// originate from a remote peer over an onion message and are not sanitised
|
||||
// here, so callers must scrub them before logging or display.
|
||||
func (ie *InvoiceError) ErrorMessage() string {
|
||||
var msg []byte
|
||||
ie.Error.WhenSome(func(r tlv.RecordT[tlv.TlvType5, tlv.Blob]) {
|
||||
msg = r.Val
|
||||
})
|
||||
|
||||
return string(msg)
|
||||
}
|
||||
|
||||
// FieldNumber returns the erroneous field number, if set.
|
||||
func (ie *InvoiceError) FieldNumber() (uint64, bool) {
|
||||
var (
|
||||
val uint64
|
||||
ok bool
|
||||
)
|
||||
ie.ErroneousField.WhenSome(
|
||||
func(r tlv.RecordT[tlv.TlvType1, TUint64]) {
|
||||
val = uint64(r.Val)
|
||||
ok = true
|
||||
},
|
||||
)
|
||||
|
||||
return val, ok
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// someErrField builds a set erroneous_field record for the given field
|
||||
// number, keeping the test tables compact.
|
||||
func someErrField(n uint64) tlv.OptionalRecordT[tlv.TlvType1, TUint64] {
|
||||
return tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType1](TUint64(n)),
|
||||
)
|
||||
}
|
||||
|
||||
// someSuggested builds a set suggested_value record from raw bytes.
|
||||
func someSuggested(b tlv.Blob) tlv.OptionalRecordT[tlv.TlvType3, tlv.Blob] {
|
||||
return tlv.SomeRecordT(tlv.NewPrimitiveRecord[tlv.TlvType3](b))
|
||||
}
|
||||
|
||||
// someError builds a set error record from a string.
|
||||
func someError(s string) tlv.OptionalRecordT[tlv.TlvType5, tlv.Blob] {
|
||||
return tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType5](tlv.Blob(s)),
|
||||
)
|
||||
}
|
||||
|
||||
// TestInvoiceErrorRoundTrip verifies that encoding an invoice_error and
|
||||
// decoding the result recovers every field, and that re-encoding the decoded
|
||||
// message reproduces the original bytes, both for a fully-populated message
|
||||
// and for the minimal error-only case. Note that this round-trip property
|
||||
// only guarantees exact byte reproducibility for messages containing only
|
||||
// known/declared fields; any unknown fields present in decoded messages are
|
||||
// intentionally dropped when re-encoded.
|
||||
func TestInvoiceErrorRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ie *InvoiceError
|
||||
wantMsg string
|
||||
wantHasField bool
|
||||
wantFieldNum uint64
|
||||
wantSuggest []byte
|
||||
}{
|
||||
{
|
||||
name: "all fields",
|
||||
ie: &InvoiceError{
|
||||
ErroneousField: someErrField(82),
|
||||
SuggestedValue: someSuggested(
|
||||
[]byte{0x00, 0x01, 0x86, 0xa0},
|
||||
),
|
||||
Error: someError("amount too low"),
|
||||
},
|
||||
wantMsg: "amount too low",
|
||||
wantHasField: true,
|
||||
wantFieldNum: 82,
|
||||
wantSuggest: []byte{0x00, 0x01, 0x86, 0xa0},
|
||||
},
|
||||
{
|
||||
name: "minimal error only",
|
||||
ie: &InvoiceError{
|
||||
Error: someError("rejected"),
|
||||
},
|
||||
wantMsg: "rejected",
|
||||
wantHasField: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encoded, err := tc.ie.Encode()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, encoded)
|
||||
|
||||
decoded, err := DecodeInvoiceError(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tc.wantMsg, decoded.ErrorMessage())
|
||||
|
||||
fieldNum, ok := decoded.FieldNumber()
|
||||
require.Equal(t, tc.wantHasField, ok)
|
||||
if tc.wantHasField {
|
||||
require.Equal(t, tc.wantFieldNum, fieldNum)
|
||||
}
|
||||
|
||||
var sugVal []byte
|
||||
decoded.SuggestedValue.WhenSome(
|
||||
func(r tlv.RecordT[tlv.TlvType3, tlv.Blob]) {
|
||||
sugVal = r.Val
|
||||
},
|
||||
)
|
||||
require.Equal(t, tc.wantSuggest, sugVal)
|
||||
|
||||
// Re-encoding the decoded message must reproduce the
|
||||
// original bytes, pinning canonical record ordering.
|
||||
reencoded, err := decoded.Encode()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encoded, reencoded)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvoiceErrorRoundTripWithUnknown verifies that decoding an invoice_error
|
||||
// containing unknown odd fields works, but re-encoding the decoded structure
|
||||
// drops those unknown fields, yielding only the known fields in the encoded
|
||||
// byte stream.
|
||||
func TestInvoiceErrorRoundTripWithUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a valid invoice_error with only known fields and encode it.
|
||||
ie := &InvoiceError{
|
||||
Error: someError("rejected with unknown field present"),
|
||||
}
|
||||
valid, err := ie.Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Append an unknown odd TLV (type 7) to the valid TLV stream.
|
||||
// 0x07 (type), 0x02 (length), 0xaa, 0xbb (value).
|
||||
streamWithUnknown := append(
|
||||
append([]byte{}, valid...), 0x07, 0x02, 0xaa, 0xbb,
|
||||
)
|
||||
|
||||
// Decode the stream. It should succeed because unknown odd fields are
|
||||
// ignored/tolerated.
|
||||
decoded, err := DecodeInvoiceError(streamWithUnknown)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, "rejected with unknown field present",
|
||||
decoded.ErrorMessage(),
|
||||
)
|
||||
|
||||
// Re-encode the decoded message.
|
||||
reencoded, err := decoded.Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// The re-encoded stream must drop the unknown type 7 field, recovering
|
||||
// exactly the 'valid' bytes, rather than 'streamWithUnknown'.
|
||||
require.Equal(t, valid, reencoded)
|
||||
}
|
||||
|
||||
// TestInvoiceErrorEncodeValidates verifies that Encode runs the writer
|
||||
// validation before serialising, so an invalid invoice_error never reaches the
|
||||
// wire.
|
||||
func TestInvoiceErrorEncodeValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ie *InvoiceError
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "missing error",
|
||||
ie: &InvoiceError{},
|
||||
wantErr: ErrMissingError,
|
||||
},
|
||||
{
|
||||
name: "empty error",
|
||||
ie: &InvoiceError{Error: someError("")},
|
||||
wantErr: ErrEmptyError,
|
||||
},
|
||||
{
|
||||
name: "non-utf8 error",
|
||||
ie: &InvoiceError{
|
||||
Error: someError(string([]byte{0xff, 0xfe})),
|
||||
},
|
||||
wantErr: ErrInvalidUTF8,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := tc.ie.Encode()
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeInvoiceError verifies decode-level behavior: a truncated stream
|
||||
// errors, and an unknown odd TLV trailing a valid message is tolerated per the
|
||||
// BOLT rule that unknown odd types may be ignored.
|
||||
func TestDecodeInvoiceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid, err := (&InvoiceError{Error: someError("rejected")}).Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// A valid message with an unknown odd TLV (type 7) appended after error
|
||||
// (type 5), kept in ascending type order.
|
||||
withOdd := append(append([]byte{}, valid...), 0x07, 0x02, 0xaa, 0xbb)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantErr bool
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
// Type 5 (error) claims length 16 but supplies one
|
||||
// byte.
|
||||
name: "truncated",
|
||||
data: []byte{0x05, 0x10, 0x01},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown odd tolerated",
|
||||
data: withOdd,
|
||||
wantMsg: "rejected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
decoded, err := DecodeInvoiceError(tc.data)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantMsg, decoded.ErrorMessage())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInvoiceErrorRead verifies the BOLT 1 must-understand rule: an
|
||||
// unknown even TLV is rejected (including a zero-length one, which still
|
||||
// occupies a type slot), while an unknown odd TLV is tolerated.
|
||||
func TestValidateInvoiceErrorRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A valid encoded invoice_error (error = "rejected", type 5). Trailers
|
||||
// use types > 5 to keep the stream strictly increasing.
|
||||
base, err := (&InvoiceError{Error: someError("rejected")}).Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trailer []byte
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "known only",
|
||||
},
|
||||
{
|
||||
name: "unknown odd tolerated",
|
||||
trailer: []byte{0x07, 0x02, 0xaa, 0xbb},
|
||||
},
|
||||
{
|
||||
name: "unknown even rejected",
|
||||
trailer: []byte{0x06, 0x02, 0xaa, 0xbb},
|
||||
wantErr: ErrUnknownEvenType,
|
||||
},
|
||||
{
|
||||
name: "unknown even zero-length rejected",
|
||||
trailer: []byte{0x06, 0x00},
|
||||
wantErr: ErrUnknownEvenType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stream := append(
|
||||
append([]byte{}, base...), tc.trailer...,
|
||||
)
|
||||
decoded, err := DecodeInvoiceError(stream)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateInvoiceErrorRead(decoded)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMissingPayerID is returned when invreq_payer_id is absent.
|
||||
ErrMissingPayerID = errors.New("missing invreq_payer_id")
|
||||
|
||||
// ErrMissingMetadata is returned when invreq_metadata is absent.
|
||||
ErrMissingMetadata = errors.New("missing invreq_metadata")
|
||||
)
|
||||
|
||||
// InvoiceRequest represents a BOLT 12 invoice_request message. It mirrors offer
|
||||
// fields from the original offer. It also adds payer-specific fields and a
|
||||
// Schnorr signature.
|
||||
//
|
||||
// An invoice request should be constructed from an offer (e.g., using
|
||||
// NewInvoiceRequestFromOffer) unless it is a spontaneous invoice request.
|
||||
type InvoiceRequest struct {
|
||||
// OfferChains are the chains that the mirrored offer is valid for.
|
||||
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
|
||||
|
||||
// OfferMetadata is the metadata from the mirrored offer.
|
||||
OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]
|
||||
|
||||
// OfferCurrency is the currency from the mirrored offer.
|
||||
OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]
|
||||
|
||||
// OfferAmount is the amount from the mirrored offer.
|
||||
OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]
|
||||
|
||||
// OfferDescription is the description from the mirrored offer.
|
||||
OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]
|
||||
|
||||
// OfferFeatures are the features required by the mirrored offer.
|
||||
OfferFeatures tlv.OptionalRecordT[
|
||||
tlv.TlvType12, lnwire.RawFeatureVector,
|
||||
]
|
||||
|
||||
// OfferAbsoluteExpiry is the absolute expiry from the mirrored offer.
|
||||
OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]
|
||||
|
||||
// OfferPaths are the blinded paths from the mirrored offer.
|
||||
OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]
|
||||
|
||||
// OfferIssuer is the issuer name from the mirrored offer.
|
||||
OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]
|
||||
|
||||
// OfferQuantityMax is the maximum quantity allowed by the mirrored
|
||||
// offer.
|
||||
OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]
|
||||
|
||||
// OfferIssuerID is the public key of the offer issuer.
|
||||
OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]
|
||||
|
||||
// InvreqMetadata is a blob of unpredictable bytes provided by the
|
||||
// payer. It serves multiple roles under the BOLT 12 specification:
|
||||
// 1. Nonce: Ensures cryptographic signature entropy.
|
||||
// 2. Idempotency Key: If the metadata is identical to a previous
|
||||
// request, the receiver may reply with the previously generated
|
||||
// invoice instead of minting a new one.
|
||||
// 3. Message Marker: As Type 0, it canonically sits at the start of
|
||||
// the invoice_request TLV stream.
|
||||
InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob]
|
||||
|
||||
// InvreqChain is the chain that the payer is using for this request.
|
||||
InvreqChain tlv.OptionalRecordT[tlv.TlvType80, [32]byte]
|
||||
|
||||
// InvreqAmount is the amount the payer is offering to pay.
|
||||
InvreqAmount tlv.OptionalRecordT[tlv.TlvType82, TUint64]
|
||||
|
||||
// InvreqFeatures are the features provided by the payer.
|
||||
InvreqFeatures tlv.OptionalRecordT[
|
||||
tlv.TlvType84, lnwire.RawFeatureVector,
|
||||
]
|
||||
|
||||
// InvreqQuantity is the quantity of the offer item being requested.
|
||||
InvreqQuantity tlv.OptionalRecordT[tlv.TlvType86, TUint64]
|
||||
|
||||
// InvreqPayerID is the public key the payer uses to sign the request.
|
||||
InvreqPayerID tlv.OptionalRecordT[tlv.TlvType88, *btcec.PublicKey]
|
||||
|
||||
// InvreqPayerNote is an optional note from the payer.
|
||||
InvreqPayerNote tlv.OptionalRecordT[tlv.TlvType89, tlv.Blob]
|
||||
|
||||
// InvreqPaths are the blinded paths the payer wants the invoice to be
|
||||
// sent to.
|
||||
InvreqPaths tlv.OptionalRecordT[tlv.TlvType90, lnwire.BlindedPaths]
|
||||
|
||||
// InvreqBip353Name is the BIP 353 name of the payer.
|
||||
InvreqBip353Name tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]
|
||||
|
||||
// Signature is a BIP-340 Schnorr signature covering all fields.
|
||||
Signature tlv.OptionalRecordT[tlv.TlvType240, [64]byte]
|
||||
|
||||
// decodedTLVs is the canonical TypeMap produced by the typed- stream
|
||||
// pass that decoded this request. See Offer.decodedTLVs for the design
|
||||
// rationale.
|
||||
decodedTLVs tlv.TypeMap
|
||||
}
|
||||
|
||||
// AllRecords returns the canonical sorted record list for this invoice request,
|
||||
// merging the typed records with any extra signed-range fields that the decoder
|
||||
// preserved.
|
||||
//
|
||||
// NOTE: this is part of the tlv.PureTLVMessage interface.
|
||||
func (ir *InvoiceRequest) AllRecords() []tlv.Record {
|
||||
return allRecordsFromTypeMap(
|
||||
ir.allRecordProducers(), ir.decodedTLVs,
|
||||
)
|
||||
}
|
||||
|
||||
var _ lnwire.PureTLVMessage = (*InvoiceRequest)(nil)
|
||||
|
||||
// allRecordProducers returns the set of records that are present.
|
||||
func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer {
|
||||
var p []tlv.RecordProducer
|
||||
|
||||
lnwire.AddOpt(&p, ir.InvreqMetadata)
|
||||
lnwire.AddOpt(&p, ir.OfferChains)
|
||||
lnwire.AddOpt(&p, ir.OfferMetadata)
|
||||
lnwire.AddOpt(&p, ir.OfferCurrency)
|
||||
lnwire.AddOpt(&p, ir.OfferAmount)
|
||||
lnwire.AddOpt(&p, ir.OfferDescription)
|
||||
lnwire.AddOpt(&p, ir.OfferFeatures)
|
||||
lnwire.AddOpt(&p, ir.OfferAbsoluteExpiry)
|
||||
lnwire.AddOpt(&p, ir.OfferPaths)
|
||||
lnwire.AddOpt(&p, ir.OfferIssuer)
|
||||
lnwire.AddOpt(&p, ir.OfferQuantityMax)
|
||||
lnwire.AddOpt(&p, ir.OfferIssuerID)
|
||||
lnwire.AddOpt(&p, ir.InvreqChain)
|
||||
lnwire.AddOpt(&p, ir.InvreqAmount)
|
||||
lnwire.AddOpt(&p, ir.InvreqFeatures)
|
||||
lnwire.AddOpt(&p, ir.InvreqQuantity)
|
||||
lnwire.AddOpt(&p, ir.InvreqPayerID)
|
||||
lnwire.AddOpt(&p, ir.InvreqPayerNote)
|
||||
lnwire.AddOpt(&p, ir.InvreqPaths)
|
||||
lnwire.AddOpt(&p, ir.InvreqBip353Name)
|
||||
lnwire.AddOpt(&p, ir.Signature)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Encode validates the invoice request per writer requirements and serialises
|
||||
// it via the PureTLVMessage shape.
|
||||
func (ir *InvoiceRequest) Encode() ([]byte, error) {
|
||||
if err := ValidateInvoiceRequestWrite(ir); err != nil {
|
||||
return nil, fmt.Errorf("validate invoice request: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := lnwire.EncodePureTLVMessage(ir, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecodeInvoiceRequest deserializes an invoice request from a TLV byte stream.
|
||||
// Decoding is permissive: callers that need spec compliance must run
|
||||
// ValidateInvoiceRequestRead.
|
||||
func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) {
|
||||
var ir InvoiceRequest
|
||||
|
||||
invreqMetadata := tlv.ZeroRecordT[tlv.TlvType0, tlv.Blob]()
|
||||
chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]()
|
||||
metadata := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]()
|
||||
currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]()
|
||||
amount := tlv.ZeroRecordT[tlv.TlvType8, TUint64]()
|
||||
desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]()
|
||||
features := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]()
|
||||
expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]()
|
||||
paths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]()
|
||||
issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]()
|
||||
qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]()
|
||||
issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]()
|
||||
invreqChain := tlv.ZeroRecordT[tlv.TlvType80, [32]byte]()
|
||||
invreqAmount := tlv.ZeroRecordT[tlv.TlvType82, TUint64]()
|
||||
invreqFeatures := tlv.ZeroRecordT[
|
||||
tlv.TlvType84, lnwire.RawFeatureVector,
|
||||
]()
|
||||
invreqQty := tlv.ZeroRecordT[tlv.TlvType86, TUint64]()
|
||||
payerID := tlv.ZeroRecordT[tlv.TlvType88, *btcec.PublicKey]()
|
||||
payerNote := tlv.ZeroRecordT[tlv.TlvType89, tlv.Blob]()
|
||||
invreqPaths := tlv.ZeroRecordT[tlv.TlvType90, lnwire.BlindedPaths]()
|
||||
bip353 := tlv.ZeroRecordT[tlv.TlvType91, tlv.Blob]()
|
||||
sig := tlv.ZeroRecordT[tlv.TlvType240, [64]byte]()
|
||||
|
||||
tm, err := decodeStream(
|
||||
data, invreqMetadata.Record(), chains.Record(),
|
||||
metadata.Record(), currency.Record(), amount.Record(),
|
||||
desc.Record(), features.Record(), expiry.Record(),
|
||||
paths.Record(), issuer.Record(), qtyMax.Record(),
|
||||
issuerID.Record(), invreqChain.Record(), invreqAmount.Record(),
|
||||
invreqFeatures.Record(), invreqQty.Record(), payerID.Record(),
|
||||
payerNote.Record(), invreqPaths.Record(), bip353.Record(),
|
||||
sig.Record(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode invoice request: %w", err)
|
||||
}
|
||||
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqMetadata, invreqMetadata)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferChains, chains)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferMetadata, metadata)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferCurrency, currency)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferAmount, amount)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferDescription, desc)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferFeatures, features)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferAbsoluteExpiry, expiry)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferPaths, paths)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferIssuer, issuer)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferQuantityMax, qtyMax)
|
||||
lnwire.SetOptFromMap(tm, &ir.OfferIssuerID, issuerID)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqChain, invreqChain)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqAmount, invreqAmount)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqFeatures, invreqFeatures)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqQuantity, invreqQty)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqPayerID, payerID)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqPayerNote, payerNote)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqPaths, invreqPaths)
|
||||
lnwire.SetOptFromMap(tm, &ir.InvreqBip353Name, bip353)
|
||||
lnwire.SetOptFromMap(tm, &ir.Signature, sig)
|
||||
|
||||
ir.decodedTLVs = tm
|
||||
|
||||
return &ir, nil
|
||||
}
|
||||
|
||||
// NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying
|
||||
// (mirroring) all fields from the provided Offer. It assigns the payer ID and
|
||||
// payer metadata; the caller should subsequently sign the request.
|
||||
//
|
||||
// Per "MUST copy all fields from the offer (including unknown fields)", the
|
||||
// offer's unknown TLVs are carried via the decodedTLVs sidecar so they are
|
||||
// signed and mirrored into the invoice. Note that because unknown even TLV
|
||||
// types in the offer would have already been rejected by ValidateOfferRead, any
|
||||
// unknown TLVs mirrored here are guaranteed to be unknown odd TLVs ("it's ok to
|
||||
// be odd") which are safe to ignore and carry forward.
|
||||
//
|
||||
// chain is the genesis hash the payer intends to pay on. invreq_chain is set
|
||||
// only when chain is not Bitcoin mainnet (absent defaults to mainnet); writer
|
||||
// validation enforces that it is one of the offer's chains.
|
||||
func NewInvoiceRequestFromOffer(offer *Offer, payerID *btcec.PublicKey,
|
||||
metadata []byte, chain [32]byte) (*InvoiceRequest, error) {
|
||||
|
||||
if payerID == nil {
|
||||
return nil, ErrMissingPayerID
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return nil, ErrMissingMetadata
|
||||
}
|
||||
|
||||
ir := &InvoiceRequest{
|
||||
OfferChains: offer.OfferChains,
|
||||
OfferMetadata: offer.OfferMetadata,
|
||||
OfferCurrency: offer.OfferCurrency,
|
||||
OfferAmount: offer.OfferAmount,
|
||||
OfferDescription: offer.OfferDescription,
|
||||
OfferFeatures: offer.OfferFeatures,
|
||||
OfferAbsoluteExpiry: offer.OfferAbsoluteExpiry,
|
||||
OfferPaths: offer.OfferPaths,
|
||||
OfferIssuer: offer.OfferIssuer,
|
||||
OfferQuantityMax: offer.OfferQuantityMax,
|
||||
OfferIssuerID: offer.OfferIssuerID,
|
||||
|
||||
InvreqPayerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType88](payerID),
|
||||
),
|
||||
InvreqMetadata: tlv.SomeRecordT(
|
||||
tlv.RecordT[tlv.TlvType0, tlv.Blob]{
|
||||
Val: metadata,
|
||||
},
|
||||
),
|
||||
|
||||
// Carry the offer's unknown signed-range TLVs. Known offer
|
||||
// types appear in the map with nil values and are skipped when
|
||||
// the sidecar is merged, so this re-emits only the unknowns and
|
||||
// never duplicates the typed fields copied above.
|
||||
decodedTLVs: maps.Clone(offer.decodedTLVs),
|
||||
}
|
||||
|
||||
// Set invreq_chain only for non-bitcoin chains; for bitcoin mainnet the
|
||||
// spec says SHOULD omit, and an absent invreq_chain defaults back to
|
||||
// mainnet on the read side.
|
||||
if chain != bitcoinMainnetGenesisHash {
|
||||
ir.InvreqChain = tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType80, [32]byte](chain),
|
||||
)
|
||||
}
|
||||
|
||||
return ir, nil
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInvoiceRequestRoundTrip pins encode→decode→re-encode for an
|
||||
// InvoiceRequest with a representative subset of optional fields.
|
||||
func TestInvoiceRequestRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, bobPub := bobKey()
|
||||
|
||||
metadata := tlv.Blob("payer-metadata")
|
||||
|
||||
ir := &InvoiceRequest{
|
||||
OfferDescription: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](
|
||||
tlv.Blob("description"),
|
||||
),
|
||||
),
|
||||
InvreqPayerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub),
|
||||
),
|
||||
InvreqMetadata: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType0](metadata),
|
||||
),
|
||||
InvreqAmount: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
|
||||
),
|
||||
Signature: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType240](
|
||||
[64]byte{0x01},
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
encoded, err := ir.Encode()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, encoded)
|
||||
|
||||
decoded, err := DecodeInvoiceRequest(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(
|
||||
t, bobPub.SerializeCompressed(),
|
||||
decoded.InvreqPayerID.UnwrapOrFailV(t).SerializeCompressed(),
|
||||
)
|
||||
require.Equal(t, metadata, decoded.InvreqMetadata.UnwrapOrFailV(t))
|
||||
require.Equal(
|
||||
t, TUint64(1000), decoded.InvreqAmount.UnwrapOrFailV(t),
|
||||
)
|
||||
|
||||
reencoded, err := decoded.Encode()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encoded, reencoded)
|
||||
}
|
||||
|
||||
// TestNewInvoiceRequestFromOffer tests the constructor for mirroring all offer
|
||||
// fields and properly assigning the payer ID and metadata.
|
||||
func TestNewInvoiceRequestFromOffer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
offer := validBobOffer(t)
|
||||
|
||||
// Add some optional offer fields for mirroring verification.
|
||||
offer.OfferDescription = tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("description")),
|
||||
)
|
||||
offer.OfferAmount = tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType8, TUint64](5000),
|
||||
)
|
||||
|
||||
priv, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
payerID := priv.PubKey()
|
||||
metadata := []byte("payer-metadata")
|
||||
|
||||
ir, err := NewInvoiceRequestFromOffer(
|
||||
offer, payerID, metadata, bitcoinMainnetGenesisHash,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ir)
|
||||
|
||||
// Verify offer fields are copied exactly
|
||||
require.Equal(t, offer.OfferIssuerID, ir.OfferIssuerID)
|
||||
require.Equal(t, offer.OfferDescription, ir.OfferDescription)
|
||||
require.Equal(t, offer.OfferAmount, ir.OfferAmount)
|
||||
|
||||
// Verify payer ID and metadata are set correctly
|
||||
require.Equal(t, payerID, ir.InvreqPayerID.UnwrapOrFailV(t))
|
||||
require.Equal(t, metadata, ir.InvreqMetadata.UnwrapOrFailV(t))
|
||||
|
||||
// For Bitcoin mainnet the spec says SHOULD omit invreq_chain.
|
||||
require.False(t, ir.InvreqChain.IsSome())
|
||||
|
||||
// A non-bitcoin chain must be set explicitly so it does not default
|
||||
// back to mainnet on the read side.
|
||||
var altChain [32]byte
|
||||
for i := range altChain {
|
||||
altChain[i] = 0xab
|
||||
}
|
||||
irAlt, err := NewInvoiceRequestFromOffer(
|
||||
offer, payerID, metadata, altChain,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, altChain, irAlt.InvreqChain.UnwrapOrFailV(t))
|
||||
}
|
||||
|
||||
// TestNewInvoiceRequestFromOfferMirrorsUnknownFields verifies the writer
|
||||
// requirement "MUST copy all fields from the offer (including unknown fields)":
|
||||
// an unknown odd TLV in the offer's signed range must survive into the
|
||||
// constructed request's record set so it is signed and later mirrored into the
|
||||
// invoice.
|
||||
func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, pub := bobKey()
|
||||
|
||||
// Build a minimal valid offer, encode it, then splice in an unknown odd
|
||||
// TLV (type 33, within the offer signed range) and decode it
|
||||
// back so the unknown lands in the offer's decodedTLVs sidecar.
|
||||
offer := &Offer{
|
||||
OfferDescription: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("desc")),
|
||||
),
|
||||
OfferIssuerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType22](pub),
|
||||
),
|
||||
}
|
||||
encoded, err := offer.Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
const unknownType = 33
|
||||
unknownVal := []byte("xyz")
|
||||
var extra bytes.Buffer
|
||||
require.NoError(t, tlv.WriteVarInt(&extra, unknownType, &[8]byte{}))
|
||||
require.NoError(t, tlv.WriteVarInt(
|
||||
&extra, uint64(len(unknownVal)), &[8]byte{},
|
||||
))
|
||||
extra.Write(unknownVal)
|
||||
|
||||
// TLV records are canonically ordered by type; type 33 sorts after the
|
||||
// offer's existing types (10, 22), so appending keeps the stream
|
||||
// sorted.
|
||||
spliced := append(append([]byte{}, encoded...), extra.Bytes()...)
|
||||
|
||||
decodedOffer, err := decodeOffer(spliced)
|
||||
require.NoError(t, err)
|
||||
|
||||
ir, err := NewInvoiceRequestFromOffer(
|
||||
decodedOffer, pub, []byte("metadata"),
|
||||
bitcoinMainnetGenesisHash,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The unknown field must appear in the request's canonical record set.
|
||||
var found bool
|
||||
for _, r := range ir.AllRecords() {
|
||||
if r.Type() == unknownType {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "unknown offer TLV not mirrored into request")
|
||||
}
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// validInvoice returns an Invoice populated with the minimum set of fields
|
||||
// required to satisfy ValidateInvoiceWrite.
|
||||
func validInvoice(t *testing.T) *Invoice {
|
||||
t.Helper()
|
||||
|
||||
_, pub := bobKey()
|
||||
|
||||
var payHash [32]byte
|
||||
for i := range payHash {
|
||||
payHash[i] = byte(i)
|
||||
}
|
||||
|
||||
_, intro := aliceKey()
|
||||
_, blinding := bobKey()
|
||||
_, hopPub := aliceKey()
|
||||
|
||||
introNode, err := lnwire.NewPubkeyIntro(intro)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &Invoice{
|
||||
InvoiceCreatedAt: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType164, TUint64](
|
||||
TUint64(1234567890),
|
||||
),
|
||||
),
|
||||
InvoiceAmount: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType170, TUint64](
|
||||
TUint64(100_000),
|
||||
),
|
||||
),
|
||||
InvoicePaymentHash: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType168, [32]byte](
|
||||
payHash,
|
||||
),
|
||||
),
|
||||
InvoiceNodeID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType176](pub),
|
||||
),
|
||||
InvoicePaths: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths](
|
||||
lnwire.BlindedPaths{
|
||||
Paths: []lnwire.BlindedPath{{
|
||||
IntroductionNode: introNode,
|
||||
BlindingPoint: blinding,
|
||||
Hops: []lnwire.BlindedHop{{
|
||||
BlindedNodeID: hopPub,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
),
|
||||
),
|
||||
InvoiceBlindedPay: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos](
|
||||
BlindedPayInfos{Infos: []BlindedPayInfo{{}}},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsableFallbackAddresses pins the BOLT 12 ignore semantics for
|
||||
// invoice_fallbacks.
|
||||
func TestUsableFallbackAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addrs := []FallbackAddress{
|
||||
// Valid: version 0, 2 bytes.
|
||||
{Version: 0, Address: []byte{0x01, 0x02}},
|
||||
// Invalid: version 17, 2 bytes. Version is not supported.
|
||||
{Version: 17, Address: []byte{0x01, 0x02}},
|
||||
// Invalid: version 0, 1 byte. Address is too short.
|
||||
{Version: 0, Address: []byte{0x01}},
|
||||
// Invalid: version 0, 41 bytes. Address is too long.
|
||||
{Version: 0, Address: make([]byte, 41)},
|
||||
// Valid: version 16, 40 bytes.
|
||||
{Version: 16, Address: make([]byte, 40)},
|
||||
}
|
||||
inv := &Invoice{
|
||||
InvoiceFallbacks: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType172, FallbackAddresses](
|
||||
FallbackAddresses{Addrs: addrs},
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
got := inv.UsableFallbackAddresses()
|
||||
require.Len(t, got, 2)
|
||||
require.Equal(t, byte(0), got[0].Version)
|
||||
require.Equal(t, byte(16), got[1].Version)
|
||||
require.Len(t, got[1].Address, 40)
|
||||
}
|
||||
|
||||
// TestUsablePaths pins the BOLT 12 reader filter that excludes any blinded path
|
||||
// whose payinfo.features carries an unknown required (even) bit, and confirms
|
||||
// each surviving entry is paired with its own payinfo by index.
|
||||
func TestUsablePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, blinding := bobKey()
|
||||
_, hopPub := aliceKey()
|
||||
_, intro := aliceKey()
|
||||
introNode, err := lnwire.NewPubkeyIntro(intro)
|
||||
require.NoError(t, err)
|
||||
|
||||
// hop builds a minimal single-hop blinded path; two of these populate
|
||||
// invoice_paths so the by-index pairing with payinfos can be observed.
|
||||
hop := lnwire.BlindedPath{
|
||||
IntroductionNode: introNode,
|
||||
BlindingPoint: blinding,
|
||||
Hops: []lnwire.BlindedHop{{BlindedNodeID: hopPub}},
|
||||
}
|
||||
pathsRecord := func(n int) tlv.OptionalRecordT[
|
||||
tlv.TlvType160, lnwire.BlindedPaths,
|
||||
] {
|
||||
|
||||
paths := make([]lnwire.BlindedPath, n)
|
||||
for i := range paths {
|
||||
paths[i] = hop
|
||||
}
|
||||
|
||||
return tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths](
|
||||
lnwire.BlindedPaths{Paths: paths},
|
||||
),
|
||||
)
|
||||
}
|
||||
payRecord := func(infos ...BlindedPayInfo) tlv.OptionalRecordT[
|
||||
tlv.TlvType162, BlindedPayInfos,
|
||||
] {
|
||||
|
||||
return tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos](
|
||||
BlindedPayInfos{Infos: infos},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The first payinfo carries an unknown required feature bit
|
||||
// (MPPRequired); the second is featureless.
|
||||
required := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
|
||||
inv := &Invoice{
|
||||
InvoicePaths: pathsRecord(2),
|
||||
InvoiceBlindedPay: payRecord(
|
||||
BlindedPayInfo{FeeBaseMsat: 1, Features: required},
|
||||
BlindedPayInfo{FeeBaseMsat: 2},
|
||||
),
|
||||
}
|
||||
|
||||
// Empty catalogue: the MPPRequired bit is unknown, so path 0 is
|
||||
// filtered out and only path 1 (fee_base 2) survives.
|
||||
got := inv.UsablePaths(nil)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, uint32(2), got[0].PayInfo.FeeBaseMsat)
|
||||
|
||||
// Once the bit is known, both paths become usable and stay paired with
|
||||
// their own payinfo in order.
|
||||
known := map[lnwire.FeatureBit]string{lnwire.MPPRequired: "mpp"}
|
||||
got = inv.UsablePaths(known)
|
||||
require.Len(t, got, 2)
|
||||
require.Equal(t, uint32(1), got[0].PayInfo.FeeBaseMsat)
|
||||
require.Equal(t, uint32(2), got[1].PayInfo.FeeBaseMsat)
|
||||
|
||||
// A length mismatch between paths and payinfos yields no usable paths
|
||||
// (rejected upstream by ValidateInvoiceRead).
|
||||
inv.InvoiceBlindedPay = payRecord(BlindedPayInfo{})
|
||||
require.Empty(t, inv.UsablePaths(known))
|
||||
}
|
||||
|
||||
// TestInvoiceRoundTripPreservesAllTypes encodes a fully populated invoice then
|
||||
// decodes it back, asserting every field is preserved byte-for-byte. The codec
|
||||
// promises bijection on the message level, and any drift (dropped record,
|
||||
// re-ordered output) breaks downstream signature verification because the
|
||||
// Merkle root depends on the exact raw TLV stream.
|
||||
func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := validInvoice(t)
|
||||
inv.Signature = tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}),
|
||||
)
|
||||
|
||||
encoded, err := inv.Encode()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, encoded)
|
||||
|
||||
decoded, err := DecodeInvoice(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash,
|
||||
InvoiceFeatureCatalogues{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-encode the decoded copy and confirm canonicality.
|
||||
// decode(encode(decode(encode(x)))) must equal decode(encode(x)).
|
||||
encoded2, err := decoded.Encode()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encoded, encoded2)
|
||||
}
|
||||
|
||||
// TestDecodeInvoiceRejectsTruncated locks in that DecodeInvoice surfaces an
|
||||
// error when fed a truncated TLV stream rather than returning a partial
|
||||
// Invoice. A silent partial-decode would let validation see fields that weren't
|
||||
// actually on the wire.
|
||||
func TestDecodeInvoiceRejectsTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := validInvoice(t)
|
||||
encoded, err := inv.Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Chop off the last byte. The truncation lands in the middle of the
|
||||
// final blinded_pay record's variable-length payload.
|
||||
truncated := encoded[:len(encoded)-1]
|
||||
|
||||
_, err = DecodeInvoice(truncated)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestNewInvoiceFromRequest verifies the constructor mirrors all non-signature
|
||||
// invoice_request fields into the invoice, applies the invreq_amount ->
|
||||
// invoice_amount writer rule, and does not copy the request's signature.
|
||||
func TestNewInvoiceFromRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, bobPub := bobKey()
|
||||
_, alicePub := aliceKey()
|
||||
|
||||
req := &InvoiceRequest{
|
||||
OfferDescription: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](
|
||||
tlv.Blob("description"),
|
||||
),
|
||||
),
|
||||
OfferIssuerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType22](alicePub),
|
||||
),
|
||||
InvreqMetadata: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType0](
|
||||
tlv.Blob("payer-metadata"),
|
||||
),
|
||||
),
|
||||
InvreqPayerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub),
|
||||
),
|
||||
InvreqAmount: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType82, TUint64](2500),
|
||||
),
|
||||
Signature: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}),
|
||||
),
|
||||
}
|
||||
|
||||
inv := NewInvoiceFromRequest(req)
|
||||
require.NotNil(t, inv)
|
||||
|
||||
// Non-signature request fields are mirrored exactly.
|
||||
require.Equal(t, req.OfferDescription, inv.OfferDescription)
|
||||
require.Equal(t, req.OfferIssuerID, inv.OfferIssuerID)
|
||||
require.Equal(t, req.InvreqMetadata, inv.InvreqMetadata)
|
||||
require.Equal(t, req.InvreqPayerID, inv.InvreqPayerID)
|
||||
require.Equal(t, req.InvreqAmount, inv.InvreqAmount)
|
||||
|
||||
// invreq_amount is mirrored into invoice_amount per the writer rule.
|
||||
require.Equal(t, TUint64(2500), inv.InvoiceAmount.UnwrapOrFailV(t))
|
||||
|
||||
// The request's signature is not copied. The invoice signs its own.
|
||||
require.True(t, inv.Signature.IsNone())
|
||||
}
|
||||
|
||||
// TestNewInvoiceFromRequestMirrorsUnknownFields verifies the writer requirement
|
||||
// "MUST copy all non-signature fields from the invoice request (including
|
||||
// unknown fields)": an unknown odd TLV in the request's signed range must
|
||||
// survive into the constructed invoice's canonical record set so it is signed.
|
||||
func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, bobPub := bobKey()
|
||||
|
||||
// Build a minimal valid spontaneous request and encode it.
|
||||
req := &InvoiceRequest{
|
||||
OfferDescription: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("desc")),
|
||||
),
|
||||
InvreqMetadata: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("meta")),
|
||||
),
|
||||
InvreqPayerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub),
|
||||
),
|
||||
InvreqAmount: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
|
||||
),
|
||||
}
|
||||
encoded, err := req.Encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fill in an unknown odd TLV (type 93, within the invreq signed range
|
||||
// and above the request's existing types) so the spliced stream stays
|
||||
// canonically sorted and the unknown lands in the decoded request's
|
||||
// decodedTLVs sidecar.
|
||||
const unknownType = 93
|
||||
unknownVal := []byte("xyz")
|
||||
var extra bytes.Buffer
|
||||
require.NoError(t, tlv.WriteVarInt(&extra, unknownType, &[8]byte{}))
|
||||
require.NoError(t, tlv.WriteVarInt(
|
||||
&extra, uint64(len(unknownVal)), &[8]byte{},
|
||||
))
|
||||
extra.Write(unknownVal)
|
||||
|
||||
spliced := append(append([]byte{}, encoded...), extra.Bytes()...)
|
||||
|
||||
decodedReq, err := DecodeInvoiceRequest(spliced)
|
||||
require.NoError(t, err)
|
||||
|
||||
inv := NewInvoiceFromRequest(decodedReq)
|
||||
|
||||
// The unknown field must appear in the invoice's canonical record set
|
||||
// with its value preserved, not just its type.
|
||||
var (
|
||||
found bool
|
||||
gotVal bytes.Buffer
|
||||
)
|
||||
for _, r := range inv.AllRecords() {
|
||||
if r.Type() != unknownType {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
require.NoError(t, r.Encode(&gotVal))
|
||||
}
|
||||
require.True(t, found, "unknown request TLV not mirrored into invoice")
|
||||
require.Equal(
|
||||
t, unknownVal, gotVal.Bytes(),
|
||||
"unknown request TLV value not preserved",
|
||||
)
|
||||
}
|
||||
|
||||
// TestInvoiceEncodeValidationGate verifies that Encode runs
|
||||
// ValidateInvoiceWrite and rejects invalid invoices.
|
||||
func TestInvoiceEncodeValidationGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := validInvoice(t)
|
||||
inv.InvoiceCreatedAt = tlv.OptionalRecordT[
|
||||
tlv.TlvType164, TUint64,
|
||||
]{}
|
||||
|
||||
_, err := inv.Encode()
|
||||
require.ErrorIs(t, err, ErrMissingCreatedAt)
|
||||
}
|
||||
167
bolt12/offer.go
167
bolt12/offer.go
|
|
@ -1,167 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// Offer represents a BOLT 12 offer message. An offer is a long-lived, reusable
|
||||
// payment template that can generate multiple invoices.
|
||||
type Offer struct {
|
||||
// OfferChains specifies which chains this offer is valid for. If
|
||||
// absent, bitcoin is implied.
|
||||
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
|
||||
|
||||
// OfferMetadata is opaque data set by the offer creator for its own
|
||||
// use.
|
||||
OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]
|
||||
|
||||
// OfferCurrency is the ISO 4217 currency code for the offer amount, if
|
||||
// the amount is not in the chain's native unit.
|
||||
OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]
|
||||
|
||||
// OfferAmount is the amount expected per item, encoded as a tu64. The
|
||||
// unit depends on OfferCurrency (msat if absent).
|
||||
OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]
|
||||
|
||||
// OfferDescription is a UTF-8 description of the purpose of the
|
||||
// payment.
|
||||
OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]
|
||||
|
||||
// OfferFeatures is the feature bit vector for this offer.
|
||||
OfferFeatures tlv.OptionalRecordT[tlv.TlvType12,
|
||||
lnwire.RawFeatureVector]
|
||||
|
||||
// OfferAbsoluteExpiry is the time (seconds since epoch) after which the
|
||||
// offer should not be used, encoded as a tu64.
|
||||
OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]
|
||||
|
||||
// OfferPaths contains one or more blinded paths to the offer issuer.
|
||||
OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]
|
||||
|
||||
// OfferIssuer is a UTF-8 string identifying the issuer.
|
||||
OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]
|
||||
|
||||
// OfferQuantityMax is the maximum number of items that can be requested
|
||||
// in a single invoice, encoded as a tu64. A value of 0 means unlimited.
|
||||
OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]
|
||||
|
||||
// OfferIssuerID is the public key of the offer issuer. The codec
|
||||
// parses the 33-byte SEC1 compressed point on decode, so a struct
|
||||
// holding a key has already passed both the length and on-curve
|
||||
// checks.
|
||||
OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]
|
||||
|
||||
// decodedTLVs is the canonical TypeMap produced by decoding this offer.
|
||||
// Handled types map to nil; unhandled types map to their value bytes.
|
||||
// Encoding and validation both derive their view from this single field
|
||||
// so they cannot drift apart, and so signed-range extras the decoder
|
||||
// did not understand are re-emitted on encode and preserve offer_id.
|
||||
decodedTLVs tlv.TypeMap
|
||||
}
|
||||
|
||||
var _ lnwire.PureTLVMessage = (*Offer)(nil)
|
||||
|
||||
// AllRecords returns the canonical sorted record list for this offer, merging
|
||||
// the typed records with any extra signed-range fields that the decoder
|
||||
// preserved.
|
||||
func (o *Offer) AllRecords() []tlv.Record {
|
||||
return allRecordsFromTypeMap(
|
||||
o.allRecordProducers(), o.decodedTLVs,
|
||||
)
|
||||
}
|
||||
|
||||
// allRecordProducers returns record producers for every set optional field, in
|
||||
// declaration order.
|
||||
func (o *Offer) allRecordProducers() []tlv.RecordProducer {
|
||||
var p []tlv.RecordProducer
|
||||
|
||||
lnwire.AddOpt(&p, o.OfferChains)
|
||||
lnwire.AddOpt(&p, o.OfferMetadata)
|
||||
lnwire.AddOpt(&p, o.OfferCurrency)
|
||||
lnwire.AddOpt(&p, o.OfferAmount)
|
||||
lnwire.AddOpt(&p, o.OfferDescription)
|
||||
lnwire.AddOpt(&p, o.OfferFeatures)
|
||||
lnwire.AddOpt(&p, o.OfferAbsoluteExpiry)
|
||||
lnwire.AddOpt(&p, o.OfferPaths)
|
||||
lnwire.AddOpt(&p, o.OfferIssuer)
|
||||
lnwire.AddOpt(&p, o.OfferQuantityMax)
|
||||
lnwire.AddOpt(&p, o.OfferIssuerID)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Encode serialises the offer into a canonical TLV byte stream.
|
||||
func (o *Offer) Encode() ([]byte, error) {
|
||||
if err := ValidateOfferWrite(o); err != nil {
|
||||
return nil, fmt.Errorf("validate offer: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := lnwire.EncodePureTLVMessage(o, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// decodeOffer parses a TLV byte stream into an Offer. Decoding is permissive —
|
||||
// the spec writer requirements are not enforced here, so callers that need a
|
||||
// valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the
|
||||
// returned offer so a later Encode can re-emit signed-range extras and keep
|
||||
// offer_id stable.
|
||||
func decodeOffer(data []byte) (*Offer, error) {
|
||||
var o Offer
|
||||
|
||||
// Prepare zero-valued records for all optional fields so the TLV
|
||||
// decoder can populate them.
|
||||
chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]()
|
||||
metadata := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]()
|
||||
currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]()
|
||||
amount := tlv.ZeroRecordT[tlv.TlvType8, TUint64]()
|
||||
desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]()
|
||||
features := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]()
|
||||
expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]()
|
||||
paths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]()
|
||||
issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]()
|
||||
qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]()
|
||||
issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]()
|
||||
|
||||
tm, err := decodeStream(
|
||||
data,
|
||||
chains.Record(),
|
||||
metadata.Record(),
|
||||
currency.Record(),
|
||||
amount.Record(),
|
||||
desc.Record(),
|
||||
features.Record(),
|
||||
expiry.Record(),
|
||||
paths.Record(),
|
||||
issuer.Record(),
|
||||
qtyMax.Record(),
|
||||
issuerID.Record(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode offer: %w", err)
|
||||
}
|
||||
|
||||
lnwire.SetOptFromMap(tm, &o.OfferChains, chains)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferMetadata, metadata)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferCurrency, currency)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferAmount, amount)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferDescription, desc)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferFeatures, features)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferAbsoluteExpiry, expiry)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferPaths, paths)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferIssuer, issuer)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferQuantityMax, qtyMax)
|
||||
lnwire.SetOptFromMap(tm, &o.OfferIssuerID, issuerID)
|
||||
|
||||
o.decodedTLVs = tm
|
||||
|
||||
return &o, nil
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestOfferRoundTrip pins encode→decode→re-encode for an Offer with a
|
||||
// representative subset of optional fields. A byte-identical re-encode is the
|
||||
// invariant that keeps offer_id stable across the codec boundary.
|
||||
func TestOfferRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
desc := tlv.Blob("coffee")
|
||||
issuer := tlv.Blob("alice")
|
||||
_, bobPub := bobKey()
|
||||
|
||||
o := &Offer{
|
||||
OfferAmount: tlv.SomeRecordT(
|
||||
tlv.NewRecordT[tlv.TlvType8](TUint64(1500)),
|
||||
),
|
||||
OfferDescription: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType10](desc),
|
||||
),
|
||||
OfferIssuer: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType18](issuer),
|
||||
),
|
||||
OfferIssuerID: tlv.SomeRecordT(
|
||||
tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub),
|
||||
),
|
||||
}
|
||||
|
||||
encoded, err := o.Encode()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, encoded)
|
||||
|
||||
decoded, err := decodeOffer(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, TUint64(1500), decoded.OfferAmount.UnwrapOrFailV(t))
|
||||
require.Equal(t, desc, decoded.OfferDescription.UnwrapOrFailV(t))
|
||||
require.Equal(t, issuer, decoded.OfferIssuer.UnwrapOrFailV(t))
|
||||
|
||||
reencoded, err := decoded.Encode()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, encoded, reencoded)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// bolt12InUnsignedRange reports whether a TLV type is excluded from the BOLT 12
|
||||
// Merkle tree. The spec reserves types 240-1000 for signature TLVs (the BIP-340
|
||||
// Schnorr signatures over the tree itself); every other allowed type sits in
|
||||
// the signed range.
|
||||
func bolt12InUnsignedRange(t tlv.Type) bool {
|
||||
return t >= 240 && t <= 1000
|
||||
}
|
||||
|
||||
// allRecordsFromTypeMap merges the typed-record producers with the signed-range
|
||||
// subset of the supplied TypeMap (preserved unknown TLVs) and returns the
|
||||
// canonical sorted record list. The signed-range subset is derived on demand
|
||||
// from the same TypeMap that drives the validators, so the two views cannot
|
||||
// drift apart.
|
||||
func allRecordsFromTypeMap(producers []tlv.RecordProducer,
|
||||
tm tlv.TypeMap) []tlv.Record {
|
||||
|
||||
if len(tm) > 0 {
|
||||
extra := lnwire.ExtraSignedFieldsFromTypeMapFn(
|
||||
tm, bolt12InUnsignedRange,
|
||||
)
|
||||
if len(extra) > 0 {
|
||||
producers = append(
|
||||
producers, lnwire.RecordsAsProducers(
|
||||
tlv.MapToRecords(extra),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return lnwire.ProduceRecordsSorted(producers...)
|
||||
}
|
||||
|
||||
// sortedTypes returns the keys of tm in ascending order. Validators iterate the
|
||||
// result for deterministic out-of-range and unknown-even error messages.
|
||||
func sortedTypes(tm tlv.TypeMap) []tlv.Type {
|
||||
out := make([]tlv.Type, 0, len(tm))
|
||||
for t := range tm {
|
||||
out = append(out, t)
|
||||
}
|
||||
slices.Sort(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,448 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// ErrTooManyChains is returned when offer_chains declares more entries than
|
||||
// maxOfferChains.
|
||||
var ErrTooManyChains = errors.New("offer_chains exceeds maxOfferChains")
|
||||
|
||||
// ErrNonMinimalFeatures is returned when a decoded feature vector is not
|
||||
// canonically (minimally) encoded.
|
||||
var ErrNonMinimalFeatures = errors.New("non-minimal feature vector encoding")
|
||||
|
||||
// ErrTooManyBlindedPayInfos is returned when decoded blinded_payinfo entries
|
||||
// exceed maxBlindedPayInfos.
|
||||
var ErrTooManyBlindedPayInfos = errors.New(
|
||||
"invoice_blindedpay exceeds maxBlindedPayInfos",
|
||||
)
|
||||
|
||||
// ErrInvalidHtlcRange is returned when a decoded blinded_payinfo entry carries
|
||||
// an htlc_minimum_msat greater than its htlc_maximum_msat.
|
||||
var ErrInvalidHtlcRange = errors.New(
|
||||
"blinded_payinfo htlc_minimum_msat exceeds htlc_maximum_msat",
|
||||
)
|
||||
|
||||
// ErrTooManyFallbackAddrs is returned when decoded fallback_address entries
|
||||
// exceed maxFallbackAddrs.
|
||||
var ErrTooManyFallbackAddrs = errors.New(
|
||||
"invoice_fallbacks exceeds maxFallbackAddrs",
|
||||
)
|
||||
|
||||
const (
|
||||
// chainHashLen is the length of a chain hash (32 bytes).
|
||||
chainHashLen = 32
|
||||
|
||||
// maxOfferChains caps decoded offer_chains entries. This is a sanity
|
||||
// check to prevent excessive memory allocation and is not a protocol
|
||||
// limit but a local implementation choice.
|
||||
maxOfferChains = 32
|
||||
|
||||
// maxBlindedPayInfos caps decoded blinded_payinfo entries to prevent
|
||||
// excessive allocation and validation cost.
|
||||
maxBlindedPayInfos = 32
|
||||
|
||||
// maxFallbackAddrs caps decoded fallback_address entries to prevent
|
||||
// excessive allocation and validation cost.
|
||||
maxFallbackAddrs = 32
|
||||
|
||||
// maxFallbackAddrLen bounds the address bytes in a single fallback
|
||||
// entry. The spec encodes the length as a uint16, so 65535 is the
|
||||
// format's ceiling.
|
||||
maxFallbackAddrLen = math.MaxUint16
|
||||
)
|
||||
|
||||
// ChainsRecord holds one or more chain hashes for the offer_chains field.
|
||||
type ChainsRecord struct {
|
||||
Chains [][chainHashLen]byte
|
||||
}
|
||||
|
||||
var _ tlv.RecordProducer = (*ChainsRecord)(nil)
|
||||
|
||||
// Record returns a TLV record for ChainsRecord.
|
||||
func (c *ChainsRecord) Record() tlv.Record {
|
||||
return tlv.MakeDynamicRecord(
|
||||
0, c,
|
||||
func() uint64 {
|
||||
return uint64(len(c.Chains)) * chainHashLen
|
||||
},
|
||||
encodeChainsRecord,
|
||||
decodeChainsRecord,
|
||||
)
|
||||
}
|
||||
|
||||
// encodeChainsRecord writes the chain hashes in sequence, without a count
|
||||
// prefix.
|
||||
func encodeChainsRecord(w io.Writer, val any, _ *[8]byte) error {
|
||||
c, ok := val.(*ChainsRecord)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *ChainsRecord, got %T", val)
|
||||
}
|
||||
|
||||
for _, chain := range c.Chains {
|
||||
if _, err := w.Write(chain[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeChainsRecord caps the count at maxOfferChains to bound allocation.
|
||||
func decodeChainsRecord(r io.Reader, val any, _ *[8]byte, l uint64) error {
|
||||
c, ok := val.(*ChainsRecord)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *ChainsRecord, got %T", val)
|
||||
}
|
||||
|
||||
if l%chainHashLen != 0 {
|
||||
return fmt.Errorf("chains length %d not a multiple of %d", l,
|
||||
chainHashLen)
|
||||
}
|
||||
|
||||
numChains := l / chainHashLen
|
||||
if numChains > maxOfferChains {
|
||||
return fmt.Errorf("%w: %d > %d", ErrTooManyChains, numChains,
|
||||
maxOfferChains)
|
||||
}
|
||||
|
||||
c.Chains = make([][chainHashLen]byte, numChains)
|
||||
for i := range c.Chains {
|
||||
if _, err := io.ReadFull(r, c.Chains[i][:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlindedPayInfo holds the payment parameters for a blinded path, corresponding
|
||||
// to the blinded_payinfo subtype.
|
||||
type BlindedPayInfo struct {
|
||||
// FeeBaseMsat is the base fee, in millisatoshis, charged for relaying a
|
||||
// payment over this blinded path.
|
||||
FeeBaseMsat uint32
|
||||
|
||||
// FeeProportionalMillionths is the proportional fee, in millionths of a
|
||||
// satoshi per relayed satoshi, charged over this blinded path.
|
||||
FeeProportionalMillionths uint32
|
||||
|
||||
// CltvExpiryDelta is the CLTV expiry delta the path requires.
|
||||
CltvExpiryDelta uint16
|
||||
|
||||
// HtlcMinimumMsat is the smallest HTLC, in millisatoshis, the path
|
||||
// accepts.
|
||||
HtlcMinimumMsat uint64
|
||||
|
||||
// HtlcMaximumMsat is the largest HTLC, in millisatoshis, the path
|
||||
// accepts.
|
||||
HtlcMaximumMsat uint64
|
||||
|
||||
// Features is the relay feature bitmap for this blinded path, typed for
|
||||
// consistency with the other BOLT 12 feature fields.
|
||||
//
|
||||
// WARNING: RawFeatureVector re-encodes to minimal length, so setting
|
||||
// non-minimal feature bytes (trailing zeros) yields different wire
|
||||
// bytes than were read and invalidates the invoice signature.
|
||||
Features lnwire.RawFeatureVector
|
||||
}
|
||||
|
||||
// BlindedPayInfos holds a list of BlindedPayInfo entries for the
|
||||
// invoice_blindedpay field.
|
||||
type BlindedPayInfos struct {
|
||||
Infos []BlindedPayInfo
|
||||
}
|
||||
|
||||
// Record returns a TLV record for BlindedPayInfos.
|
||||
//
|
||||
// NOTE: This implements the tlv.RecordProducer interface.
|
||||
func (bp *BlindedPayInfos) Record() tlv.Record {
|
||||
return tlv.MakeDynamicRecord(
|
||||
0, bp,
|
||||
func() uint64 {
|
||||
return blindedPayInfosSize(bp)
|
||||
},
|
||||
encodeBlindedPayInfos, decodeBlindedPayInfos,
|
||||
)
|
||||
}
|
||||
|
||||
// blindedPayInfosSize returns the encoded byte length of all blinded_payinfo
|
||||
// entries, used to size the dynamic TLV record.
|
||||
func blindedPayInfosSize(bp *BlindedPayInfos) uint64 {
|
||||
var size uint64
|
||||
for _, info := range bp.Infos {
|
||||
// fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) +
|
||||
// htlc_max(8) + flen(2) + features.
|
||||
size += 4 + 4 + 2 + 8 + 8 + 2 +
|
||||
uint64(info.Features.SerializeSize())
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// encodeBlindedPayInfos writes each blinded_payinfo entry in sequence: the
|
||||
// fixed fee, cltv and htlc fields followed by a u16-length-prefixed feature
|
||||
// vector. Entries are concatenated without a count prefix; the count is
|
||||
// recovered on decode from the surrounding invoice_paths length.
|
||||
func encodeBlindedPayInfos(
|
||||
w io.Writer, val interface{}, buf *[8]byte) error {
|
||||
|
||||
bp, ok := val.(*BlindedPayInfos)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
|
||||
}
|
||||
|
||||
for _, info := range bp.Infos {
|
||||
binary.BigEndian.PutUint32(buf[:4], info.FeeBaseMsat)
|
||||
if _, err := w.Write(buf[:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint32(
|
||||
buf[:4], info.FeeProportionalMillionths,
|
||||
)
|
||||
if _, err := w.Write(buf[:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(buf[:2], info.CltvExpiryDelta)
|
||||
if _, err := w.Write(buf[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint64(buf[:8], info.HtlcMinimumMsat)
|
||||
if _, err := w.Write(buf[:8]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint64(buf[:8], info.HtlcMaximumMsat)
|
||||
if _, err := w.Write(buf[:8]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// flen is a u16, so guard the cast before framing the minimal
|
||||
// feature bytes, mirroring encodeFallbackAddrs.
|
||||
flen := info.Features.SerializeSize()
|
||||
if flen > math.MaxUint16 {
|
||||
return fmt.Errorf("features %d exceed limit %d",
|
||||
flen, math.MaxUint16)
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(buf[:2], uint16(flen))
|
||||
if _, err := w.Write(buf[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := info.Features.EncodeBase256(w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBlindedPayInfos reads blinded_payinfo entries until the record bytes
|
||||
// are exhausted. The entry count is capped at maxBlindedPayInfos to prevent
|
||||
// excessive memory allocation and validation cost.
|
||||
func decodeBlindedPayInfos(
|
||||
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {
|
||||
|
||||
bp, ok := val.(*BlindedPayInfos)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
|
||||
}
|
||||
|
||||
lr := &io.LimitedReader{R: r, N: int64(l)}
|
||||
|
||||
for lr.N > 0 {
|
||||
if len(bp.Infos) >= maxBlindedPayInfos {
|
||||
return ErrTooManyBlindedPayInfos
|
||||
}
|
||||
|
||||
var info BlindedPayInfo
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:4]); err != nil {
|
||||
return fmt.Errorf("read fee_base: %w", err)
|
||||
}
|
||||
info.FeeBaseMsat = binary.BigEndian.Uint32(buf[:4])
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:4]); err != nil {
|
||||
return fmt.Errorf("read fee_prop: %w", err)
|
||||
}
|
||||
info.FeeProportionalMillionths = binary.BigEndian.Uint32(
|
||||
buf[:4],
|
||||
)
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
|
||||
return fmt.Errorf("read cltv_delta: %w", err)
|
||||
}
|
||||
info.CltvExpiryDelta = binary.BigEndian.Uint16(buf[:2])
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:8]); err != nil {
|
||||
return fmt.Errorf("read htlc_min: %w", err)
|
||||
}
|
||||
info.HtlcMinimumMsat = binary.BigEndian.Uint64(buf[:8])
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:8]); err != nil {
|
||||
return fmt.Errorf("read htlc_max: %w", err)
|
||||
}
|
||||
info.HtlcMaximumMsat = binary.BigEndian.Uint64(buf[:8])
|
||||
|
||||
// Defense-in-depth decode check, mirroring the
|
||||
// ErrNonMinimalFeatures guard below: reject an inverted HTLC
|
||||
// range so the htlc_min <= htlc_max invariant holds for every
|
||||
// downstream consumer instead of being re-derived per caller.
|
||||
if info.HtlcMinimumMsat > info.HtlcMaximumMsat {
|
||||
return ErrInvalidHtlcRange
|
||||
}
|
||||
|
||||
// flen then features, mirroring decodeFallbackAddrs: reject a
|
||||
// length that overruns the remaining bytes before allocating.
|
||||
// Decode into a constructed vector so its map is initialised.
|
||||
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
|
||||
return fmt.Errorf("read flen: %w", err)
|
||||
}
|
||||
flen := binary.BigEndian.Uint16(buf[:2])
|
||||
if int64(flen) > lr.N {
|
||||
return fmt.Errorf("flen %d exceeds remaining %d",
|
||||
flen, lr.N)
|
||||
}
|
||||
|
||||
fv := lnwire.NewRawFeatureVector()
|
||||
if err := fv.DecodeBase256(lr, int(flen)); err != nil {
|
||||
return fmt.Errorf("read features: %w", err)
|
||||
}
|
||||
if fv.SerializeSize() != int(flen) {
|
||||
return ErrNonMinimalFeatures
|
||||
}
|
||||
info.Features = *fv
|
||||
|
||||
bp.Infos = append(bp.Infos, info)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FallbackAddress represents an on-chain fallback address.
|
||||
type FallbackAddress struct {
|
||||
Version byte
|
||||
Address []byte
|
||||
}
|
||||
|
||||
// FallbackAddresses holds a list of fallback addresses for the
|
||||
// invoice_fallbacks field.
|
||||
type FallbackAddresses struct {
|
||||
Addrs []FallbackAddress
|
||||
}
|
||||
|
||||
// Record returns a TLV record for FallbackAddresses.
|
||||
//
|
||||
// NOTE: This implements the tlv.RecordProducer interface.
|
||||
func (fa *FallbackAddresses) Record() tlv.Record {
|
||||
return tlv.MakeDynamicRecord(
|
||||
0, fa,
|
||||
func() uint64 {
|
||||
return fallbackAddrsSize(fa)
|
||||
},
|
||||
encodeFallbackAddrs, decodeFallbackAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
// fallbackAddrsSize returns the encoded byte length of all fallback_address
|
||||
// entries, used to size the dynamic TLV record.
|
||||
func fallbackAddrsSize(fa *FallbackAddresses) uint64 {
|
||||
var size uint64
|
||||
for _, a := range fa.Addrs {
|
||||
// version(1) + len(2) + address
|
||||
size += 1 + 2 + uint64(len(a.Address))
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// encodeFallbackAddrs writes each fallback_address entry as a version byte, a
|
||||
// u16 address length and the raw address bytes, concatenated without a count
|
||||
// prefix.
|
||||
func encodeFallbackAddrs(
|
||||
w io.Writer, val interface{}, buf *[8]byte) error {
|
||||
|
||||
fa, ok := val.(*FallbackAddresses)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
|
||||
}
|
||||
|
||||
for i, a := range fa.Addrs {
|
||||
if len(a.Address) > maxFallbackAddrLen {
|
||||
return fmt.Errorf("fallback %d: address %d exceeds "+
|
||||
"limit %d", i, len(a.Address),
|
||||
maxFallbackAddrLen)
|
||||
}
|
||||
|
||||
buf[0] = a.Version
|
||||
if _, err := w.Write(buf[:1]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(buf[:2], uint16(len(a.Address)))
|
||||
if _, err := w.Write(buf[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(a.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeFallbackAddrs reads fallback_address entries until the record bytes are
|
||||
// exhausted. The entry count is capped at maxFallbackAddrs to prevent
|
||||
// excessive memory allocation and validation cost.
|
||||
func decodeFallbackAddrs(
|
||||
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {
|
||||
|
||||
fa, ok := val.(*FallbackAddresses)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
|
||||
}
|
||||
|
||||
lr := &io.LimitedReader{R: r, N: int64(l)}
|
||||
|
||||
for lr.N > 0 {
|
||||
if len(fa.Addrs) >= maxFallbackAddrs {
|
||||
return ErrTooManyFallbackAddrs
|
||||
}
|
||||
|
||||
var a FallbackAddress
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:1]); err != nil {
|
||||
return fmt.Errorf("read version: %w", err)
|
||||
}
|
||||
a.Version = buf[0]
|
||||
|
||||
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
|
||||
return fmt.Errorf("read addrlen: %w", err)
|
||||
}
|
||||
addrLen := binary.BigEndian.Uint16(buf[:2])
|
||||
if int64(addrLen) > lr.N {
|
||||
return fmt.Errorf("addrlen %d exceeds remaining %d",
|
||||
addrLen, lr.N)
|
||||
}
|
||||
|
||||
a.Address = make([]byte, addrLen)
|
||||
if _, err := io.ReadFull(lr, a.Address); err != nil {
|
||||
return fmt.Errorf("read address: %w", err)
|
||||
}
|
||||
|
||||
fa.Addrs = append(fa.Addrs, a)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,415 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDecodeChainsRecord pins the chain-array decoder's structural rejections.
|
||||
func TestDecodeChainsRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantErr error
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "length not multiple of 32",
|
||||
data: append(
|
||||
bytes.Repeat(
|
||||
[]byte{0xaa}, chainHashLen,
|
||||
),
|
||||
187,
|
||||
),
|
||||
wantMsg: "not a multiple of",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
data: bytes.Repeat(
|
||||
[]byte{0x00}, (maxOfferChains+1)*chainHashLen,
|
||||
),
|
||||
wantErr: ErrTooManyChains,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(
|
||||
tc.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var c ChainsRecord
|
||||
err := decodeChainsRecord(
|
||||
bytes.NewReader(tc.data), &c,
|
||||
new([8]byte),
|
||||
uint64(
|
||||
len(tc.data),
|
||||
),
|
||||
)
|
||||
require.Error(t, err)
|
||||
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
}
|
||||
|
||||
if tc.wantMsg != "" {
|
||||
require.Contains(
|
||||
t, err.Error(), tc.wantMsg,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainsRecordRoundTrip pins decode→re-encode against the BOLT 12 offer
|
||||
// test vectors.
|
||||
func TestChainsRecordRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// bitcoinHash is the bitcoin mainnet genesis hash hex-decoded into a
|
||||
// fixed array. Defined locally so the test does not depend on constants
|
||||
// introduced by later commits.
|
||||
bitcoinHashHex := "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365" +
|
||||
"e15a089c68d6190000000000"
|
||||
|
||||
var bitcoinHash [chainHashLen]byte
|
||||
bitcoinHashBytes, err := hex.DecodeString(bitcoinHashHex)
|
||||
require.NoError(t, err)
|
||||
copy(bitcoinHash[:], bitcoinHashBytes)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// hex is the on-wire bytes of the offer_chains TLV value
|
||||
// (concatenated 32-byte chain hashes), copied from
|
||||
// bolt12/offers-test.json.
|
||||
hex string
|
||||
wantLen int
|
||||
wantHash [chainHashLen]byte
|
||||
}{
|
||||
{
|
||||
name: "single testnet chain",
|
||||
hex: "43497fd7f826957108f4a30fd9cec3ae" +
|
||||
"ba79972084e90ead01ea330900000000",
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "single bitcoin chain",
|
||||
hex: bitcoinHashHex,
|
||||
wantLen: 1,
|
||||
wantHash: bitcoinHash,
|
||||
},
|
||||
{
|
||||
name: "two chains liquidv1 then bitcoin",
|
||||
hex: "1466275836220db2944ca059a3a10ef6fd2ea684b" +
|
||||
"0688d2c379296888a206003" + bitcoinHashHex,
|
||||
wantLen: 2,
|
||||
// Second chain in the list is bitcoin mainnet.
|
||||
wantHash: bitcoinHash,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := hex.DecodeString(tc.hex)
|
||||
require.NoError(t, err)
|
||||
|
||||
var c ChainsRecord
|
||||
err = decodeChainsRecord(
|
||||
bytes.NewReader(data), &c, new([8]byte),
|
||||
uint64(
|
||||
len(data),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, c.Chains, tc.wantLen)
|
||||
|
||||
// Cross-check the canonical bitcoin chain hash where
|
||||
// the row knows which slot it lives in.
|
||||
var zero [chainHashLen]byte
|
||||
if tc.wantHash != zero {
|
||||
idx := tc.wantLen - 1
|
||||
require.Equal(
|
||||
t, tc.wantHash, c.Chains[idx],
|
||||
"bitcoin hash mismatch in slot %d",
|
||||
idx,
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(
|
||||
t, encodeChainsRecord(&buf, &c, new([8]byte)),
|
||||
)
|
||||
|
||||
require.Equal(t, data, buf.Bytes())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackAddressesRoundTrip encodes a list of fallback addresses
|
||||
// covering BIP-141 v0, BIP-350 v1, a forward-compatible v2 entry, and
|
||||
// a v17 entry that the spec mandates a *reader* ignore but the codec
|
||||
// layer must still round-trip faithfully (the ignore policy lives at
|
||||
// the invoice-consumer layer, not at the codec). The fallback list is
|
||||
// on-chain payment data: a wrong version byte or mis-framed length
|
||||
// translates into funds going to an unintended script, so encode/
|
||||
// decode must be a faithful bijection across the entire version
|
||||
// range.
|
||||
func TestFallbackAddressesRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addrs := &FallbackAddresses{
|
||||
Addrs: []FallbackAddress{
|
||||
{
|
||||
Version: 0,
|
||||
Address: bytes.Repeat([]byte{0xab}, 20),
|
||||
},
|
||||
{
|
||||
Version: 1,
|
||||
Address: bytes.Repeat([]byte{0xcd}, 32),
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
Address: bytes.Repeat([]byte{0xef}, 64),
|
||||
},
|
||||
{
|
||||
Version: 17,
|
||||
Address: bytes.Repeat([]byte{0x99}, 20),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, encodeFallbackAddrs(&buf, addrs, new([8]byte)))
|
||||
encoded := buf.Bytes()
|
||||
|
||||
expectedSize := fallbackAddrsSize(addrs)
|
||||
require.Equal(t, expectedSize, uint64(len(encoded)))
|
||||
|
||||
var decoded FallbackAddresses
|
||||
err := decodeFallbackAddrs(
|
||||
bytes.NewReader(encoded), &decoded, new([8]byte),
|
||||
uint64(len(encoded)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, addrs.Addrs, decoded.Addrs)
|
||||
}
|
||||
|
||||
// TestBlindedPayInfosRoundTrip encodes a list of blinded_payinfo entries and
|
||||
// asserts decode reproduces them exactly.
|
||||
func TestBlindedPayInfosRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noFeats := *lnwire.NewRawFeatureVector()
|
||||
someFeats := *lnwire.NewRawFeatureVector(8, 15)
|
||||
|
||||
infos := &BlindedPayInfos{
|
||||
Infos: []BlindedPayInfo{
|
||||
{
|
||||
FeeBaseMsat: 1000,
|
||||
FeeProportionalMillionths: 250,
|
||||
CltvExpiryDelta: 144,
|
||||
HtlcMinimumMsat: 1,
|
||||
HtlcMaximumMsat: 1_000_000,
|
||||
Features: noFeats,
|
||||
},
|
||||
{
|
||||
FeeBaseMsat: 0,
|
||||
FeeProportionalMillionths: 0,
|
||||
CltvExpiryDelta: 40,
|
||||
HtlcMinimumMsat: 0,
|
||||
HtlcMaximumMsat: math.MaxUint64,
|
||||
Features: someFeats,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, encodeBlindedPayInfos(&buf, infos, new([8]byte)))
|
||||
encoded := buf.Bytes()
|
||||
|
||||
require.Equal(t, blindedPayInfosSize(infos), uint64(len(encoded)))
|
||||
|
||||
var decoded BlindedPayInfos
|
||||
err := decodeBlindedPayInfos(
|
||||
bytes.NewReader(encoded), &decoded,
|
||||
new([8]byte), uint64(len(encoded)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, infos.Infos, decoded.Infos)
|
||||
}
|
||||
|
||||
// TestDecodeBlindedPayInfosRejectsTruncated covers truncation before the fixed
|
||||
// fields and before the declared features payload. Each must fail rather than
|
||||
// yield a partial BlindedPayInfos with corrupt entries.
|
||||
func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
declLen uint64
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "missing fee_base",
|
||||
data: nil,
|
||||
declLen: 4,
|
||||
errSubstr: "read fee_base",
|
||||
},
|
||||
{
|
||||
name: "features length exceeds remaining",
|
||||
// fee_base(4) fee_prop(4) cltv(2) htlc_min(8)
|
||||
// htlc_max(8) then flen=0xffff with no payload.
|
||||
data: append(
|
||||
make([]byte, 26), []byte{0xff, 0xff}...,
|
||||
),
|
||||
declLen: 28,
|
||||
errSubstr: "exceeds remaining",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
data: make([]byte, (maxBlindedPayInfos+1)*28),
|
||||
declLen: (maxBlindedPayInfos + 1) * 28,
|
||||
errSubstr: "exceeds maxBlindedPayInfos",
|
||||
},
|
||||
{
|
||||
name: "non-minimal features",
|
||||
// fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) +
|
||||
// htlc_max(8) followed by flen = 1, and 1 non-minimal
|
||||
// feature byte (trailing zero).
|
||||
data: append(
|
||||
make([]byte, 26), []byte{0x00, 0x01, 0x00}...,
|
||||
),
|
||||
declLen: 29,
|
||||
errSubstr: "non-minimal",
|
||||
},
|
||||
{
|
||||
name: "inverted htlc range",
|
||||
// htlc_min at bytes [10:18] = 1000, htlc_max at bytes
|
||||
// [18:26] = 500, so min > max must be rejected before
|
||||
// the flen/features are ever read.
|
||||
data: func() []byte {
|
||||
b := make([]byte, 26)
|
||||
b[16], b[17] = 0x03, 0xe8 // htlc_min = 1000
|
||||
b[24], b[25] = 0x01, 0xf4 // htlc_max = 500
|
||||
|
||||
return b
|
||||
}(),
|
||||
declLen: 26,
|
||||
errSubstr: "htlc_minimum_msat exceeds",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var bp BlindedPayInfos
|
||||
err := decodeBlindedPayInfos(
|
||||
bytes.NewReader(tc.data), &bp, new([8]byte),
|
||||
tc.declLen,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errSubstr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeFallbackAddrsRejectsOversize asserts the maxFallbackAddrLen cap is
|
||||
// enforced before any bytes hit the writer.
|
||||
func TestEncodeFallbackAddrsRejectsOversize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addrs := &FallbackAddresses{
|
||||
Addrs: []FallbackAddress{{
|
||||
Version: 0,
|
||||
Address: make([]byte, maxFallbackAddrLen+1),
|
||||
}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := encodeFallbackAddrs(&buf, addrs, new([8]byte))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "exceeds limit")
|
||||
require.Zero(t, buf.Len(),
|
||||
"no bytes should be written when validation fails")
|
||||
}
|
||||
|
||||
// TestDecodeFallbackAddrsRejectsTruncated covers the three truncation points in
|
||||
// decodeFallbackAddrs: stream ends before the version byte, before the 16-bit
|
||||
// length, and before the address payload of the declared size. Each must fail
|
||||
// with an error rather than yielding a partial FallbackAddresses with corrupt
|
||||
// entries.
|
||||
func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each case declares a TLV-record length that overshoots the bytes
|
||||
// actually present, simulating a malformed wire payload that promises
|
||||
// more data than it delivers.
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
declLen uint64
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "missing version byte",
|
||||
data: nil,
|
||||
declLen: 1,
|
||||
errSubstr: "read version",
|
||||
},
|
||||
{
|
||||
name: "missing length bytes",
|
||||
data: []byte{0x00},
|
||||
declLen: 3,
|
||||
errSubstr: "read addrlen",
|
||||
},
|
||||
{
|
||||
name: "truncated address payload",
|
||||
data: []byte{
|
||||
0x00, 0x00, 0x05, 0xab, 0xab,
|
||||
},
|
||||
declLen: 8,
|
||||
errSubstr: "read address",
|
||||
},
|
||||
{
|
||||
// addrlen > remaining trips the guard before
|
||||
// allocation; without it a hostile addrlen would force
|
||||
// a huge make([]byte, addrLen).
|
||||
name: "addrlen exceeds remaining",
|
||||
data: []byte{0x00, 0xff, 0xff, 0xab},
|
||||
declLen: 4,
|
||||
errSubstr: "exceeds remaining",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
data: make([]byte, (maxFallbackAddrs+1)*3),
|
||||
declLen: (maxFallbackAddrs + 1) * 3,
|
||||
errSubstr: "exceeds maxFallbackAddrs",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var fa FallbackAddresses
|
||||
err := decodeFallbackAddrs(
|
||||
bytes.NewReader(tc.data), &fa, new([8]byte),
|
||||
tc.declLen,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errSubstr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package bolt12
|
||||
|
||||
import (
|
||||
"github.com/lightningnetwork/lnd/tlv"
|
||||
)
|
||||
|
||||
// TUint64 is a uint64 that serializes using truncated encoding (tu64)
|
||||
// as required by BOLT 12. Leading zero bytes are omitted.
|
||||
type TUint64 uint64
|
||||
|
||||
// Record returns a TLV record using truncated uint64 encoding.
|
||||
//
|
||||
// NOTE: This implements the tlv.RecordProducer interface.
|
||||
func (t *TUint64) Record() tlv.Record {
|
||||
return tlv.MakeDynamicRecord(
|
||||
0, (*uint64)(t),
|
||||
func() uint64 {
|
||||
return tlv.SizeTUint64(uint64(*t))
|
||||
},
|
||||
tlv.ETUint64, tlv.DTUint64,
|
||||
)
|
||||
}
|
||||
|
||||
// TUint32 is a uint32 that serializes using truncated encoding (tu32) as
|
||||
// required by BOLT 12. Leading zero bytes are omitted.
|
||||
type TUint32 uint32
|
||||
|
||||
// Record returns a TLV record using truncated uint32 encoding.
|
||||
//
|
||||
// NOTE: This implements the tlv.RecordProducer interface.
|
||||
func (t *TUint32) Record() tlv.Record {
|
||||
return tlv.MakeDynamicRecord(
|
||||
0, (*uint32)(t),
|
||||
func() uint64 {
|
||||
return tlv.SizeTUint32(uint32(*t))
|
||||
},
|
||||
tlv.ETUint32, tlv.DTUint32,
|
||||
)
|
||||
}
|
||||
1845
bolt12/validate.go
1845
bolt12/validate.go
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -92,6 +92,7 @@ func TestParseAndSetDebugLevels(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := &mockSubLogger{
|
||||
subLogLevels: make(map[string]string),
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@ const (
|
|||
AppMajor uint = 0
|
||||
|
||||
// AppMinor defines the minor version of this binary.
|
||||
AppMinor uint = 21
|
||||
AppMinor uint = 20
|
||||
|
||||
// AppPatch defines the application patch for this binary.
|
||||
AppPatch uint = 99
|
||||
AppPatch uint = 02
|
||||
|
||||
// AppPreRelease MUST only contain characters from semanticAlphabet per
|
||||
// the semantic versioning spec.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/lightningnetwork/lnd/cert
|
||||
|
||||
go 1.25.11
|
||||
go 1.24.11
|
||||
|
||||
require github.com/stretchr/testify v1.8.2
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
)
|
||||
|
||||
// BestBlockView is an interface that allows the querying of the most
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
"testing/quick"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
"github.com/lightningnetwork/lnd/lntest/mock"
|
||||
"github.com/lightningnetwork/lnd/lntest/wait"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
package bitcoindnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/btcsuite/btcd/btcjson"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/txscript/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
@ -176,7 +175,7 @@ func (b *BitcoindNotifier) startNotifier() error {
|
|||
|
||||
// Connect to bitcoind, and register for notifications on connected,
|
||||
// and disconnected blocks.
|
||||
if err := b.chainConn.Start(context.Background()); err != nil {
|
||||
if err := b.chainConn.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.chainConn.NotifyBlocks(); err != nil {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@
|
|||
package bitcoindnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
)
|
||||
|
|
@ -24,7 +23,7 @@ func (b *BitcoindNotifier) UnsafeStart(bestHeight int32, bestHash *chainhash.Has
|
|||
|
||||
// Connect to bitcoind, and register for notifications on connected,
|
||||
// and disconnected blocks.
|
||||
if err := b.chainConn.Start(context.Background()); err != nil {
|
||||
if err := b.chainConn.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.chainConn.NotifyBlocks(); err != nil {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/integration/rpctest"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcjson"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/btcsuite/btcd/txscript/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/integration/rpctest"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import (
|
|||
|
||||
"github.com/btcsuite/btcd/blockchain"
|
||||
"github.com/btcsuite/btcd/btcjson"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
package chainntnfs
|
||||
|
||||
import "github.com/btcsuite/btcd/chainhash/v2"
|
||||
import "github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
|
||||
// TestChainNotifier enables the use of methods that are only present during
|
||||
// testing for ChainNotifiers.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/lnutils"
|
||||
)
|
||||
|
||||
|
|
@ -298,6 +298,8 @@ func (m *MempoolNotifier) notifySpent(spentInputs inputsWithTx) {
|
|||
|
||||
// Iterate the spent inputs to notify the subscribers concurrently.
|
||||
for op, tx := range spentInputs {
|
||||
op, tx := op, tx
|
||||
|
||||
m.wg.Add(1)
|
||||
go notifyAll(tx, op)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package chainntnfs
|
||||
|
||||
import (
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/fn/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcjson"
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/btcutil/v2/gcs/builder"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/gcs/builder"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/btcsuite/btcd/txscript/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/neutrino"
|
||||
"github.com/lightninglabs/neutrino/headerfs"
|
||||
"github.com/lightningnetwork/lnd/blockcache"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/lightninglabs/neutrino"
|
||||
"github.com/lightningnetwork/lnd/chainntnfs"
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/v2"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/integration/rpctest"
|
||||
"github.com/btcsuite/btcd/rpcclient"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
_ "github.com/btcsuite/btcwallet/walletdb/bdb" // Required to auto-register the boltdb walletdb implementation.
|
||||
"github.com/lightninglabs/neutrino"
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/address/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcjson"
|
||||
"github.com/btcsuite/btcd/chainhash/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/integration/rpctest"
|
||||
"github.com/btcsuite/btcd/txscript/v2"
|
||||
"github.com/btcsuite/btcd/wire/v2"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lntest/unittest"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -36,8 +36,8 @@ func randPubKeyHashScript() ([]byte, *btcec.PrivateKey, error) {
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
pubKeyHash := address.Hash160(privKey.PubKey().SerializeCompressed())
|
||||
addrScript, err := address.NewAddressWitnessPubKeyHash(
|
||||
pubKeyHash := btcutil.Hash160(privKey.PubKey().SerializeCompressed())
|
||||
addrScript, err := btcutil.NewAddressWitnessPubKeyHash(
|
||||
pubKeyHash, unittest.NetParams,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue