Repository: lightningnetwork/lnd
Pull request #8512 (2024-03-01 01:45:22)

[3/4] - lnwallet/chancloser: add new protofsm based RBF chan closer 
## Overview 

In this PR, we add a new RBF based co-op channel close state machine. This uses the new `protofsm` package to define the states and transitions for the state machine. The new state machine is then also updated to become a `peer.MsgEndpoint` which allows us to use the new `peer.MsgRouter` to handle all message dispatch _into_ the state machine. A series of new wrapper structs (taking care to always accept interfaces to decouple from the concrete types) are created to be able to initialize the environment needed by the new state machine. 

This PR doesn't yet include integration within the peer struct. That will come in a follow up PR as we need some additional changes to be able to handle the two possible co-op types that will exist. 

The diff might look somewhat large, but over half of it us unit tests, and a large portion of the non-test diff is the new state and event declarations. 
 
## State Machine Flow

The new state machine can be modeled with the following diagram:
```mermaid
---
title: Co-Op Close V2
---
stateDiagram-v2
    state CoopCloseV2 {
    [*] --> ChannelActive
    ChannelActive --> ShutdownPending: send_shutdown
    ChannelActive --> ShutdownPending: shutdown_received

	ShutdownPending --> ChannelFlushing: shutdown_received
    ShutdownPending --> ChannelFlushing: shutdown_complete
    
    ChannelFlushing --> ClosingNegotiation: channel_flushed

    state ClosingNegotiation {
		direction TB
        [*] --> LocalCloseStart
        [*] --> RemoteCloseStart
        
        LocalCloseStart --> LocalOfferSent: send_offer
        LocalOfferSent --> ClosePending: local_sig_received
        
        RemoteCloseStart --> ClosePending: offer_received
    }
    
    ClosingNegotiation --> ShutdownPending: send_shutdown
    ClosingNegotiation --> ShutdownPending: shutdown_received
   
    ClosingNegotiation --> CloseFin: txn_confirmation
    }
```

## Remaining TODOs


- [x] unit tests for the state machine
- [ ] commit the above diagram and a msg flow diagram to repo as documentation 



Fixes https://github.com/lightningnetwork/lnd/issues/7091

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Auto Review Skipped
> 
> Auto reviews are disabled on base/target branches other than the default branch. Please add the base/target branch pattern to the list of additional branches to be reviewed in the settings.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository.
> 
> To trigger a single review, invoke the `@coderabbitai review` command.
> 

<!-- end of auto-generated comment: skip review by coderabbit.ai --><!-- tweet_start -->

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

<details>
<summary>Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<!-- tweet_end --><!-- tips_start -->

---

<details>
<summary>Tips</summary>

### Chat

