Compare commits

..

137 commits

Author SHA1 Message Date
ziggieXXX
c863397a4f
Merge pull request #10953 from ziggie1984/bump-v0.20.2-final
build: bump version to v0.20.2
2026-07-07 20:50:06 -03:00
ziggie
5e26b56067
build: bump version to v0.20.2 2026-07-07 07:08:59 -03:00
ziggieXXX
529bdb1a8b
Merge pull request #10935 from ziggie1984/bump-v0.20.2
build: bump version to 0.20.2-beta.rc1
2026-06-29 10:48:35 -03:00
ziggie
f4fa341e02
build: bump version to 0.20.2-beta.rc1 2026-06-29 07:51:35 -03:00
ziggieXXX
bf8df0a144
Merge pull request #10933 from lightningnetwork/backport-10895-to-v0.20.x-branch
[v0.20.x-branch] Backport #10895: htlcswitch: separate onchain and offchain intercpeted HTLCs cleanly
2026-06-27 14:14:31 -03:00
ziggie
cf20de9bfb
docs: add v0.20.2 release note
(cherry picked from commit 9c5f32a2ec)
2026-06-26 14:29:35 -03:00
ziggie
bc8463a98f
routerrpc: add clarifying docs for the intercepted forward
routerrpc: document on-chain interceptor responses
(cherry picked from commit 8909c2fbf5)
2026-06-26 14:29:23 -03:00
ziggie
7a42b56cbf
witnessbeacon: avoid interceptor deadlock
Release the preimage beacon lock before invoking the on-chain
interceptor. The interceptor path can block on the htlcswitch event
loop, while resolution of another held on-chain HTLC can call back
into the beacon to add a preimage.

If interceptor delivery fails after the subscriber was registered,
cancel the subscription before returning the error.

On-chain held entries are replay handles for the interceptor while
contractcourt waits for a preimage or on-chain expiry. Once the resolver
tears down, keeping the handle until the refund timeout can replay a stale
HTLC to a reconnecting interceptor.

Thread a dedicated cleanup signal from the witness subscription cancel path
back through the interceptable switch event loop. The held set only removes
on-chain entries for that signal, leaving off-chain entries under the link
flow lifecycle.

(cherry picked from commit 98da7b4a56)
2026-06-26 14:29:23 -03:00
ziggie
b32e432e0b
htlcswitch: track held HTLC source
Store held forwards as off-chain or on-chain entries instead of a raw
InterceptedForward map. Off-chain entries keep the existing resume, fail,
settle and auto-fail behavior. On-chain entries are settle-only and
expire by pruning local interceptor state.

When contractcourt re-offers a circuit that is already held off-chain,
replace the stored entry with the on-chain forward so a later SETTLE
reaches the witness beacon instead of the old link mailbox path.

Also set the on-chain interceptor deadline to the HTLC refund timeout.
This keeps the public interceptor deadline populated while ensuring only
off-chain held entries use that value to fail back.

Only off-chain held HTLCs can be released when an optional interceptor
disconnects, because they can resume into the link forwarding flow.

On-chain held HTLCs have no link flow to resume. Keep them in the held
set so a reconnecting interceptor can replay and settle them while
contractcourt waits for the preimage or on-chain expiry.

Use distinct internal deadline types for off-chain auto-fail heights and
on-chain settlement deadlines instead of overloading the intercepted packet
field.

Project both variants back into the existing router RPC auto_fail_height
field to preserve wire compatibility. Reject mismatched held HTLC deadline
types in tests.

On-chain intercepted HTLCs can only be settled. Resume and fail actions
already return concrete errors through the on-chain intercepted forward, so
let those errors propagate to the interceptor client instead of converting
them to success.

Keep the held entry tracked on these errors so the client can reconnect and
settle the HTLC later.

(cherry picked from commit eb1193f80b)
2026-06-26 14:29:23 -03:00
ziggie
de8ca73610
itest: cover on-chain interceptor settlement
Add coverage for held forwards that move on chain after the
incoming channel force closes.

The restart case exercises the path where Bob loses the in-memory
held set and contractcourt re-offers the HTLC through the witness
beacon. The no-restart case keeps the original off-chain hold and
proves that settlement must still reach the on-chain resolver.

(cherry picked from commit 9b31ba83ef)
2026-06-26 14:29:22 -03:00
Yong
77989b955d
Merge pull request #10929 from lightningnetwork/backport-10927-to-v0.20.x-branch
[v0.20.x-branch] Backport #10927: htlcswitch+invoices: tighten final-hop CLTV validation
2026-06-26 22:35:22 +08:00
ziggie
b9619a28cd
docs: update release notes
(cherry picked from commit bffae65a60)
2026-06-26 07:58:48 -03:00
ziggie
30ddd606fc
htlcswitch: fix outgoing cltv spelling
Rename the ForwardingInfo.OutgoingCTLV field to OutgoingCLTV and update all
call sites. This keeps the exported field spelling consistent with the CLTV
terminology used elsewhere.

Also fix the remaining CTLV typos in nearby comments.

(cherry picked from commit 4518bc7276)
2026-06-26 07:58:48 -03:00
ziggie
1c82b7a27d
contractcourt: align final htlc handling in contest resolver
Mirror the link's final-hop HTLC checks in the incoming contest resolver so
the off-chain link path and on-chain resolver use the same final-hop handling.

Use MaxFinalCltvDelta directly in contractcourt to match invoice creation and
link processing.

Preserve the link's custom HTLC behavior by leaving amount checks to auxiliary
traffic shapers when custom HTLC handling applies.

(cherry picked from commit 03ca45a282)
2026-06-26 07:58:48 -03:00
ziggie
141acda471
config: check cltv expiry policy range
Check configured and advertised forwarding CLTV deltas against max-cltv-expiry
so local configuration and advertised channel policy stay within the same
supported range.

(cherry picked from commit b8e861fe6b)
2026-06-26 07:58:47 -03:00
ziggie
6968e2bdab htlcswitch+invoices: align final-hop CLTV handling
Apply the same supported CLTV delta range to final-hop HTLC handling that is
already used for forwarding.

Use a shared helper for the exit-hop link path so final-hop amount and CLTV
checks remain consistent across invoice creation and HTLC handling.

(cherry picked from commit c5992d381f)
2026-06-26 10:15:18 +00:00
ziggieXXX
3061190445
Merge pull request #10924 from lightningnetwork/backport-10914-to-v0.20.x-branch
[v0.20.x-branch] Backport #10914: discovery: fix panic in DNS fallback SRV lookup
2026-06-25 18:30:24 -03:00
ziggieXXX
0b20b0aab7
Merge pull request #10925 from lightningnetwork/backport-10904-to-v0.20.x-branch
[v0.20.x-branch] Backport #10904: rpcperms: recover RPC handler panics
2026-06-25 17:09:20 -03:00
Erick Cestari
010f72681b
docs: add release note for 0.20.2
(cherry picked from commit 2ee49698af)
2026-06-25 14:26:48 -03:00
Erick Cestari
2c4af78fe1
discovery: fix panic in DNS fallback SRV lookup
The fallback SRV lookup type-asserted each DNS Answer record to *dns.SRV
unconditionally. If the response contains a non-SRV record (e.g. an A or
CNAME), the type assertion panics and crashes the daemon. Use the
comma-ok form to skip non-SRV records instead.

Also guard against an empty LookupHost result for the shim, which would
otherwise panic on an out-of-bounds index into addrs.

This is safe to discuss and fix in public. The bug is very unlikely to be
exploitable: triggering it requires either a DNS seeder to serve a
malformed response, or an on-path MITM injecting one (the fallback
response is unauthenticated). A malicious seeder already has far more
direct ways to disrupt a node, and a MITM attack is hard to mount, so the
panic does not meaningfully widen the attack surface.

(cherry picked from commit 2a3642c691)
2026-06-25 14:21:43 -03:00
yyforyongyu
0b04e339a4
rpcperms: recover RPC handler panics
(cherry picked from commit 4bbfcab910)
2026-06-25 13:36:43 -03:00
ziggieXXX
848b72ce96
Merge pull request #10551 from lightningnetwork/v-0-20-1-final
build: bump version to v0.20.1
2026-02-10 07:42:54 +01:00
Olaoluwa Osuntokun
f9035f74f5 build: bump version to v0.20.1 2026-02-09 18:00:34 -08:00
Yong
a4f375ffdd
Merge pull request #10554 from lightningnetwork/backport-10540-to-v0.20.x-branch
[v0.20.x-branch] Backport #10540: discovery: fix gossiper shutdown deadlock
2026-02-06 10:15:49 +08:00
Olaoluwa Osuntokun
cac164694f
Merge pull request #10556 from ziggie1984/v0.20.x-branch
Pin sqldb package to pseudo-version
2026-02-05 14:59:07 -08:00
ziggie
b6bd8c4fd2
mod: pin sqldb to pseudo-version for v0.20.x release
This removes the local replace directive for the sqldb package and
pins it to v1.0.12-0.20260113193010-8565d12e40b1 (commit 8565d12e4).
2026-02-05 15:55:40 -05:00
Matt Morehouse
f427fee341 docs: add release note for #10540 2026-02-04 17:16:20 -08:00
Matt Morehouse
81d4da7ffd discovery: fix gossiper shutdown deadlock
When processing a remote network announcement, it is possible for two
error messages to be sent back on the errChan.  Since Brontide doesn't
actually read from errChan, and since errChan only buffered one error
message, the sending goroutine would deadlock forever.  This would only
become apparent when the gossiper attempted to shut down and got hung
up.

For now, we can fix this simply by buffering up to two error messages on
errChan.  There is an existing TODO to restructure this logic entirely
to use the actor model, and we can do a more thorough fix as part of
that work.

This bug was discovered while doing full node fuzz testing and was
triggered by sending a specific channel_announcement message and then
shutting down LND.

(cherry picked from commit 21588acb3d)
2026-02-05 01:12:49 +00:00
ziggieXXX
d190a01be4
Merge pull request #10546 from lightningnetwork/backport-10529-to-v0.20.x-branch
[v0.20.x-branch] Backport #10529: graphdb: fix backwards-compat for channel edge feature deserialization
2026-02-04 13:38:17 -05:00
Olaoluwa Osuntokun
fe486e13a9 graphdb: fix backwards-compat for channel edge feature deserialization
This commit fixes a backwards compatibility issue that prevented nodes
from upgrading from v0.19.x to v0.20.x.

In v0.19.x, channel edge features were serialized as raw feature bytes
without a length prefix. In v0.20.x (commit 2f2845dfc), the serialization
changed to use Features.Encode() which adds a 2-byte big-endian length
prefix before the feature bits. The deserialization code was updated to
use Features.Decode() which expects this length prefix.

When v0.20.x reads a database created by v0.19.x, Decode() tries to read
a length prefix that doesn't exist, causing an EOF error:

    unable to decode features: EOF

The fix adds a deserializeChanEdgeFeatures() helper that detects which
format is being read and decodes accordingly:

- New format (v0.20+): First 2 bytes encode the length of the remaining
  bytes. Detected when uint16(bytes[0:2]) == len(bytes)-2.