There are 3 ways to chat with CodeRabbit:

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
	- `I pushed a fix in commit <commit_id>.`
	- `Generate unit-tests for this file.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
	- `@coderabbitai generate unit tests for this file.`
	-	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
	- `@coderabbitai generate interesting stats about this repository from git and render them as a table.`
	- `@coderabbitai show all the console.log statements in this repository.`
	- `@coderabbitai read src/utils.ts and generate unit tests.`
	- `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (invoked as PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger a review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai help` to get help.


Additionally, you can add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.

### CodeRabbit Configration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- The JSON schema for the configuration file is available [here](https://coderabbit.ai/integrations/coderabbit-overrides.v2.json).
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/coderabbit-overrides.v2.json`

### CodeRabbit Discord Community

Join our [Discord Community](https://discord.com/invite/GsXnASn26c) to get help, request features, and share feedback.

</details>

<!-- tips_end -->
github-actions[bot]: ## Pull reviewers stats
Stats of the last 30 days for [lnd](https://github.com/lightningnetwork/lnd):
|                                                                                                                                                                     | User               | Total reviews  | Time to review                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Total comments  |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| <a href="https://github.com/guggero"><img src="https://avatars.githubusercontent.com/u/1008879?u=2c337f92b7dbc69b8aaff7885926f2bc1865db0d&v=4" width="32"></a>      | guggero<br/>🥇     | **14**<br/>▀▀▀ | [1d 3h 52m](https://app.flowwer.dev/charts/review-time/~(u~(i~'1008879~n~'guggero)~p~30~r~(~(d~'s84llj~t~'eb4)~(d~'s863f0~t~'j3s)~(d~'s863j4~t~'p9g)~(d~'s863zc~t~'1wox)~(d~'s8661m~t~'1yr7)~(d~'s8665r~t~'1yvc)~(d~'s86nxh~t~'2gn2)~(d~'s86rc1~t~'170t)~(d~'s8dizq~t~'493c)~(d~'s8dkf8~t~'3me0)~(d~'s8dkgn~t~'49hy)~(d~'s8fok6~t~'a48y)~(d~'s8hjr6~t~'79)~(d~'s8qi0d~t~'4xg7)~(d~'s8ua1f~t~'p9a)~(d~'s93hjf~t~'2gkf)~(d~'s975cb~t~'kyy)~(d~'s9gjo1~t~'8ncl)~(d~'s9h0f1~t~'c1g)~(d~'s9itsv~t~'25fa)~(d~'s9j1ll~t~'2d80)~(d~'s9k4nw~t~'l29)~(d~'s9k4zo~t~'c8o8)~(d~'s9lxxd~t~'e1lx)~(d~'s9ly0w~t~'2ef9))))<br/> | 26<br/>▀▀       |
| <a href="https://github.com/yyforyongyu"><img src="https://avatars.githubusercontent.com/u/5845072?u=75cc18e789ec4b275a32c170ca6cd5118fdfe120&v=4" width="32"></a>  | yyforyongyu<br/>🥈 | 9<br/>▀▀       | [1d 16h 25m](https://app.flowwer.dev/charts/review-time/~(u~(i~'5845072~n~'yyforyongyu)~p~30~r~(~(d~'s85v2j~t~'arb)~(d~'s88785~t~'34ag)~(d~'s89mqq~t~'4jt1)~(d~'s8dk35~t~'4a6r)~(d~'s8dlz9~t~'3ny1)~(d~'s8hzd8~t~'ftb)~(d~'s91oiv~t~'y7d)~(d~'s91pea~t~'ofa)~(d~'s91zfm~t~'nkbh)~(d~'s960c8~t~'rl83)~(d~'s9is7b~t~'23tq))))<br/>                                                                                                                                                                                                                                                                               | 8<br/>▀         |
| <a href="https://github.com/ellemouton"><img src="https://avatars.githubusercontent.com/u/25280249?u=4da24e4115facc85d4f9937fe6cbcff5d3abf6e0&v=4" width="32"></a>  | ellemouton<br/>🥉  | 8<br/>▀▀       | [4d 8h 7m](https://app.flowwer.dev/charts/review-time/~(u~(i~'25280249~n~'ellemouton)~p~30~r~(~(d~'s8658b~t~'12am)~(d~'s86ebr~t~'271c)~(d~'s86ogw~t~'2h6h)~(d~'s8ub25~t~'qa0)~(d~'s9462x~t~'1yr)~(d~'s9718k~t~'gv7)~(d~'s9ibg3~t~'e34s)~(d~'s9iut9~t~'6m6a)~(d~'s9k61a~t~'j8tv)~(d~'s9k99d~t~'jc1y)~(d~'s9k9e2~t~'80r3)~(d~'s9k9mi~t~'80zj)~(d~'s9ka3f~t~'81gg)~(d~'s9kaez~t~'81s0)~(d~'s9kakt~t~'81xu)~(d~'s9kapl~t~'822m)~(d~'s9kazq~t~'82cr)~(d~'s9kyoi~t~'8q1j))))<br/>▀                                                                                                                                   | 25<br/>▀▀       |
| <a href="https://github.com/ProofOfKeags"><img src="https://avatars.githubusercontent.com/u/4033651?u=20a8114dedff5aa07ee208a3860147f96895d94d&v=4" width="32"></a> | ProofOfKeags<br/>  | 6<br/>▀        | [1d 16h 3m](https://app.flowwer.dev/charts/review-time/~(u~(i~'4033651~n~'ProofOfKeags)~p~30~r~(~(d~'s854en~t~'i1q)~(d~'s86r3c~t~'24qf)~(d~'s86r9c~t~'24wf)~(d~'s86t80~t~'26v3)~(d~'s8id33~t~'28pd)~(d~'s8jwpr~t~'3sc1)~(d~'s94qp7~t~'ml1)~(d~'s96ii9~t~'qe4j)~(d~'s9h4ek~t~'339q)~(d~'s9h4v4~t~'ghj)~(d~'s9horx~t~'dknr)~(d~'s9hq4e~t~'5hhf)~(d~'s9hqep~t~'5hrq)~(d~'s9jfpd~t~'772e)~(d~'s9jk2k~t~'7bfl))))<br/>                                                                                                                                                                                              | **42**<br/>▀▀▀▀ |
| <a href="https://github.com/bhandras"><img src="https://avatars.githubusercontent.com/u/554360?u=e94d0ce1157aaef37a43df671cffe181a61230ad&v=4" width="32"></a>      | bhandras<br/>      | 5<br/>▀        | [4d 10h 42m](https://app.flowwer.dev/charts/review-time/~(u~(i~'554360~n~'bhandras)~p~30~r~(~(d~'s86hqz~t~'1eta)~(d~'s89pks~t~'em3)~(d~'s8yi81~t~'k500)~(d~'s92krw~t~'o7jv)~(d~'s9ir8w~t~'22vb)~(d~'s9kgz9~t~'88ca)~(d~'s9kh12~t~'88e3)~(d~'s9kh4e~t~'88hf))))<br/>▀                                                                                                                                                                                                                                                                                                                                           | 5<br/>          |
| <a href="https://github.com/Crypt-iQ"><img src="https://avatars.githubusercontent.com/u/15145615?u=a19ad52f8c597f3518a89a52014fabefda644078&v=4" width="32"></a>    | Crypt-iQ<br/>      | 3<br/>▀        | [1d 21h 30m](https://app.flowwer.dev/charts/review-time/~(u~(i~'15145615~n~'Crypt-iQ)~p~30~r~(~(d~'s84tqy~t~'7e1)~(d~'s86lo1~t~'1zb4)~(d~'s86rzh~t~'25mk)~(d~'s86se0~t~'2613)~(d~'s8rcs4~t~'b8ee)~(d~'s8susx~t~'cqf7)~(d~'s8sy6k~t~'ctsu)~(d~'s9h3e9~t~'4ura))))<br/>                                                                                                                                                                                                                                                                                                                                          | 8<br/>▀         |
| <a href="https://github.com/Roasbeef"><img src="https://avatars.githubusercontent.com/u/998190?v=4" width="32"></a>                                                 | Roasbeef<br/>      | 2<br/>         | [12h 16m](https://app.flowwer.dev/charts/review-time/~(u~(i~'998190~n~'Roasbeef)~p~30~r~(~(d~'s8evo8~t~'gk3)~(d~'s9hmfy~t~'y2d)~(d~'s9hmh3~t~'y3i))))<br/>                                                                                                                                                                                                                                                                                                                                                                                                                                                     | 3<br/>          |
| <a href="https://github.com/saubyk"><img src="https://avatars.githubusercontent.com/u/39208279?u=393cf55b8befb6673d7c3df73e4eccd90c0fedc9&v=4" width="32"></a>      | saubyk<br/>        | 2<br/>         | [**8h 12m**](https://app.flowwer.dev/charts/review-time/~(u~(i~'39208279~n~'saubyk)~p~30~r~(~(d~'s8ll0y~t~'gs)~(d~'s92a14~t~'1924))))<br/>                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | 0<br/>          |
| <a href="https://github.com/morehouse"><img src="https://avatars.githubusercontent.com/u/19176026?v=4" width="32"></a>                                              | morehouse<br/>     | 1<br/>         | [14d 21h 46m](https://app.flowwer.dev/charts/review-time/~(u~(i~'19176026~n~'morehouse)~p~30~r~(~(d~'s960xd~t~'rlt8))))<br/>▀▀▀▀                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | 0<br/>          |
| <a href="https://github.com/ziggie1984"><img src="https://avatars.githubusercontent.com/u/90319354?u=04ef4180951baf4d77c14f58b7c931b21ae5353c&v=4" width="32"></a>  | ziggie1984<br/>    | 1<br/>         | [10d 15h 59m](https://app.flowwer.dev/charts/review-time/~(u~(i~'90319354~n~'ziggie1984)~p~30~r~(~(d~'s8wcpw~t~'hzhv)~(d~'s8zvuz~t~'limy))))<br/>▀▀▀                                                                                                                                                                                                                                                                                                                                                                                                                                                           | 2<br/>          |
saubyk: Hi @Roasbeef can you please explain the scenario where you're expecting the state to transition back from `ClosingNegotiation` to `ShutdownPending`?

Given the channel flushing has already happened before the state moved to `ClosingNegotiation`, what would be the scenario where that step would need to be executed again?
Roasbeef: @saubyk good question, so I need to update the diagram slightly, but you're right in that at that point the channel is _already_ flushed. With the code that's checked in, there's a special fast path that'll transition from `ChannelActive` all the way to `ClosingNegotiation` if we detect that the channel is already flushed. It'll already be flushed if we're doing an RBF iteration, or if we're reconnecting and want to resume the closing process. 

Here's the location of one of the fast path transitions: 
  * https://github.com/lightningnetwork/lnd/blob/c273bb4755a43e675d4f538c9fdad1b74f713690/lnwallet/chancloser/rbf_coop_transitions.go#L350-L364

So in that case we sent shutdown, received it, then rather than _wait_ in the `ChannelFlushing` state, we'll emit an internal event to have us go directly to the `ChannelNegotiation` phase. 

Also just to clarify, the way the protocol works is that when either side wants to do a new RBF iteration (RBF their closing txn), they send the `shutdown` message _again_. You effectively can drop all state, then act as if you're closing for the first time. Reading between the lines, I think yuo're right in that you could just send the signatures again, but perhaps one side wants to close to a new address, and that information is contained within teh `shutdown` message. 

I kept things like this rather than introducing some new states to keep the amount of total states to a minimum, and re-use more of the existing transition logic. As an example, if they send a `shutdown` message to us, we still want to validate that they're using the same upfront shutdown address, etc. 
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@proofofkeags: review reminder
@morehouse: review reminder
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @yyforyongyu: review reminder
@crypt-iq: review reminder
@morehouse: review reminder
@roasbeef, remember to re-request review from reviewers when ready
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
Roasbeef: Updated to point to master. 
lightninglabs-deploy: @roasbeef, remember to re-request review from reviewers when ready
Crypt-iQ: Haven't looked too much into 4/4 yet, so I'm not sure if these items are addressed there. I do think the following should be addressed though (especially points 2 & 3):
- need to validate that OP_RETURN in the scriptPubKey is ok in some cases but not in others
- apparently, sometimes the remote `closing_complete` is allowed to be invalid. See this flow from the BOLT here: https://github.com/lightning/bolts/pull/1205/files#diff-ed04ca2c673fd6aabde69389511fa9ee60cb44d6b2ef6c88b549ffaa753d6afeR1540-R1554
- in addition to wire fuzz tests, I think we should have a fuzzer for the actual state machine introduced here where we can feed it random events.

Besides that the PR looks good and can approve once CI passes
Roasbeef: > apparently, sometimes the remote closing_complete is allowed to be invalid? See this flow from the BOLT here where Alice ignores Bob's closing_complete: https://github.com/lightning/bolts/pull/1205/files#diff-ed04ca2c673fd6aabde69389511fa9ee60cb44d6b2ef6c88b549ffaa753d6afeR1540-R1554


Looks like that's trying to handle a case where Alice has sent a shutdown with one script, but then wants to switch to another. I don't quite get that example though, Alice sent the script using the shutdown so she much send another one (after the round) if she wants to swap her script. Will reply on the spec. 

Patch:
diff --git a/docs/release-notes/release-notes-0.18.4.md b/docs/release-notes/release-notes-0.18.4.md
index e52dbc3d1..ab72c01c3 100644
--- a/docs/release-notes/release-notes-0.18.4.md
+++ b/docs/release-notes/release-notes-0.18.4.md
@@ -26,9 +26,6 @@
 * [Fixed a bug](https://github.com/lightningnetwork/lnd/pull/9324) to prevent
   potential deadlocks when LND depends on external components (e.g. aux
   components, hooks).
-  
-* [Make sure blinded payment failures are handled correctly in the mission
-controller](https://github.com/lightningnetwork/lnd/pull/9316).
 
 # New Features
 
@@ -124,5 +121,4 @@ types in a series of changes:
 * George Tsagkarelis
 * Olaoluwa Osuntokun
 * Oliver Gugger
-* Ziggie
 
diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md
index 6f91a8fc8..ee0b0c136 100644
--- a/docs/release-notes/release-notes-0.19.0.md
+++ b/docs/release-notes/release-notes-0.19.0.md
@@ -60,7 +60,7 @@
 * [Make the contract resolutions for the channel arbitrator optional](
   https://github.com/lightningnetwork/lnd/pull/9253)
 
-* [Fixed a bug](https://github.com/lightningnetwork/lnd/pull/9322) that caused
+  * [Fixed a bug](https://github.com/lightningnetwork/lnd/pull/9322) that caused
     estimateroutefee to ignore the default payment timeout.
 
 # New Features
@@ -80,7 +80,7 @@
 ## RPC Additions
 
 * [Add a new rpc endpoint](https://github.com/lightningnetwork/lnd/pull/8843)
-  `BumpForceCloseFee` which moves the functionality solely available in the
+  `BumpForceCloseFee` which moves the functionality soley available in the
   `lncli` to LND hence making it more universal.
 
 * [The `walletrpc.FundPsbt` RPC method now has an option to specify the fee as
diff --git a/input/script_utils.go b/input/script_utils.go
index 90e368dd1..000efe958 100644
--- a/input/script_utils.go
+++ b/input/script_utils.go
@@ -29,9 +29,9 @@ var (
 	SequenceLockTimeSeconds = uint32(1 << 22)
 )
 
-// MustParsePubKey parses a hex encoded public key string into a public key and
+// mustParsePubKey parses a hex encoded public key string into a public key and
 // panic if parsing fails.
-func MustParsePubKey(pubStr string) btcec.PublicKey {
+func mustParsePubKey(pubStr string) btcec.PublicKey {
 	pubBytes, err := hex.DecodeString(pubStr)
 	if err != nil {
 		panic(err)
@@ -55,7 +55,7 @@ var (
 	// https://github.com/lightninglabs/lightning-node-connect/tree/
 	// master/mailbox/numsgen, with the seed phrase "Lightning Simple
 	// Taproot".
-	TaprootNUMSKey = MustParsePubKey(TaprootNUMSHex)
+	TaprootNUMSKey = mustParsePubKey(TaprootNUMSHex)
 )
 
 // Signature is an interface for objects that can populate signatures during
diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go
index 398a8a9f3..5081f0beb 100644
--- a/lnwallet/chancloser/chancloser.go
+++ b/lnwallet/chancloser/chancloser.go
@@ -539,8 +539,8 @@ func (c *ChanCloser) AuxOutputs() fn.Option[AuxCloseOutputs] {
 // upfront script is set, we check whether it matches the script provided by
 // our peer. If they do not match, we use the disconnect function provided to
 // disconnect from the peer.
-func validateShutdownScript(disconnect func() error, upfrontScript,
-	peerScript lnwire.DeliveryAddress, netParams *chaincfg.Params) error {
+func validateShutdownScript(upfrontScript, peerScript lnwire.DeliveryAddress,
+	netParams *chaincfg.Params) error {
 
 	// Either way, we'll make sure that the script passed meets our
 	// standards. The upfrontScript should have already been checked at an
@@ -568,12 +568,6 @@ func validateShutdownScript(disconnect func() error, upfrontScript,
 		chancloserLog.Warnf("peer's script: %x does not match upfront "+
 			"shutdown script: %x", peerScript, upfrontScript)
 
-		// Disconnect from the peer because they have violated option upfront
-		// shutdown.
-		if err := disconnect(); err != nil {
-			return err
-		}
-
 		return ErrUpfrontShutdownScriptMismatch
 	}
 
@@ -630,7 +624,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
 		// If the remote node opened the channel with option upfront
 		// shutdown script, check that the script they provided matches.
 		if err := validateShutdownScript(
-			c.cfg.Disconnect,
 			c.cfg.Channel.RemoteUpfrontShutdownScript(),
 			msg.Address, c.cfg.ChainParams,
 		); err != nil {
@@ -681,7 +674,6 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) (
 		// If the remote node opened the channel with option upfront
 		// shutdown script, check that the script they provided matches.
 		if err := validateShutdownScript(
-			c.cfg.Disconnect,
 			c.cfg.Channel.RemoteUpfrontShutdownScript(),
 			msg.Address, c.cfg.ChainParams,
 		); err != nil {
diff --git a/lnwallet/chancloser/interface.go b/lnwallet/chancloser/interface.go
index f774c8103..74f096973 100644
--- a/lnwallet/chancloser/interface.go
+++ b/lnwallet/chancloser/interface.go
@@ -3,7 +3,6 @@ package chancloser
 import (
 	"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
 	"github.com/btcsuite/btcd/btcutil"
-	"github.com/btcsuite/btcd/chaincfg/chainhash"
 	"github.com/btcsuite/btcd/wire"
 	"github.com/lightningnetwork/lnd/channeldb"
 	"github.com/lightningnetwork/lnd/fn/v2"
@@ -100,7 +99,7 @@ type Channel interface { //nolint:interfacebloat
 		localDeliveryScript []byte, remoteDeliveryScript []byte,
 		closeOpt ...lnwallet.ChanCloseOpt,
 	) (
-		input.Signature, *chainhash.Hash, btcutil.Amount, error)
+		input.Signature, *wire.MsgTx, btcutil.Amount, error)
 
 	// CompleteCooperativeClose persistently "completes" the cooperative
 	// close by producing a fully signed co-op close transaction.
diff --git a/lnwallet/chancloser/mock.go b/lnwallet/chancloser/mock.go
new file mode 100644
index 000000000..a2cc4270c
--- /dev/null
+++ b/lnwallet/chancloser/mock.go
@@ -0,0 +1,176 @@
+package chancloser
+
+import (
+	"sync/atomic"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg/chainhash"
+	"github.com/btcsuite/btcd/wire"
+	"github.com/lightningnetwork/lnd/chainntnfs"
+	"github.com/lightningnetwork/lnd/channeldb"
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/input"
+	"github.com/lightningnetwork/lnd/lnwallet"
+	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
+	"github.com/lightningnetwork/lnd/lnwire"
+	"github.com/stretchr/testify/mock"
+)
+
+type dummyAdapters struct {
+	mock.Mock
+
+	msgSent atomic.Bool
+
+	confChan  chan *chainntnfs.TxConfirmation
+	spendChan chan *chainntnfs.SpendDetail
+}
+
+func newDaemonAdapters() *dummyAdapters {
+	return &dummyAdapters{
+		confChan:  make(chan *chainntnfs.TxConfirmation, 1),
+		spendChan: make(chan *chainntnfs.SpendDetail, 1),
+	}
+}
+
+func (d *dummyAdapters) SendMessages(pub btcec.PublicKey,
+	msgs []lnwire.Message) error {
+
+	defer d.msgSent.Store(true)
+
+	args := d.Called(pub, msgs)
+
+	return args.Error(0)
+}
+
+func (d *dummyAdapters) BroadcastTransaction(tx *wire.MsgTx,
+	label string) error {
+
+	args := d.Called(tx, label)
+
+	return args.Error(0)
+}
+
+func (d *dummyAdapters) DisableChannel(op wire.OutPoint) error {
+	args := d.Called(op)
+
+	return args.Error(0)
+}
+
+func (d *dummyAdapters) RegisterConfirmationsNtfn(txid *chainhash.Hash,
+	pkScript []byte, numConfs, heightHint uint32,
+	opts ...chainntnfs.NotifierOption,
+) (*chainntnfs.ConfirmationEvent, error) {
+
+	args := d.Called(txid, pkScript, numConfs)
+
+	err := args.Error(0)
+
+	return &chainntnfs.ConfirmationEvent{
+		Confirmed: d.confChan,
+	}, err
+}
+
+func (d *dummyAdapters) RegisterSpendNtfn(outpoint *wire.OutPoint,
+	pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) {
+
+	args := d.Called(outpoint, pkScript, heightHint)
+
+	err := args.Error(0)
+
+	return &chainntnfs.SpendEvent{
+		Spend: d.spendChan,
+	}, err
+}
+
+type mockFeeEstimator struct {
+	mock.Mock
+}
+
+func (m *mockFeeEstimator) EstimateFee(chanType channeldb.ChannelType,
+	localTxOut, remoteTxOut *wire.TxOut,
+	idealFeeRate chainfee.SatPerKWeight) btcutil.Amount {
+
+	args := m.Called(chanType, localTxOut, remoteTxOut, idealFeeRate)
+	return args.Get(0).(btcutil.Amount)
+}
+
+type mockChanObserver struct {
+	mock.Mock
+}
+
+func (m *mockChanObserver) NoDanglingUpdates() bool {
+	args := m.Called()
+	return args.Bool(0)
+}
+
+func (m *mockChanObserver) DisableIncomingAdds() error {
+	args := m.Called()
+	return args.Error(0)
+}
+
+func (m *mockChanObserver) DisableOutgoingAdds() error {
+	args := m.Called()
+	return args.Error(0)
+}
+
+func (m *mockChanObserver) MarkCoopBroadcasted(txn *wire.MsgTx,
+	local bool) error {
+
+	args := m.Called(txn, local)
+	return args.Error(0)
+}
+
+func (m *mockChanObserver) MarkShutdownSent(deliveryAddr []byte,
+	isInitiator bool) error {
+
+	args := m.Called(deliveryAddr, isInitiator)
+	return args.Error(0)
+}
+
+func (m *mockChanObserver) FinalBalances() fn.Option[ShutdownBalances] {
+	args := m.Called()
+	return args.Get(0).(fn.Option[ShutdownBalances])
+}
+
+func (m *mockChanObserver) DisableChannel() error {
+	args := m.Called()
+	return args.Error(0)
+}
+
+type mockErrorReporter struct {
+	mock.Mock
+}
+
+func (m *mockErrorReporter) ReportError(err error) {
+	m.Called(err)
+}
+
+type mockCloseSigner struct {
+	mock.Mock
+}
+
+func (m *mockCloseSigner) CreateCloseProposal(fee btcutil.Amount,
+	localScript []byte, remoteScript []byte,
+	closeOpt ...lnwallet.ChanCloseOpt) (
+	input.Signature, *wire.MsgTx, btcutil.Amount, error) {
+
+	args := m.Called(fee, localScript, remoteScript, closeOpt)
+
+	return args.Get(0).(input.Signature), args.Get(1).(*wire.MsgTx),
+		args.Get(2).(btcutil.Amount), args.Error(3)
+}
+
+func (m *mockCloseSigner) CompleteCooperativeClose(localSig,
+	remoteSig input.Signature,
+	localScript, remoteScript []byte,
+	fee btcutil.Amount, closeOpt ...lnwallet.ChanCloseOpt,
+) (*wire.MsgTx, btcutil.Amount, error) {
+
+	args := m.Called(
+		localSig, remoteSig, localScript, remoteScript, fee, closeOpt,
+	)
+
+	return args.Get(0).(*wire.MsgTx), args.Get(1).(btcutil.Amount),
+		args.Error(2)
+}
diff --git a/lnwallet/chancloser/rbf_coop_msg_mapper.go b/lnwallet/chancloser/rbf_coop_msg_mapper.go
new file mode 100644
index 000000000..96c855686
--- /dev/null
+++ b/lnwallet/chancloser/rbf_coop_msg_mapper.go
@@ -0,0 +1,77 @@
+package chancloser
+
+import (
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/lnwire"
+)
+
+// RbfMsgMapper is a struct that implements the MsgMapper interface for the
+// rbf-coop close state machine. This enables the state machine to be used with
+// protofsm.
+type RbfMsgMapper struct {
+	// blockHeight is the height of the block when the co-op close request
+	// was initiated. This is used to validate conditions related to the
+	// thaw height.
+	blockHeight uint32
+
+	// chanID is the channel ID of the channel being closed.
+	chanID lnwire.ChannelID
+}
+
+// NewRbfMsgMapper creates a new RbfMsgMapper instance given the current block
+// height when the co-op close request was initiated.
+func NewRbfMsgMapper(blockHeight uint32,
+	chanID lnwire.ChannelID) *RbfMsgMapper {
+
+	return &RbfMsgMapper{
+		blockHeight: blockHeight,
+		chanID:      chanID,
+	}
+}
+
+// someEvent returns the target type as a protocol event option.
+func someEvent[T ProtocolEvent](m T) fn.Option[ProtocolEvent] {
+	return fn.Some(ProtocolEvent(m))
+}
+
+// isExpectedChanID returns true if the channel ID of the message matches the
+// bound instance.
+func (r *RbfMsgMapper) isExpectedChanID(chanID lnwire.ChannelID) bool {
+	return r.chanID == chanID
+}
+
+// MapMsg maps a wire message into a FSM event. If the message is not mappable,
+// then an error is returned.
+func (r *RbfMsgMapper) MapMsg(wireMsg lnwire.Message) fn.Option[ProtocolEvent] {
+	switch msg := wireMsg.(type) {
+	case *lnwire.Shutdown:
+		if !r.isExpectedChanID(msg.ChannelID) {
+			return fn.None[ProtocolEvent]()
+		}
+
+		return someEvent(&ShutdownReceived{
+			BlockHeight:    r.blockHeight,
+			ShutdownScript: msg.Address,
+		})
+
+	case *lnwire.ClosingComplete:
+		if !r.isExpectedChanID(msg.ChannelID) {
+			return fn.None[ProtocolEvent]()
+		}
+
+		return someEvent(&OfferReceivedEvent{
+			SigMsg: *msg,
+		})
+
+	case *lnwire.ClosingSig:
+		if !r.isExpectedChanID(msg.ChannelID) {
+			return fn.None[ProtocolEvent]()
+		}
+
+		return someEvent(&LocalSigReceived{
+			SigMsg: *msg,
+		})
+	}
+
+	return fn.None[ProtocolEvent]()
+}
diff --git a/lnwallet/chancloser/rbf_coop_states.go b/lnwallet/chancloser/rbf_coop_states.go
new file mode 100644
index 000000000..d5c207066
--- /dev/null
+++ b/lnwallet/chancloser/rbf_coop_states.go
@@ -0,0 +1,763 @@
+package chancloser
+
+import (
+	"fmt"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg"
+	"github.com/btcsuite/btcd/wire"
+	"github.com/lightningnetwork/lnd/chainntnfs"
+	"github.com/lightningnetwork/lnd/channeldb"
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/input"
+	"github.com/lightningnetwork/lnd/lntypes"
+	"github.com/lightningnetwork/lnd/lnwallet"
+	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
+	"github.com/lightningnetwork/lnd/lnwire"
+	"github.com/lightningnetwork/lnd/protofsm"
+)
+
+var (
+	// ErrInvalidStateTransition is returned when we receive an unexpected
+	// event for a given state.
+	ErrInvalidStateTransition = fmt.Errorf("invalid state transition")
+
+	// ErrTooManySigs is returned when we receive too many sigs from the
+	// remote party in the ClosingSigs message.
+	ErrTooManySigs = fmt.Errorf("too many sigs received")
+
+	// ErrNoSig is returned when we receive no sig from the remote party.
+	ErrNoSig = fmt.Errorf("no sig received")
+
+	// ErrUnknownFinalBalance is returned if we're unable to determine the
+	// final channel balance after a flush.
+	ErrUnknownFinalBalance = fmt.Errorf("unknown final balance")
+
+	// ErrRemoteCannotPay is returned if the remote party cannot pay the
+	// pay for the fees when it sends a signature.
+	ErrRemoteCannotPay = fmt.Errorf("remote cannot pay fees")
+
+	// ErrNonFinalSequence is returned if we receive a non-final sequence
+	// from the remote party for their signature.
+	ErrNonFinalSequence = fmt.Errorf("received non-final sequence")
+
+	// ErrCloserNoClosee is returned if our balance is dust, but the remote
+	// party includes our output.
+	ErrCloserNoClosee = fmt.Errorf("expected CloserNoClosee sig")
+
+	// ErrCloserAndClosee is returned when we expect a sig covering both
+	// outputs, it isn't present.
+	ErrCloserAndClosee = fmt.Errorf("expected CloserAndClosee sig")
+)
+
+// ProtocolEvent is a special interface used to create the equivalent of a
+// sum-type, but using a "sealed" interface. Protocol events can be used as
+// input to trigger a state transition, and also as output to trigger a new set
+// of events into the very same state machine.
+type ProtocolEvent interface {
+	protocolSealed()
+}
+
+// ProtocolEvents is a special type constraint that enumerates all the possible
+// protocol events. This is used mainly as type-level documentation, and may
+// also be useful to constraint certain state transition functions.
+type ProtocolEvents interface {
+	SendShutdown | ShutdownReceived | ShutdownComplete | ChannelFlushed |
+		SendOfferEvent | OfferReceivedEvent | LocalSigReceived |
+		SpendEvent
+}
+
+// SpendEvent indicates that a transaction spending the funding outpoint has
+// been confirmed in the main chain.
+type SpendEvent struct {
+	// Tx is the spending transaction that has been confirmed.
+	Tx *wire.MsgTx
+
+	// BlockHeight is the height of the block that confirmed the
+	// transaction.
+	BlockHeight uint32
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *SpendEvent) protocolSealed() {}
+
+// SendShutdown indicates that the user wishes to co-op close the channel, so we
+// should send a new shutdown message to the remote party.
+//
+// transition:
+//   - fromState: ChannelActive
+//   - toState: ChannelFlushing
+type SendShutdown struct {
+	// DeliveryAddr is the address we'd like to receive the funds to. If
+	// None, then a new addr will be generated.
+	DeliveryAddr fn.Option[lnwire.DeliveryAddress]
+
+	// IdealFeeRate is the ideal fee rate we'd like to use for the closing
+	// attempt.
+	IdealFeeRate chainfee.SatPerVByte
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *SendShutdown) protocolSealed() {}
+
+// ShutdownReceived indicates that we received a shutdown event so we need to
+// enter the flushing state.
+//
+// transition:
+//   - fromState: ChannelActive
+//   - toState: ChannelFlushing
+type ShutdownReceived struct {
+	// ShutdownScript is the script the remote party wants to use to
+	// shutdown.
+	ShutdownScript lnwire.DeliveryAddress
+
+	// BlockHeight is the height at which the shutdown message was
+	// received. This is used for channel leases to determine if a co-op
+	// close can occur.
+	BlockHeight uint32
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *ShutdownReceived) protocolSealed() {}
+
+// ShutdownComplete is an event that indicates the channel has been fully
+// shutdown. At this point, we'll go to the ChannelFlushing state so we can
+// wait for all pending updates to be gone from the channel.
+//
+// transition:
+//   - fromState: ShutdownPending
+//   - toState: ChannelFlushing
+type ShutdownComplete struct {
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *ShutdownComplete) protocolSealed() {}
+
+// ShutdownBalances holds the local+remote balance once the channel has been
+// fully flushed.
+type ShutdownBalances struct {
+	// LocalBalance is the local balance of the channel.
+	LocalBalance lnwire.MilliSatoshi
+
+	// RemoteBalance is the remote balance of the channel.
+	RemoteBalance lnwire.MilliSatoshi
+}
+
+// unknownBalance is a special variable used to denote an unknown channel
+// balance (channel not fully flushed yet).
+var unknownBalance = ShutdownBalances{}
+
+// ChannelFlushed is an event that indicates the channel has been fully flushed
+// can we can now start closing negotiation.
+//
+// transition:
+//   - fromState: ChannelFlushing
+//   - toState: ClosingNegotiation
+type ChannelFlushed struct {
+	// FreshFlush indicates if this is the first time the channel has been
+	// flushed, or if this is a flush as part of an RBF iteration.
+	FreshFlush bool
+
+	// ShutdownBalances is the balances of the channel once it has been
+	// flushed. We tie this to the ChannelFlushed state as this may not be
+	// the same as the starting value.
+	ShutdownBalances
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (c *ChannelFlushed) protocolSealed() {}
+
+// SendOfferEvent is a self-triggered event that transitions us from the
+// LocalCloseStart state to the LocalOfferSent state. This kicks off the new
+// signing process for the co-op close process.
+//
+// transition:
+//   - fromState: LocalCloseStart
+//   - toState: LocalOfferSent
+type SendOfferEvent struct {
+	// TargetFeeRate is the fee rate we'll use for the closing transaction.
+	TargetFeeRate chainfee.SatPerVByte
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *SendOfferEvent) protocolSealed() {}
+
+// LocalSigReceived is an event that indicates we've received a signature from
+// the remote party, which signs our the co-op close transaction at our
+// specified fee rate.
+//
+// transition:
+//   - fromState: LocalOfferSent
+//   - toState: ClosePending
+type LocalSigReceived struct {
+	// SigMsg is the sig message we received from the remote party.
+	SigMsg lnwire.ClosingSig
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *LocalSigReceived) protocolSealed() {}
+
+// OfferReceivedEvent is an event that indicates we've received an offer from
+// the remote party. This applies to the RemoteCloseStart state.
+//
+// transition:
+//   - fromState: RemoteCloseStart
+//   - toState: ClosePending
+type OfferReceivedEvent struct {
+	// SigMsg is the signature message we received from the remote party.
+	SigMsg lnwire.ClosingComplete
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (s *OfferReceivedEvent) protocolSealed() {}
+
+// CloseSigner is an interface that abstracts away the details of the signing
+// new coop close transactions.
+type CloseSigner interface {
+	// CreateCloseProposal creates a new co-op close proposal in the form
+	// of a valid signature, the chainhash of the final txid, and our final
+	// balance in the created state.
+	CreateCloseProposal(proposedFee btcutil.Amount,
+		localDeliveryScript []byte, remoteDeliveryScript []byte,
+		closeOpt ...lnwallet.ChanCloseOpt,
+	) (
+		input.Signature, *wire.MsgTx, btcutil.Amount, error)
+
+	// CompleteCooperativeClose persistently "completes" the cooperative
+	// close by producing a fully signed co-op close transaction.
+	CompleteCooperativeClose(localSig, remoteSig input.Signature,
+		localDeliveryScript, remoteDeliveryScript []byte,
+		proposedFee btcutil.Amount, closeOpt ...lnwallet.ChanCloseOpt,
+	) (*wire.MsgTx, btcutil.Amount, error)
+}
+
+// ChanStateObserver is an interface used to observe state changes that occur
+// in a channel. This can be used to figure out if we're able to send a
+// shutdown message or not.
+type ChanStateObserver interface {
+	// NoDanglingUpdates returns true if there are no dangling updates in
+	// the channel. In other words, there are no active update messages
+	// that haven't already been covered by a commit sig.
+	NoDanglingUpdates() bool
+
+	// DisableIncomingAdds instructs the channel link to disable process new
+	// incoming add messages.
+	DisableIncomingAdds() error
+
+	// DisableOutgoingAdds instructs the channel link to disable process
+	// new outgoing add messages.
+	DisableOutgoingAdds() error
+
+	// DisableChannel attempts to disable a channel (marking it ineligible
+	// to forward), and also sends out a network update to disable the
+	// channel.
+	DisableChannel() error
+
+	// MarkCoopBroadcasted persistently marks that the channel close
+	// transaction has been broadcast.
+	MarkCoopBroadcasted(*wire.MsgTx, bool) error
+
+	// MarkShutdownSent persists the given ShutdownInfo. The existence of
+	// the ShutdownInfo represents the fact that the Shutdown message has
+	// been sent by us and so should be re-sent on re-establish.
+	MarkShutdownSent(deliveryAddr []byte, isInitiator bool) error
+
+	// FinalBalances is the balances of the channel once it has been
+	// flushed. If Some, then this indicates that the channel is now in a
+	// state where it's always flushed, so we can accelerate the state
+	// transitions.
+	FinalBalances() fn.Option[ShutdownBalances]
+}
+
+// Environment is a set of dependencies that a state machine may need to carry
+// out the logic for a given state transition. All fields are to be considered
+// immutable, and will be fixed for the lifetime of the state machine.
+type Environment struct {
+	// ChainParams is the chain parameters for the channel.
+	ChainParams chaincfg.Params
+
+	// ChanPeer is the peer we're attempting to close the channel with.
+	ChanPeer btcec.PublicKey
+
+	// ChanPoint is the channel point of the active channel.
+	ChanPoint wire.OutPoint
+
+	// ChanID is the channel ID of the channel we're attempting to close.
+	ChanID lnwire.ChannelID
+
+	// ShortChanID is the short channel ID of the channel we're attempting
+	// to close.
+	Scid lnwire.ShortChannelID
+
+	// ChanType is the type of channel we're attempting to close.
+	ChanType channeldb.ChannelType
+
+	// BlockHeight is the current block height.
+	BlockHeight uint32
+
+	// DefaultFeeRate is the fee we'll use for the closing transaction if
+	// the user didn't specify an ideal fee rate. This may happen if the
+	// remote party is the one that initiates the co-op close.
+	DefaultFeeRate chainfee.SatPerVByte
+
+	// ThawHeight is the height at which the channel will be thawed. If
+	// this is None, then co-op close can occur at any moment.
+	ThawHeight fn.Option[uint32]
+
+	// RemoteUprontShutdown is the upfront shutdown addr of the remote
+	// party. We'll use this to validate if the remote peer is authorized to
+	// close the channel with the sent addr or not.
+	RemoteUpfrontShutdown fn.Option[lnwire.DeliveryAddress]
+
+	// LocalUprontShutdown is our upfront shutdown address. If Some, then
+	// we'll default to using this.
+	LocalUpfrontShutdown fn.Option[lnwire.DeliveryAddress]
+
+	// NewDeliveryScript is a function that returns a new delivery script.
+	// This is used if we don't have an upfront shutdown addr, and no addr
+	// was specified at closing time.
+	NewDeliveryScript func() (lnwire.DeliveryAddress, error)
+
+	// FeeEstimator is the fee estimator we'll use to determine the fee in
+	// satoshis we'll pay given a local and/or remote output.
+	FeeEstimator CoopFeeEstimator
+
+	// ChanObserver is an interface used to observe state changes to the
+	// channel. We'll use this to figure out when/if we can send certain
+	// messages.
+	ChanObserver ChanStateObserver
+
+	// CloseSigner is the signer we'll use to sign the close transaction.
+	// This is a part of the ChannelFlushed state, as the channel state
+	// we'll be signing can only be determined once the channel has been
+	// flushed.
+	CloseSigner CloseSigner
+}
+
+// Name returns the name of the environment. This is used to uniquely identify
+// the environment of related state machines. For this state machine, the name
+// is based on the channel ID.
+func (e *Environment) Name() string {
+	return fmt.Sprintf("rbf_chan_closer(%v)", e.ChanPoint)
+}
+
+// CloseStateTransition is the StateTransition type specific to the coop close
+// state machine.
+//
+//nolint:ll
+type CloseStateTransition = protofsm.StateTransition[ProtocolEvent, *Environment]
+
+// ProtocolState is our sum-type ish interface that represents the current
+// protocol state.
+type ProtocolState interface {
+	// protocolStateSealed is a special method that is used to seal the
+	// interface (only types in this package can implement it).
+	protocolStateSealed()
+
+	// IsTerminal returns true if the target state is a terminal state.
+	IsTerminal() bool
+
+	// ProcessEvent takes a protocol event, and implements a state
+	// transition for the state.
+	ProcessEvent(ProtocolEvent, *Environment) (*CloseStateTransition, error)
+}
+
+// AsymmetricPeerState is an extension of the normal ProtocolState interface
+// that gives a caller a hit on if the target state should process an incoming
+// event or not.
+type AsymmetricPeerState interface {
+	ProtocolState
+
+	// ShouldRouteTo returns true if the target state should process the
+	// target event.
+	ShouldRouteTo(ProtocolEvent) bool
+}
+
+// ProtocolStates is a special type constraint that enumerates all the possible
+// protocol states.
+type ProtocolStates interface {
+	ChannelActive | ShutdownPending | ChannelFlushing | ClosingNegotiation |
+		LocalCloseStart | LocalOfferSent | RemoteCloseStart |
+		ClosePending | CloseFin
+}
+
+// ChannelActive is the base state for the channel closer state machine. In
+// this state, we haven't begun the shutdown process yet, so the channel is
+// still active. Receiving the ShutdownSent or ShutdownReceived events will
+// transition us to the ChannelFushing state.
+//
+// When we transition to this state, we emit a DaemonEvent to send the shutdown
+// message if we received one ourselves. Alternatively, we may send out a new
+// shutdown if we're initiating it for the very first time.
+//
+// transition:
+//   - fromState: None
+//   - toState: ChannelFlushing
+//
+// input events:
+//   - SendShutdown
+//   - ShutdownReceived
+type ChannelActive struct {
+}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (c *ChannelActive) IsTerminal() bool {
+	return false
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (c *ChannelActive) protocolStateSealed() {}
+
+// ShutdownScripts is a set of scripts that we'll use to co-op close the
+// channel.
+type ShutdownScripts struct {
+	// LocalDeliveryScript is the script that we'll send our settled
+	// channel funds to.
+	LocalDeliveryScript lnwire.DeliveryAddress
+
+	// RemoteDeliveryScript is the script that we'll send the remote
+	// party's settled channel funds to.
+	RemoteDeliveryScript lnwire.DeliveryAddress
+}
+
+// ShutdownPending is the state we enter into after we've sent or received the
+// shutdown message. If we sent the shutdown, then we'll wait for the remote
+// party to send a shutdown. Otherwise, if we received it, then we'll send our
+// shutdown then go to the next state.
+//
+// transition:
+//   - fromState: ChannelActive
+//   - toState: ChannelFlushing
+//
+// input events:
+//   - SendShutdown
+//   - ShutdownReceived
+type ShutdownPending struct {
+	// ShutdownScripts store the set of scripts we'll use to initiate a coop
+	// close.
+	ShutdownScripts
+
+	// IdealFeeRate is the ideal fee rate we'd like to use for the closing
+	// attempt.
+	IdealFeeRate fn.Option[chainfee.SatPerVByte]
+}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (s *ShutdownPending) IsTerminal() bool {
+	return false
+}
+
+// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
+func (s *ShutdownPending) protocolStateSealed() {}
+
+// ChannelFlushing is the state we enter into after we've received or sent a
+// shutdown message. In this state, we wait the ChannelFlushed event, after
+// which we'll transition to the CloseReady state.
+//
+// transition:
+//   - fromState: ShutdownPending
+//   - toState: ClosingNegotiation
+//
+// input events:
+//   - ShutdownComplete
+//   - ShutdownReceived
+type ChannelFlushing struct {
+	// EarlyRemoteOffer is the offer we received from the remote party
+	// before we obtained the local channel flushed event. We'll stash this
+	// to process later.
+	EarlyRemoteOffer fn.Option[OfferReceivedEvent]
+
+	// ShutdownScripts store the set of scripts we'll use to initiate a coop
+	// close.
+	ShutdownScripts
+
+	// IdealFeeRate is the ideal fee rate we'd like to use for the closing
+	// transaction. Once the channel has been flushed, we'll use this as
+	// our target fee rate.
+	IdealFeeRate fn.Option[chainfee.SatPerVByte]
+}
+
+// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
+func (c *ChannelFlushing) protocolStateSealed() {}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (c *ChannelFlushing) IsTerminal() bool {
+	return false
+}
+
+// ClosingNegotiation is the state we transition to once the channel has been
+// flushed. This is actually a composite state that contains one for each side
+// of the channel, as the closing process is asymmetric. Once either of the
+// peer states reaches the CloseFin state, then the channel is fully closed,
+// and we'll transition to that terminal state.
+//
+// transition:
+//   - fromState: ChannelFlushing
+//   - toState: CloseFin
+//
+// input events:
+//   - ChannelFlushed
+type ClosingNegotiation struct {
+	// PeerStates is a composite state that contains the state for both the
+	// local and remote parties. Our usage of Dual makes this a special
+	// state that allows us to treat two states as a single state. We'll use
+	// the ShouldRouteTo method to determine which state route incoming
+	// events to.
+	PeerState lntypes.Dual[AsymmetricPeerState]
+}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (c *ClosingNegotiation) IsTerminal() bool {
+	return false
+}
+
+// protocolSealed indicates that this struct is a ProtocolEvent instance.
+func (c *ClosingNegotiation) protocolStateSealed() {}
+
+// CloseChannelTerms is a set of terms that we'll use to close the channel. This
+// includes the balances of the channel, and the scripts we'll use to send each
+// party's funds to.
+type CloseChannelTerms struct {
+	ShutdownScripts
+
+	ShutdownBalances
+}
+
+// DeriveCloseTxOuts takes the close terms, and returns the local and remote tx
+// out for the close transaction. If an output is dust, then it'll be nil.
+//
+// TODO(roasbeef): add func for w/e heuristic to not manifest own output?
+func (c *CloseChannelTerms) DeriveCloseTxOuts() (*wire.TxOut, *wire.TxOut) {
+	//nolint:ll
+	deriveTxOut := func(balance btcutil.Amount, pkScript []byte) *wire.TxOut {
+		dustLimit := lnwallet.DustLimitForSize(len(pkScript))
+		if balance >= dustLimit {
+			return &wire.TxOut{
+				PkScript: pkScript,
+				Value:    int64(balance),
+			}
+		}
+
+		return nil
+	}
+
+	localTxOut := deriveTxOut(
+		c.LocalBalance.ToSatoshis(),
+		c.LocalDeliveryScript,
+	)
+	remoteTxOut := deriveTxOut(
+		c.RemoteBalance.ToSatoshis(),
+		c.RemoteDeliveryScript,
+	)
+
+	return localTxOut, remoteTxOut
+}
+
+// RemoteAmtIsDust returns true if the remote output is dust.
+func (c *CloseChannelTerms) RemoteAmtIsDust() bool {
+	return c.RemoteBalance.ToSatoshis() < lnwallet.DustLimitForSize(
+		len(c.RemoteDeliveryScript),
+	)
+}
+
+// LocalAmtIsDust returns true if the local output is dust.
+func (c *CloseChannelTerms) LocalAmtIsDust() bool {
+	return c.LocalBalance.ToSatoshis() < lnwallet.DustLimitForSize(
+		len(c.LocalDeliveryScript),
+	)
+}
+
+// LocalCanPayFees returns true if the local party can pay the absolute fee
+// from their local settled balance.
+func (c *CloseChannelTerms) LocalCanPayFees(absoluteFee btcutil.Amount) bool {
+	return c.LocalBalance.ToSatoshis() >= absoluteFee
+}
+
+// RemoteCanPayFees returns true if the remote party can pay the absolute fee
+// from their remote settled balance.
+func (c *CloseChannelTerms) RemoteCanPayFees(absoluteFee btcutil.Amount) bool {
+	return c.RemoteBalance.ToSatoshis() >= absoluteFee
+}
+
+// LocalCloseStart is the state we enter into after we've received or sent
+// shutdown, and the channel has been flushed. In this state, we'll emit a new
+// event to send our offer to drive the rest of the process.
+//
+// transition:
+//   - fromState: ChannelFlushing
+//   - toState: LocalOfferSent
+//
+// input events:
+//   - SendOfferEvent
+type LocalCloseStart struct {
+	CloseChannelTerms
+}
+
+// ShouldRouteTo returns true if the target state should process the target
+// event.
+func (l *LocalCloseStart) ShouldRouteTo(event ProtocolEvent) bool {
+	switch event.(type) {
+	case *SendOfferEvent:
+		return true
+	default:
+		return false
+	}
+}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (l *LocalCloseStart) IsTerminal() bool {
+	return false
+}
+
+// protocolStateaSealed indicates that this struct is a ProtocolEvent instance.
+func (l *LocalCloseStart) protocolStateSealed() {}
+
+// LocalOfferSent is the state we transition to after we reveiver the
+// SendOfferEvent in the LocalCloseStart state. With this state we send our
+// offer to the remote party, then await a sig from them which concludes the
+// local cooperative close process.
+//
+// transition:
+//   - fromState: LocalCloseStart
+//   - toState: ClosePending
+//
+// input events:
+//   - LocalSigReceived
+type LocalOfferSent struct {
+	CloseChannelTerms
+
+	// ProposedFee is the fee we proposed to the remote party.
+	ProposedFee btcutil.Amount
+
+	// LocalSig is the signature we sent to the remote party.
+	LocalSig lnwire.Sig
+}
+
+// ShouldRouteTo returns true if the target state should process the target
+// event.
+func (l *LocalOfferSent) ShouldRouteTo(event ProtocolEvent) bool {
+	switch event.(type) {
+	case *LocalSigReceived:
+		return true
+	default:
+		return false
+	}
+}
+
+// protocolStateaSealed indicates that this struct is a ProtocolEvent instance.
+func (l *LocalOfferSent) protocolStateSealed() {}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (l *LocalOfferSent) IsTerminal() bool {
+	return false
+}
+
+// ClosePending is the state we enter after concluding the negotiation for the
+// remote or local state. At this point, given a confirmation notification we
+// can terminate the process. Otherwise, we can receive a fresh CoopCloseReq to
+// go back to the very start.
+//
+// transition:
+//   - fromState: LocalOfferSent || RemoteCloseStart
+//   - toState: CloseFin
+//
+// input events:
+//   - LocalSigReceived
+//   - OfferReceivedEvent
+type ClosePending struct {
+	// CloseTx is the pending close transaction.
+	CloseTx *wire.MsgTx
+}
+
+// ShouldRouteTo returns true if the target state should process the target
+// event.
+func (c *ClosePending) ShouldRouteTo(event ProtocolEvent) bool {
+	switch event.(type) {
+	case *SpendEvent:
+		return true
+	default:
+		return false
+	}
+}
+
+// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
+func (c *ClosePending) protocolStateSealed() {}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (c *ClosePending) IsTerminal() bool {
+	return true
+}
+
+// CloseFin is the terminal state for the channel closer state machine. At this
+// point, the close tx has been confirmed on chain.
+type CloseFin struct {
+	// ConfirmedTx is the transaction that confirmed the channel close.
+	ConfirmedTx *wire.MsgTx
+}
+
+// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
+func (c *CloseFin) protocolStateSealed() {}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (c *CloseFin) IsTerminal() bool {
+	return true
+}
+
+// RemoteCloseStart is similar to the LocalCloseStart, but is used to drive the
+// process of signing an offer for the remote party
+//
+// transition:
+//   - fromState: ChannelFlushing
+//   - toState: ClosePending
+type RemoteCloseStart struct {
+	CloseChannelTerms
+}
+
+// ShouldRouteTo returns true if the target state should process the target
+// event.
+func (l *RemoteCloseStart) ShouldRouteTo(event ProtocolEvent) bool {
+	switch event.(type) {
+	case *OfferReceivedEvent:
+		return true
+	default:
+		return false
+	}
+}
+
+// protocolStateSealed indicates that this struct is a ProtocolEvent instance.
+func (l *RemoteCloseStart) protocolStateSealed() {}
+
+// IsTerminal returns true if the target state is a terminal state.
+func (l *RemoteCloseStart) IsTerminal() bool {
+	return false
+}
+
+// RbfChanCloser is a state machine that handles the RBF-enabled cooperative
+// channel close protocol.
+type RbfChanCloser = protofsm.StateMachine[ProtocolEvent, *Environment]
+
+// RbfChanCloserCfg is a configuration struct that is used to initialize a new
+// RBF chan closer state machine.
+type RbfChanCloserCfg = protofsm.StateMachineCfg[ProtocolEvent, *Environment]
+
+// RbfSpendMapper is a type used to map the generic spend event to one specific
+// to this package.
+type RbfSpendMapper = protofsm.SpendMapper[ProtocolEvent]
+
+func SpendMapper(spendEvent *chainntnfs.SpendDetail) ProtocolEvent {
+	return &SpendEvent{
+		Tx:          spendEvent.SpendingTx,
+		BlockHeight: uint32(spendEvent.SpendingHeight),
+	}
+}
+
+// RbfMsgMapperT is a type used to map incoming wire messages to protocol
+// events.
+type RbfMsgMapperT = protofsm.MsgMapper[ProtocolEvent]
+
+// RbfState is a type alias for the state of the RBF channel closer.
+type RbfState = protofsm.State[ProtocolEvent, *Environment]
+
+// RbfEvent is a type alias for the event type of the RBF channel closer.
+type RbfEvent = protofsm.EmittedEvent[ProtocolEvent]
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
new file mode 100644
index 000000000..da45908b7
--- /dev/null
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -0,0 +1,1045 @@
+package chancloser
+
+import (
+	"fmt"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/chaincfg"
+	"github.com/btcsuite/btcd/mempool"
+	"github.com/btcsuite/btcd/wire"
+	"github.com/davecgh/go-spew/spew"
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/input"
+	"github.com/lightningnetwork/lnd/labels"
+	"github.com/lightningnetwork/lnd/lntypes"
+	"github.com/lightningnetwork/lnd/lnutils"
+	"github.com/lightningnetwork/lnd/lnwallet"
+	"github.com/lightningnetwork/lnd/lnwire"
+	"github.com/lightningnetwork/lnd/protofsm"
+	"github.com/lightningnetwork/lnd/tlv"
+)
+
+// sendShutdownEvents is a helper function that returns a set of daemon events
+// we need to emit when we decide that we should send a shutdown message. We'll
+// also mark the channel as borked as well, as at this point, we no longer want
+// to continue with normal operation.
+func sendShutdownEvents(chanID lnwire.ChannelID, chanPoint wire.OutPoint,
+	deliveryAddr lnwire.DeliveryAddress, peerPub btcec.PublicKey,
+	postSendEvent fn.Option[ProtocolEvent],
+	chanState ChanStateObserver) (protofsm.DaemonEventSet, error) {
+
+	// We'll emit a daemon event that instructs the daemon to send out a
+	// new shutdown message to the remote peer.
+	msgsToSend := &protofsm.SendMsgEvent[ProtocolEvent]{
+		TargetPeer: peerPub,
+		Msgs: []lnwire.Message{&lnwire.Shutdown{
+			ChannelID: chanID,
+			Address:   deliveryAddr,
+		}},
+		SendWhen: fn.Some(func() bool {
+			ok := chanState.NoDanglingUpdates()
+			if ok {
+				chancloserLog.Infof("ChannelPoint(%v): no "+
+					"dangling updates sending shutdown "+
+					"message", chanPoint)
+			}
+
+			return ok
+		}),
+		PostSendEvent: postSendEvent,
+	}
+
+	// If a close is already in process (we're in the RBF loop), then we
+	// can skip everything below, and just send out the shutdown message.
+	if chanState.FinalBalances().IsSome() {
+		return protofsm.DaemonEventSet{msgsToSend}, nil
+	}
+
+	// Before closing, we'll attempt to send a disable update for the
+	// channel.  We do so before closing the channel as otherwise the
+	// current edge policy won't be retrievable from the graph.
+	if err := chanState.DisableChannel(); err != nil {
+		return nil, fmt.Errorf("unable to disable channel: %w", err)
+	}
+
+	// If we have a post-send event, then this means that we're the
+	// responder. We'll use this fact below to update state in the DB.
+	isInitiator := postSendEvent.IsNone()
+
+	chancloserLog.Infof("ChannelPoint(%v): disabling outgoing adds",
+		chanPoint)
+
+	// As we're about to send a shutdown, we'll disable adds in the
+	// outgoing direction.
+	if err := chanState.DisableOutgoingAdds(); err != nil {
+		return nil, fmt.Errorf("unable to disable outgoing "+
+			"adds: %w", err)
+	}
+
+	// To be able to survive a restart, we'll also write to disk
+	// information about the shutdown we're about to send out.
+	err := chanState.MarkShutdownSent(deliveryAddr, isInitiator)
+	if err != nil {
+		return nil, fmt.Errorf("unable to mark shutdown sent: %w", err)
+	}
+
+	chancloserLog.Debugf("ChannelPoint(%v): marking channel as borked",
+		chanPoint)
+
+	return protofsm.DaemonEventSet{msgsToSend}, nil
+}
+
+// validateShutdown is a helper function that validates that the shutdown has a
+// proper delivery script, and can be sent based on the current thaw height of
+// the channel.
+func validateShutdown(chanThawHeight fn.Option[uint32],
+	upfrontAddr fn.Option[lnwire.DeliveryAddress],
+	msg *ShutdownReceived, chanPoint wire.OutPoint,
+	chainParams chaincfg.Params) error {
+
+	// If we've received a shutdown message, and we have a thaw height,
+	// then we need to make sure that the channel can now be co-op closed.
+	err := fn.MapOptionZ(chanThawHeight, func(thawHeight uint32) error {
+		// If the current height is below the thaw height, then we'll
+		// reject the shutdown message as we can't yet co-op close the
+		// channel.
+		if msg.BlockHeight < thawHeight {
+			return fmt.Errorf("initiator attempting to "+
+				"co-op close frozen ChannelPoint(%v) "+
+				"(current_height=%v, thaw_height=%v)",
+				chanPoint, msg.BlockHeight,
+				thawHeight)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	// Next, we'll verify that the remote party is sending the expected
+	// shutdown script.
+	return fn.MapOption(func(addr lnwire.DeliveryAddress) error {
+		return validateShutdownScript(
+			addr, msg.ShutdownScript, &chainParams,
+		)
+	})(upfrontAddr).UnwrapOr(nil)
+}
+
+// ProcessEvent takes a protocol event, and implements a state transition for
+// the state. From this state, we can receive two possible incoming events:
+// SendShutdown and ShutdownReceived. Both of these will transition us to the
+// ChannelFlushing state.
+func (c *ChannelActive) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) {
+	// If we get a confirmation, then a prior transaction we broadcasted
+	// has confirmed, so we can move to our terminal state early.
+	case *SpendEvent:
+		return &CloseStateTransition{
+			NextState: &CloseFin{
+				ConfirmedTx: msg.Tx,
+			},
+		}, nil
+
+	// If we receive the SendShutdown event, then we'll send our shutdown
+	// with a special SendPredicate, then go to the ShutdownPending where
+	// we'll wait for the remote to send their shutdown.
+	case *SendShutdown:
+		// If we have an upfront shutdown addr or a delivery addr then
+		// we'll use that. Otherwise, we'll generate a new delivery
+		// addr.
+		shutdownScript, err := env.LocalUpfrontShutdown.Alt(
+			msg.DeliveryAddr,
+		).UnwrapOrFuncErr(env.NewDeliveryScript)
+		if err != nil {
+			return nil, err
+		}
+
+		// We'll emit some daemon events to send the shutdown message
+		// and disable the channel on the network level. In this case,
+		// we don't need a post send event as receive their shutdown is
+		// what'll move us beyond the ShutdownPending state.
+		daemonEvents, err := sendShutdownEvents(
+			env.ChanID, env.ChanPoint, shutdownScript,
+			env.ChanPeer, fn.None[ProtocolEvent](),
+			env.ChanObserver,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): sending shutdown msg, "+
+			"delivery_script=%v", env.ChanPoint, shutdownScript)
+
+		// From here, we'll transition to the shutdown pending state. In
+		// this state we await their shutdown message (self loop), then
+		// also the flushing event.
+		return &CloseStateTransition{
+			NextState: &ShutdownPending{
+				IdealFeeRate: fn.Some(msg.IdealFeeRate),
+				ShutdownScripts: ShutdownScripts{
+					LocalDeliveryScript: shutdownScript,
+				},
+			},
+			NewEvents: fn.Some(RbfEvent{
+				ExternalEvents: daemonEvents,
+			}),
+		}, nil
+
+	// When we receive a shutdown from the remote party, we'll validate the
+	// shutdown message, then transition to the ShutdownPending state. We'll
+	// also emit similar events like the above to send out shutdown, and
+	// also disable the channel.
+	case *ShutdownReceived:
+		chancloserLog.Infof("ChannelPoint(%v): received shutdown msg")
+
+		// Validate that they can send the message now, and also that
+		// they haven't violated their commitment to a prior upfront
+		// shutdown addr.
+		err := validateShutdown(
+			env.ThawHeight, env.RemoteUpfrontShutdown, msg,
+			env.ChanPoint, env.ChainParams,
+		)
+		if err != nil {
+			chancloserLog.Errorf("ChannelPoint(%v): rejecting "+
+				"shutdown attempt: %v", err)
+
+			return nil, err
+		}
+
+		// If we have an upfront shutdown addr we'll use that,
+		// otherwise, we'll generate a new delivery script.
+		shutdownAddr, err := env.LocalUpfrontShutdown.UnwrapOrFuncErr(
+			env.NewDeliveryScript,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): sending shutdown msg "+
+			"at next clean commit state", env.ChanPoint)
+
+		// Now that we know the shutdown message is valid, we'll obtain
+		// the set of daemon events we need to emit. We'll also specify
+		// that once the message has actually been sent, that we
+		// generate receive an input event of a ShutdownComplete.
+		daemonEvents, err := sendShutdownEvents(
+			env.ChanID, env.ChanPoint, shutdownAddr,
+			env.ChanPeer,
+			fn.Some[ProtocolEvent](&ShutdownComplete{}),
+			env.ChanObserver,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): disabling incoming adds",
+			env.ChanPoint)
+
+		// We just received a shutdown, so we'll disable the adds in
+		// the outgoing direction.
+		if err := env.ChanObserver.DisableIncomingAdds(); err != nil {
+			return nil, fmt.Errorf("unable to disable incoming "+
+				"adds: %w", err)
+		}
+
+		remoteAddr := msg.ShutdownScript
+
+		return &CloseStateTransition{
+			NextState: &ShutdownPending{
+				ShutdownScripts: ShutdownScripts{
+					LocalDeliveryScript:  shutdownAddr,
+					RemoteDeliveryScript: remoteAddr,
+				},
+			},
+			NewEvents: fn.Some(protofsm.EmittedEvent[ProtocolEvent]{
+				ExternalEvents: daemonEvents,
+			}),
+		}, nil
+
+	// Any other messages in this state will result in an error, as this is
+	// an undefined state transition.
+	default:
+		return nil, fmt.Errorf("%w: received %T while in ChannelActive",
+			ErrInvalidStateTransition, msg)
+	}
+}
+
+// ProcessEvent takes a protocol event, and implements a state transition for
+// the state. Our path to this state will determine the set of valid events. If
+// we were the one that sent the shutdown, then we'll just wait on the
+// ShutdownReceived event. Otherwise, we received the shutdown, and can move
+// forward once we receive the ShutdownComplete event. Receiving
+// ShutdownComplete means that we've sent our shutdown, as this was specified
+// as a post send event.
+func (s *ShutdownPending) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) {
+	// If we get a confirmation, then a prior transaction we broadcasted
+	// has confirmed, so we can move to our terminal state early.
+	case *SpendEvent:
+		return &CloseStateTransition{
+			NextState: &CloseFin{
+				ConfirmedTx: msg.Tx,
+			},
+		}, nil
+
+	// When we receive a shutdown from the remote party, we'll validate the
+	// shutdown message, then transition to the ChannelFlushing state.
+	case *ShutdownReceived:
+		chancloserLog.Infof("ChannelPoint(%v): received shutdown msg",
+			env.ChanPoint)
+
+		// Validate that they can send the message now, and also that
+		// they haven't violated their commitment to a prior upfront
+		// shutdown addr.
+		err := validateShutdown(
+			env.ThawHeight, env.RemoteUpfrontShutdown, msg,
+			env.ChanPoint, env.ChainParams,
+		)
+		if err != nil {
+			chancloserLog.Errorf("ChannelPoint(%v): rejecting "+
+				"shutdown attempt: %v", err)
+
+			return nil, err
+		}
+
+		// If the channel is *already* flushed, and the close is
+		// go straight into negotiation, as this is the RBF loop.
+		// already in progress, then we can skip the flushing state and
+		var eventsToEmit fn.Option[protofsm.EmittedEvent[ProtocolEvent]]
+		finalBalances := env.ChanObserver.FinalBalances().UnwrapOr(
+			unknownBalance,
+		)
+		if finalBalances != unknownBalance {
+			channelFlushed := ProtocolEvent(&ChannelFlushed{
+				ShutdownBalances: finalBalances,
+			})
+			eventsToEmit = fn.Some(RbfEvent{
+				InternalEvent: []ProtocolEvent{
+					channelFlushed,
+				},
+			})
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): disabling incoming adds",
+			env.ChanPoint)
+
+		// We just received a shutdown, so we'll disable the adds in
+		// the outgoing direction.
+		if err := env.ChanObserver.DisableIncomingAdds(); err != nil {
+			return nil, fmt.Errorf("unable to disable incoming "+
+				"adds: %w", err)
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): waiting for channel to "+
+			"be flushed...", env.ChanPoint)
+
+		// We transition to the ChannelFlushing state, where we await
+		// the ChannelFlushed event.
+		return &CloseStateTransition{
+			NextState: &ChannelFlushing{
+				IdealFeeRate: s.IdealFeeRate,
+				ShutdownScripts: ShutdownScripts{
+					LocalDeliveryScript:  s.LocalDeliveryScript, //nolint:ll
+					RemoteDeliveryScript: msg.ShutdownScript,    //nolint:ll
+				},
+			},
+			NewEvents: eventsToEmit,
+		}, nil
+
+	// If we get this message, then this means that we were finally able to
+	// send out shutdown after receiving it from the remote party. We'll
+	// now transition directly to the ChannelFlushing state.
+	case *ShutdownComplete:
+		chancloserLog.Infof("ChannelPoint(%v): waiting for channel to "+
+			"be flushed...", env.ChanPoint)
+
+		// If the channel is *already* flushed, and the close is
+		// already in progress, then we can skip the flushing state and
+		// go straight into negotiation, as this is the RBF loop.
+		var eventsToEmit fn.Option[protofsm.EmittedEvent[ProtocolEvent]]
+		finalBalances := env.ChanObserver.FinalBalances().UnwrapOr(
+			unknownBalance,
+		)
+		if finalBalances != unknownBalance {
+			channelFlushed := ProtocolEvent(&ChannelFlushed{
+				ShutdownBalances: finalBalances,
+			})
+			eventsToEmit = fn.Some(RbfEvent{
+				InternalEvent: []ProtocolEvent{
+					channelFlushed,
+				},
+			})
+		}
+
+		// From here, we'll transition to the channel flushing state.
+		// We'll stay here until we receive the ChannelFlushed event.
+		return &CloseStateTransition{
+			NextState: &ChannelFlushing{
+				IdealFeeRate:    s.IdealFeeRate,
+				ShutdownScripts: s.ShutdownScripts,
+			},
+			NewEvents: eventsToEmit,
+		}, nil
+
+	// Any other messages in this state will result in an error, as this is
+	// an undefined state transition.
+	default:
+		return nil, fmt.Errorf("%w: received %T while in "+
+			"ShutdownPending", ErrInvalidStateTransition, msg)
+	}
+}
+
+// ProcessEvent takes a new protocol event, and figures out if we can
+// transition to the next state, or just loop back upon ourself. If we receive
+// a ShutdownReceived event, then we'll stay in the ChannelFlushing state, as
+// we haven't yet fully cleared the channel. Otherwise, we can move to the
+// CloseReady state which'll being the channel closing process.
+func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) {
+	// If we get a confirmation, then a prior transaction we broadcasted
+	// has confirmed, so we can move to our terminal state early.
+	case *SpendEvent:
+		return &CloseStateTransition{
+			NextState: &CloseFin{
+				ConfirmedTx: msg.Tx,
+			},
+		}, nil
+
+	// If we get an OfferReceived event, then the channel is flushed from
+	// the PoV of the remote party. However, due to propagation delay or
+	// concurrency, we may not have received the ChannelFlushed event yet.
+	// In this case, we'll stash the event and wait for the ChannelFlushed
+	// event.
+	case *OfferReceivedEvent:
+		chancloserLog.Infof("ChannelPoint(%v): received remote offer "+
+			"early, stashing...", env.ChanPoint)
+
+		c.EarlyRemoteOffer = fn.Some(*msg)
+
+		// TODO(roasbeef): unit test!
+		//  * actually do this ^
+
+		// We'll perform a noop update so we can wait for the actual
+		// channel flushed event.
+		return &CloseStateTransition{
+			NextState: c,
+		}, nil
+
+	// If we receive the ChannelFlushed event, then the coast is clear so
+	// we'll now morph into the dual peer state so we can handle any
+	// messages needed to drive forward the close process.
+	case *ChannelFlushed:
+		// Both the local and remote losing negotiation needs the terms
+		// we'll be using to close the channel, so we'll create them
+		// here.
+		closeTerms := CloseChannelTerms{
+			ShutdownScripts:  c.ShutdownScripts,
+			ShutdownBalances: msg.ShutdownBalances,
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): channel flushed! "+
+			"proceeding with co-op close", env.ChanPoint)
+
+		// Now that the channel has been flushed, we'll mark on disk
+		// that we're approaching the point of no return where we'll
+		// send a new signature to the remote party.
+		//
+		// TODO(roasbeef): doesn't actually matter if initiator here?
+		if msg.FreshFlush {
+			err := env.ChanObserver.MarkCoopBroadcasted(nil, true)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// If an ideal fee rate was specified, then we'll use that,
+		// otherwise we'll fall back to the default value given in the
+		// env.
+		idealFeeRate := c.IdealFeeRate.UnwrapOr(env.DefaultFeeRate)
+
+		// We'll then use that fee rate to determine the absolute fee
+		// we'd propose.
+		//
+		// TODO(roasbeef): need to sign the 3 diff versions of this?
+		localTxOut, remoteTxOut := closeTerms.DeriveCloseTxOuts()
+		absoluteFee := env.FeeEstimator.EstimateFee(
+			env.ChanType, localTxOut, remoteTxOut,
+			idealFeeRate.FeePerKWeight(),
+		)
+
+		chancloserLog.Infof("ChannelPoint(%v): using ideal_fee=%v, "+
+			"absolute_fee=%v", env.ChanPoint, idealFeeRate,
+			absoluteFee)
+
+		var (
+			internalEvents []ProtocolEvent
+			newEvents      fn.Option[RbfEvent]
+		)
+
+		// If we received a remote offer early from the remote party,
+		// then we'll add that to the set of internal events to emit.
+		c.EarlyRemoteOffer.WhenSome(func(offer OfferReceivedEvent) {
+			internalEvents = append(internalEvents, &offer)
+		})
+
+		// Only if we have enough funds to pay for the fees do we need
+		// to emit a localOfferSign event.
+		//
+		// TODO(roasbeef): also only proceed if was higher than fee in
+		// last round?
+		if closeTerms.LocalCanPayFees(absoluteFee) {
+			// Each time we go into this negotiation flow, we'll
+			// kick off our local state with a new close attempt.
+			// So we'll emit a internal event to drive forward that
+			// part of the state.
+			localOfferSign := ProtocolEvent(&SendOfferEvent{
+				TargetFeeRate: idealFeeRate,
+			})
+			internalEvents = append(internalEvents, localOfferSign)
+		} else {
+			chancloserLog.Infof("ChannelPoint(%v): unable to pay "+
+				"fees with local balance, skipping "+
+				"closing_complete", env.ChanPoint)
+		}
+
+		if len(internalEvents) > 0 {
+			newEvents = fn.Some(RbfEvent{
+				InternalEvent: internalEvents,
+			})
+		}
+
+		return &CloseStateTransition{
+			NextState: &ClosingNegotiation{
+				PeerState: lntypes.Dual[AsymmetricPeerState]{
+					Local: &LocalCloseStart{
+						CloseChannelTerms: closeTerms,
+					},
+					Remote: &RemoteCloseStart{
+						CloseChannelTerms: closeTerms,
+					},
+				},
+			},
+			NewEvents: newEvents,
+		}, nil
+
+	default:
+		return nil, fmt.Errorf("%w: received %T while in "+
+			"ChannelFlushing", ErrInvalidStateTransition, msg)
+	}
+}
+
+// processNegotiateEvent is a helper function that processes a new event to
+// local channel state once we're in the ClosingNegotiation state.
+func processNegotiateEvent(c *ClosingNegotiation, event ProtocolEvent,
+	env *Environment, chanPeer lntypes.ChannelParty,
+) (*CloseStateTransition, error) {
+
+	targetPeerState := c.PeerState.GetForParty(chanPeer)
+
+	// Drive forward the remote state based on the next event.
+	transition, err := targetPeerState.ProcessEvent(
+		event, env,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	nextPeerState, ok := transition.NextState.(AsymmetricPeerState) //nolint:ll
+	if !ok {
+		return nil, fmt.Errorf("expected %T to be "+
+			"AsymmetricPeerState", transition.NextState)
+	}
+
+	// Make a copy of the input state, then update the peer state of the
+	// proper party.
+	newPeerState := *c
+	newPeerState.PeerState.SetForParty(chanPeer, nextPeerState)
+
+	return &CloseStateTransition{
+		NextState: &newPeerState,
+		NewEvents: transition.NewEvents,
+	}, nil
+}
+
+// ProcessEvent drives forward the composite states for the local and remote
+// party in response to new events. From this state, we'll continue to drive
+// forward the local and remote states until we arrive at the StateFin stage,
+// or we loop back up to the ShutdownPending state.
+func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	// There're two classes of events that can break us out of this state:
+	// we receive a confirmation event, or we receive a signal to restart
+	// the co-op close process.
+	switch msg := event.(type) {
+	// If we get a confirmation, then the spend request we issued when we
+	// were leaving the ChannelFlushing state has been confirmed.  We'll
+	// now transition to the StateFin state.
+	case *SpendEvent:
+		return &CloseStateTransition{
+			NextState: &CloseFin{
+				ConfirmedTx: msg.Tx,
+			},
+		}, nil
+
+	// Otherwise, if we receive a shutdown, or receive an event to send a
+	// shutdown, then we'll go back up to the ChannelActive state, and have
+	// it handle this event by emitting an internal event.
+	//
+	// TODO(roasbeef): both will have fee rate specified, so ok?
+	case *ShutdownReceived, *SendShutdown:
+		chancloserLog.Infof("ChannelPoint(%v): RBF case triggered, "+
+			"restarting negotiation", env.ChanPoint)
+
+		return &CloseStateTransition{
+			NextState: &ChannelActive{},
+			NewEvents: fn.Some(RbfEvent{
+				InternalEvent: []ProtocolEvent{event},
+			}),
+		}, nil
+	}
+
+	// If we get to this point, then we have an event that'll drive forward
+	// the negotiation process.  Based on the event, we'll figure out which
+	// state we'll be modifying.
+	switch {
+	case c.PeerState.GetForParty(lntypes.Local).ShouldRouteTo(event):
+		chancloserLog.Infof("ChannelPoint(%v): routing %T to local "+
+			"chan state", env.ChanPoint, event)
+
+		// Drive forward the local state based on the next event.
+		return processNegotiateEvent(c, event, env, lntypes.Local)
+
+	case c.PeerState.GetForParty(lntypes.Remote).ShouldRouteTo(event):
+		chancloserLog.Infof("ChannelPoint(%v): routing %T to remote "+
+			"chan state", env.ChanPoint, event)
+
+		// Drive forward the remote state based on the next event.
+		return processNegotiateEvent(c, event, env, lntypes.Remote)
+	}
+
+	return nil, fmt.Errorf("%w: received %T while in ClosingNegotiation",
+		ErrInvalidStateTransition, event)
+}
+
+// newSigTlv is a helper function that returns a new optional TLV sig field for
+// the parametrized tlv.TlvType value.
+func newSigTlv[T tlv.TlvType](s lnwire.Sig) tlv.OptionalRecordT[T, lnwire.Sig] {
+	return tlv.SomeRecordT(tlv.NewRecordT[T](s))
+}
+
+// ProcessEvent implements the event processing to kick off the process of
+// obtaining a new (possibly RBF'd) signature for our commitment transaction.
+func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) { //nolint:gocritic
+	// If we receive a SendOfferEvent, then we'll use the specified fee
+	// rate to generate for the closing transaction with our ideal fee
+	// rate.
+	case *SendOfferEvent:
+		// First, we'll figure out the absolute fee rate we should pay
+		// given the state of the local/remote outputs.
+		localTxOut, remoteTxOut := l.DeriveCloseTxOuts()
+		absoluteFee := env.FeeEstimator.EstimateFee(
+			env.ChanType, localTxOut, remoteTxOut,
+			msg.TargetFeeRate.FeePerKWeight(),
+		)
+
+		// Now that we know what fee we want to pay, we'll create a new
+		// signature over our co-op close transaction. For our
+		// proposals, we'll just always use the known RBF sequence
+		// value.
+		localScript := l.LocalDeliveryScript
+		rawSig, closeTx, closeBalance, err := env.CloseSigner.CreateCloseProposal( //nolint:ll
+			absoluteFee, localScript, l.RemoteDeliveryScript,
+			lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
+			lnwallet.WithCustomPayer(lntypes.Local),
+		)
+		if err != nil {
+			return nil, err
+		}
+		wireSig, err := lnwire.NewSigFromSignature(rawSig)
+		if err != nil {
+			return nil, err
+		}
+
+		chancloserLog.Infof("closing w/ local_addr=%x, "+
+			"remote_addr=%x, fee=%v", localScript[:],
+			l.RemoteDeliveryScript[:], absoluteFee)
+
+		chancloserLog.Infof("proposing closing_tx=%v",
+			spew.Sdump(closeTx))
+
+		// Now that we have our signature, we'll set the proper
+		// closingSigs field based on if the remote party's output is
+		// dust or not.
+		var closingSigs lnwire.ClosingSigs
+		switch {
+		// If the remote party's output is dust, then we'll set the
+		// CloserNoClosee field.
+		case remoteTxOut == nil:
+			closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](
+				wireSig,
+			)
+
+		// If after paying for fees, our balance is below dust, then
+		// we'll set the NoCloserClosee field.
+		case closeBalance < lnwallet.DustLimitForSize(len(localScript)):
+			closingSigs.NoCloserClosee = newSigTlv[tlv.TlvType2](
+				wireSig,
+			)
+
+		// Otherwise, we'll set the CloserAndClosee field.
+		//
+		// TODO(roasbeef): should actually set both??
+		default:
+			closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](
+				wireSig,
+			)
+		}
+
+		// Now that we have our sig, we'll emit a daemon event to send
+		// it to the remote party, then transition to the
+		// LocalOfferSent state.
+		//
+		// TODO(roasbeef): type alias for protocol event
+		sendEvent := protofsm.DaemonEventSet{&protofsm.SendMsgEvent[ProtocolEvent]{ //nolint:ll
+			TargetPeer: env.ChanPeer,
+			// TODO(roasbeef): mew new func
+			Msgs: []lnwire.Message{&lnwire.ClosingComplete{
+				ChannelID:   env.ChanID,
+				FeeSatoshis: absoluteFee,
+				LockTime:    env.BlockHeight,
+				ClosingSigs: closingSigs,
+			}},
+		}}
+
+		chancloserLog.Infof("ChannelPoint(%v): sending closing sig "+
+			"to remote party, fee_sats=%v", env.ChanPoint,
+			absoluteFee)
+
+		return &CloseStateTransition{
+			NextState: &LocalOfferSent{
+				ProposedFee:       absoluteFee,
+				LocalSig:          wireSig,
+				CloseChannelTerms: l.CloseChannelTerms,
+			},
+			NewEvents: fn.Some(RbfEvent{
+				ExternalEvents: sendEvent,
+			}),
+		}, nil
+	}
+
+	return nil, fmt.Errorf("%w: received %T while in LocalCloseStart",
+		ErrInvalidStateTransition, event)
+}
+
+// extractSig extracts the expected signature from the closing sig message.
+// Only one of them should actually be populated as the closing sig message is
+// sent in response to a ClosingComplete message, it should only sign the same
+// version of the co-op close tx as the sender did.
+func extractSig(msg lnwire.ClosingSig) fn.Result[lnwire.Sig] {
+	// First, we'll validate that only one signature is included in their
+	// response to our initial offer. If not, then we'll exit here, and
+	// trigger a recycle of the connection.
+	sigInts := []bool{
+		msg.CloserNoClosee.IsSome(), msg.NoCloserClosee.IsSome(),
+		msg.CloserAndClosee.IsSome(),
+	}
+	numSigs := fn.Foldl(0, sigInts, func(acc int, sigInt bool) int {
+		if sigInt {
+			return acc + 1
+		}
+
+		return acc
+	})
+	if numSigs != 1 {
+		return fn.Errf[lnwire.Sig]("%w: only one sig should be set, "+
+			"got %v", ErrTooManySigs, numSigs)
+	}
+
+	// The final sig is the one that's actually set.
+	sig := msg.CloserAndClosee.ValOpt().Alt(
+		msg.NoCloserClosee.ValOpt(),
+	).Alt(
+		msg.CloserNoClosee.ValOpt(),
+	)
+
+	return fn.NewResult(sig.UnwrapOrErr(ErrNoSig))
+}
+
+// ProcessEvent implements the state transition function for the
+// LocalOfferSent state. In this state, we'll wait for the remote party to
+// send a close_signed message which gives us the ability to broadcast a new
+// co-op close transaction.
+func (l *LocalOfferSent) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) { //nolint:gocritic
+	// If we receive a LocalSigReceived event, then we'll attempt to
+	// validate the signature from the remote party. If valid, then we can
+	// broadcast the transaction, and transition to the ClosePending state.
+	case *LocalSigReceived:
+		// Extract and validate that only one sig field is set.
+		sig, err := extractSig(msg.SigMsg).Unpack()
+		if err != nil {
+			return nil, err
+		}
+
+		remoteSig, err := sig.ToSignature()
+		if err != nil {
+			return nil, err
+		}
+		localSig, err := l.LocalSig.ToSignature()
+		if err != nil {
+			return nil, err
+		}
+
+		// Now that we have their signature, we'll attempt to validate
+		// it, then extract a valid closing signature from it.
+		closeTx, _, err := env.CloseSigner.CompleteCooperativeClose(
+			localSig, remoteSig, l.LocalDeliveryScript,
+			l.RemoteDeliveryScript, l.ProposedFee,
+			lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
+			lnwallet.WithCustomPayer(lntypes.Local),
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		// As we're about to broadcast a new version of the co-op close
+		// transaction, we'll mark again as broadcast, but with this
+		// variant of the co-op close tx.
+		err = env.ChanObserver.MarkCoopBroadcasted(closeTx, true)
+		if err != nil {
+			return nil, err
+		}
+
+		broadcastEvent := protofsm.DaemonEventSet{&protofsm.BroadcastTxn{ //nolint:ll
+			Tx: closeTx,
+			Label: labels.MakeLabel(
+				labels.LabelTypeChannelClose, &env.Scid,
+			),
+		}}
+
+		chancloserLog.Infof("ChannelPoint(%v): received sig from "+
+			"remote party, broadcasting: tx=%v", env.ChanPoint,
+			lnutils.SpewLogClosure(closeTx),
+		)
+
+		return &CloseStateTransition{
+			NextState: &ClosePending{
+				CloseTx: closeTx,
+			},
+			NewEvents: fn.Some(protofsm.EmittedEvent[ProtocolEvent]{
+				ExternalEvents: broadcastEvent,
+			}),
+		}, nil
+	}
+
+	return nil, fmt.Errorf("%w: received %T while in LocalOfferSent",
+		ErrInvalidStateTransition, event)
+}
+
+// ProcessEvent implements the state transition function for the
+// RemoteCloseStart. In this state, we'll wait for the remote party to send a
+// closing_complete message. Assuming they can pay for the fees, we'll sign it
+// ourselves, then transition to the next state of ClosePending.
+func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) { //nolint:gocritic
+	// If we receive a OfferReceived event, we'll make sure they can
+	// actually pay for the fee. If so, then we'll counter sign and
+	// transition to a terminal state.
+	case *OfferReceivedEvent:
+		// To start, we'll perform some basic validation of the sig
+		// message they've sent. We'll validate that the remote party
+		// actually has enough fees to pay the closing fees.
+		if !l.RemoteCanPayFees(msg.SigMsg.FeeSatoshis) {
+			return nil, fmt.Errorf("%w: %v vs %v",
+				ErrRemoteCannotPay,
+				msg.SigMsg.FeeSatoshis,
+				l.RemoteBalance.ToSatoshis())
+		}
+
+		// With the basic sanity checks out of the way, we'll now
+		// figure out which signature that we'll attempt to sign
+		// against.
+		var (
+			remoteSig input.Signature
+			noClosee  bool
+		)
+		switch {
+		// If our balance is dust, then we expect the CloserNoClosee
+		// sig to be set.
+		case l.LocalAmtIsDust():
+			if msg.SigMsg.CloserNoClosee.IsNone() {
+				return nil, ErrCloserNoClosee
+			}
+			msg.SigMsg.CloserNoClosee.WhenSomeV(func(s lnwire.Sig) {
+				remoteSig, _ = s.ToSignature()
+				noClosee = true
+			})
+
+		// Otherwise, we'll assume that CloseAndClosee is set.
+		//
+		// TODO(roasbeef): NoCloserClosee, but makes no sense?
+		default:
+			if msg.SigMsg.CloserAndClosee.IsNone() {
+				return nil, ErrCloserAndClosee
+			}
+			msg.SigMsg.CloserAndClosee.WhenSomeV(func(s lnwire.Sig) { //nolint:ll
+				remoteSig, _ = s.ToSignature()
+			})
+		}
+
+		chanOpts := []lnwallet.ChanCloseOpt{
+			lnwallet.WithCustomSequence(mempool.MaxRBFSequence),
+			lnwallet.WithCustomLockTime(msg.SigMsg.LockTime),
+			lnwallet.WithCustomPayer(lntypes.Remote),
+		}
+
+		chancloserLog.Infof("responding to close w/ local_addr=%x, "+
+			"remote_addr=%x, fee=%v",
+			l.LocalDeliveryScript[:], l.RemoteDeliveryScript[:],
+			msg.SigMsg.FeeSatoshis)
+
+		// Now that we have the remote sig, we'll sign the version they
+		// signed, then attempt to complete the cooperative close
+		// process.
+		//
+		// TODO(roasbeef): need to be able to omit an output when
+		// signing based on the above, as closing opt
+		rawSig, _, _, err := env.CloseSigner.CreateCloseProposal(
+			msg.SigMsg.FeeSatoshis, l.LocalDeliveryScript,
+			l.RemoteDeliveryScript, chanOpts...,
+		)
+		if err != nil {
+			return nil, err
+		}
+		wireSig, err := lnwire.NewSigFromSignature(rawSig)
+		if err != nil {
+			return nil, err
+		}
+
+		localSig, err := wireSig.ToSignature()
+		if err != nil {
+			return nil, err
+		}
+
+		// With our signature created, we'll now attempt to finalize the
+		// close process.
+		closeTx, _, err := env.CloseSigner.CompleteCooperativeClose(
+			localSig, remoteSig, l.LocalDeliveryScript,
+			l.RemoteDeliveryScript, msg.SigMsg.FeeSatoshis,
+			chanOpts...,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		chancloserLog.Infof("ChannelPoint(%v): received sig (fee=%v "+
+			"sats) from remote party, signing new tx=%v",
+			env.ChanPoint, msg.SigMsg.FeeSatoshis,
+			lnutils.SpewLogClosure(closeTx),
+		)
+
+		var closingSigs lnwire.ClosingSigs
+		if noClosee {
+			closingSigs.CloserNoClosee = newSigTlv[tlv.TlvType1](
+				wireSig,
+			)
+		} else {
+			closingSigs.CloserAndClosee = newSigTlv[tlv.TlvType3](
+				wireSig,
+			)
+		}
+
+		// As we're about to broadcast a new version of the co-op close
+		// transaction, we'll mark again as broadcast, but with this
+		// variant of the co-op close tx.
+		//
+		// TODO(roasbeef): db will only store one instance, store both?
+		err = env.ChanObserver.MarkCoopBroadcasted(closeTx, false)
+		if err != nil {
+			return nil, err
+		}
+
+		// As we transition, we'll omit two events: one to broadcast
+		// the transaction, and the other to send our ClosingSig
+		// message to the remote party.
+		sendEvent := &protofsm.SendMsgEvent[ProtocolEvent]{
+			TargetPeer: env.ChanPeer,
+			Msgs: []lnwire.Message{&lnwire.ClosingSig{
+				ChannelID:   env.ChanID,
+				ClosingSigs: closingSigs,
+			}},
+		}
+		broadcastEvent := &protofsm.BroadcastTxn{
+			Tx: closeTx,
+			Label: labels.MakeLabel(
+				labels.LabelTypeChannelClose, &env.Scid,
+			),
+		}
+		daemonEvents := protofsm.DaemonEventSet{
+			sendEvent, broadcastEvent,
+		}
+
+		// Now that we've extracted the signature, we'll transition to
+		// the next state where we'll sign+broadcast the sig.
+		return &CloseStateTransition{
+			NextState: &ClosePending{
+				CloseTx: closeTx,
+			},
+			NewEvents: fn.Some(protofsm.EmittedEvent[ProtocolEvent]{
+				ExternalEvents: daemonEvents,
+			}),
+		}, nil
+	}
+
+	return nil, fmt.Errorf("%w: received %T while in RemoteCloseStart",
+		ErrInvalidStateTransition, event)
+}
+
+// ProcessEvent is a semi-terminal state in the rbf-coop close state machine.
+// In this state, we're waiting for either a confirmation, or for either side
+// to attempt to create a new RBF'd co-op close transaction.
+func (c *ClosePending) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	switch msg := event.(type) {
+	// If we can a spend while waiting for the close, then we'll go to our
+	// terminal state.
+	case *SpendEvent:
+		return &CloseStateTransition{
+			NextState: &CloseFin{
+				ConfirmedTx: msg.Tx,
+			},
+		}, nil
+
+	default:
+
+		return &CloseStateTransition{
+			NextState: c,
+		}, nil
+	}
+}
+
+// ProcessEvent is the event processing for out terminal state. In this state,
+// we just keep looping back on ourselves.
+func (c *CloseFin) ProcessEvent(event ProtocolEvent, env *Environment,
+) (*CloseStateTransition, error) {
+
+	return &CloseStateTransition{
+		NextState: c,
+	}, nil
+}
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index d190acdf5..dc45a12be 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -8199,6 +8199,12 @@ type chanCloseOpt struct {
 	// transaction outputs. If this isn't set, then the default BIP-69
 	// sorting is used.
 	customSort CloseSortFunc
+
+	customSequence fn.Option[uint32]
+
+	customLockTime fn.Option[uint32]
+
+	customPayer fn.Option[lntypes.ChannelParty]
 }
 
 // ChanCloseOpt is a closure type that cen be used to modify the set of default
@@ -8235,6 +8241,31 @@ func WithCustomCoopSort(sorter CloseSortFunc) ChanCloseOpt {
 	}
 }
 
+// WithCustomSequence can be used to specify a custom sequence number for the
+// co-op close process. Otherwise, a default non-final sequence will be used.
+func WithCustomSequence(sequence uint32) ChanCloseOpt {
+	return func(opts *chanCloseOpt) {
+		opts.customSequence = fn.Some(sequence)
+	}
+}
+
+// WithCustomLockTime can be used to specify a custom lock time for the coop
+// close transaction.
+func WithCustomLockTime(lockTime uint32) ChanCloseOpt {
+	return func(opts *chanCloseOpt) {
+		opts.customLockTime = fn.Some(lockTime)
+	}
+}
+
+// WithCustomPayer can be used to specify a custom payer for the closing
+// transaction. This overrides the default payer, which is the initiator of the
+// channel.
+func WithCustomPayer(payer lntypes.ChannelParty) ChanCloseOpt {
+	return func(opts *chanCloseOpt) {
+		opts.customPayer = fn.Some(payer)
+	}
+}
+
 // CreateCloseProposal is used by both parties in a cooperative channel close
 // workflow to generate proposed close transactions and signatures. This method
 // should only be executed once all pending HTLCs (if any) on the channel have
@@ -8244,22 +8275,23 @@ func WithCustomCoopSort(sorter CloseSortFunc) ChanCloseOpt {
 // returned.
 func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
 	localDeliveryScript []byte, remoteDeliveryScript []byte,
-	closeOpts ...ChanCloseOpt) (input.Signature, *chainhash.Hash,
+	closeOpts ...ChanCloseOpt) (input.Signature, *wire.MsgTx,
 	btcutil.Amount, error) {
 
 	lc.Lock()
 	defer lc.Unlock()
 
-	// If we're already closing the channel, then ignore this request.
-	if lc.isClosed {
-		return nil, nil, 0, ErrChanClosing
-	}
-
 	opts := defaultCloseOpts()
 	for _, optFunc := range closeOpts {
 		optFunc(opts)
 	}
 
+	// Unless there's a custom payer (sign of the RBF flow), if we're
+	// already closing the channel, then ignore this request.
+	if lc.isClosed && opts.customPayer.IsNone() {
+		return nil, nil, 0, ErrChanClosing
+	}
+
 	// Get the final balances after subtracting the proposed fee, taking
 	// care not to persist the adjusted balance, as the feeRate may change
 	// during the channel closing process.
@@ -8269,6 +8301,7 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
 		lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
 		lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
 		lc.channelState.LocalCommitment.CommitFee,
+		opts.customPayer,
 	)
 	if err != nil {
 		return nil, nil, 0, err
@@ -8294,6 +8327,18 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
 		)
 	}
 
+	opts.customSequence.WhenSome(func(sequence uint32) {
+		closeTxOpts = append(closeTxOpts, WithCustomTxInSequence(
+			sequence,
+		))
+	})
+
+	opts.customLockTime.WhenSome(func(lockTime uint32) {
+		closeTxOpts = append(closeTxOpts, WithCustomTxLockTime(
+			lockTime,
+		))
+	})
+
 	closeTx, err := CreateCooperativeCloseTx(
 		fundingTxIn(lc.channelState), lc.channelState.LocalChanCfg.DustLimit,
 		lc.channelState.RemoteChanCfg.DustLimit, ourBalance, theirBalance,
@@ -8332,8 +8377,8 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
 		}
 	}
 
-	closeTXID := closeTx.TxHash()
-	return sig, &closeTXID, ourBalance, nil
+	return sig, closeTx, ourBalance, nil
+
 }
 
 // CompleteCooperativeClose completes the cooperative closure of the target
@@ -8352,17 +8397,17 @@ func (lc *LightningChannel) CompleteCooperativeClose(
 	lc.Lock()
 	defer lc.Unlock()
 
-	// If the channel is already closing, then ignore this request.
-	if lc.isClosed {
-		// TODO(roasbeef): check to ensure no pending payments
-		return nil, 0, ErrChanClosing
-	}
-
 	opts := defaultCloseOpts()
 	for _, optFunc := range closeOpts {
 		optFunc(opts)
 	}
 
+	// Unless there's a custom payer (sign of the RBF flow), if we're
+	// already closing the channel, then ignore this request.
+	if lc.isClosed && opts.customPayer.IsNone() {
+		return nil, 0, ErrChanClosing
+	}
+
 	// Get the final balances after subtracting the proposed fee.
 	ourBalance, theirBalance, err := CoopCloseBalance(
 		lc.channelState.ChanType, lc.channelState.IsInitiator,
@@ -8370,6 +8415,7 @@ func (lc *LightningChannel) CompleteCooperativeClose(
 		lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
 		lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
 		lc.channelState.LocalCommitment.CommitFee,
+		opts.customPayer,
 	)
 	if err != nil {
 		return nil, 0, err
@@ -8395,6 +8441,18 @@ func (lc *LightningChannel) CompleteCooperativeClose(
 		)
 	}
 
+	opts.customSequence.WhenSome(func(sequence uint32) {
+		closeTxOpts = append(closeTxOpts, WithCustomTxInSequence(
+			sequence,
+		))
+	})
+
+	opts.customLockTime.WhenSome(func(lockTime uint32) {
+		closeTxOpts = append(closeTxOpts, WithCustomTxLockTime(
+			lockTime,
+		))
+	})
+
 	// Create the transaction used to return the current settled balance
 	// on this active channel back to both parties. In this current model,
 	// the initiator pays full fees for the cooperative close transaction.
@@ -9111,6 +9169,13 @@ type closeTxOpts struct {
 	// transaction outputs. If this isn't set, then the default BIP-69
 	// sorting is used.
 	customSort CloseSortFunc
+
+	// customSequence is an optional custom sequence to set on the co-op
+	// close transaction. This gives slightly more control compared to the
+	// enableRBF option.
+	customSequence fn.Option[uint32]
+
+	customLockTime fn.Option[uint32]
 }
 
 // defaultCloseTxOpts returns a closeTxOpts struct with default values.
@@ -9147,6 +9212,20 @@ func WithCustomTxSort(sorter CloseSortFunc) CloseTxOpt {
 	}
 }
 
+// WithCustomTxInSequence allows a caller to set a custom sequence on the sole
+// input of the co-op close tx.
+func WithCustomTxInSequence(sequence uint32) CloseTxOpt {
+	return func(o *closeTxOpts) {
+		o.customSequence = fn.Some(sequence)
+	}
+}
+
+func WithCustomTxLockTime(lockTime uint32) CloseTxOpt {
+	return func(o *closeTxOpts) {
+		o.customLockTime = fn.Some(lockTime)
+	}
+}
+
 // CreateCooperativeCloseTx creates a transaction which if signed by both
 // parties, then broadcast cooperatively closes an active channel. The creation
 // of the closure transaction is modified by a boolean indicating if the party
@@ -9169,6 +9248,11 @@ func CreateCooperativeCloseTx(fundingTxIn wire.TxIn,
 		fundingTxIn.Sequence = mempool.MaxRBFSequence
 	}
 
+	// Otherwise, a custom sequence might be specified.
+	opts.customSequence.WhenSome(func(sequence uint32) {
+		fundingTxIn.Sequence = sequence
+	})
+
 	// Construct the transaction to perform a cooperative closure of the
 	// channel. In the event that one side doesn't have any settled funds
 	// within the channel then a refund output for that particular side can
@@ -9176,6 +9260,12 @@ func CreateCooperativeCloseTx(fundingTxIn wire.TxIn,
 	closeTx := wire.NewMsgTx(2)
 	closeTx.AddTxIn(&fundingTxIn)
 
+	opts.customLockTime.WhenSome(func(lockTime uint32) {
+		closeTx.LockTime = lockTime
+	})
+
+	// TODO(roasbeef): needs support for dropping inputs
+
 	// Create both cooperative closure outputs, properly respecting the
 	// dust limits of both parties.
 	var localOutputIdx fn.Option[int]
diff --git a/lnwallet/commitment.go b/lnwallet/commitment.go
index 787e8a71e..ab20d9afa 100644
--- a/lnwallet/commitment.go
+++ b/lnwallet/commitment.go
@@ -1033,8 +1033,9 @@ func CreateCommitTx(chanType channeldb.ChannelType,
 // CoopCloseBalance returns the final balances that should be used to create
 // the cooperative close tx, given the channel type and transaction fee.
 func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
-	coopCloseFee, ourBalance, theirBalance,
-	commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) {
+	coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount,
+	feePayer fn.Option[lntypes.ChannelParty],
+) (btcutil.Amount, btcutil.Amount, error) {
 
 	// We'll make sure we account for the complete balance by adding the
 	// current dangling commitment fee to the balance of the initiator.
@@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
 		initiatorDelta += 2 * AnchorSize
 	}
 
-	// The initiator will pay the full coop close fee, subtract that value
-	// from their balance.
-	initiatorDelta -= coopCloseFee
-
+	// To start with, we'll add the anchor and/or commitment fee to the
+	// balance of the initiator.
 	if isInitiator {
 		ourBalance += initiatorDelta
 	} else {
 		theirBalance += initiatorDelta
 	}
 
+	// With the initiator's balance credited, we'll now subtract the closing
+	// fee from the closing party. By default, the initiator pays the full
+	// amount, but this can be overridden by the feePayer option.
+	defaultPayer := func() lntypes.ChannelParty {
+		if isInitiator {
+			return lntypes.Local
+		}
+
+		return lntypes.Remote
+	}()
+	payer := feePayer.UnwrapOr(defaultPayer)
+
+	// Based on the payer computed above, we'll subtract the closing fee.
+	switch payer {
+	case lntypes.Local:
+		ourBalance -= coopCloseFee
+	case lntypes.Remote:
+		theirBalance -= coopCloseFee
+	}
+
 	// During fee negotiation it should always be verified that the
 	// initiator can pay the proposed fee, but we do a sanity check just to
 	// be sure here.
diff --git a/lnwire/closing_complete.go b/lnwire/closing_complete.go
index d33abf672..c16760a4e 100644
--- a/lnwire/closing_complete.go
+++ b/lnwire/closing_complete.go
@@ -34,9 +34,9 @@ type ClosingComplete struct {
 	// channel would like to propose for the close transaction.
 	FeeSatoshis btcutil.Amount
 
-	// Sequence is the sequence number to be used in the input spending the
+	// LockTime is the locktime number to be used in the input spending the
 	// funding transaction.
-	Sequence uint32
+	LockTime uint32
 
 	// ClosingSigs houses the 3 possible signatures that can be sent.
 	ClosingSigs
@@ -79,7 +79,7 @@ func decodeClosingSigs(c *ClosingSigs, tlvRecords ExtraOpaqueData) error {
 // passed io.Reader.
 func (c *ClosingComplete) Decode(r io.Reader, _ uint32) error {
 	// First, read out all the fields that are hard coded into the message.
-	err := ReadElements(r, &c.ChannelID, &c.FeeSatoshis, &c.Sequence)
+	err := ReadElements(r, &c.ChannelID, &c.FeeSatoshis, &c.LockTime)
 	if err != nil {
 		return err
 	}
@@ -129,7 +129,7 @@ func (c *ClosingComplete) Encode(w *bytes.Buffer, _ uint32) error {
 		return err
 	}
 
-	if err := WriteUint32(w, c.Sequence); err != nil {
+	if err := WriteUint32(w, c.LockTime); err != nil {
 		return err
 	}
 
diff --git a/peer/brontide.go b/peer/brontide.go
index a41b5080c..f0399b4e8 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -2275,6 +2275,13 @@ func messageSummary(msg lnwire.Message) string {
 		return fmt.Sprintf("chan_id=%v, script=%x", msg.ChannelID,
 			msg.Address[:])
 
+	case *lnwire.ClosingComplete:
+		return fmt.Sprintf("chan_id=%v, fee_sat=%v, locktime=%v",
+			msg.ChannelID, msg.FeeSatoshis, msg.LockTime)
+
+	case *lnwire.ClosingSig:
+		return fmt.Sprintf("chan_id=%v", msg.ChannelID)
+
 	case *lnwire.ClosingSigned:
 		return fmt.Sprintf("chan_id=%v, fee_sat=%v", msg.ChannelID,
 			msg.FeeSatoshis)
diff --git a/protofsm/state_machine.go b/protofsm/state_machine.go
index a81f5746b..2cc121902 100644
--- a/protofsm/state_machine.go
+++ b/protofsm/state_machine.go
@@ -34,11 +34,11 @@ type EmittedEvent[Event any] struct {
 	// InternalEvent is an optional internal event that is to be routed
 	// back to the target state. This enables state to trigger one or many
 	// state transitions without a new external event.
-	InternalEvent fn.Option[[]Event]
+	InternalEvent []Event
 
 	// ExternalEvent is an optional external event that is to be sent to
 	// the daemon for dispatch. Usually, this is some form of I/O.
-	ExternalEvents fn.Option[DaemonEventSet]
+	ExternalEvents DaemonEventSet
 }
 
 // StateTransition is a state transition type. It denotes the next state to go
@@ -573,46 +573,31 @@ func (s *StateMachine[Event, Env]) applyEvents(currentState State[Event, Env],
 				// With the event processed, we'll process any
 				// new daemon events that were emitted as part
 				// of this new state transition.
-				//
-				//nolint:ll
-				err := fn.MapOptionZ(events.ExternalEvents, func(dEvents DaemonEventSet) error {
-					log.Debugf("FSM(%v): processing "+
-						"daemon %v daemon events",
-						s.cfg.Env.Name(), len(dEvents))
-
-					for _, dEvent := range dEvents {
-						err := s.executeDaemonEvent(
-							dEvent,
-						)
-						if err != nil {
-							return err
-						}
+				for _, dEvent := range events.ExternalEvents {
+					err := s.executeDaemonEvent(
+						dEvent,
+					)
+					if err != nil {
+						return err
 					}
-
-					return nil
-				})
-				if err != nil {
-					return err
 				}
 
-				// Next, we'll add any new emitted events to
-				// our event queue.
+				// Next, we'll add any new emitted events to our
+				// event queue.
 				//
 				//nolint:ll
-				events.InternalEvent.WhenSome(func(es []Event) {
-					for _, inEvent := range es {
-						log.Debugf("FSM(%v): adding "+
-							"new internal event "+
-							"to queue: %v",
-							s.cfg.Env.Name(),
-							lnutils.SpewLogClosure(
-								inEvent,
-							),
-						)
-
-						eventQueue.Enqueue(inEvent)
-					}
-				})
+				for _, inEvent := range events.InternalEvent {
+					log.Debugf("FSM(%v): adding "+
+						"new internal event "+
+						"to queue: %v",
+						s.cfg.Env.Name(),
+						lnutils.SpewLogClosure(
+							inEvent,
+						),
+					)
+
+					eventQueue.Enqueue(inEvent)
+				}
 
 				return nil
 			})
diff --git a/routing/blinding.go b/routing/blinding.go
index a343c15da..0c27e8743 100644
--- a/routing/blinding.go
+++ b/routing/blinding.go
@@ -1,27 +1,17 @@
 package routing
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 
 	"github.com/btcsuite/btcd/btcec/v2"
-	"github.com/decred/dcrd/dcrec/secp256k1/v4"
 	sphinx "github.com/lightningnetwork/lightning-onion"
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/graph/db/models"
-	"github.com/lightningnetwork/lnd/input"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 )
 
-// BlindedPathNUMSHex is the hex encoded version of the blinded path target
-// NUMs key (in compressed format) which has no known private key.
-// This was generated using the following script:
-// https://github.com/lightninglabs/lightning-node-connect/tree/master/
-// mailbox/numsgen, with the seed phrase "Lightning Blinded Path".
-const BlindedPathNUMSHex = "02667a98ef82ecb522f803b17a74f14508a48b25258f9831" +
-	"dd6e95f5e299dfd54e"
-
 var (
 	// ErrNoBlindedPath is returned when the blinded path in a blinded
 	// payment is missing.
@@ -35,14 +25,6 @@ var (
 	// ErrHTLCRestrictions is returned when a blinded path has invalid
 	// HTLC maximum and minimum values.
 	ErrHTLCRestrictions = errors.New("invalid htlc minimum and maximum")
-
-	// BlindedPathNUMSKey is a NUMS key (nothing up my sleeves number) that
-	// has no known private key.
-	BlindedPathNUMSKey = input.MustParsePubKey(BlindedPathNUMSHex)
-
-	// CompressedBlindedPathNUMSKey is the compressed version of the
-	// BlindedPathNUMSKey.
-	CompressedBlindedPathNUMSKey = BlindedPathNUMSKey.SerializeCompressed()
 )
 
 // BlindedPaymentPathSet groups the data we need to handle sending to a set of
@@ -88,9 +70,7 @@ type BlindedPaymentPathSet struct {
 }
 
 // NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of
-// BlindedPayments. For blinded paths which have more than one single hop a
-// dummy hop via a NUMS key is appeneded to allow for MPP path finding via
-// multiple blinded paths.
+// BlindedPayments.
 func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet,
 	error) {
 
@@ -123,53 +103,36 @@ func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet,
 		}
 	}
 
-	// Deep copy the paths to avoid mutating the original paths.
-	pathSet := make([]*BlindedPayment, len(paths))
-	for i, path := range paths {
-		pathSet[i] = path.deepCopy()
+	// Derive an ephemeral target priv key that will be injected into each
+	// blinded path final hop.
+	targetPriv, err := btcec.NewPrivateKey()
+	if err != nil {
+		return nil, err
 	}
+	targetPub := targetPriv.PubKey()
 
-	// For blinded paths we use the NUMS key as a target if the blinded
-	// path has more hops than just the introduction node.
-	targetPub := &BlindedPathNUMSKey
-
-	var finalCLTVDelta uint16
-
-	// In case the paths do NOT include a single hop route we append a
-	// dummy hop via a NUMS key to allow for MPP path finding via multiple
-	// blinded paths. A unified target is needed to use all blinded paths
-	// during the payment lifecycle. A dummy hop is solely added for the
-	// path finding process and is removed after the path is found. This
-	// ensures that we still populate the mission control with the correct
-	// data and also respect these mc entries when looking for a path.
-	for _, path := range pathSet {
-		pathLength := len(path.BlindedPath.BlindedHops)
-
-		// If any provided blinded path only has a single hop (ie, the
-		// destination node is also the introduction node), then we
-		// discard all other paths since we know the real pub key of the
-		// destination node. We also then set the final CLTV delta to
-		// the path's delta since there are no other edge hints that
-		// will account for it.
-		if pathLength == 1 {
-			pathSet = []*BlindedPayment{path}
-			finalCLTVDelta = path.CltvExpiryDelta
-			targetPub = path.BlindedPath.IntroductionPoint
-
-			break
+	var (
+		pathSet        = paths
+		finalCLTVDelta uint16
+	)
+	// If any provided blinded path only has a single hop (ie, the
+	// destination node is also the introduction node), then we discard all
+	// other paths since we know the real pub key of the destination node.
+	// We also then set the final CLTV delta to the path's delta since
+	// there are no other edge hints that will account for it. For a single
+	// hop path, there is also no need for the pseudo target pub key
+	// replacement, so our target pub key in this case just remains the
+	// real introduction node ID.
+	for _, path := range paths {
+		if len(path.BlindedPath.BlindedHops) != 1 {
+			continue
 		}
 
-		lastHop := path.BlindedPath.BlindedHops[pathLength-1]
-		path.BlindedPath.BlindedHops = append(
-			path.BlindedPath.BlindedHops,
-			&sphinx.BlindedHopInfo{
-				BlindedNodePub: &BlindedPathNUMSKey,
-				// We add the last hop's cipher text so that
-				// the payload size of the final hop is equal
-				// to the real last hop.
-				CipherText: lastHop.CipherText,
-			},
-		)
+		pathSet = []*BlindedPayment{path}
+		finalCLTVDelta = path.CltvExpiryDelta
+		targetPub = path.BlindedPath.IntroductionPoint
+
+		break
 	}
 
 	return &BlindedPaymentPathSet{
@@ -235,33 +198,21 @@ func (s *BlindedPaymentPathSet) FinalCLTVDelta() uint16 {
 // LargestLastHopPayloadPath returns the BlindedPayment in the set that has the
 // largest last-hop payload. This is to be used for onion size estimation in
 // path finding.
-func (s *BlindedPaymentPathSet) LargestLastHopPayloadPath() (*BlindedPayment,
-	error) {
-
+func (s *BlindedPaymentPathSet) LargestLastHopPayloadPath() *BlindedPayment {
 	var (
 		largestPath *BlindedPayment
 		currentMax  int
 	)
-
-	if len(s.paths) == 0 {
-		return nil, fmt.Errorf("no blinded paths in the set")
-	}
-
-	// We set the largest path to make sure we always return a path even
-	// if the cipher text is empty.
-	largestPath = s.paths[0]
-
 	for _, path := range s.paths {
 		numHops := len(path.BlindedPath.BlindedHops)
 		lastHop := path.BlindedPath.BlindedHops[numHops-1]
 
 		if len(lastHop.CipherText) > currentMax {
 			largestPath = path
-			currentMax = len(lastHop.CipherText)
 		}
 	}
 
-	return largestPath, nil
+	return largestPath
 }
 
 // ToRouteHints converts the blinded path payment set into a RouteHints map so
@@ -271,7 +222,7 @@ func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
 	hints := make(RouteHints)
 
 	for _, path := range s.paths {
-		pathHints, err := path.toRouteHints()
+		pathHints, err := path.toRouteHints(fn.Some(s.targetPubKey))
 		if err != nil {
 			return nil, err
 		}
@@ -288,12 +239,6 @@ func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
 	return hints, nil
 }
 
-// IsBlindedRouteNUMSTargetKey returns true if the given public key is the
-// NUMS key used as a target for blinded path final hops.
-func IsBlindedRouteNUMSTargetKey(pk []byte) bool {
-	return bytes.Equal(pk, CompressedBlindedPathNUMSKey)
-}
-
 // BlindedPayment provides the path and payment parameters required to send a
 // payment along a blinded path.
 type BlindedPayment struct {
@@ -346,22 +291,6 @@ func (b *BlindedPayment) Validate() error {
 			b.HtlcMaximum, b.HtlcMinimum)
 	}
 
-	for _, hop := range b.BlindedPath.BlindedHops {
-		// The first hop of the blinded path does not necessarily have
-		// blinded node pub key because it is the introduction point.
-		if hop.BlindedNodePub == nil {
-			continue
-		}
-
-		if IsBlindedRouteNUMSTargetKey(
-			hop.BlindedNodePub.SerializeCompressed(),
-		) {
-
-			return fmt.Errorf("blinded path cannot include NUMS "+
-				"key: %s", BlindedPathNUMSHex)
-		}
-	}
-
 	return nil
 }
 
@@ -372,8 +301,11 @@ func (b *BlindedPayment) Validate() error {
 // effectively the final_cltv_delta for the receiving introduction node). In
 // the case of multiple blinded hops, CLTV delta is fully accounted for in the
 // hints (both for intermediate hops and the final_cltv_delta for the receiving
-// node).
-func (b *BlindedPayment) toRouteHints() (RouteHints, error) {
+// node). The pseudoTarget, if provided,  will be used to override the pub key
+// of the destination node in the path.
+func (b *BlindedPayment) toRouteHints(
+	pseudoTarget fn.Option[*btcec.PublicKey]) (RouteHints, error) {
+
 	// If we just have a single hop in our blinded route, it just contains
 	// an introduction node (this is a valid path according to the spec).
 	// Since we have the un-blinded node ID for the introduction node, we
@@ -461,77 +393,16 @@ func (b *BlindedPayment) toRouteHints() (RouteHints, error) {
 		hints[fromNode] = []AdditionalEdge{lastEdge}
 	}
 
-	return hints, nil
-}
-
-// deepCopy returns a deep copy of the BlindedPayment.
-func (b *BlindedPayment) deepCopy() *BlindedPayment {
-	if b == nil {
-		return nil
-	}
-
-	cpyPayment := &BlindedPayment{
-		BaseFee:             b.BaseFee,
-		ProportionalFeeRate: b.ProportionalFeeRate,
-		CltvExpiryDelta:     b.CltvExpiryDelta,
-		HtlcMinimum:         b.HtlcMinimum,
-		HtlcMaximum:         b.HtlcMaximum,
-	}
-
-	// Deep copy the BlindedPath if it exists
-	if b.BlindedPath != nil {
-		cpyPayment.BlindedPath = &sphinx.BlindedPath{
-			BlindedHops: make([]*sphinx.BlindedHopInfo,
-				len(b.BlindedPath.BlindedHops)),
-		}
-
-		if b.BlindedPath.IntroductionPoint != nil {
-			cpyPayment.BlindedPath.IntroductionPoint =
-				copyPublicKey(b.BlindedPath.IntroductionPoint)
+	pseudoTarget.WhenSome(func(key *btcec.PublicKey) {
+		// For the very last hop on the path, switch out the ToNodePub
+		// for the pseudo target pub key.
+		lastEdge.policy.ToNodePubKey = func() route.Vertex {
+			return route.NewVertex(key)
 		}
 
-		if b.BlindedPath.BlindingPoint != nil {
-			cpyPayment.BlindedPath.BlindingPoint =
-				copyPublicKey(b.BlindedPath.BlindingPoint)
-		}
-
-		// Copy each blinded hop info.
-		for i, hop := range b.BlindedPath.BlindedHops {
-			if hop == nil {
-				continue
-			}
-
-			cpyHop := &sphinx.BlindedHopInfo{
-				CipherText: hop.CipherText,
-			}
-
-			if hop.BlindedNodePub != nil {
-				cpyHop.BlindedNodePub =
-					copyPublicKey(hop.BlindedNodePub)
-			}
-
-			cpyHop.CipherText = make([]byte, len(hop.CipherText))
-			copy(cpyHop.CipherText, hop.CipherText)
-
-			cpyPayment.BlindedPath.BlindedHops[i] = cpyHop
-		}
-	}
-
-	// Deep copy the Features if they exist
-	if b.Features != nil {
-		cpyPayment.Features = b.Features.Clone()
-	}
-
-	return cpyPayment
-}
-
-// copyPublicKey makes a deep copy of a public key.
-//
-// TODO(ziggie): Remove this function if this is available in the btcec library.
-func copyPublicKey(pk *btcec.PublicKey) *btcec.PublicKey {
-	var result secp256k1.JacobianPoint
-	pk.AsJacobian(&result)
-	result.ToAffine()
+		// Then override the final hint with this updated edge.
+		hints[fromNode] = []AdditionalEdge{lastEdge}
+	})
 
-	return btcec.NewPublicKey(&result.X, &result.Y)
+	return hints, nil
 }
diff --git a/routing/pathfind.go b/routing/pathfind.go
index 3f1d0ba09..80f4b1e68 100644
--- a/routing/pathfind.go
+++ b/routing/pathfind.go
@@ -158,32 +158,6 @@ func newRoute(sourceVertex route.Vertex,
 	)
 
 	pathLength := len(pathEdges)
-
-	// When paying to a blinded route we might have appended a dummy hop at
-	// the end to make MPP payments possible via all paths of the blinded
-	// route set. We always append a dummy hop when the internal pathfiner
-	// looks for a route to a blinded path which is at least one hop long
-	// (excluding the introduction point). We add this dummy hop so that
-	// we search for a universal target but also respect potential mc
-	// entries which might already be present for a particular blinded path.
-	// However when constructing the Sphinx packet we need to remove this
-	// dummy hop again which we do here.
-	//
-	// NOTE: The path length is always at least 1 because there must be one
-	// edge from the source to the destination. However we check for > 0
-	// just for robustness here.
-	if blindedPathSet != nil && pathLength > 0 {
-		finalBlindedPubKey := pathEdges[pathLength-1].policy.
-			ToNodePubKey()
-
-		if IsBlindedRouteNUMSTargetKey(finalBlindedPubKey[:]) {
-			// If the last hop is the NUMS key for blinded paths, we
-			// remove the dummy hop from the route.
-			pathEdges = pathEdges[:pathLength-1]
-			pathLength--
-		}
-	}
-
 	for i := pathLength - 1; i >= 0; i-- {
 		// Now we'll start to calculate the items within the per-hop
 		// payload for the hop this edge is leading to.
@@ -345,6 +319,10 @@ func newRoute(sourceVertex route.Vertex,
 			dataIndex      = 0
 
 			blindedPath = blindedPayment.BlindedPath
+			numHops     = len(blindedPath.BlindedHops)
+			realFinal   = blindedPath.BlindedHops[numHops-1].
+					BlindedNodePub
+
 			introVertex = route.NewVertex(
 				blindedPath.IntroductionPoint,
 			)
@@ -372,6 +350,11 @@ func newRoute(sourceVertex route.Vertex,
 			if i != len(hops)-1 {
 				hop.AmtToForward = 0
 				hop.OutgoingTimeLock = 0
+			} else {
+				// For the final hop, we swap out the pub key
+				// bytes to the original destination node pub
+				// key for that payment path.
+				hop.PubKeyBytes = route.NewVertex(realFinal)
 			}
 
 			dataIndex++
@@ -700,10 +683,7 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig,
 
 	// The payload size of the final hop differ from intermediate hops
 	// and depends on whether the destination is blinded or not.
-	lastHopPayloadSize, err := lastHopPayloadSize(r, finalHtlcExpiry, amt)
-	if err != nil {
-		return nil, 0, err
-	}
+	lastHopPayloadSize := lastHopPayloadSize(r, finalHtlcExpiry, amt)
 
 	// We can't always assume that the end destination is publicly
 	// advertised to the network so we'll manually include the target node.
@@ -921,13 +901,6 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig,
 		// included. If we are coming from the source hop, the payload
 		// size is zero, because the original htlc isn't in the onion
 		// blob.
-		//
-		// NOTE: For blinded paths with the NUMS key as the last hop,
-		// the payload size accounts for this dummy hop which is of
-		// the same size as the real last hop. So we account for a
-		// bigger size than the route is however we accept this
-		// little inaccuracy here because we are over estimating by
-		// 1 hop.
 		var payloadSize uint64
 		if fromVertex != source {
 			// In case the unifiedEdge does not have a payload size
@@ -1436,15 +1409,11 @@ func getProbabilityBasedDist(weight int64, probability float64,
 // It depends on the tlv types which are present and also whether the hop is
 // part of a blinded route or not.
 func lastHopPayloadSize(r *RestrictParams, finalHtlcExpiry int32,
-	amount lnwire.MilliSatoshi) (uint64, error) {
+	amount lnwire.MilliSatoshi) uint64 {
 
 	if r.BlindedPaymentPathSet != nil {
-		paymentPath, err := r.BlindedPaymentPathSet.
+		paymentPath := r.BlindedPaymentPathSet.
 			LargestLastHopPayloadPath()
-		if err != nil {
-			return 0, err
-		}
-
 		blindedPath := paymentPath.BlindedPath.BlindedHops
 		blindedPoint := paymentPath.BlindedPath.BlindingPoint
 
@@ -1459,7 +1428,7 @@ func lastHopPayloadSize(r *RestrictParams, finalHtlcExpiry int32,
 		}
 
 		// The final hop does not have a short chanID set.
-		return finalHop.PayloadSize(0), nil
+		return finalHop.PayloadSize(0)
 	}
 
 	var mpp *record.MPP
@@ -1485,7 +1454,7 @@ func lastHopPayloadSize(r *RestrictParams, finalHtlcExpiry int32,
 	}
 
 	// The final hop does not have a short chanID set.
-	return finalHop.PayloadSize(0), nil
+	return finalHop.PayloadSize(0)
 }
 
 // overflowSafeAdd adds two MilliSatoshi values and returns the result. If an
diff --git a/routing/result_interpretation.go b/routing/result_interpretation.go
index 60dd22c43..bc1749dfb 100644
--- a/routing/result_interpretation.go
+++ b/routing/result_interpretation.go
@@ -100,27 +100,8 @@ func interpretResult(rt *mcRoute,
 
 // processSuccess processes a successful payment attempt.
 func (i *interpretedResult) processSuccess(route *mcRoute) {
-	// For successes, all nodes must have acted in the right way.
-	// Therefore we mark all of them with a success result. However we need
-	// to handle the blinded route part separately because for intermediate
-	// blinded nodes the amount field is set to zero so we use the receiver
-	// amount.
-	introIdx, isBlinded := introductionPointIndex(route)
-	if isBlinded {
-		// Report success for all the pairs until the introduction
-		// point.
-		i.successPairRange(route, 0, introIdx-1)
-
-		// Handle the blinded route part.
-		//
-		// NOTE: The introIdx index here does describe the node after
-		// the introduction point.
-		i.markBlindedRouteSuccess(route, introIdx)
-
-		return
-	}
-
-	// Mark nodes as successful in the non-blinded case of the payment.
+	// For successes, all nodes must have acted in the right way. Therefore
+	// we mark all of them with a success result.
 	i.successPairRange(route, 0, len(route.hops.Val)-1)
 }
 
@@ -536,22 +517,11 @@ func (i *interpretedResult) processPaymentOutcomeIntermediate(route *mcRoute,
 		if introIdx == len(route.hops.Val)-1 {
 			i.finalFailureReason = &reasonError
 		} else {
-			// We penalize the final hop of the blinded route which
-			// is sufficient to not reuse this route again and is
-			// also more memory efficient because the other hops
-			// of the blinded path are ephemeral and will only be
-			// used in conjunction with the final hop. Moreover we
-			// don't want to punish the introduction node because
-			// the blinded failure does not necessarily mean that
-			// the introduction node was at fault.
-			//
-			// TODO(ziggie): Make sure we only keep mc data for
-			// blinded paths, in both the success and failure case,
-			// in memory during the time of the payment and remove
-			// it afterwards. Blinded paths and their blinded hop
-			// keys are always changing per blinded route so there
-			// is no point in persisting this data.
-			i.failBlindedRoute(route)
+			// If there are other hops between the recipient and
+			// introduction node, then we just penalize the last
+			// hop in the blinded route to minimize the storage of
+			// results for ephemeral keys.
+			i.failPairBalance(route, len(route.hops.Val)-1)
 		}
 
 	// In all other cases, we penalize the reporting node. These are all
@@ -858,41 +828,6 @@ func (i *interpretedResult) successPairRange(rt *mcRoute, fromIdx, toIdx int) {
 	}
 }
 
-// failBlindedRoute marks a blinded route as failed for the specific amount to
-// send by only punishing the last pair.
-func (i *interpretedResult) failBlindedRoute(rt *mcRoute) {
-	// We fail the last pair of the route, in order to fail the complete
-	// blinded route. This is because the combination of ephemeral pubkeys
-	// is unique to the route. We fail the last pair in order to not punish
-	// the introduction node, since we don't want to disincentivize them
-	// from providing that service.
-	pair, _ := getPair(rt, len(rt.hops.Val)-1)
-
-	// Since all the hops along a blinded path don't have any amount set, we
-	// extract the minimal amount to punish from the value that is tried to
-	// be sent to the receiver.
-	amt := rt.hops.Val[len(rt.hops.Val)-1].amtToFwd.Val
-
-	i.pairResults[pair] = failPairResult(amt)
-}
-
-// markBlindedRouteSuccess marks the hops of the blinded route AFTER the
-// introduction node as successful.
-//
-// NOTE: The introIdx must be the index of the first hop of the blinded route
-// AFTER the introduction node.
-func (i *interpretedResult) markBlindedRouteSuccess(rt *mcRoute, introIdx int) {
-	// For blinded hops we do not have the forwarding amount so we take the
-	// minimal amount which went through the route by looking at the last
-	// hop.
-	successAmt := rt.hops.Val[len(rt.hops.Val)-1].amtToFwd.Val
-	for idx := introIdx; idx < len(rt.hops.Val); idx++ {
-		pair, _ := getPair(rt, idx)
-
-		i.pairResults[pair] = successPairResult(successAmt)
-	}
-}
-
 // getPair returns a node pair from the route and the amount passed between that
 // pair.
 func getPair(rt *mcRoute, channelIdx int) (DirectedNodePair,


Test patch:
diff --git a/lnwallet/chancloser/chancloser_test.go b/lnwallet/chancloser/chancloser_test.go
index fe71fe5e3..f7bdc74b4 100644
--- a/lnwallet/chancloser/chancloser_test.go
+++ b/lnwallet/chancloser/chancloser_test.go
@@ -129,8 +129,8 @@ func TestMaybeMatchScript(t *testing.T) {
 			t.Parallel()
 
 			err := validateShutdownScript(
-				func() error { return nil }, test.upfrontScript,
-				test.shutdownScript, &chaincfg.SimNetParams,
+				test.upfrontScript, test.shutdownScript,
+				&chaincfg.SimNetParams,
 			)
 
 			if err != test.expectedErr {
@@ -189,7 +189,7 @@ func (m *mockChannel) RemoteUpfrontShutdownScript() lnwire.DeliveryAddress {
 
 func (m *mockChannel) CreateCloseProposal(fee btcutil.Amount,
 	localScript, remoteScript []byte,
-	_ ...lnwallet.ChanCloseOpt) (input.Signature, *chainhash.Hash,
+	_ ...lnwallet.ChanCloseOpt) (input.Signature, *wire.MsgTx,
 	btcutil.Amount, error) {
 
 	if m.chanType.IsTaproot() {
diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
new file mode 100644
index 000000000..07bc32e3d
--- /dev/null
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -0,0 +1,1582 @@
+package chancloser
+
+import (
+	"bytes"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"math/rand"
+	"testing"
+	"time"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/btcsuite/btcd/btcec/v2/ecdsa"
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/btcsuite/btcd/chaincfg"
+	"github.com/btcsuite/btcd/mempool"
+	"github.com/btcsuite/btcd/txscript"
+	"github.com/btcsuite/btcd/wire"
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/input"
+	"github.com/lightningnetwork/lnd/lntest/wait"
+	"github.com/lightningnetwork/lnd/lntypes"
+	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
+	"github.com/lightningnetwork/lnd/lnwire"
+	"github.com/lightningnetwork/lnd/protofsm"
+	"github.com/lightningnetwork/lnd/tlv"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	localAddr = lnwire.DeliveryAddress(append(
+		[]byte{txscript.OP_1, txscript.OP_DATA_32},
+		bytes.Repeat([]byte{0x01}, 32)...,
+	))
+
+	remoteAddr = lnwire.DeliveryAddress(append(
+		[]byte{txscript.OP_1, txscript.OP_DATA_32},
+		bytes.Repeat([]byte{0x02}, 32)...,
+	))
+
+	localSigBytes = fromHex("3045022100cd496f2ab4fe124f977ffe3caa09f757" +
+		"6d8a34156b4e55d326b4dffc0399a094022013500a0510b5094bff220c7" +
+		"4656879b8ca0369d3da78004004c970790862fc03")
+	localSig     = sigMustParse(localSigBytes)
+	localSigWire = mustWireSig(&localSig)
+
+	remoteSigBytes = fromHex("304502210082235e21a2300022738dabb8e1bbd9d1" +
+		"9cfb1e7ab8c30a23b0afbb8d178abcf3022024bf68e256c534ddfaf966b" +
+		"f908deb944305596f7bdcc38d69acad7f9c868724")
+	remoteSig     = sigMustParse(remoteSigBytes)
+	remoteWireSig = mustWireSig(&remoteSig)
+
+	localTx = wire.MsgTx{Version: 2}
+
+	closeTx = wire.NewMsgTx(2)
+)
+
+func sigMustParse(sigBytes []byte) ecdsa.Signature {
+	sig, err := ecdsa.ParseSignature(sigBytes)
+	if err != nil {
+		panic(err)
+	}
+
+	return *sig
+}
+
+func mustWireSig(e input.Signature) lnwire.Sig {
+	wireSig, err := lnwire.NewSigFromSignature(e)
+	if err != nil {
+		panic(err)
+	}
+
+	return wireSig
+}
+
+func fromHex(s string) []byte {
+	r, err := hex.DecodeString(s)
+	if err != nil {
+		panic("invalid hex in source file: " + s)
+	}
+
+	return r
+}
+
+func randOutPoint(t *testing.T) wire.OutPoint {
+	var op wire.OutPoint
+	if _, err := rand.Read(op.Hash[:]); err != nil {
+		t.Fatalf("unable to generate random outpoint: %v", err)
+	}
+	op.Index = rand.Uint32()
+
+	return op
+}
+
+func randPubKey(t *testing.T) *btcec.PublicKey {
+	priv, err := btcec.NewPrivateKey()
+	if err != nil {
+		t.Fatalf("unable to generate private key: %v", err)
+	}
+
+	return priv.PubKey()
+}
+
+func assertStateTransitions[Event any, Env protofsm.Environment](
+	t *testing.T, stateSub protofsm.StateSubscriber[Event, Env],
+	expectedStates []protofsm.State[Event, Env]) {
+
+	t.Helper()
+
+	for _, expectedState := range expectedStates {
+		newState := <-stateSub.NewItemCreated.ChanOut()
+
+		require.IsType(t, expectedState, newState)
+	}
+
+	// We should have no more states.
+	select {
+	case newState := <-stateSub.NewItemCreated.ChanOut():
+		t.Fatalf("unexpected state transition: %v", newState)
+	default:
+	}
+}
+
+// unknownEvent is a dummy event that is used to test that the state machine
+// transitions properly fail when an unknown event is received.
+type unknownEvent struct {
+}
+
+func (u *unknownEvent) protocolSealed() {
+}
+
+// assertUnknownEventFail asserts that the state machine fails as expected
+// given an unknown event.
+func assertUnknownEventFail(t *testing.T, startingState ProtocolState) {
+	t.Helper()
+
+	// Any other event should be ignored.
+	t.Run("unknown_event", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some(startingState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		closeHarness.expectFailure(ErrInvalidStateTransition)
+
+		closeHarness.chanCloser.SendEvent(&unknownEvent{})
+
+		// There should be no further state transitions.
+		closeHarness.assertNoStateTransitions()
+	})
+}
+
+type harnessCfg struct {
+	initialState fn.Option[ProtocolState]
+
+	thawHeight fn.Option[uint32]
+
+	localUpfrontAddr  fn.Option[lnwire.DeliveryAddress]
+	remoteUpfrontAddr fn.Option[lnwire.DeliveryAddress]
+}
+
+// rbfCloserTestHarness is a test harness for the RBF closer.
+type rbfCloserTestHarness struct {
+	*testing.T
+
+	cfg *harnessCfg
+
+	chanCloser *RbfChanCloser
+	env        Environment
+
+	newAddrErr error
+
+	pkScript []byte
+	peerPub  btcec.PublicKey
+
+	startingState RbfState
+
+	feeEstimator   *mockFeeEstimator
+	chanObserver   *mockChanObserver
+	signer         *mockCloseSigner
+	daemonAdapters *dummyAdapters
+	errReporter    *mockErrorReporter
+
+	stateSub protofsm.StateSubscriber[ProtocolEvent, *Environment]
+}
+
+var errfailAddr = fmt.Errorf("fail")
+
+// failNewAddrFunc causes the newAddrFunc to fail.
+func (r *rbfCloserTestHarness) failNewAddrFunc() {
+	r.T.Helper()
+
+	r.newAddrErr = errfailAddr
+}
+
+func (r *rbfCloserTestHarness) newAddrFunc() (
+	lnwire.DeliveryAddress, error) {
+
+	r.T.Helper()
+
+	return lnwire.DeliveryAddress{}, r.newAddrErr
+}
+
+func (r *rbfCloserTestHarness) assertExpectations() {
+	r.T.Helper()
+
+	r.feeEstimator.AssertExpectations(r.T)
+	r.chanObserver.AssertExpectations(r.T)
+	r.daemonAdapters.AssertExpectations(r.T)
+	r.errReporter.AssertExpectations(r.T)
+	r.signer.AssertExpectations(r.T)
+}
+
+func (r *rbfCloserTestHarness) stopAndAssert() {
+	r.T.Helper()
+
+	defer r.chanCloser.RemoveStateSub(r.stateSub)
+	r.chanCloser.Stop()
+
+	r.assertExpectations()
+}
+
+func (r *rbfCloserTestHarness) assertStartupAssertions() {
+	r.T.Helper()
+
+	// When the state machine has started up, we recv a starting state
+	// transition for the initial state.
+	expectedStates := []RbfState{r.startingState}
+	assertStateTransitions(r.T, r.stateSub, expectedStates)
+
+	// Registering the spend transaction should have been called.
+	r.daemonAdapters.AssertCalled(
+		r.T, "RegisterSpendNtfn", &r.env.ChanPoint, r.pkScript,
+		r.env.Scid.BlockHeight,
+	)
+}
+
+func (r *rbfCloserTestHarness) assertNoStateTransitions() {
+	select {
+	case newState := <-r.stateSub.NewItemCreated.ChanOut():
+		r.T.Fatalf("unexpected state transition: %T", newState)
+	case <-time.After(10 * time.Millisecond):
+	}
+}
+
+func (r *rbfCloserTestHarness) assertStateTransitions(states ...RbfState) {
+	assertStateTransitions(r.T, r.stateSub, states)
+}
+
+func (r *rbfCloserTestHarness) currentState() RbfState {
+	state, err := r.chanCloser.CurrentState()
+	require.NoError(r.T, err)
+
+	return state
+}
+
+type shutdownExpect struct {
+	isInitiator   bool
+	allowSend     bool
+	recvShutdown  bool
+	finalBalances fn.Option[ShutdownBalances]
+}
+
+func (r *rbfCloserTestHarness) expectShutdownEvents(expect shutdownExpect) {
+	r.T.Helper()
+
+	r.chanObserver.On("FinalBalances").Return(expect.finalBalances)
+
+	if expect.isInitiator {
+		r.chanObserver.On("NoDanglingUpdates").Return(expect.allowSend)
+	} else {
+		r.chanObserver.On("NoDanglingUpdates").Return(
+			expect.allowSend,
+		).Maybe()
+	}
+
+	// When we're receiving a shutdown, we should also disable incoming
+	// adds.
+	if expect.recvShutdown {
+		r.chanObserver.On("DisableIncomingAdds").Return(nil)
+	}
+
+	// If a close is in progress, this is an RBF iteration, the link has
+	// already been cleaned up, so we don't expect any assertions, other
+	// than to check the closing state.
+	if expect.finalBalances.IsSome() {
+		return
+	}
+
+	r.chanObserver.On("DisableOutgoingAdds").Return(nil)
+
+	r.chanObserver.On(
+		"MarkShutdownSent", mock.Anything, expect.isInitiator,
+	).Return(nil)
+
+	r.chanObserver.On("DisableChannel").Return(nil)
+}
+
+func (r *rbfCloserTestHarness) expectFinalBalances(
+	b fn.Option[ShutdownBalances]) {
+
+	r.chanObserver.On("FinalBalances").Return(b)
+}
+
+func (r *rbfCloserTestHarness) expectIncomingAddsDisabled() {
+	r.T.Helper()
+
+	r.chanObserver.On("DisableIncomingAdds").Return(nil)
+}
+
+type msgMatcher func([]lnwire.Message) bool
+
+func singleMsgMatcher[M lnwire.Message](f func(M) bool) msgMatcher {
+	return func(msgs []lnwire.Message) bool {
+		if len(msgs) != 1 {
+			return false
+		}
+
+		wireMsg := msgs[0]
+
+		msg, ok := wireMsg.(M)
+		if !ok {
+			return false
+		}
+
+		if f == nil {
+			return true
+		}
+
+		return f(msg)
+	}
+}
+
+func (r *rbfCloserTestHarness) expectMsgSent(matcher msgMatcher) {
+	r.T.Helper()
+
+	if matcher == nil {
+		r.daemonAdapters.On(
+			"SendMessages", r.peerPub, mock.Anything,
+		).Return(nil)
+	} else {
+		r.daemonAdapters.On(
+			"SendMessages", r.peerPub, mock.MatchedBy(matcher),
+		).Return(nil)
+	}
+}
+
+func (r *rbfCloserTestHarness) expectFeeEstimate(absoluteFee btcutil.Amount,
+	numTimes int) {
+
+	r.T.Helper()
+
+	// TODO(roasbeef): mo assertions for dust case
+
+	r.feeEstimator.On(
+		"EstimateFee", mock.Anything, mock.Anything, mock.Anything,
+		mock.Anything,
+	).Return(absoluteFee, nil).Times(numTimes)
+}
+
+func (r *rbfCloserTestHarness) expectFailure(err error) {
+	r.T.Helper()
+
+	errorMatcher := func(e error) bool {
+		return errors.Is(e, err)
+	}
+
+	r.errReporter.On("ReportError", mock.MatchedBy(errorMatcher)).Return()
+}
+
+func (r *rbfCloserTestHarness) expectNewCloseSig(
+	localScript, remoteScript []byte, fee btcutil.Amount,
+	closeBalance btcutil.Amount) {
+
+	r.T.Helper()
+
+	r.signer.On(
+		"CreateCloseProposal", fee, localScript, remoteScript,
+		mock.Anything,
+	).Return(&localSig, &localTx, closeBalance, nil)
+}
+
+func (r *rbfCloserTestHarness) waitForMsgSent() {
+	r.T.Helper()
+
+	err := wait.Predicate(func() bool {
+		return r.daemonAdapters.msgSent.Load()
+	}, time.Second*3)
+	require.NoError(r.T, err)
+}
+
+func (r *rbfCloserTestHarness) expectRemoteCloseFinalized(
+	localCoopSig, remoteCoopSig input.Signature, localScript,
+	remoteScript []byte, fee btcutil.Amount,
+	balanceAfterClose btcutil.Amount, isLocal bool) {
+
+	r.expectNewCloseSig(
+		localScript, remoteScript, fee, balanceAfterClose,
+	)
+
+	r.expectCloseFinalized(
+		localCoopSig, remoteCoopSig, localScript, remoteScript,
+		fee, balanceAfterClose, isLocal,
+	)
+
+	r.expectMsgSent(singleMsgMatcher[*lnwire.ClosingSig](nil))
+}
+
+func (r *rbfCloserTestHarness) expectCloseFinalized(
+	localCoopSig, remoteCoopSig input.Signature, localScript,
+	remoteScript []byte, fee btcutil.Amount,
+	balanceAfterClose btcutil.Amount, isLocal bool) {
+
+	// The caller should obtain the final signature.
+	r.signer.On("CompleteCooperativeClose",
+		localCoopSig, remoteCoopSig, localScript,
+		remoteScript, fee, mock.Anything,
+	).Return(closeTx, balanceAfterClose, nil)
+
+	// The caller should also mark the transaction as broadcast on disk.
+	r.chanObserver.On("MarkCoopBroadcasted", closeTx, isLocal).Return(nil)
+
+	// Finally, we expect that the daemon executor should broadcast the
+	// above transaction.
+	r.daemonAdapters.On(
+		"BroadcastTransaction", closeTx, mock.Anything,
+	).Return(nil)
+}
+
+func (r *rbfCloserTestHarness) expectChanPendingClose() {
+	var nilTx *wire.MsgTx
+	r.chanObserver.On("MarkCoopBroadcasted", nilTx, true).Return(nil)
+}
+
+func (r *rbfCloserTestHarness) assertLocalClosePending() {
+	// We should then remain in the outer close negotiation state.
+	r.assertStateTransitions(&ClosingNegotiation{})
+
+	// If we examine the final resting state, we should see that the we're
+	// now in the negotiation state still for our local peer state.
+	currentState := assertStateT[*ClosingNegotiation](r)
+
+	// From this, we'll assert the resulting peer state, and that the co-op
+	// close txn is known.
+	closePendingState, ok := currentState.PeerState.GetForParty(
+		lntypes.Local,
+	).(*ClosePending)
+	require.True(r.T, ok)
+
+	require.Equal(r.T, closeTx, closePendingState.CloseTx)
+}
+
+type dustExpectation uint
+
+const (
+	noDustExpect dustExpectation = iota
+
+	localDustExpect
+
+	remoteDustExpect
+)
+
+func (d dustExpectation) String() string {
+	switch d {
+	case noDustExpect:
+		return "no dust"
+	case localDustExpect:
+		return "local dust"
+	case remoteDustExpect:
+		return "remote dust"
+	default:
+		return "unknown"
+	}
+}
+
+// expectHalfSignerIteration asserts that we carry out 1/2 of the locally
+// initiated signer iteration. This constitutes sending a ClosingComplete
+// message to the remote party, and all the other intermediate steps.
+func (r *rbfCloserTestHarness) expectHalfSignerIteration(
+	initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
+	dustExpect dustExpectation) {
+
+	numFeeCalls := 2
+
+	// If we're using the SendOfferEvent as a trigger, we only need to call
+	// the few estimation once.
+	if _, ok := initEvent.(*SendOfferEvent); ok {
+		numFeeCalls = 1
+	}
+
+	// We'll now expect that fee estimation is called, and then
+	// send in the flushed event. We expect two calls: one to
+	// figure out if we can pay the fee, and then another when we
+	// actually pay the fee.
+	r.expectFeeEstimate(absoluteFee, numFeeCalls)
+
+	// Next, we'll assert that we receive calls to generate a new
+	// commitment signature, and then send out the commit
+	// ClosingComplete message to the remote peer.
+	r.expectNewCloseSig(
+		localAddr, remoteAddr, absoluteFee, balanceAfterClose,
+	)
+
+	// We expect that only the closer and closee sig is set as both
+	// parties have a balance.
+	msgExpect := singleMsgMatcher(func(m *lnwire.ClosingComplete) bool {
+		r.T.Helper()
+
+		switch {
+		case m.CloserNoClosee.IsSome():
+			r.T.Logf("closer no closee field set, expected: %v",
+				dustExpect)
+
+			return dustExpect == remoteDustExpect
+		case m.NoCloserClosee.IsSome():
+			r.T.Logf("no close closee field set, expected: %v",
+				dustExpect)
+
+			return dustExpect == localDustExpect
+		default:
+			r.T.Logf("no dust field set, expected: %v", dustExpect)
+
+			return (m.CloserAndClosee.IsSome() &&
+				dustExpect == noDustExpect)
+		}
+	})
+	r.expectMsgSent(msgExpect)
+
+	r.chanCloser.SendEvent(initEvent)
+
+	// Based on the init event, we'll either just go to the closing
+	// negotiation state, or go through the channel flushing state first.
+	var expectedStates []RbfState
+	switch initEvent.(type) {
+	case *ShutdownReceived:
+		expectedStates = []RbfState{
+			&ChannelFlushing{}, &ClosingNegotiation{},
+		}
+
+	case *SendOfferEvent:
+
+		expectedStates = []RbfState{&ClosingNegotiation{}}
+	case *ChannelFlushed:
+		// If we're sending a flush event here, then this means that we
+		// also have enough balance to cover the fee so we'll have
+		// another inner transition to the negotiation state.
+		expectedStates = []RbfState{
+			&ClosingNegotiation{}, &ClosingNegotiation{},
+		}
+
+	default:
+		r.T.Fatalf("unknown event type: %T", initEvent)
+	}
+
+	// We should transition from the negotiation state back to
+	// itself.
+	//
+	// TODO(roasbeef): take in expected set of transitions!!!
+	//  * or base off of event, if shutdown recv'd know we're doing a full
+	//  loop
+	r.assertStateTransitions(expectedStates...)
+
+	// If we examine the final resting state, we should see that
+	// the we're now in the LocalOffersSent for our local peer
+	// state.
+	currentState := assertStateT[*ClosingNegotiation](r)
+
+	// From this, we'll assert the resulting peer state.
+	offerSentState, ok := currentState.PeerState.GetForParty(
+		lntypes.Local,
+	).(*LocalOfferSent)
+	require.True(r.T, ok)
+
+	// The proposed fee, as well as our local signature should be
+	// properly stashed in the state.
+	require.Equal(r.T, absoluteFee, offerSentState.ProposedFee)
+	require.Equal(r.T, localSigWire, offerSentState.LocalSig)
+}
+
+func (r *rbfCloserTestHarness) assertSingleRbfIteration(
+	initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
+	dustExpect dustExpectation) {
+
+	// We'll now send in the send offer event, which should trigger 1/2 of
+	// the RBF loop, ending us in the LocalOfferSent state.
+	r.expectHalfSignerIteration(
+		initEvent, balanceAfterClose, absoluteFee, noDustExpect,
+	)
+
+	// Now that we're in the local offer sent state, we'll send the
+	// response of the remote party, which completes one iteration
+	localSigEvent := &LocalSigReceived{
+		SigMsg: lnwire.ClosingSig{
+			ClosingSigs: lnwire.ClosingSigs{
+				CloserAndClosee: newSigTlv[tlv.TlvType3](
+					remoteWireSig,
+				),
+			},
+		},
+	}
+
+	// Before we send the event, we expect the close the final signature to
+	// be combined/obtained, and for the close to finalized on disk.
+	r.expectCloseFinalized(
+		&localSig, &remoteSig, localAddr, remoteAddr, absoluteFee,
+		balanceAfterClose, true,
+	)
+
+	r.chanCloser.SendEvent(localSigEvent)
+
+	// We should transition to the pending closing state now.
+	r.assertLocalClosePending()
+}
+
+func (r *rbfCloserTestHarness) assertSingleRemoteRbfIteration(
+	initEvent ProtocolEvent, balanceAfterClose, absoluteFee btcutil.Amount,
+	sequence uint32, iteration bool) {
+
+	// If this is an iteration, then we expect some intermediate states,
+	// before we enter the main RBF/sign loop.
+	if iteration {
+		r.expectFeeEstimate(absoluteFee, 1)
+
+		r.assertStateTransitions(
+			&ChannelActive{}, &ShutdownPending{},
+			&ChannelFlushing{}, &ClosingNegotiation{},
+		)
+	}
+
+	// When we receive the signature below, our local state machine should
+	// move to finalize the close.
+	r.expectRemoteCloseFinalized(
+		&localSig, &remoteSig, localAddr, remoteAddr,
+		absoluteFee, balanceAfterClose, false,
+	)
+
+	r.chanCloser.SendEvent(initEvent)
+
+	// Our outer state should transition to ClosingNegotiation state.
+	r.assertStateTransitions(&ClosingNegotiation{})
+
+	// If we examine the final resting state, we should see that the we're
+	// now in the ClosePending state for the remote peer.
+	currentState := assertStateT[*ClosingNegotiation](r)
+
+	// From this, we'll assert the resulting peer state.
+	pendingState, ok := currentState.PeerState.GetForParty(
+		lntypes.Remote,
+	).(*ClosePending)
+	require.True(r.T, ok)
+
+	// The proposed fee, as well as our local signature should be properly
+	// stashed in the state.
+	require.Equal(r.T, closeTx, pendingState.CloseTx)
+}
+
+func assertStateT[T ProtocolState](h *rbfCloserTestHarness) T {
+	h.T.Helper()
+
+	currentState, ok := h.currentState().(T)
+	require.True(h.T, ok)
+
+	return currentState
+}
+
+// newRbfCloserTestHarness creates a new test harness for the RBF closer.
+func newRbfCloserTestHarness(t *testing.T,
+	cfg *harnessCfg) *rbfCloserTestHarness {
+
+	startingHeight := 200
+
+	chanPoint := randOutPoint(t)
+	chanID := lnwire.NewChanIDFromOutPoint(chanPoint)
+
+	scid := lnwire.NewShortChanIDFromInt(rand.Uint64())
+
+	peerPub := randPubKey(t)
+
+	msgMapper := NewRbfMsgMapper(uint32(startingHeight), chanID)
+
+	initialState := cfg.initialState.UnwrapOr(&ChannelActive{})
+
+	defaultFeeRate := chainfee.FeePerKwFloor
+
+	feeEstimator := &mockFeeEstimator{}
+	mockObserver := &mockChanObserver{}
+	errReporter := &mockErrorReporter{}
+	mockSigner := &mockCloseSigner{}
+
+	harness := &rbfCloserTestHarness{
+		T:             t,
+		cfg:           cfg,
+		startingState: initialState,
+		feeEstimator:  feeEstimator,
+		signer:        mockSigner,
+		chanObserver:  mockObserver,
+		errReporter:   errReporter,
+		peerPub:       *peerPub,
+	}
+
+	env := Environment{
+		ChainParams:           chaincfg.RegressionNetParams,
+		ChanPeer:              *peerPub,
+		ChanPoint:             chanPoint,
+		ChanID:                chanID,
+		Scid:                  scid,
+		DefaultFeeRate:        defaultFeeRate.FeePerVByte(),
+		ThawHeight:            cfg.thawHeight,
+		RemoteUpfrontShutdown: cfg.remoteUpfrontAddr,
+		LocalUpfrontShutdown:  cfg.localUpfrontAddr,
+		NewDeliveryScript:     harness.newAddrFunc,
+		FeeEstimator:          feeEstimator,
+		ChanObserver:          mockObserver,
+		CloseSigner:           mockSigner,
+	}
+	harness.env = env
+
+	var pkScript []byte
+	harness.pkScript = pkScript
+
+	spendEvent := protofsm.RegisterSpend[ProtocolEvent]{
+		OutPoint:       chanPoint,
+		HeightHint:     scid.BlockHeight,
+		PkScript:       pkScript,
+		PostSpendEvent: fn.Some[RbfSpendMapper](SpendMapper),
+	}
+
+	daemonAdapters := newDaemonAdapters()
+	harness.daemonAdapters = daemonAdapters
+
+	protoCfg := RbfChanCloserCfg{
+		ErrorReporter: errReporter,
+		Daemon:        daemonAdapters,
+		InitialState:  initialState,
+		Env:           &env,
+		InitEvent:     fn.Some[protofsm.DaemonEvent](&spendEvent),
+		MsgMapper: fn.Some[protofsm.MsgMapper[ProtocolEvent]](
+			msgMapper,
+		),
+		CustomPollInterval: fn.Some(time.Nanosecond),
+	}
+
+	// Before we start we always expect an initial spend event.
+	daemonAdapters.On(
+		"RegisterSpendNtfn", &chanPoint, pkScript, scid.BlockHeight,
+	).Return(nil)
+
+	chanCloser := protofsm.NewStateMachine(protoCfg)
+	chanCloser.Start()
+
+	harness.stateSub = chanCloser.RegisterStateEvents()
+
+	harness.chanCloser = &chanCloser
+
+	return harness
+}
+
+func newCloser(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness {
+	chanCloser := newRbfCloserTestHarness(t, cfg)
+
+	// We should start in the active state, and have our spend req
+	// daemon event handled.
+	chanCloser.assertStartupAssertions()
+
+	return chanCloser
+}
+
+// TestRbfChannelActiveTransitions tests the transitions of from the
+// ChannelActive state.
+func TestRbfChannelActiveTransitions(t *testing.T) {
+	localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20))
+	remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20))
+
+	feeRate := chainfee.SatPerVByte(1000)
+
+	// Test that if a spend event is received, the FSM transitions to the
+	// CloseFin terminal state.
+	t.Run("spend_event", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		closeHarness.chanCloser.SendEvent(&SpendEvent{})
+
+		closeHarness.assertStateTransitions(&CloseFin{})
+	})
+
+	// If we send in a local shutdown event, but fail to get an addr, the
+	// state machine should terminate.
+	t.Run("local_initiated_close_addr_fail", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{})
+		defer closeHarness.stopAndAssert()
+
+		closeHarness.failNewAddrFunc()
+		closeHarness.expectFailure(errfailAddr)
+
+		// We don't specify an upfront shutdown addr, and don't specify
+		// on here in the vent, so we should call new addr, but then
+		// fail.
+		closeHarness.chanCloser.SendEvent(&SendShutdown{})
+
+		// We shouldn't have transitioned to a new state.
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// Initiating the shutdown should have us transition to the shutdown
+	// pending state. We should also emit events to disable the channel,
+	// and also send a message to our target peer.
+	t.Run("local_initiated_close_ok", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// Once we send the event below, we should get calls to the
+		// chan observer, the msg sender, and the link control.
+		closeHarness.expectShutdownEvents(shutdownExpect{
+			isInitiator: true,
+			allowSend:   true,
+		})
+		closeHarness.expectMsgSent(nil)
+
+		// If we send the shutdown event, we should transition to the
+		// shutdown pending state.
+		closeHarness.chanCloser.SendEvent(&SendShutdown{
+			IdealFeeRate: feeRate,
+		})
+		closeHarness.assertStateTransitions(&ShutdownPending{})
+
+		// If we examine the internal state, it should be consistent
+		// with the fee+addr we sent in.
+		currentState := assertStateT[*ShutdownPending](closeHarness)
+
+		require.Equal(
+			t, feeRate, currentState.IdealFeeRate.UnsafeFromSome(),
+		)
+		require.Equal(
+			t, localAddr,
+			currentState.ShutdownScripts.LocalDeliveryScript,
+		)
+
+		// Wait till the msg has been sent to assert our expectations.
+		//
+		// TODO(roasbeef): can use call.WaitFor here?
+		closeHarness.waitForMsgSent()
+	})
+
+	// TODO(roasbeef): thaw height fail
+
+	// When we receive a shutdown, we should transition to the shutdown
+	// pending state, with the local+remote shutdown addrs known.
+	t.Run("remote_initiated_close_ok", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We assert our shutdown events, and also that we eventually
+		// send a shutdown to the remote party. We'll hold back the
+		// send in this case though, as we should only send once the no
+		// updates are dangling.
+		closeHarness.expectShutdownEvents(shutdownExpect{
+			isInitiator:  false,
+			allowSend:    false,
+			recvShutdown: true,
+		})
+
+		// Next, we'll emit the recv event, with the addr of the remote
+		// party.
+		closeHarness.chanCloser.SendEvent(&ShutdownReceived{
+			ShutdownScript: remoteAddr,
+		})
+
+		// We should transition to the shutdown pending state.
+		closeHarness.assertStateTransitions(&ShutdownPending{})
+
+		currentState := assertStateT[*ShutdownPending](closeHarness)
+
+		// Both the local and remote shutdown scripts should be set.
+		require.Equal(
+			t, localAddr,
+			currentState.ShutdownScripts.LocalDeliveryScript,
+		)
+		require.Equal(
+			t, remoteAddr,
+			currentState.ShutdownScripts.RemoteDeliveryScript,
+		)
+	})
+
+	// Any other event should be ignored.
+	assertUnknownEventFail(t, &ChannelActive{})
+}
+
+// TestRbfShutdownPendingTransitions tests the transitions of the RBF closer
+// once we get to the shutdown pending state. In this state, we wait for either
+// a shutdown to be received, or a notification that we're able to send a
+// shutdown ourselves.
+func TestRbfShutdownPendingTransitions(t *testing.T) {
+	t.Parallel()
+
+	startingState := &ShutdownPending{}
+
+	// Test that if a spend event is received, the FSM transitions to the
+	// CloseFin terminal state.
+	t.Run("spend_event", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](
+				startingState,
+			),
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		closeHarness.chanCloser.SendEvent(&SpendEvent{})
+
+		closeHarness.assertStateTransitions(&CloseFin{})
+	})
+
+	// If the remote party sends us a diff shutdown addr than we expected,
+	// then we'll fail.
+	t.Run("initiator_shutdown_recv_validate_fail", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](
+				startingState,
+			),
+			remoteUpfrontAddr: fn.Some(remoteAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We should fail as the shutdown script isn't what we
+		// expected.
+		closeHarness.expectFailure(ErrUpfrontShutdownScriptMismatch)
+
+		// We'll now send in a ShutdownReceived event, but with a
+		// different address provided in the shutdown message. This
+		// should result in an error.
+		closeHarness.chanCloser.SendEvent(&ShutdownReceived{
+			ShutdownScript: localAddr,
+		})
+
+		// We shouldn't have transitioned to a new state.
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// Otherwise, if the shutdown is well composed, then we should
+	// transition to the ChannelFlushing state.
+	t.Run("initiator_shutdown_recv_ok", func(t *testing.T) {
+		firstState := *startingState
+		firstState.IdealFeeRate = fn.Some(
+			chainfee.FeePerKwFloor.FeePerVByte(),
+		)
+		firstState.ShutdownScripts = ShutdownScripts{
+			LocalDeliveryScript:  localAddr,
+			RemoteDeliveryScript: remoteAddr,
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](
+				&firstState,
+			),
+			localUpfrontAddr:  fn.Some(localAddr),
+			remoteUpfrontAddr: fn.Some(remoteAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We should disable the outgoing adds for the channel at this
+		// point as well.
+		closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
+		closeHarness.expectIncomingAddsDisabled()
+
+		// We'll send in a shutdown received event, with the expected
+		// co-op close addr.
+		closeHarness.chanCloser.SendEvent(&ShutdownReceived{
+			ShutdownScript: remoteAddr,
+		})
+
+		// We should transition to the channel flushing state.
+		closeHarness.assertStateTransitions(&ChannelFlushing{})
+
+		// Now we'll ensure that the flushing state has the proper
+		// co-op close state.
+		currentState := assertStateT[*ChannelFlushing](closeHarness)
+
+		require.Equal(t, localAddr, currentState.LocalDeliveryScript)
+		require.Equal(t, remoteAddr, currentState.RemoteDeliveryScript)
+		require.Equal(
+			t, firstState.IdealFeeRate, currentState.IdealFeeRate,
+		)
+	})
+
+	// If we received the shutdown event, then we'll rely on the external
+	// signal that we were able to send the message once there were no
+	// updates dangling.
+	t.Run("responder_complete", func(t *testing.T) {
+		firstState := *startingState
+		firstState.IdealFeeRate = fn.Some(
+			chainfee.FeePerKwFloor.FeePerVByte(),
+		)
+		firstState.ShutdownScripts = ShutdownScripts{
+			LocalDeliveryScript:  localAddr,
+			RemoteDeliveryScript: remoteAddr,
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](
+				&firstState,
+			),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// In this case we're doing the shutdown dance for the first
+		// time, so we'll mark the channel as not being flushed.
+		closeHarness.expectFinalBalances(fn.None[ShutdownBalances]())
+
+		// We'll send in a shutdown received event.
+		closeHarness.chanCloser.SendEvent(&ShutdownComplete{})
+
+		// We should transition to the channel flushing state.
+		closeHarness.assertStateTransitions(&ChannelFlushing{})
+	})
+
+	// Any other event should be ignored.
+	assertUnknownEventFail(t, startingState)
+}
+
+// TestRbfChannelFlushingTransitions tests the transitions of the RBF closer
+// for the channel flushing state. Once the coast is clear, we should
+// transition to the negotiation state.
+func TestRbfChannelFlushingTransitions(t *testing.T) {
+	t.Parallel()
+
+	localBalance := lnwire.NewMSatFromSatoshis(10_000)
+	remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
+
+	absoluteFee := btcutil.Amount(10_100)
+
+	startingState := &ChannelFlushing{
+		ShutdownScripts: ShutdownScripts{
+			LocalDeliveryScript:  localAddr,
+			RemoteDeliveryScript: remoteAddr,
+		},
+	}
+
+	flushTemplate := &ChannelFlushed{
+		ShutdownBalances: ShutdownBalances{
+			LocalBalance:  localBalance,
+			RemoteBalance: remoteBalance,
+		},
+	}
+
+	// If send in the channel flushed event, but the local party can't pay
+	// for fees, then we should just head to the negotiation state.
+	for _, isFreshFlush := range []bool{true, false} {
+		chanFlushedEvent := *flushTemplate
+		chanFlushedEvent.FreshFlush = isFreshFlush
+
+		testName := fmt.Sprintf("local_cannot_pay_for_fee/"+
+			"fresh_flush=%v", isFreshFlush)
+
+		t.Run(testName, func(t *testing.T) {
+			firstState := *startingState
+
+			closeHarness := newCloser(t, &harnessCfg{
+				initialState: fn.Some[ProtocolState](
+					&firstState,
+				),
+			})
+			defer closeHarness.stopAndAssert()
+
+			// As part of the set up for this state, we'll have the
+			// final absolute fee required be greater than the
+			// balance of the local party.
+			closeHarness.expectFeeEstimate(absoluteFee, 1)
+
+			// If this is a fresh flush, then we expect the state
+			// to be marked on disk.
+			if isFreshFlush {
+				closeHarness.expectChanPendingClose()
+			}
+
+			// We'll now send in the event which should trigger
+			// this code path.
+			closeHarness.chanCloser.SendEvent(&chanFlushedEvent)
+
+			// With the event sent, we should now transition
+			// straight to the ClosingNegotiation state, with no
+			// further state transitions.
+			closeHarness.assertStateTransitions(
+				&ClosingNegotiation{},
+			)
+		})
+	}
+
+	for _, isFreshFlush := range []bool{true, false} {
+		flushEvent := *flushTemplate
+		flushEvent.FreshFlush = isFreshFlush
+
+		// We'll modify the starting balance to be 3x the required fee
+		// to ensure that we can pay for the fee.
+		flushEvent.ShutdownBalances.LocalBalance = lnwire.NewMSatFromSatoshis( //nolint:ll
+			absoluteFee * 3,
+		)
+
+		testName := fmt.Sprintf("local_can_pay_for_fee/"+
+			"fresh_flush=%v", isFreshFlush)
+
+		// This scenario, we'll have the local party be able to pay for
+		// the fees, which will trigger additional state transitions.
+		t.Run(testName, func(t *testing.T) {
+			firstState := *startingState
+
+			closeHarness := newCloser(t, &harnessCfg{
+				initialState: fn.Some[ProtocolState](
+					&firstState,
+				),
+			})
+			defer closeHarness.stopAndAssert()
+
+			localBalance := flushEvent.ShutdownBalances.LocalBalance
+			balanceAfterClose := localBalance.ToSatoshis() - absoluteFee //nolint:ll
+
+			// If this is a fresh flush, then we expect the state
+			// to be marked on disk.
+			if isFreshFlush {
+				closeHarness.expectChanPendingClose()
+			}
+
+			// From where, we expect the state transition to go
+			// back to closing negotiated, for a ClosingComplete
+			// message to be sent and then for us to terminate at
+			// that state. This is 1/2 of the normal RBF signer
+			// flow.
+			closeHarness.expectHalfSignerIteration(
+				&flushEvent, balanceAfterClose, absoluteFee,
+				noDustExpect,
+			)
+		})
+	}
+
+	// Any other event should be ignored.
+	assertUnknownEventFail(t, startingState)
+}
+
+// TestRbfCloseClosingNegotiationLocal tests the local portion of the primary
+// RBF close loop. We should be able to transition to a close state, get a sig,
+// then restart all over again to re-request a signature of at new higher fee
+// rate.
+func TestRbfCloseClosingNegotiationLocal(t *testing.T) {
+	t.Parallel()
+
+	localBalance := lnwire.NewMSatFromSatoshis(40_000)
+	remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
+
+	absoluteFee := btcutil.Amount(10_100)
+
+	closeTerms := &CloseChannelTerms{
+		ShutdownBalances: ShutdownBalances{
+			LocalBalance:  localBalance,
+			RemoteBalance: remoteBalance,
+		},
+		ShutdownScripts: ShutdownScripts{
+			LocalDeliveryScript:  localAddr,
+			RemoteDeliveryScript: remoteAddr,
+		},
+	}
+	startingState := &ClosingNegotiation{
+		PeerState: lntypes.Dual[AsymmetricPeerState]{
+			Local: &LocalCloseStart{
+				CloseChannelTerms: *closeTerms,
+			},
+		},
+	}
+
+	sendOfferEvent := &SendOfferEvent{
+		TargetFeeRate: chainfee.FeePerKwFloor.FeePerVByte(),
+	}
+
+	balanceAfterClose := localBalance.ToSatoshis() - absoluteFee
+
+	// In this state, we'll simulate deciding that we need to send a new
+	// offer to the remote party.
+	t.Run("send_offer_iteration_no_dust", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](startingState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We'll now send in the initial sender offer event, which
+		// should then trigger a single RBF iteration, ending at the
+		// pending state.
+		closeHarness.assertSingleRbfIteration(
+			sendOfferEvent, balanceAfterClose, absoluteFee,
+			noDustExpect,
+		)
+	})
+
+	// We'll run a similar test as above, but verify that if more than one
+	// sig field is set, we error out.
+	t.Run("send_offer_too_many_sigs_received", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](startingState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We'll kick off the test as normal, triggering a send offer
+		// event to advance the state machine.
+		closeHarness.expectHalfSignerIteration(
+			sendOfferEvent, balanceAfterClose, absoluteFee,
+			noDustExpect,
+		)
+
+		// We'll now craft the local sig received event, but this time
+		// we'll specify 2 signature fields.
+		localSigEvent := &LocalSigReceived{
+			SigMsg: lnwire.ClosingSig{
+				ClosingSigs: lnwire.ClosingSigs{
+					CloserNoClosee: newSigTlv[tlv.TlvType1](
+						remoteWireSig,
+					),
+					CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+						remoteWireSig,
+					),
+				},
+			},
+		}
+
+		// We expect that the state machine fails as we received more
+		// than one error.
+		closeHarness.expectFailure(ErrTooManySigs)
+
+		// We should fail as the remote party sent us more than one
+		// signature.
+		closeHarness.chanCloser.SendEvent(localSigEvent)
+	})
+
+	// Next, we'll verify that if the balance of the remote party is dust,
+	// then the proper sig field is set.
+	t.Run("send_offer_iteration_remote_dust", func(t *testing.T) {
+		// We'll modify the starting state to reduce the balance of the
+		// remote party to something that'll be dust.
+		newCloseTerms := *closeTerms
+		newCloseTerms.ShutdownBalances.RemoteBalance = 100
+		firstState := &ClosingNegotiation{
+			PeerState: lntypes.Dual[AsymmetricPeerState]{
+				Local: &LocalCloseStart{
+					CloseChannelTerms: newCloseTerms,
+				},
+			},
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](firstState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We'll kick off the half iteration as normal, but this time
+		// we expect that the remote party's output is dust, so the
+		// proper field is set.
+		closeHarness.expectHalfSignerIteration(
+			sendOfferEvent, balanceAfterClose, absoluteFee,
+			remoteDustExpect,
+		)
+	})
+
+	// Similarly, we'll verify that if our final closing balance is dust,
+	// then we send the sig that omits our output.
+	t.Run("send_offer_iteration_local_dust", func(t *testing.T) {
+		firstState := &ClosingNegotiation{
+			PeerState: lntypes.Dual[AsymmetricPeerState]{
+				Local: &LocalCloseStart{
+					CloseChannelTerms: *closeTerms,
+				},
+			},
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](firstState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We'll kick off the half iteration as normal, but this time
+		// we'll have our balance after close be dust, so we expect
+		// that the local output is dust in the sig we send.
+		dustBalance := btcutil.Amount(100)
+		closeHarness.expectHalfSignerIteration(
+			sendOfferEvent, dustBalance, absoluteFee,
+			localDustExpect,
+		)
+	})
+
+	// In this test, we'll assert that we're able to restart the RBF loop
+	// to trigger additional signature iterations.
+	t.Run("send_offer_rbf_iteration_loop", func(t *testing.T) {
+		firstState := &ClosingNegotiation{
+			PeerState: lntypes.Dual[AsymmetricPeerState]{
+				Local: &LocalCloseStart{
+					CloseChannelTerms: *closeTerms,
+				},
+			},
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState:     fn.Some[ProtocolState](firstState),
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We'll start out by first triggering a routine iteration,
+		// assuming we start in this negotiation state.
+		closeHarness.assertSingleRbfIteration(
+			sendOfferEvent, balanceAfterClose, absoluteFee,
+			noDustExpect,
+		)
+
+		// Next, we'll send in a new SendShutdown event which simulates
+		// the user requesting a RBF fee bump. We'll use 10x the fee we
+		// used in the last iteration.
+		rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() * 10
+		sendShutdown := &SendShutdown{
+			IdealFeeRate: rbfFeeBump,
+		}
+
+		// We should send shutdown as normal, but skip some other
+		// checks as we know the close is in progress.
+		closeHarness.expectShutdownEvents(shutdownExpect{
+			allowSend:     true,
+			finalBalances: fn.Some(closeTerms.ShutdownBalances),
+			recvShutdown:  true,
+		})
+		closeHarness.expectMsgSent(
+			singleMsgMatcher[*lnwire.Shutdown](nil),
+		)
+
+		closeHarness.chanCloser.SendEvent(sendShutdown)
+
+		// We should first transition to the Channel Active state
+		// momentarily, before transitioning to the shutdown pending
+		// state.
+		closeHarness.assertStateTransitions(
+			&ChannelActive{}, &ShutdownPending{},
+		)
+
+		// Next, we'll send in the shutdown received event, which
+		// should transition us to the channel flushing state.
+		shutdownEvent := &ShutdownReceived{
+			ShutdownScript: remoteAddr,
+		}
+
+		// Now we expect that aanother full RBF iteration takes place
+		// (we initiatea a new local sig).
+		closeHarness.assertSingleRbfIteration(
+			shutdownEvent, balanceAfterClose, absoluteFee,
+			noDustExpect,
+		)
+
+		// We should terminate in the negotiation state.
+		closeHarness.assertStateTransitions(
+			&ClosingNegotiation{},
+		)
+	})
+}
+
+// TestRbfCloseClosingNegotiationRemote tests that state machine is able to
+// handle RBF iterations to sign for the closing transaction of the remote
+// party.
+func TestRbfCloseClosingNegotiationRemote(t *testing.T) {
+	t.Parallel()
+
+	localBalance := lnwire.NewMSatFromSatoshis(40_000)
+	remoteBalance := lnwire.NewMSatFromSatoshis(50_000)
+
+	absoluteFee := btcutil.Amount(10_100)
+
+	closeTerms := &CloseChannelTerms{
+		ShutdownBalances: ShutdownBalances{
+			LocalBalance:  localBalance,
+			RemoteBalance: remoteBalance,
+		},
+		ShutdownScripts: ShutdownScripts{
+			LocalDeliveryScript:  localAddr,
+			RemoteDeliveryScript: remoteAddr,
+		},
+	}
+	startingState := &ClosingNegotiation{
+		PeerState: lntypes.Dual[AsymmetricPeerState]{
+			Local: &LocalCloseStart{
+				CloseChannelTerms: *closeTerms,
+			},
+			Remote: &RemoteCloseStart{
+				CloseChannelTerms: *closeTerms,
+			},
+		},
+	}
+
+	balanceAfterClose := remoteBalance.ToSatoshis() - absoluteFee
+
+	sequence := uint32(mempool.MaxRBFSequence)
+
+	// This case tests that if we receive a signature from the remote
+	// party, where they can't pay for the fees, we exit.
+	t.Run("recv_offer_cannot_pay_for_fees", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](startingState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We should fail as they sent a sig, but can't pay for fees.
+		closeHarness.expectFailure(ErrRemoteCannotPay)
+
+		// We'll send in a new fee proposal, but the proposed fee will
+		// be higher than the remote party's balance.
+		feeOffer := &OfferReceivedEvent{
+			SigMsg: lnwire.ClosingComplete{
+				FeeSatoshis: absoluteFee * 10,
+			},
+		}
+		closeHarness.chanCloser.SendEvent(feeOffer)
+
+		// We shouldn't have transitioned to a new state.
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// If our balance, is dust, then the remote party should send a
+	// signature that doesn't include our output.
+	t.Run("recv_offer_err_closer_no_closee", func(t *testing.T) {
+		// We'll modify our local balance to be dust.
+		closingTerms := *closeTerms
+		closingTerms.ShutdownBalances.LocalBalance = 100
+
+		firstState := &ClosingNegotiation{
+			PeerState: lntypes.Dual[AsymmetricPeerState]{
+				Local: &LocalCloseStart{
+					CloseChannelTerms: closingTerms,
+				},
+				Remote: &RemoteCloseStart{
+					CloseChannelTerms: closingTerms,
+				},
+			},
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](firstState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We should fail as they included the wrong sig.
+		closeHarness.expectFailure(ErrCloserNoClosee)
+
+		// Our balance is dust, so we should reject this signature that
+		// includes our output.
+		feeOffer := &OfferReceivedEvent{
+			SigMsg: lnwire.ClosingComplete{
+				FeeSatoshis: absoluteFee,
+				ClosingSigs: lnwire.ClosingSigs{
+					CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+						remoteWireSig,
+					),
+				},
+			},
+		}
+		closeHarness.chanCloser.SendEvent(feeOffer)
+
+		// We shouldn't have transitioned to a new state.
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// If no balances are dust, then they should send a sig covering both
+	// outputs.
+	t.Run("recv_offer_err_closer_and_closee", func(t *testing.T) {
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState: fn.Some[ProtocolState](startingState),
+		})
+		defer closeHarness.stopAndAssert()
+
+		// We should fail as they included the wrong sig.
+		closeHarness.expectFailure(ErrCloserAndClosee)
+
+		// Both balances are above dust, we should reject this
+		// signature as it excludes an output.
+		feeOffer := &OfferReceivedEvent{
+			SigMsg: lnwire.ClosingComplete{
+				FeeSatoshis: absoluteFee,
+				ClosingSigs: lnwire.ClosingSigs{
+					CloserNoClosee: newSigTlv[tlv.TlvType1]( //nolint:ll
+						remoteWireSig,
+					),
+				},
+			},
+		}
+		closeHarness.chanCloser.SendEvent(feeOffer)
+
+		// We shouldn't have transitioned to a new state.
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// If everything lines up, then we should be able to do multiple RBF
+	// loops to enable the remote party to sign.new versions of the co-op
+	// close transaction.
+	t.Run("recv_offer_rbf_loop_iterations", func(t *testing.T) {
+		// We'll modify our s.t we're unable to pay for fees, but
+		// aren't yet dust.
+		closingTerms := *closeTerms
+		closingTerms.ShutdownBalances.LocalBalance = lnwire.NewMSatFromSatoshis( //nolint:ll
+			9000,
+		)
+
+		firstState := &ClosingNegotiation{
+			PeerState: lntypes.Dual[AsymmetricPeerState]{
+				Local: &LocalCloseStart{
+					CloseChannelTerms: closingTerms,
+				},
+				Remote: &RemoteCloseStart{
+					CloseChannelTerms: closingTerms,
+				},
+			},
+		}
+
+		closeHarness := newCloser(t, &harnessCfg{
+			initialState:     fn.Some[ProtocolState](firstState),
+			localUpfrontAddr: fn.Some(localAddr),
+		})
+		defer closeHarness.stopAndAssert()
+
+		feeOffer := &OfferReceivedEvent{
+			SigMsg: lnwire.ClosingComplete{
+				FeeSatoshis: absoluteFee,
+				LockTime:    1,
+				ClosingSigs: lnwire.ClosingSigs{
+					CloserAndClosee: newSigTlv[tlv.TlvType3]( //nolint:ll
+						remoteWireSig,
+					),
+				},
+			},
+		}
+
+		// As we're already in the negotiation phase, we'll now trigger
+		// a new iteration by having the remote party send a new offer
+		// sig.
+		closeHarness.assertSingleRemoteRbfIteration(
+			feeOffer, balanceAfterClose, absoluteFee, sequence,
+			false,
+		)
+
+		// At this point, we've completed a single RBF iteration, and
+		// want to test further iterations, so we'll use a shutdown
+		// even tot kick it all off.
+		//
+		// Before we send the shutdown messages below, we'll mark the
+		// balances as so we fast track to the negotiation state.
+		closeHarness.expectShutdownEvents(shutdownExpect{
+			allowSend:     true,
+			finalBalances: fn.Some(closingTerms.ShutdownBalances),
+			recvShutdown:  true,
+		})
+		closeHarness.expectMsgSent(
+			singleMsgMatcher[*lnwire.Shutdown](nil),
+		)
+
+		// We'll now simulate the start of the RBF loop, by receiving a
+		// new Shutdown message from the remote party. This signals
+		// that they want to obtain a new commit sig.
+		closeHarness.chanCloser.SendEvent(&ShutdownReceived{
+			ShutdownScript: remoteAddr,
+		})
+
+		// Next, we'll receive an offer from the remote party, and
+		// drive another RBF iteration. This time, we'll increase the
+		// absolute fee by 1k sats.
+		feeOffer.SigMsg.FeeSatoshis += 1000
+		absoluteFee = feeOffer.SigMsg.FeeSatoshis
+		closeHarness.assertSingleRemoteRbfIteration(
+			feeOffer, balanceAfterClose, absoluteFee, sequence,
+			true,
+		)
+
+		closeHarness.assertNoStateTransitions()
+	})
+
+	// TODO(roasbeef): cross sig case? tested isolation, so wolog?
+}
diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go
index d0caa9781..9da174a3d 100644
--- a/lnwallet/channel_test.go
+++ b/lnwallet/channel_test.go
@@ -770,29 +770,66 @@ func TestCommitHTLCSigCustomRecordSize(t *testing.T) {
 }
 
 // TestCooperativeChannelClosure checks that the coop close process finishes
-// with an agreement from both parties, and that the final balances of the
-// close tx check out.
+// with an agreement from both parties, and that the final balances of the close
+// tx check out.
 func TestCooperativeChannelClosure(t *testing.T) {
-	t.Run("tweakless", func(t *testing.T) {
-		testCoopClose(t, &coopCloseTestCase{
-			chanType: channeldb.SingleFunderTweaklessBit,
-		})
-	})
-	t.Run("anchors", func(t *testing.T) {
-		testCoopClose(t, &coopCloseTestCase{
-			chanType: channeldb.SingleFunderTweaklessBit |
-				channeldb.AnchorOutputsBit,
-			anchorAmt: AnchorSize * 2,
+	testCases := []struct {
+		name      string
+		closeCase coopCloseTestCase
+	}{
+		{
+			name: "tweakless",
+			closeCase: coopCloseTestCase{
+				chanType: channeldb.SingleFunderTweaklessBit,
+			},
+		},
+		{
+			name: "anchors",
+			closeCase: coopCloseTestCase{
+				chanType: channeldb.SingleFunderTweaklessBit |
+					channeldb.AnchorOutputsBit,
+				anchorAmt: AnchorSize * 2,
+			},
+		},
+		{
+			name: "anchors local pay",
+			closeCase: coopCloseTestCase{
+				chanType: channeldb.SingleFunderTweaklessBit |
+					channeldb.AnchorOutputsBit,
+				anchorAmt:   AnchorSize * 2,
+				customPayer: fn.Some(lntypes.Local),
+			},
+		},
+		{
+			name: "anchors remote pay",
+			closeCase: coopCloseTestCase{
+				chanType: channeldb.SingleFunderTweaklessBit |
+					channeldb.AnchorOutputsBit,
+				anchorAmt:   AnchorSize * 2,
+				customPayer: fn.Some(lntypes.Remote),
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			testCoopClose(t, testCase.closeCase)
 		})
-	})
+	}
 }
 
 type coopCloseTestCase struct {
 	chanType  channeldb.ChannelType
 	anchorAmt btcutil.Amount
+
+	customPayer fn.Option[lntypes.ChannelParty]
+}
+
+type closeOpts struct {
+	aliceOpts []ChanCloseOpt
+	bobOpts   []ChanCloseOpt
 }
 
-func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
+func testCoopClose(t *testing.T, testCase coopCloseTestCase) {
 	t.Parallel()
 
 	// Create a test channel which will be used for the duration of this
@@ -813,17 +850,38 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
 		bobChannel.channelState.LocalCommitment.FeePerKw,
 	)
 
+	customPayer := testCase.customPayer
+
+	closeOpts := fn.MapOptionZ(
+		customPayer, func(payer lntypes.ChannelParty) closeOpts {
+			// If the local party is paying then from Alice's PoV,
+			// then local party is paying. From Bob's PoV, the
+			// remote party is paying. If the remote party is, then
+			// the opposite is true.
+			return closeOpts{
+				aliceOpts: []ChanCloseOpt{
+					WithCustomPayer(payer),
+				},
+				bobOpts: []ChanCloseOpt{
+					WithCustomPayer(payer.CounterParty()),
+				},
+			}
+		},
+	)
+
 	// We'll start with both Alice and Bob creating a new close proposal
 	// with the same fee.
 	aliceFee := aliceChannel.CalcFee(aliceFeeRate)
 	aliceSig, _, _, err := aliceChannel.CreateCloseProposal(
 		aliceFee, aliceDeliveryScript, bobDeliveryScript,
+		closeOpts.aliceOpts...,
 	)
 	require.NoError(t, err, "unable to create alice coop close proposal")
 
 	bobFee := bobChannel.CalcFee(bobFeeRate)
 	bobSig, _, _, err := bobChannel.CreateCloseProposal(
 		bobFee, bobDeliveryScript, aliceDeliveryScript,
+		closeOpts.bobOpts...,
 	)
 	require.NoError(t, err, "unable to create bob coop close proposal")
 
@@ -832,14 +890,14 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
 	// transaction is well formed, and the signatures verify.
 	aliceCloseTx, bobTxBalance, err := bobChannel.CompleteCooperativeClose(
 		bobSig, aliceSig, bobDeliveryScript, aliceDeliveryScript,
-		bobFee,
+		bobFee, closeOpts.bobOpts...,
 	)
 	require.NoError(t, err, "unable to complete alice cooperative close")
 	bobCloseSha := aliceCloseTx.TxHash()
 
 	bobCloseTx, aliceTxBalance, err := aliceChannel.CompleteCooperativeClose(
 		aliceSig, bobSig, aliceDeliveryScript, bobDeliveryScript,
-		aliceFee,
+		aliceFee, closeOpts.aliceOpts...,
 	)
 	require.NoError(t, err, "unable to complete bob cooperative close")
 	aliceCloseSha := bobCloseTx.TxHash()
@@ -848,18 +906,43 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
 		t.Fatalf("alice and bob close transactions don't match: %v", err)
 	}
 
-	// Finally, make sure the final balances are correct from both's
-	// perspective.
+	type chanFees struct {
+		alice btcutil.Amount
+		bob   btcutil.Amount
+	}
+
+	// Compute the closing fees for each party. If not specified, Alice will
+	// always pay the fees. Otherwise, it depends on who the payer is.
+	closeFees := fn.MapOption(func(payer lntypes.ChannelParty) chanFees {
+		var alice, bob btcutil.Amount
+
+		switch payer {
+		case lntypes.Local:
+			alice = bobFee
+			bob = 0
+		case lntypes.Remote:
+			bob = bobFee
+			alice = 0
+		}
+
+		return chanFees{
+			alice: alice,
+			bob:   bob,
+		}
+	})(testCase.customPayer).UnwrapOr(chanFees{alice: bobFee})
+
+	// Finally, make sure the final balances are correct from both
+	// perspectives.
 	aliceBalance := aliceChannel.channelState.LocalCommitment.
 		LocalBalance.ToSatoshis()
 
-	// The commit balance have had the initiator's (Alice) commitfee and
+	// The commit balance have had the initiator's (Alice) commit fee and
 	// any anchors subtracted, so add that back to the final expected
 	// balance. Alice also pays the coop close fee, so that must be
 	// subtracted.
 	commitFee := aliceChannel.channelState.LocalCommitment.CommitFee
 	expBalanceAlice := aliceBalance + commitFee +
-		testCase.anchorAmt - bobFee
+		testCase.anchorAmt - closeFees.alice
 	if aliceTxBalance != expBalanceAlice {
 		t.Fatalf("expected balance %v got %v", expBalanceAlice,
 			aliceTxBalance)
@@ -868,7 +951,7 @@ func testCoopClose(t *testing.T, testCase *coopCloseTestCase) {
 	// Bob is not the initiator, so his final balance should simply be
 	// equal to the latest commitment balance.
 	expBalanceBob := bobChannel.channelState.LocalCommitment.
-		LocalBalance.ToSatoshis()
+		LocalBalance.ToSatoshis() - closeFees.bob
 	if bobTxBalance != expBalanceBob {
 		t.Fatalf("expected bob's balance to be %v got %v",
 			expBalanceBob, bobTxBalance)
diff --git a/lnwallet/close_test.go b/lnwallet/close_test.go
new file mode 100644
index 000000000..7ece3fc45
--- /dev/null
+++ b/lnwallet/close_test.go
@@ -0,0 +1,241 @@
+package lnwallet
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/btcsuite/btcd/btcutil"
+	"github.com/lightningnetwork/lnd/channeldb"
+	"github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightningnetwork/lnd/lntypes"
+	"github.com/stretchr/testify/require"
+	"pgregory.net/rapid"
+)
+
+// genValidAmount generates valid bitcoin amounts (non-negative).
+func genValidAmount(t *rapid.T, label string) btcutil.Amount {
+	return btcutil.Amount(
+		rapid.Int64Range(
+			100_000, 21_000_000*100_000_000,
+		).Draw(t, label),
+	)
+}
+
+// genCoopCloseFee generates a reasonable non-zero cooperative close fee.
+func genCoopCloseFee(t *rapid.T) btcutil.Amount {
+	// Generate a fee between 250-10000 sats which is a reasonable range for
+	// closing transactions
+	return btcutil.Amount(
+		rapid.Int64Range(250, 10_000).Draw(t, "coop_close_fee"),
+	)
+}
+
+// genChannelType generates various channel types, ensuring good coverage of
+// different channel configurations including anchor outputs and other features.
+func genChannelType(t *rapid.T) channeldb.ChannelType {
+	var chanType channeldb.ChannelType
+
+	// For each bit, decide randomly if it should be set.
+	bits := []channeldb.ChannelType{
+		channeldb.DualFunderBit,
+		channeldb.SingleFunderTweaklessBit,
+		channeldb.NoFundingTxBit,
+		channeldb.AnchorOutputsBit,
+		channeldb.FrozenBit,
+		channeldb.ZeroHtlcTxFeeBit,
+		channeldb.LeaseExpirationBit,
+		channeldb.ZeroConfBit,
+		channeldb.ScidAliasChanBit,
+		channeldb.ScidAliasFeatureBit,
+		channeldb.SimpleTaprootFeatureBit,
+		channeldb.TapscriptRootBit,
+	}
+
+	// Helper to bias towards setting specific bits more frequently.
+	setBit := func(bit channeldb.ChannelType, probability int) {
+		bitRange := rapid.IntRange(0, 100)
+		label := "bit_" + strconv.FormatUint(uint64(bit), 2)
+		if bitRange.Draw(t, label) < probability {
+			chanType |= bit
+		}
+	}
+
+	// We want to ensure good coverage of anchor outputs since they affect
+	// the balance calculation directly. We'll set the anchor bit with a 50%
+	// chance.
+	setBit(channeldb.AnchorOutputsBit, 50)
+
+	// For other bits, use varying probabilities to ensure good
+	// distribution.
+	for _, bit := range bits {
+		// The anchor bit was already set above so we can skip it here.
+		if bit == channeldb.AnchorOutputsBit {
+			continue
+		}
+
+		// Some bits are related, so we'll make sure we capture that
+		// dep.
+		switch bit {
+		case channeldb.TapscriptRootBit:
+			// If we have TapscriptRootBit, we must have
+			// SimpleTaprootFeatureBit.
+			if chanType&channeldb.SimpleTaprootFeatureBit != 0 {
+				// 70% chance if taproot is enabled.
+				setBit(bit, 70)
+			}
+
+		case channeldb.DualFunderBit:
+			// 40% chance of dual funding.
+			setBit(bit, 40)
+
+		default:
+			// 30% chance for other bits.
+			setBit(bit, 30)
+		}
+	}
+
+	return chanType
+}
+
+// genFeePayer generates optional fee payer.
+func genFeePayer(t *rapid.T) fn.Option[lntypes.ChannelParty] {
+	if !rapid.Bool().Draw(t, "has_fee_payer") {
+		return fn.None[lntypes.ChannelParty]()
+	}
+
+	if rapid.Bool().Draw(t, "is_local") {
+		return fn.Some(lntypes.Local)
+	}
+
+	return fn.Some(lntypes.Remote)
+}
+
+// genCommitFee generates a reasonable non-zero commitment fee.
+func genCommitFee(t *rapid.T) btcutil.Amount {
+	// Generate a reasonable commit fee between 100-5000 sats
+	return btcutil.Amount(
+		rapid.Int64Range(100, 5_000).Draw(t, "commit_fee"),
+	)
+}
+
+// TestCoopCloseBalance tests fundamental properties of CoopCloseBalance. This
+// ensures that the closing fee is always subtracted from the correct balance,
+// amongst other properties.
+func TestCoopCloseBalance(tt *testing.T) {
+	tt.Parallel()
+
+	rapid.Check(tt, func(t *rapid.T) {
+		require := require.New(t)
+
+		// Generate test inputs
+		chanType := genChannelType(t)
+		isInitiator := rapid.Bool().Draw(t, "is_initiator")
+
+		// Generate amounts using specific generators
+		coopCloseFee := genCoopCloseFee(t)
+		ourBalance := genValidAmount(t, "local balance")
+		theirBalance := genValidAmount(t, "remote balance")
+		feePayer := genFeePayer(t)
+		commitFee := genCommitFee(t)
+
+		ourFinal, theirFinal, err := CoopCloseBalance(
+			chanType, isInitiator, coopCloseFee,
+			ourBalance, theirBalance, commitFee, feePayer,
+		)
+
+		// Property 1: If inputs are non-negative, we either get valid
+		// outputs or an error.
+		if err != nil {
+			// On error, final balances should be 0
+			require.Zero(
+				ourFinal,
+				"expected zero our_balance on error",
+			)
+			require.Zero(
+				theirFinal,
+				"expected zero their_balance on error",
+			)
+
+			return
+		}
+
+		// Property 2: Final balances should be non-negative.
+		require.GreaterOrEqual(
+			ourFinal, btcutil.Amount(0),
+			"our final balance should be non-negative",
+		)
+		require.GreaterOrEqual(
+			theirFinal, btcutil.Amount(0),
+			"their final balance should be non-negative",
+		)
+
+		// Property 3: Total balance should be conserved minus fees.
+		initialTotal := ourBalance + theirBalance
+		initialTotal += commitFee
+
+		if chanType.HasAnchors() {
+			initialTotal += 2 * AnchorSize
+		}
+
+		finalTotal := ourFinal + theirFinal + coopCloseFee
+		require.Equal(
+			initialTotal, finalTotal,
+			"total balance should be conserved",
+		)
+
+		// Property 4: When feePayer is specified, that party's balance
+		// should be reduced by exactly the coopCloseFee.
+		if feePayer.IsSome() {
+			payer := feePayer.UnwrapOrFail(tt)
+
+			if payer == lntypes.Local {
+				require.LessOrEqual(
+					ourBalance-(ourFinal+coopCloseFee),
+					btcutil.Amount(0),
+					"local balance reduced by more than fee", //nolint:ll
+				)
+			} else {
+				require.LessOrEqual(
+					theirBalance-(theirFinal+coopCloseFee),
+					btcutil.Amount(0),
+					"remote balance reduced by more than fee", //nolint:ll
+				)
+			}
+		}
+
+		// Property 5: For anchor channels, verify the correct final
+		// balance factors in the anchor amount.
+		if chanType.HasAnchors() {
+			// The initiator delta is the commit fee plus anchor
+			// amount.
+			initiatorDelta := commitFee + 2*AnchorSize
+
+			// Default to initiator paying unless explicitly
+			// specified.
+			isLocalPaying := isInitiator
+			if feePayer.IsSome() {
+				isLocalPaying = feePayer.UnwrapOrFail(tt) ==
+					lntypes.Local
+			}
+
+			if isInitiator {
+				expectedBalance := ourBalance + initiatorDelta
+				if isLocalPaying {
+					expectedBalance -= coopCloseFee
+				}
+
+				require.Equal(expectedBalance, ourFinal,
+					"initiator (local) balance incorrect")
+			} else {
+				// They are the initiator
+				expectedBalance := theirBalance + initiatorDelta
+				if !isLocalPaying {
+					expectedBalance -= coopCloseFee
+				}
+
+				require.Equal(expectedBalance, theirFinal,
+					"initiator (remote) balance incorrect")
+			}
+		}
+	})
+}
diff --git a/lnwire/lnwire_test.go b/lnwire/lnwire_test.go
index 6bfbb465e..f42426ac5 100644
--- a/lnwire/lnwire_test.go
+++ b/lnwire/lnwire_test.go
@@ -1352,7 +1352,7 @@ func TestLightningWireProtocol(t *testing.T) {
 			req := ClosingComplete{
 				ChannelID:   ChannelID(c),
 				FeeSatoshis: btcutil.Amount(r.Int63()),
-				Sequence:    uint32(r.Int63()),
+				LockTime:    uint32(r.Int63()),
 				ClosingSigs: ClosingSigs{},
 			}
 
diff --git a/protofsm/state_machine_test.go b/protofsm/state_machine_test.go
index fc7a4ccfd..ea596dc25 100644
--- a/protofsm/state_machine_test.go
+++ b/protofsm/state_machine_test.go
@@ -80,9 +80,7 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
 		return &StateTransition[dummyEvents, *dummyEnv]{
 			NextState: &dummyStateStart{},
 			NewEvents: fn.Some(EmittedEvent[dummyEvents]{
-				InternalEvent: fn.Some(
-					[]dummyEvents{&goToFin{}},
-				),
+				InternalEvent: []dummyEvents{&goToFin{}},
 			}),
 		}, nil
 
@@ -114,13 +112,13 @@ func (d *dummyStateStart) ProcessEvent(event dummyEvents, env *dummyEnv,
 				canSend: d.canSend,
 			},
 			NewEvents: fn.Some(EmittedEvent[dummyEvents]{
-				ExternalEvents: fn.Some(DaemonEventSet{
+				ExternalEvents: DaemonEventSet{
 					sendEvent, sendEvent2,
 					&BroadcastTxn{
 						Tx:    &wire.MsgTx{},
 						Label: "test",
 					},
-				}),
+				},
 			}),
 		}, nil
 	}
diff --git a/routing/blinding_test.go b/routing/blinding_test.go
index 0a8846adb..8f83f7fd8 100644
--- a/routing/blinding_test.go
+++ b/routing/blinding_test.go
@@ -2,11 +2,11 @@ package routing
 
 import (
 	"bytes"
-	"reflect"
 	"testing"
 
 	"github.com/btcsuite/btcd/btcec/v2"
 	sphinx "github.com/lightningnetwork/lightning-onion"
+	"github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/graph/db/models"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
@@ -129,7 +129,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
 		HtlcMaximum:         htlcMax,
 		Features:            features,
 	}
-	hints, err := blindedPayment.toRouteHints()
+	hints, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
 	require.NoError(t, err)
 	require.Nil(t, hints)
 
@@ -184,7 +184,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
 		},
 	}
 
-	actual, err := blindedPayment.toRouteHints()
+	actual, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
 	require.NoError(t, err)
 
 	require.Equal(t, len(expected), len(actual))
@@ -218,63 +218,3 @@ func TestBlindedPaymentToHints(t *testing.T) {
 		require.Equal(t, expectedHint[0], actualHint[0])
 	}
 }
-
-// TestBlindedPaymentDeepCopy tests the deep copy method of the BLindedPayment
-// struct.
-//
-// TODO(ziggie): Make this a property test instead.
-func TestBlindedPaymentDeepCopy(t *testing.T) {
-	_, pkBlind1 := btcec.PrivKeyFromBytes([]byte{1})
-	_, blindingPoint := btcec.PrivKeyFromBytes([]byte{2})
-	_, pkBlind2 := btcec.PrivKeyFromBytes([]byte{3})
-
-	// Create a test BlindedPayment with non-nil fields
-	original := &BlindedPayment{
-		BaseFee:             1000,
-		ProportionalFeeRate: 2000,
-		CltvExpiryDelta:     144,
-		HtlcMinimum:         1000,
-		HtlcMaximum:         1000000,
-		Features:            lnwire.NewFeatureVector(nil, nil),
-		BlindedPath: &sphinx.BlindedPath{
-			IntroductionPoint: pkBlind1,
-			BlindingPoint:     blindingPoint,
-			BlindedHops: []*sphinx.BlindedHopInfo{
-				{
-					BlindedNodePub: pkBlind2,
-					CipherText:     []byte("test cipher"),
-				},
-			},
-		},
-	}
-
-	// Make a deep copy
-	cpyPayment := original.deepCopy()
-
-	// Test 1: Verify the copy is not the same pointer
-	if cpyPayment == original {
-		t.Fatal("deepCopy returned same pointer")
-	}
-
-	// Verify all fields are equal
-	if !reflect.DeepEqual(original, cpyPayment) {
-		t.Fatal("copy is not equal to original")
-	}
-
-	// Modify the copy and verify it doesn't affect the original
-	cpyPayment.BaseFee = 2000
-	cpyPayment.BlindedPath.BlindedHops[0].CipherText = []byte("modified")
-
-	require.NotEqual(t, original.BaseFee, cpyPayment.BaseFee)
-
-	require.NotEqual(
-		t,
-		original.BlindedPath.BlindedHops[0].CipherText,
-		cpyPayment.BlindedPath.BlindedHops[0].CipherText,
-	)
-
-	// Verify nil handling.
-	var nilPayment *BlindedPayment
-	nilCopy := nilPayment.deepCopy()
-	require.Nil(t, nilCopy)
-}
diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go
index f91d95d6b..c463b8135 100644
--- a/routing/pathfind_test.go
+++ b/routing/pathfind_test.go
@@ -765,9 +765,6 @@ func TestPathFinding(t *testing.T) {
 	}, {
 		name: "path finding with additional edges",
 		fn:   runPathFindingWithAdditionalEdges,
-	}, {
-		name: "path finding with duplicate blinded hop",
-		fn:   runPathFindingWithBlindedPathDuplicateHop,
 	}, {
 		name: "path finding with redundant additional edges",
 		fn:   runPathFindingWithRedundantAdditionalEdges,
@@ -1268,107 +1265,6 @@ func runPathFindingWithAdditionalEdges(t *testing.T, useCache bool) {
 	assertExpectedPath(t, graph.aliasMap, path, "songoku", "doge")
 }
 
-// runPathFindingWithBlindedPathDuplicateHop tests that in case a blinded path
-// has duplicate hops that the path finding algorithm does not fail or behave
-// incorrectly. This can happen because the creator of the blinded path can
-// specify the same hop multiple times and this will only be detected at the
-// forwarding nodes, so it is important that we can handle this case.
-func runPathFindingWithBlindedPathDuplicateHop(t *testing.T, useCache bool) {
-	graph, err := parseTestGraph(t, useCache, basicGraphFilePath)
-	require.NoError(t, err, "unable to create graph")
-
-	sourceNode, err := graph.graph.SourceNode()
-	require.NoError(t, err, "unable to fetch source node")
-
-	paymentAmt := lnwire.NewMSatFromSatoshis(100)
-
-	songokuPubKeyBytes := graph.aliasMap["songoku"]
-	songokuPubKey, err := btcec.ParsePubKey(songokuPubKeyBytes[:])
-	require.NoError(t, err, "unable to parse public key from bytes")
-
-	_, pkb1 := btcec.PrivKeyFromBytes([]byte{2})
-	_, pkb2 := btcec.PrivKeyFromBytes([]byte{3})
-	_, blindedPoint := btcec.PrivKeyFromBytes([]byte{5})
-
-	sizeEncryptedData := 100
-	cipherText := bytes.Repeat(
-		[]byte{1}, sizeEncryptedData,
-	)
-
-	vb1 := route.NewVertex(pkb1)
-	vb2 := route.NewVertex(pkb2)
-
-	// Payments to blinded paths always pay to the NUMS target key.
-	dummyTarget := route.NewVertex(&BlindedPathNUMSKey)
-
-	graph.aliasMap["pkb1"] = vb1
-	graph.aliasMap["pkb2"] = vb2
-	graph.aliasMap["dummyTarget"] = dummyTarget
-
-	// Create a blinded payment with duplicate hops and make sure the
-	// path finding algorithm can cope with that. We add blinded hop 2
-	// 3 times. The path finding algorithm should create a path with a
-	// single hop to pkb2 (the first entry).
-	blindedPayment := &BlindedPayment{
-		BlindedPath: &sphinx.BlindedPath{
-			IntroductionPoint: songokuPubKey,
-			BlindingPoint:     blindedPoint,
-			BlindedHops: []*sphinx.BlindedHopInfo{
-				{
-					CipherText: cipherText,
-				},
-				{
-					BlindedNodePub: pkb2,
-					CipherText:     cipherText,
-				},
-				{
-					BlindedNodePub: pkb1,
-					CipherText:     cipherText,
-				},
-				{
-					BlindedNodePub: pkb2,
-					CipherText:     cipherText,
-				},
-				{
-					BlindedNodePub: &BlindedPathNUMSKey,
-					CipherText:     cipherText,
-				},
-				{
-					BlindedNodePub: pkb2,
-					CipherText:     cipherText,
-				},
-			},
-		},
-		HtlcMinimum:     1,
-		HtlcMaximum:     100_000_000,
-		CltvExpiryDelta: 140,
-	}
-
-	blindedPath, err := blindedPayment.toRouteHints()
-	require.NoError(t, err)
-
-	find := func(r *RestrictParams) (
-		[]*unifiedEdge, error) {
-
-		return dbFindPath(
-			graph.graph, blindedPath, &mockBandwidthHints{},
-			r, testPathFindingConfig,
-			sourceNode.PubKeyBytes, dummyTarget, paymentAmt,
-			0, 0,
-		)
-	}
-
-	// We should now be able to find a path however not the chained path
-	// of the blinded hops.
-	path, err := find(noRestrictions)
-	require.NoError(t, err, "unable to create route to blinded path")
-
-	// The path should represent the following hops:
-	//	source node -> songoku -> pkb2 -> dummyTarget
-	assertExpectedPath(t, graph.aliasMap, path, "songoku", "pkb2",
-		"dummyTarget")
-}
-
 // runPathFindingWithRedundantAdditionalEdges asserts that we are able to find
 // paths to nodes ignoring additional edges that are already known by self node.
 func runPathFindingWithRedundantAdditionalEdges(t *testing.T, useCache bool) {
@@ -3388,7 +3284,9 @@ func TestBlindedRouteConstruction(t *testing.T) {
 	// that make up the graph we'll give to route construction. The hints
 	// map is keyed by source node, so we can retrieve our blinded edges
 	// accordingly.
-	blindedEdges, err := blindedPayment.toRouteHints()
+	blindedEdges, err := blindedPayment.toRouteHints(
+		fn.None[*btcec.PublicKey](),
+	)
 	require.NoError(t, err)
 
 	carolDaveEdge := blindedEdges[carolVertex][0]
@@ -3517,48 +3415,32 @@ func TestLastHopPayloadSize(t *testing.T) {
 		customRecords = map[uint64][]byte{
 			record.CustomTypeStart: {1, 2, 3},
 		}
-
-		encrypedDataSmall = bytes.Repeat(
-			[]byte{1}, 5,
+		sizeEncryptedData = 100
+		encrypedData      = bytes.Repeat(
+			[]byte{1}, sizeEncryptedData,
 		)
-		encrypedDataLarge = bytes.Repeat(
-			[]byte{1}, 100,
-		)
-		_, blindedPoint          = btcec.PrivKeyFromBytes([]byte{5})
-		paymentAddr              = &[32]byte{1}
-		ampOptions               = &AMPOptions{}
-		amtToForward             = lnwire.MilliSatoshi(10000)
-		emptyEncryptedData       = []byte{}
-		finalHopExpiry     int32 = 144
+		_, blindedPoint       = btcec.PrivKeyFromBytes([]byte{5})
+		paymentAddr           = &[32]byte{1}
+		ampOptions            = &AMPOptions{}
+		amtToForward          = lnwire.MilliSatoshi(10000)
+		finalHopExpiry  int32 = 144
 
 		oneHopPath = &sphinx.BlindedPath{
 			BlindedHops: []*sphinx.BlindedHopInfo{
 				{
-					CipherText: emptyEncryptedData,
-				},
-			},
-			BlindingPoint: blindedPoint,
-		}
-
-		twoHopPathSmallHopSize = &sphinx.BlindedPath{
-			BlindedHops: []*sphinx.BlindedHopInfo{
-				{
-					CipherText: encrypedDataLarge,
-				},
-				{
-					CipherText: encrypedDataLarge,
+					CipherText: encrypedData,
 				},
 			},
 			BlindingPoint: blindedPoint,
 		}
 
-		twoHopPathLargeHopSize = &sphinx.BlindedPath{
+		twoHopPath = &sphinx.BlindedPath{
 			BlindedHops: []*sphinx.BlindedHopInfo{
 				{
-					CipherText: encrypedDataSmall,
+					CipherText: encrypedData,
 				},
 				{
-					CipherText: encrypedDataSmall,
+					CipherText: encrypedData,
 				},
 			},
 			BlindingPoint: blindedPoint,
@@ -3571,19 +3453,15 @@ func TestLastHopPayloadSize(t *testing.T) {
 	require.NoError(t, err)
 
 	twoHopBlindedPayment, err := NewBlindedPaymentPathSet(
-		[]*BlindedPayment{
-			{BlindedPath: twoHopPathLargeHopSize},
-			{BlindedPath: twoHopPathSmallHopSize},
-		},
+		[]*BlindedPayment{{BlindedPath: twoHopPath}},
 	)
 	require.NoError(t, err)
 
 	testCases := []struct {
-		name                  string
-		restrictions          *RestrictParams
-		finalHopExpiry        int32
-		amount                lnwire.MilliSatoshi
-		expectedEncryptedData []byte
+		name           string
+		restrictions   *RestrictParams
+		finalHopExpiry int32
+		amount         lnwire.MilliSatoshi
 	}{
 		{
 			name: "Non blinded final hop",
@@ -3601,18 +3479,16 @@ func TestLastHopPayloadSize(t *testing.T) {
 			restrictions: &RestrictParams{
 				BlindedPaymentPathSet: oneHopBlindedPayment,
 			},
-			amount:                amtToForward,
-			finalHopExpiry:        finalHopExpiry,
-			expectedEncryptedData: emptyEncryptedData,
+			amount:         amtToForward,
+			finalHopExpiry: finalHopExpiry,
 		},
 		{
 			name: "Blinded final hop of a two hop payment",
 			restrictions: &RestrictParams{
 				BlindedPaymentPathSet: twoHopBlindedPayment,
 			},
-			amount:                amtToForward,
-			finalHopExpiry:        finalHopExpiry,
-			expectedEncryptedData: encrypedDataLarge,
+			amount:         amtToForward,
+			finalHopExpiry: finalHopExpiry,
 		},
 	}
 
@@ -3636,23 +3512,16 @@ func TestLastHopPayloadSize(t *testing.T) {
 
 			var finalHop route.Hop
 			if tc.restrictions.BlindedPaymentPathSet != nil {
-				bPSet := tc.restrictions.BlindedPaymentPathSet
-				path, err := bPSet.LargestLastHopPayloadPath()
-				require.NotNil(t, path)
-
-				require.NoError(t, err)
-
+				path := tc.restrictions.BlindedPaymentPathSet.
+					LargestLastHopPayloadPath()
 				blindedPath := path.BlindedPath.BlindedHops
 				blindedPoint := path.BlindedPath.BlindingPoint
-				lastHop := blindedPath[len(blindedPath)-1]
-				require.Equal(t, lastHop.CipherText,
-					tc.expectedEncryptedData)
 
 				//nolint:ll
 				finalHop = route.Hop{
 					AmtToForward:     tc.amount,
 					OutgoingTimeLock: uint32(tc.finalHopExpiry),
-					EncryptedData:    lastHop.CipherText,
+					EncryptedData:    blindedPath[len(blindedPath)-1].CipherText,
 				}
 				if len(blindedPath) == 1 {
 					finalHop.BlindingPoint = blindedPoint
@@ -3672,11 +3541,11 @@ func TestLastHopPayloadSize(t *testing.T) {
 			payLoad, err := createHopPayload(finalHop, 0, true)
 			require.NoErrorf(t, err, "failed to create hop payload")
 
-			expectedPayloadSize, err := lastHopPayloadSize(
+			expectedPayloadSize := lastHopPayloadSize(
 				tc.restrictions, tc.finalHopExpiry,
 				tc.amount,
 			)
-			require.NoError(t, err)
+
 			require.Equal(
 				t, expectedPayloadSize,
 				uint64(payLoad.NumBytes()),
diff --git a/routing/result_interpretation_test.go b/routing/result_interpretation_test.go
index a0c3e9062..8c67bdeea 100644
--- a/routing/result_interpretation_test.go
+++ b/routing/result_interpretation_test.go
@@ -96,17 +96,13 @@ var (
 				AmtToForward: 99,
 			},
 			{
-				PubKeyBytes: hops[2],
-				// Intermediate blinded hops don't have an
-				// amount set.
-				AmtToForward:  0,
+				PubKeyBytes:   hops[2],
+				AmtToForward:  95,
 				BlindingPoint: genTestPubKey(),
 			},
 			{
-				PubKeyBytes: hops[3],
-				// Intermediate blinded hops don't have an
-				// amount set.
-				AmtToForward: 0,
+				PubKeyBytes:  hops[3],
+				AmtToForward: 88,
 			},
 			{
 				PubKeyBytes:  hops[4],
@@ -126,10 +122,8 @@ var (
 				AmtToForward: 99,
 			},
 			{
-				PubKeyBytes: hops[2],
-				// Intermediate blinded hops don't have an
-				// amount set.
-				AmtToForward:  0,
+				PubKeyBytes:   hops[2],
+				AmtToForward:  95,
 				BlindingPoint: genTestPubKey(),
 			},
 			{
@@ -146,17 +140,13 @@ var (
 		TotalAmount:  100,
 		Hops: []*route.Hop{
 			{
-				PubKeyBytes: hops[1],
-				// Intermediate blinded hops don't have an
-				// amount set.
-				AmtToForward:  0,
+				PubKeyBytes:   hops[1],
+				AmtToForward:  90,
 				BlindingPoint: genTestPubKey(),
 			},
 			{
-				PubKeyBytes: hops[2],
-				// Intermediate blinded hops don't have an
-				// amount set.
-				AmtToForward: 0,
+				PubKeyBytes:  hops[2],
+				AmtToForward: 75,
 			},
 			{
 				PubKeyBytes:  hops[3],
@@ -562,12 +552,7 @@ var resultTestCases = []resultTestCase{
 			pairResults: map[DirectedNodePair]pairResult{
 				getTestPair(0, 1): successPairResult(100),
 				getTestPair(1, 2): successPairResult(99),
-
-				// The amount for the last hop is always the
-				// receiver amount because the amount to forward
-				// is always set to 0 for intermediate blinded
-				// hops.
-				getTestPair(3, 4): failPairResult(77),
+				getTestPair(3, 4): failPairResult(88),
 			},
 		},
 	},
@@ -582,12 +567,7 @@ var resultTestCases = []resultTestCase{
 		expectedResult: &interpretedResult{
 			pairResults: map[DirectedNodePair]pairResult{
 				getTestPair(0, 1): successPairResult(100),
-
-				// The amount for the last hop is always the
-				// receiver amount because the amount to forward
-				// is always set to 0 for intermediate blinded
-				// hops.
-				getTestPair(2, 3): failPairResult(58),
+				getTestPair(2, 3): failPairResult(75),
 			},
 		},
 	},
@@ -702,25 +682,6 @@ var resultTestCases = []resultTestCase{
 			finalFailureReason: &reasonError,
 		},
 	},
-	// Test a multi-hop blinded route and that in a success case the amounts
-	// for the blinded route part are correctly set to the receiver amount.
-	{
-		name:    "blinded multi-hop success",
-		route:   blindedMultiToIntroduction,
-		success: true,
-		expectedResult: &interpretedResult{
-			pairResults: map[DirectedNodePair]pairResult{
-				getTestPair(0, 1): successPairResult(100),
-
-				// For the route blinded part of the route the
-				// success amount is determined by the receiver
-				// amount because the intermediate blinded hops
-				// set the forwarded amount to 0.
-				getTestPair(1, 2): successPairResult(58),
-				getTestPair(2, 3): successPairResult(58),
-			},
-		},
-	},
 }
 
 // TestResultInterpretation executes a list of test cases that test the result