- Legacy format (pre-v0.20): Raw feature bits without length prefix.
  Uses DecodeBase256 with the known length.

The format detection is safe because in the legacy format, the first byte
always has at least one bit set (the serialization uses minimum bytes),
so the first two bytes can never encode a value equal to len-2.

Fixes #10528.

(cherry picked from commit 56a7f45b99)
2026-02-03 18:24:39 -08:00
ziggieXXX
802849f0d5
Merge pull request #10539 from Roasbeef/v0-20-1-rc-2
build: bump version to v0.20.1 rc2
2026-02-02 20:08:10 -05:00
Olaoluwa Osuntokun
3be1baf9cf build: bump version to v0.20.1 rc2 2026-02-02 17:15:34 -04:00
Olaoluwa Osuntokun
60bc0f389c
Merge pull request #10523 from lightningnetwork/revert-10331-backport
[v0.20.x-branch] Revert backport #10331: re-org aware channel close logic
2026-01-26 14:39:19 -05:00
Olaoluwa Osuntokun
1495920bb9 Revert "Merge pull request #10510 from lightningnetwork/backport-10331-to-v0.20.x-branch"
This reverts commit 40d8b475b5, reversing
changes made to 0b9e8c33e1.
2026-01-26 13:36:14 -04:00
ziggieXXX
706ec5e136
Merge pull request #10518 from lightningnetwork/backport-10514-to-v0.20.x-branch
[v0.20.x-branch] Backport #10514: Reduce graphDB log noise
2026-01-22 19:51:11 +01:00
ziggie
175933a6ca graphdb: reduce log noise from WRN to DBG
(cherry picked from commit c78a75f5d8)
2026-01-22 15:50:59 +00:00
ziggieXXX
adda56d40b
Merge pull request #10511 from lightningnetwork/v0-20-1-rc1
build: bump version to v0.20.1 rc1
2026-01-17 09:03:38 +01:00
Olaoluwa Osuntokun
a8b00fcb70 build: bump version to v0.20.1 rc1 2026-01-16 17:06:26 -08:00
Olaoluwa Osuntokun
40d8b475b5
Merge pull request #10510 from lightningnetwork/backport-10331-to-v0.20.x-branch
[v0.20.x-branch] Backport #10331: multi: update close logic to handle re-orgs of depth n-1, where n is num confs - add min conf floor
2026-01-16 16:59:07 -08:00
Olaoluwa Osuntokun
82b4345a3e multi: increase min cltv delta to 24 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
8cf08fde1d routing: increase MinCLTVDelta from 18 to 24 blocks
This increases the minimum CLTV delta allowed for invoice creation to
provide more headroom above DefaultFinalCltvRejectDelta (19 blocks).
The previous value of 18 was below the reject threshold, which could
allow users to create invoices with CLTV deltas that would be rejected
when receiving payments.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
c12c9e7bd2 docs/release-notes: add release notes entry 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
3a6a756e0a lncfg: increase DefaultIncomingBroadcastDelta to 16
With this change, we'll go to chain even earlier to ensure that we have
enough time to sweep a potentially contested HTLC, now that we're
waiting longer before sweeps to ensure that the commitment transaction
is sufficeitnyl burried before we sweep.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
ab7a002c94 contractcourt: unify+simplify new re-org aware logic 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
4adfa8ec39 contractcourt: add sync dispatch fast-path for single confirmation closes
In this commit, we add a fast-path optimization to the chain watcher's
closeObserver that immediately dispatches close events when only a single
confirmation is required (numConfs == 1). This addresses a timing issue
with integration tests that were designed around the old synchronous
blockbeat behavior, where close events were dispatched immediately upon
spend detection.

The recent async confirmation architecture (introduced in commit f6f716ab7)
properly handles reorgs by waiting for N confirmations before dispatching
close events. However, this created a race condition in integration tests
that mine blocks synchronously and expect immediate close notifications.
With the build tag setting numConfs to 1 for itests, the async confirmation
notification could arrive after the test already started waiting for the
close event, causing timeouts.

We introduce a new handleSpendDispatch method that checks if numConfs == 1
and, if so, immediately calls handleCommitSpend to dispatch the close event
synchronously, then returns true to skip the async state machine. This
preserves the old behavior for integration tests while maintaining the full
async reorg protection for production (where numConfs >= 3).

The implementation adds the fast-path check in both spend detection paths
(blockbeat and spend notification) to ensure consistent behavior regardless
of which detects the spend first. We also update the affected unit tests to
remove their expectation of confirmation registration, since the fast-path
bypasses that step entirely.

This approach optimizes for the integration test scenario without compromising
production safety, as the fast-path only activates when a single confirmation
is sufficient - a configuration that only exists in the controlled test
environment.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
fadfdecd0e itest: add new coop close rbf itest
This ensures that during the RBF process, if one confirms, a re-org
occurs, then another confirms, that we'll properly detect this case.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
25b19461db contractcourt: add generic close re-org tests
In this commit, we add a set of generic close re-org tests. The most
important test is the property based test, they will randomly confirm
transactions, generate a re-org, then assert that eventually we dtect
the final version.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
8de352e276 contractcourt: add unit tests for rbf re-org cases
This set of new tests ensures that if have created N RBF variants of the
coop close transaction, that any of then can confirm, and be re-org'd,
with us detecting the final spend once it confirms deeploy enough.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
19d8bb2a6c contractcourt: update existing chain watcher tests due to new logic
All the tests need to send a confirmation _after_ the spend is detected
now.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
6f6034dd51 contractcourt: add new chainWatcherTestHarness
We'll use this for all the upcoming tests.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
a8e37b08e4 lntest: add new wait for conf helper method to ChainNotifier 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
4d5a14de3d contractcourt: update close logic to handle re-orgs of depth n-1, where n is num confs
In this commit, we update the close logic to handle re-ogs up to the
final amount of confirmations. This is done generically, so we're able
to handle events such as: coop close confirm, re-org, breach confirm,
re-org, force close confirm, re-org, etc.

The upcoming set of new tests will exercise all of these cases.

We modify the block beat handling to unify the control flow. As it's
possible we get the beat, then see the spend, or the oher way around.
2026-01-16 15:32:42 -08:00
Olaoluwa Osuntokun
99b32c9465 peer: send out a notification after the 1st conf, then wait for the rest
We wnt to add better handling, but not break any UIs or wallets. So
we'll continue to send out a notification after a single confirmation,
then send another after things are fully confirmed.
2026-01-16 15:31:04 -08:00
Olaoluwa Osuntokun
65d53ce875 multi: add new ChannelCloseConfs param, thread thru as needed
In this commit, we add a new param that'll allow us to scale up the
number of confirmations before we act on a new close. We'll use this
later to improve the current on chain handling logic.
2026-01-16 15:31:03 -08:00
Olaoluwa Osuntokun
a28a09670a lncfg: add new dev config option for scaling channel close confs
This'll be useful for the set up upcoming itests.
2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
d9d2bf466e peer+rpcserver: use new conf scaling for notifications 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
bd6fa84d8d lnwallet: add tests for new conf scaling helper funcs 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
e5c5011900 lnwallet: define helper func to coop close conf scaling
We have two versions: for itests, we just use one conf, but in prod,
we'll scale the number of confirmations.
2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
8a38c86628 server: use new FundingConfsForAmounts helper func 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
55807ac4d0 lnwallet: add new helper functions to scale confirmations based on amt 2026-01-16 15:28:27 -08:00
ziggieXXX
0b9e8c33e1
Merge pull request #10494 from ziggie1984/backport-10491-to-v0.20.x-branch
Backport #10491 to v0.20.x-branch
2026-01-13 22:55:58 +01:00
ziggie
c8364afaeb
fn: fix printf vet check in TestSomeToOkf Go 1.24+
See also https://github.com/golang/go/issues/60529. Now we need
to use a constant.

(cherry picked from commit 7085f4706d)
2026-01-13 20:30:10 +01:00
ziggie
8565d12e40
mod: update the minimum go version to 1.24.11
We update the minimum required go version to build the LND
exectuable to the latest minor release of go 1.24.11.

(cherry picked from commit e26a114cdc)
2026-01-13 20:30:10 +01:00
ziggie
a7ca338720
build: update CI+release version to Go 1.25.5
This updates the toolchain which we require to build the LND
executable.

(cherry picked from commit 61d82fd1f2)
2026-01-13 20:30:09 +01:00
ziggieXXX
d59e98b3f4
Merge pull request #10492 from lightningnetwork/backport-10470-to-v0.20.x-branch
[v0.20.x-branch] Backport #10470: discovery: add panic recovery for gossip message processing
2026-01-13 17:03:49 +01:00
ziggie
56829a9c8a
discovery: fix new usetesting linter issues
(cherry picked from commit 35260fc4a7)
2026-01-13 13:08:06 +01:00
Olaoluwa Osuntokun
5cf38edc62
docs/release-notes: add release notes
(cherry picked from commit 7da41cb367)
2026-01-13 08:17:50 +01:00
Olaoluwa Osuntokun
a0be1c926b discovery: add panic recovery for serial announce signatures processing
In this commit, we extend the panic recovery mechanism to cover the
serial processing path for AnnounceSignatures1 messages. Unlike other
gossip messages which are processed in parallel goroutines, announcement
signatures are processed serially in the main networkHandler loop.

A panic during this serial processing would previously crash the entire
gossiper. This change wraps the processing in an anonymous function with
a deferred panic recovery, ensuring resilience without changing the
serial processing semantics.

Since AnnounceSignatures bypass the validation barrier, we pass nil for
the jobID parameter.

(cherry picked from commit bcb65f5ac7)
2026-01-13 01:11:00 +00:00
Olaoluwa Osuntokun
b7b73b02e1 discovery: add panic recovery for gossip message processing
In this commit, we add a centralized panic recovery mechanism for gossip
goroutines. This increases the robustness of message processing in the
gossiper, as now we are able to keep on trucking in the face of logic
errors that may lead to panics.

We ensure that any deps are freed and we log the panic trace to help
catch bugs in the future.

(cherry picked from commit caf4850f74)
2026-01-13 01:11:00 +00:00
ziggieXXX
1827699848
Merge pull request #10490 from lightningnetwork/backport-10469-to-v0.20.x-branch
[v0.20.x-branch] Backport #10469: lnwire: enforce non-zero timestamp in gossip messages
2026-01-12 17:46:30 +01:00
Olaoluwa Osuntokun
8b471eadeb
discovery: enforce non-zero timestamp in gossip messages
In this commit, we add validation for channel updates and node
announcements to ensure that we reject gossip messages with zero
timestamps at the discovery layer.

From BOLT 7:
  "MUST set timestamp to greater than 0, AND to greater than any
  previously-sent channel_update for this short_channel_id."

This validation is performed in the gossip handlers (handleNodeAnnouncement
and handleChanUpdate) rather than at the wire protocol level. This approach
ensures we can still decode messages from disk or embedded in onion errors
while rejecting invalid gossip from peers.

Remote peers sending zero-timestamp gossip will have their ban score
incremented.

(cherry picked from commit cad1b957bf)
2026-01-12 14:05:21 +01:00
Olaoluwa Osuntokun
2cb9ff24fc
Merge pull request #10483 from lightningnetwork/backport-10462-to-v0.20.x-branch
[v0.20.x-branch] Backport #10462: channeldb: fix race condition in link node pruning
2026-01-07 11:56:04 -08:00
ziggie
f5527e1e60
multi: make sure previous inconsitent states are fixed
We make sure that nodes previously suffering from this error will
have a consitent db view when restarting their node.

(cherry picked from commit d9fb9092b6)
2026-01-07 09:21:24 +01:00
ziggie
57069cf3a1 channeldb: fix race condition in link node pruning
This commit fixes a critical race condition in MarkChanFullyClosed and
pruneLinkNode where link nodes could be incorrectly deleted despite
having pending or open channels.

The race occurred because the check for open channels and the link node
deletion happened in separate database transactions:

  Thread A: TX1 checks open channels → [] (empty)
  Thread A: TX1 commits
  Thread B: Opens new channel with same peer
  Thread A: TX2 deletes link node (using stale data)
  Result: Link node deleted despite pending channel existing

This creates a TOCTOU (time-of-check to time-of-use) vulnerability where
database state changes between reading the channel count and deleting
the node.

Fix for MarkChanFullyClosed:
- Move link node deletion into the same transaction as the channel
  closing check, making the check-and-delete operation atomic

Fix for pruneLinkNode:
- Add double-check within the write transaction to verify no channels
  were opened since the caller's initial check
- Maintains performance by keeping early return for common case
- Prevents deletion if channels exist at delete time

This ensures the invariant: "link node exists iff channels exist"
is never violated, preventing database corruption and potential
connection issues.

(cherry picked from commit 51f3c6f528)
2026-01-07 07:56:04 +00:00
ziggieXXX
96652812d7
Merge pull request #10475 from lightningnetwork/backport-10356-to-v0.20.x-branch
[v0.20.x-branch] Backport #10356: graph: fix inefficient query for IsPublicNode
2026-01-05 23:05:00 +01:00
Abdullahi Yunus
580e820b6a
docs: add release note
(cherry picked from commit 68f558c865)
2026-01-05 20:31:45 +01:00
ziggieXXX
86cc097fec
Merge pull request #10477 from lightningnetwork/backport-10476-to-v0.20.x-branch
[v0.20.x-branch] Backport #10476: itest: fix endorsement itests
2026-01-05 20:30:11 +01:00
ziggie
f1332fe4a7
itest: fix endorsement itests
Due to the signaling period expiring tests had to be adopted bc
they were not taking the activation time period into account.

(cherry picked from commit 5f30797738)
2026-01-05 17:02:21 +01:00
Abdullahi Yunus
10aff6c580 graph: use UNION for isPublicNode query
In this commit we updated the IsPublicV1Node query to use UNION
instead of OR, since sqlite struggles to efficiently use
multiple indexes in a single query involving OR conditions across
different columns.

We use UNION ALL since the query doesn't care about duplicates.

(cherry picked from commit ac2cec462c)
2026-01-05 09:35:35 +00:00
Abdullahi Yunus
6c656a6af6 graphdb: add benchmark for isPublicNode query
In this commit we add a benchmark to test the performance of
IsPublicNode query.

(cherry picked from commit 86cde4b93f)
2026-01-05 09:35:35 +00:00
Yong
1a83e9a8a0
Merge pull request #10451 from lightningnetwork/backport-10449-to-v0.20.x-branch
[v0.20.x-branch] Backport #10449: server: fix timestamp comparison in setSelfNode
2025-12-17 16:47:28 +08:00
ziggie
99b136e39e
docs: add release-notes for LND 20.1
(cherry picked from commit e5b0704d73)
2025-12-16 18:18:22 +01:00
ziggie
677ffabed8 server: fix timestamp comparison in setSelfNode
Fix bug where setSelfNode compared only the seconds component of
timestamps instead of the full timestamp. This caused the node to
attempt persisting an older timestamp than what existed in the
database during restart, resulting in "sql: no rows in result set"
errors.

(cherry picked from commit 865e1556d4)
2025-12-16 08:58:46 +00:00
ziggieXXX
eceaadc958
Merge pull request #10445 from lightningnetwork/backport-10439-to-v0.20.x-branch
[v0.20.x-branch] Backport #10439: Always add the payment address when probing an invoice
2025-12-15 12:59:18 +01:00
Elle
07b845207f
Merge pull request #10443 from ellemouton/backport-fixMissingEdge
[backport] localchans: populate FundingScript for missing edges
2025-12-15 08:02:08 +02:00
ziggie
f82c35c777
docs: add release-notes for LND 20.1
(cherry picked from commit ac30443cc1)
2025-12-13 01:28:32 +01:00
ziggie
1a543fbfcd
routerrpc: fix payment address deep copy
using copy for a slice of size 0 will not copy anything so we need
to first initialize the slice before we do the deep copy.

(cherry picked from commit 1c4bcc3b7d)
2025-12-13 01:28:32 +01:00
Olaoluwa Osuntokun
c6467a6305
Merge pull request #10444 from ziggie1984/backport/enhance-lsp-heuritic
backport/enhance lsp heuritic
2025-12-12 16:16:42 -08:00
ziggie
43091bdd51
docs: add release-notes for LND 20.1 2025-12-12 10:10:40 +01:00
ziggie
68c4809913
docs: update api documentation for estimateRouteFee 2025-12-12 10:10:40 +01:00
ziggie
6af171f365
itest: enhance testEstimateRouteFee with multi-LSP scenarios
This commit enhances the integration test to validate the LSP heuristic
end-to-end with real network topology and payment probing.

Network topology additions:
- Added Frank node as a private destination
- Created multi-LSP test scenario with Bob, Eve, and Dave as LSPs

New test cases:

1. "probe based estimate, public target with public hop hints"
   - Validates Rule 1: public invoice target routes directly
   - Even with public hop hints, direct routing is used
   - Expected: standard single-hop fees

2. "probe based estimate, multiple different public LSPs"
   - Validates multi-LSP worst-case selection
   - Frank has routes through Bob (low fee), Eve (HIGH fee), Dave (medium)
   - Expected: Eve's worst-case fees (most expensive)
   - Tests griefing protection (max 3 LSP probes)
2025-12-12 10:10:40 +01:00
ziggie
f1fc329eb2
routerrpc: implement LSP heuristic and multi-LSP worst-case probing
This commit implements a comprehensive LSP (Lightning Service Provider)
detection heuristic and updates the payment probing logic to handle
multiple LSPs with worst-case fee estimation.

Key changes:

1. LSP Detection Heuristic (isLSP function):
   Implements three rules to detect LSP setups:
   - Rule 1: If invoice target is public → NOT an LSP (route directly)
   - Rule 2: If at least one destination hop is public → IS an LSP
   - Rule 3: If all destination hops are private → NOT an LSP

2. LSP Route Preparation (prepareLspRouteHints function):
   - Groups route hints by unique public LSP nodes
   - Filters out non-LSP routes based on the heuristic
   - Tracks worst-case fees and CLTV delays for each LSP
   - Returns adjusted route hints with LSP hop stripped

3. Multi-LSP Probing (probePaymentRequest updates):
   - Probes up to 3 unique LSPs maximum (griefing protection)
   - Selects the WORST-CASE (most expensive) route for conservative
     fee estimation
   - Adds comprehensive debug logging for worst-case selection process
   - Properly formats vertex logging using %v (calls Vertex.String())

The worst-case approach ensures users won't be surprised by higher fees
when the actual payment is sent, providing a more conservative and
reliable fee estimate.

This commit also  adds extensive unit test coverage for the LSP detection
heuristic and route preparation logic.

TestIsLsp:
- Edge cases: empty route hints, nil scenarios
- Rule 1: Public invoice target (3 tests)
- Rule 2: All private destination hops (4 tests)
- Rule 3: At least one public destination hop (6 tests)

TestPrepareLspRouteHints:
- LSP grouping and filtering logic
- Worst-case fee selection across route hints
- Worst-case CLTV delta tracking
- Adjusted route hints validation (LSP hop stripped)
- Multi-LSP scenarios with different fees
2025-12-12 10:10:40 +01:00
ziggie
04ebd363c2
routerrpc: add HasNode backend function for LSP heuristic
This commit adds the HasNode function to the RouterBackend struct,
which checks if a node exists in the graph (i.e., has public channels).
This function is needed by the LSP detection heuristic to determine
if a node is publicly reachable.

The function is wired up in rpcserver.go to query the graph database.
2025-12-12 10:10:39 +01:00
ziggie
246abd1697
graph/db: fix HasNode comment
The comment was incorrectly referring to HasLightningNode but the
function is named HasNode. Update the comment to match the actual
function name.
2025-12-12 10:10:39 +01:00
Elle Mouton
53994daf13
docs: update release notes 2025-12-12 10:51:14 +02:00
Elle Mouton
7d695f581f
localchans: populate funding script for missing edges
When creating a missing edge, we need to populate the funding script too
so that the graph builder can update its ChainView appropriately. We use
the MakeFundingScript helper from the funding package which ensures that
we are using the same logic for creating a funding script as is used for
any of the channels that we own.
2025-12-12 10:23:51 +02:00
Elle Mouton
c32cbd940a
funding: export MakeFundingScript
So that we can re-use this helper else where.
2025-12-12 10:23:50 +02:00
Yong
189e23af97
Merge pull request #10429 from ziggie1984/backport/fix-sql-pool-exhaustion
backport: fix potential sql tx exhaustion
2025-12-11 23:02:42 +08:00
Olaoluwa Osuntokun
0d225a1110
Merge pull request #10433 from ellemouton/backport-removePubKeyCaching
backport: graph: fix various races
2025-12-09 17:11:57 -08:00
ziggie
0c7db1a206
docs: add release-notes for LND 20.1 2025-12-09 16:16:46 +01:00
ziggie
c079362c2c
graphdb: fix potential sql tx exhaustion
We should avoid taking the lock of a mutex inside transaction.
Currently we also take this lock in other places and there is a
chance that in case the application lock aquires the lock but
all transactions are already blocked waiting for the mutex to
unlock, we end up in a deadlock.
2025-12-09 16:16:45 +01:00
Elle Mouton
ad87b492d1
docs: add release notes for race condition fixes 2025-12-09 10:18:39 +02:00
Elle Mouton
439c3ede7d
graph/db: fix Node receivers
Let all the "Node" struct receivers be "n" in order to fix the linter
check.
2025-12-09 10:18:01 +02:00
Elle Mouton
a83d1177b8
graph/db: fix race in DisconnectBlockAtHeight cache access
The DisconnectBlockAtHeight method was modifying the rejectCache and
chanCache without holding the cacheMu lock. This caused races with
other operations that properly held the lock, such as AddChannelEdge
which modifies the caches in its OnCommit callback while the batch
scheduler holds cacheMu.

Fix by acquiring cacheMu before removing channels from the caches.
2025-12-08 14:22:08 +02:00
Elle Mouton
550de8d348
graph/db/models: fix race conditions in ChannelEdgeInfo
Both NodeKey1 and NodeKey2 methods had the same race condition as the
Node.PubKey method, where concurrent calls could race to write to the
cached fields.

Remove the caching for the same reasons: parsing overhead is minimal
and doesn't justify the complexity and race risk.
2025-12-08 14:22:08 +02:00
Elle Mouton
540224239b
graph/db/models: fix race condition in Node.PubKey
The PubKey method had a race condition where concurrent calls could
all pass the nil check and race to write to the cached pubKey field.
This is a classic check-then-act race.

Remove the caching entirely to fix the race. The overhead of parsing
a public key is minimal and doesn't justify the added complexity and
race risk of caching.
2025-12-08 13:49:17 +02:00
Yong
ec480f033f
Merge pull request #10422 from mohamedawnallah/handle-partial-tls-files-v0.20.x
tls_manager: port tls partial files handling back to `v0.20.x-branch`
2025-12-05 20:05:54 +08:00
Yong
5bfcdf9957
Merge pull request #10418 from ellemouton/fixSourceNodeSetting-v0.20.x
backport[0.20.x]: graph/db: fix SetSourceNode no rows error
2025-12-04 20:35:52 +08:00
Mohamed Awnallah
222e038a77 docs: update release notes 2025-12-04 11:20:41 +00:00
Thiago Romão Barcala
76b0701745 tls_manager.go: handle case when either TLS pair files exist 2025-12-04 11:19:57 +00:00
Mohamed Awnallah
24de03d766 tls_manager_test.go: reproduce partial tls files handling
When there is only one of the tls pairs (key/certificate) and the
other is missing, the TLS manager currently assumes it exists
and ignore generating them. This results in error propgated to user
that the other tls pair file is missing/not found.
2025-12-04 11:19:57 +00:00
Elle Mouton
2590593c3f
docs: add release note 2025-12-04 12:21:42 +02:00
Elle Mouton
c207461aca
graph/db: fix SetSourceNode race with lenient upsert
This commit fixes a race condition where multiple goroutines call
SetSourceNode concurrently during startup, causing sql.ErrNoRows
errors. The race occurs when multiple code paths (setSelfNode,
createNewHiddenService, RPC updates) read the same old timestamp,
independently increment it to the same new value (T+1), and race to
write.

The fix uses the new UpsertSourceNode SQL query (without strict
timestamp constraint) instead of UpsertNode. This allows
last-write-wins semantics for our own node, ensuring all parameter
changes persist even when timestamps collide.

Refactored sql_store.go for reusability:
- upsertNodeAncillaryData: common logic for features/addresses/extras
- populateNodeParams: common parameter building with callback pattern
- buildNodeUpsertParams: builds params for strict UpsertNode
- buildSourceNodeUpsertParams: builds params for lenient UpsertSourceNode
- upsertSourceNode: new function using lenient query

Updated TestSetSourceNodeSameTimestamp to verify that concurrent
updates with the same timestamp now succeed and parameter changes
persist.

Fixes the itest error:
"unable to upsert source node: upserting node(...): sql: no rows in
result set"
2025-12-04 12:21:40 +02:00
Elle Mouton
2a9e82b60a
sqldb: add UpsertSelfNode query
This query is less strict in terms of the latest update timestamp field.
We want to be less strict with our own node data since we always want
our own updates recorded.
2025-12-04 12:15:06 +02:00
Elle Mouton
13c6a3777c
graph/db: add test for SetSourceNode same timestamp behavior
This commit adds TestSetSourceNodeSameTimestamp to demonstrate the
current behavior when SetSourceNode is called with the same last update
timestamp. The test reveals a difference between the SQL and bbolt
implementations:

- SQL store returns sql.ErrNoRows when attempting to update with the
  same timestamp, as the upsert query's UPDATE clause requires the new
  timestamp to be strictly greater than the existing one
- bbolt store silently ignores stale updates and returns no error

This behavior is important to document because our own node
announcements may change quickly with the same timestamp, unlike
announcements from other nodes where same timestamp typically means
identical parameters.
2025-12-04 12:15:02 +02:00
Olaoluwa Osuntokun
b60920b1ae
Merge pull request #10402 from ziggie1984/backport/fix-mission-control-startup
backport:allow misson control manager to startup despite errors
2025-12-02 19:20:56 -08:00
ziggie
1fa6f70b0d
docs: add release-notes for LND 20.1 2025-12-02 20:47:50 +01:00
ziggie
2d477d699d
routing: allow misson control manager to startup despite errors
We now allow the mission control manager to skip over deserializable
errors. We cannot repair this these results but we just skip over
it so we can startup properly.

When fetchAll() encounters entries that fail to deserialize, in
addition to skipping them, now also:

- Delete the corrupted entries from the database
- Remove them from the in-memory keysMap and keys tracking structures

This prevents corrupted entries from:
- Being counted toward maxRecords, which would cause valid entries
  to be pruned prematurely
- Persisting in the database indefinitely
- Causing inaccurate entry counts in startup logs
2025-12-02 20:47:50 +01:00
Olaoluwa Osuntokun
4eaf57d71d
Merge pull request #10397 from ziggie1984/backport/add-back-globallock
backport: readd global lock for postgres and the channeldb_kv table
2025-12-01 14:44:34 -08:00
ziggie
6bdfb1dc4d
docs: add release-notes for LND 20.1 2025-11-28 09:36:34 +01:00
ziggie
a25659a6e4
lncfg+scripts: use configurable global lock for postgres backends
Replace hardcoded WithGlobalLock assignment with configurable
options wallet postgres backends. Also add the WithGlobalLock
option to the channeldb table for postgres backends.

Defaults:
- channeldb: false (allow concurrent access)
- wallet: true (maintain safe single-writer behavior)

Users can now override these defaults via:
- db.postgres.channeldb-with-global-lock
- db.postgres.walletdb-with-global-lock

This gives operators flexibility while maintaining safe defaults
until full native SQL migration is complete.

Moreover exclude db.postgres.walletdb-with-global-lock check
in the sample config file script. We cannot easily check the
correct default because we set it later in the LND startup
sequence so we exclude it.
2025-11-28 09:36:34 +01:00
ziggie
dd304e94fa
mod: use local path for sqldb until the new version is tagged 2025-11-28 09:36:33 +01:00
ziggie
bd8f49afd6
sqldb: add global lock config options for postgres
Add two configuration options to control global lock usage for
different postgres database backends:

- ChannelDBWithGlobalLock: for channeldb access (default: false)
- WalletDBWithGlobalLock: for wallet database access (default: true)

These allow fine-grained control over which databases use global
locks, rather than hardcoding the behavior. This is a temporary
measure until the revocation log and wallet are migrated to native
SQL and become fully concurrent-safe.
2025-11-28 09:27:29 +01:00
Yong
0aa757b022
Merge pull request #10398 from bitromortac/backport/2511-fix-tor-healthcheck
backport: server: prevent duplicate onion addresses in getinfo
2025-11-28 00:06:14 +08:00
bitromortac
7bf9f30b55
docs: add release-notes for lnd v0.20.1 2025-11-27 13:11:18 +01:00
bitromortac
63da9b3259
server: ensure unique addresses for node ann
Modifiers of the node announcement may add duplicate addresses, which we
remove here after the modifications were applied. This also ensures that
any previously added duplicate addresses are removed as well.
2025-11-27 13:11:18 +01:00
Yong
f8b5cb0e89
Merge pull request #10395 from ffranr/backport/v0.20.x-branch-add-field-committxblockheight-to-resolutionreq
backport: lnwallet: add field `CommitTxBlockHeight` to ResolutionReq
2025-11-27 09:41:18 +08:00
ffranr
07003fc3a0
lnwallet: add field CommitTxBlockHeight to ResolutionReq
Introduce `CommitTxBlockHeight` field to the `ResolutionReq` structure
and related methods. This field records the block height where a
commitment transaction has confirmed.

(cherry picked from commit 3d2b0d703e)
2025-11-26 15:53:21 +00:00
Olaoluwa Osuntokun
64e2f013e2
Merge pull request #10388 from ziggie1984/backport/fix-scanning-issue
Backport contracourt fix #10387
2025-11-24 10:10:06 -08:00
ziggie
465013f097
docs: add release-notes for LND 20.1 2025-11-22 09:47:23 +01:00
ziggie
856693d70c
contractcourt: use confheight instead of rescanning the chain 2025-11-22 09:47:23 +01:00
ziggie
c4d8dc8e00
contracourt: fix comment 2025-11-22 09:47:22 +01:00
ziggie
dac47cf698
contracourt: rename broadcastHeight to confirmHeight
The broadcastHeight was misleading because the commit resolver
is only created when the commitment transaction is confirmed.
2025-11-22 09:47:17 +01:00
Olaoluwa Osuntokun
911f7296a8
Merge pull request #10381 from ziggie1984/backport/10378
Backport graph cache fix #10378 to minor release branch
2025-11-19 14:36:33 -08:00
ziggie
da55e567e9
docs: add release-notes for 20.1 2025-11-19 19:03:19 +01:00
ziggie
8bc240770a
graph: add regression test for the fixed behaviour 2025-11-19 19:03:19 +01:00
ziggie
f16886041d
graph: fix graph cache population for channels with both policies disabled
Fix a bug where channels with both policies disabled were not added to
the graph cache during startup. When a policy update later re-enabled
one of the directions, the update would succeed in the database but fail
to update the graph cache (since the channel structure was never added),
preventing the channel from being used for routing.
2025-11-19 19:03:15 +01:00
1190 changed files with 39484 additions and 139049 deletions

View file

@ -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
View file

@ -0,0 +1,4 @@
version: v1.57.0
plugins:
- module: 'github.com/lightningnetwork/lnd/tools/linters'
path: ./tools/linters

View file

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

View file

@ -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"}'

View file

@ -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[@]}"

View file

@ -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:*)'

View file

@ -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 }}

View file

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

View file

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

View file

@ -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

View file

@ -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
```

View file

@ -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
View file

@ -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*

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -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`.

View file

@ -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`.

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}
}
}
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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.
}

View file

@ -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)
},
)
}

View file

@ -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)
}()
}

View file

@ -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",
)
})
}

View file

@ -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

View file

@ -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=

View file

@ -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]
}

View file

@ -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
}

View file

@ -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
}
}
}
}

View file

@ -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")
}

View file

@ -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 + ")"
}

View file

@ -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
}

View file

@ -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)
}

View file

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

View file

@ -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()

View file

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

View file

@ -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

View file

@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnwire"
)

View file

@ -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"
)

View file

@ -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,
)

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -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"
)

View file

@ -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)
}

View file

@ -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"
)

View file

@ -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
}

View file

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

View file

@ -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() {

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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()

View file

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

View file

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

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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,
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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),

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import (
"fmt"
"time"
"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/lightningnetwork/lnd/chainntnfs"
)

View file

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

View file

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

View file

@ -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"
)

View file

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

View file

@ -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)
}

View file

@ -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"
)

View file

@ -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"
)

View file

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

View file

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

View file

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

View file

@ -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