diff --git a/cmd/loopserver-regtest/main.go b/cmd/loopserver-regtest/main.go new file mode 100644 index 000000000..10fad0535 --- /dev/null +++ b/cmd/loopserver-regtest/main.go @@ -0,0 +1,203 @@ +// loopserver-regtest is a disposable, source-available Loop server that moves +// real regtest Bitcoin and pays real regtest Lightning invoices. It must never +// be used on testnet, signet or mainnet. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/rpcclient" + "github.com/lightninglabs/lndclient" + regtestserver "github.com/lightninglabs/loop/regtest/server" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc/verrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +type config struct { + listen string + tlsCertPath string + tlsKeyPath string + + lndHost string + lndMacaroonPath string + lndTLSPath string + + bitcoinHost string + bitcoinUser string + bitcoinPassword string + + minAmount int64 + maxAmount int64 + timeout time.Duration +} + +func parseConfig() config { + var cfg config + + flag.StringVar(&cfg.listen, "listen", "0.0.0.0:11009", + "address for the regtest-only gRPC server") + flag.StringVar(&cfg.tlsCertPath, "tls.certpath", "", + "optional path to the server TLS certificate") + flag.StringVar(&cfg.tlsKeyPath, "tls.keypath", "", + "optional path to the server TLS private key") + flag.StringVar(&cfg.lndHost, "lnd.host", "localhost:10009", + "server-side lnd RPC address") + flag.StringVar(&cfg.lndMacaroonPath, "lnd.macaroonpath", "", + "path to the server-side lnd admin macaroon") + flag.StringVar(&cfg.lndTLSPath, "lnd.tlspath", "", + "path to the server-side lnd TLS certificate") + flag.StringVar(&cfg.bitcoinHost, "bitcoin.host", "localhost:18443", + "Bitcoin Core RPC address") + flag.StringVar(&cfg.bitcoinUser, "bitcoin.user", "lightning", + "Bitcoin Core RPC user") + flag.StringVar(&cfg.bitcoinPassword, "bitcoin.password", "lightning", + "Bitcoin Core RPC password") + flag.Int64Var(&cfg.minAmount, "minamt", 50_000, + "minimum swap amount in satoshis") + flag.Int64Var(&cfg.maxAmount, "maxamt", 5_000_000, + "maximum swap amount in satoshis") + flag.DurationVar(&cfg.timeout, "paymenttimeout", time.Minute, + "maximum time for a regtest Lightning payment") + flag.Parse() + + return cfg +} + +func run(ctx context.Context, cfg config) error { + // These are the oldest APIs needed by the regtest server: MuSig2 signer, + // wallet kit, chain notifier, router and invoices. Keeping the explicit + // floor also allows the repository's v0.18.5 regtest image to be used. + minLndVersion := &verrpc.Version{ + AppMajor: 0, + AppMinor: 18, + AppPatch: 4, + BuildTags: []string{ + "signrpc", "walletrpc", "chainrpc", "invoicesrpc", + }, + } + + lnd, err := lndclient.NewLndServices(&lndclient.LndServicesConfig{ + LndAddress: cfg.lndHost, + Network: lndclient.NetworkRegtest, + CustomMacaroonPath: cfg.lndMacaroonPath, + TLSPath: cfg.lndTLSPath, + CheckVersion: minLndVersion, + CallerCtx: ctx, + RPCTimeout: 30 * time.Second, + + BlockUntilChainSynced: true, + BlockUntilUnlocked: true, + BlockUntilChainNotifier: true, + }) + if err != nil { + return fmt.Errorf("connect to lnd: %w", err) + } + defer lnd.Close() + + bitcoin, err := rpcclient.New(&rpcclient.ConnConfig{ + Host: cfg.bitcoinHost, + User: cfg.bitcoinUser, + Pass: cfg.bitcoinPassword, + Params: "regtest", + DisableTLS: true, + HTTPPostMode: true, + }, nil) + if err != nil { + return fmt.Errorf("connect to bitcoind: %w", err) + } + defer bitcoin.Shutdown() + + chainInfo, err := bitcoin.GetBlockChainInfo() + if err != nil { + return fmt.Errorf("query bitcoind: %w", err) + } + if chainInfo.Chain != "regtest" { + return fmt.Errorf("refusing Bitcoin network %q", chainInfo.Chain) + } + + serverCfg := regtestserver.Config{ + Lnd: &lnd.LndServices, + Bitcoin: bitcoin, + MinSwapAmount: btcutil.Amount(cfg.minAmount), + MaxSwapAmount: btcutil.Amount(cfg.maxAmount), + PaymentTimeout: cfg.timeout, + } + loopServer, err := regtestserver.New(ctx, serverCfg) + if err != nil { + return err + } + defer loopServer.Stop() + + listener, err := net.Listen("tcp", cfg.listen) + if err != nil { + return fmt.Errorf("listen on %s: %w", cfg.listen, err) + } + + var serverOpts []grpc.ServerOption + switch { + case cfg.tlsCertPath == "" && cfg.tlsKeyPath == "": + + case cfg.tlsCertPath == "" || cfg.tlsKeyPath == "": + return fmt.Errorf("both TLS certificate and key paths are required") + + default: + creds, err := credentials.NewServerTLSFromFile( + cfg.tlsCertPath, cfg.tlsKeyPath, + ) + if err != nil { + return fmt.Errorf("load server TLS credentials: %w", err) + } + + serverOpts = append(serverOpts, grpc.Creds(creds)) + } + + grpcServer := grpc.NewServer(serverOpts...) + swapserverrpc.RegisterSwapServerServer(grpcServer, loopServer) + swapserverrpc.RegisterStaticAddressServerServer(grpcServer, loopServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + errChan := make(chan error, 1) + go func() { + errChan <- grpcServer.Serve(listener) + }() + + log.Printf("regtest Loop server listening on %s", cfg.listen) + select { + case err := <-errChan: + return err + + case <-ctx.Done(): + healthServer.Shutdown() + grpcServer.GracefulStop() + return nil + } +} + +func main() { + cfg := parseConfig() + ctx, cancel := signal.NotifyContext( + context.Background(), os.Interrupt, syscall.SIGTERM, + ) + + err := run(ctx, cfg) + cancel() + if err != nil { + log.Fatalf("loopserver-regtest: %v", err) + } +} diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 50ed672ac..448a97e7f 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -2,6 +2,10 @@ #### New Features +* Added a source-built, regtest-only Loop server and Docker environment that + completes real Loop Out, standard Loop In, and static-address deposit Loop In + swaps without the proprietary server image. + #### Breaking Changes #### Bug Fixes diff --git a/regtest/Dockerfile b/regtest/Dockerfile new file mode 100644 index 000000000..ad620e21e --- /dev/null +++ b/regtest/Dockerfile @@ -0,0 +1,19 @@ +FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +RUN apk add --no-cache alpine-sdk git + +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath \ + -o /bin/loopserver-regtest ./cmd/loopserver-regtest + +FROM --platform=${TARGETPLATFORM} alpine:3.22 + +RUN apk add --no-cache ca-certificates +COPY --from=builder /bin/loopserver-regtest /bin/loopserver-regtest + +EXPOSE 11009 +ENTRYPOINT ["/bin/loopserver-regtest"] diff --git a/regtest/README.md b/regtest/README.md index 93bb279d6..17a52002e 100644 --- a/regtest/README.md +++ b/regtest/README.md @@ -1,328 +1,201 @@ -# Local regtest setup - -To help with development of apps that use the Loop daemon (`loopd`) for -liquidity management, it is often useful to test it in controlled environments -such as on the Bitcoin `regtest` network. - -Lightning Labs provides a stripped down version of the Loop server that works -only for `regtest` and is only published in compiled, binary form (currently as -a Docker image). - -The `docker-compose.yml` shows an example setup and is accompanied by a quick -start script that should make getting started very easy. +# Source-built Loop regtest server + +This directory runs a complete, disposable Loop environment without the +proprietary `lightninglabs/loopserver` image. The server is built from +`cmd/loopserver-regtest` in this repository and executes the real protocol: + +- Loop Out uses real hold invoices, a real P2TR HTLC, and a real client sweep. +- Loop In uses the probe-invoice handshake, a real client-funded P2TR HTLC, a + real Lightning payment, and a real server claim. +- Static-address Loop In creates a real Taproot deposit address, verifies and + co-signs the safety transactions, pays the client invoice, and settles the + selected deposits on chain. + +The topology contains Bitcoin Core, a client and server `lnd`, the source-built +Loop server, `loopd`, and Aperture. Aperture is retained because static-address +ownership is tied to the client's paid L402 token and its authenticated +notification stream. + +> [!WARNING] +> This binary deliberately has regtest-only policy, in-memory state, cheap +> deterministic fees, and no operational hardening. It refuses non-regtest +> nodes, but it must still never be exposed to an untrusted network or used +> with valuable funds. ## Requirements -To get this quick start demo environment operational, you need to have the -following tools installed on your system: - - Docker - - `docker-compose` - - `jq` - - `git` +- Docker with either `docker compose` or `docker-compose` +- `jq` +- Bash -## Starting the environment +## Start the environment -Simply download the Loop repository and run the `start` command of the helper -script. This will boot up the `docker-compose` environment, mine some `regtest` -coins, connect and create channels between the two `lnd` nodes and hook up the -Loop client and servers to each other. +From any directory in the checkout: ```shell -$ git clone https://github.com/lightninglabs/loop -$ cd loop/regtest -$ ./regtest.sh start +./regtest/regtest.sh start ``` -## Looping out +`start` always removes any previous regtest containers and volumes so the +in-memory server, the client database, and Aperture's L402 state cannot drift +apart. Do not use this disposable topology for data you need to retain. -Looping out is the process of moving funds from your channel (off-chain) balance -into your on-chain wallet through what is called a submarine swap. We use the -`--fast` option here, otherwise the server would wait 30 minutes before -proceeding to allow for batched swaps. +The helper builds both Loop binaries, starts the containers, mines spendable +coins, funds both Lightning nodes, and opens one channel in each direction. It +shares Aperture's certificate with `loopd` through a read-only volume, pays the +regtest L402 challenge, and waits until the authenticated client is ready. -```shell -$ ./regtest.sh loop out --amt 500000 --fast -Send off-chain: 500000 sat -Receive on-chain: 492688 sat -Estimated total fee: 7312 sat +Useful commands are: -Fast swap requested. +```shell +./regtest/regtest.sh info +./regtest/regtest.sh logs +./regtest/regtest.sh loop getinfo +./regtest/regtest.sh mine 1 +./regtest/regtest.sh stop +``` -CONTINUE SWAP? (y/n): y -Swap initiated -ID: f96b2e40fd75a63eba61a6ee1e0b17788d77804c0454beb5fbbe4a42a8a06245 -HTLC address: bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn +`stop` removes the Compose volumes. Server swap state is intentionally not +durable, so do not restart the server while a swap is in progress. -Run `loop monitor` to monitor progress. -``` +## Run all three flows automatically -The swap has been initiated now. Let's observe how it works: +After `start`, the acceptance script runs a Loop Out, a standard Loop In, and a +static-address deposit Loop In through the real `loopd` gRPC API. It mines the +required confirmations and fails if any state machine reports failure: ```shell -$ ./regtest.sh loop monitor -Note: offchain cost may report as 0 after loopd restart during swap -2021-03-19T17:01:39Z LOOP_OUT INITIATED 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn +./regtest/e2e.sh ``` -Leave the command running, it will update with every new event. Open a second -terminal window in the same folder/location and mine one block: +Set `TIMEOUT_SECONDS` if the host needs more time: ```shell -$ ./regtest.sh mine 1 +TIMEOUT_SECONDS=300 ./regtest/e2e.sh ``` -You should see your previous window with the `monitor` command update: +Set `START_TIMEOUT_SECONDS` to change the default three-minute readiness timeout +used by `regtest.sh start`. + +## Run a Loop Out manually + +Start the swap: ```shell -2021-03-19T17:01:39Z LOOP_OUT INITIATED 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn -2021-03-19T17:04:37Z LOOP_OUT PREIMAGE_REVEALED 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn +./regtest/regtest.sh loop out --amt 500000 --fast --force ``` -As soon as the `PREIMAGE_REVEALED` message appears, you know the HTLC was -published to the chain. Finish the process by mining yet another block (in the -second terminal window): +The server waits until both hold invoices are accepted and then funds the exact +P2TR HTLC. Confirm it: ```shell -$ ./regtest.sh mine 1 +./regtest/regtest.sh mine 1 ``` -Your `monitor` window should now show the completion of the swap: +The client reveals the preimage and publishes its sweep. A Loop Out sweep uses +three confirmations for its terminal success state: ```shell -2021-03-19T17:01:39Z LOOP_OUT INITIATED 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn -2021-03-19T17:04:37Z LOOP_OUT PREIMAGE_REVEALED 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn -2021-03-19T17:06:25Z LOOP_OUT SUCCESS 0.005 BTC - bcrt1qwgzsmag0579twr4d8hgfvarqxn27mdmdgfh7wc2hdlupsfqtxwyq5zltqn (cost: server 50, onchain 6687, offchain 0) +./regtest/regtest.sh mine 3 +./regtest/regtest.sh loop listswaps --loop_out_only ``` -Congratulations, you just did a loop out! +## Run a standard Loop In manually -# Loop in - -Looping in is very similar but does the opposite: swap your on-chain balance for -off-chain (channel) balance: +Start the swap. During initiation the server really pays the client's hold +probe, waits for the client to cancel it, and only then returns the contract: ```shell -$ ./regtest.sh loop in --amt 500000 -Send on-chain: 500000 sat -Receive off-chain: 492279 sat -Estimated total fee: 7721 sat - -CONTINUE SWAP? (y/n): y -Swap initiated -ID: d95167e33e79df292e5ffeda29d34614a5a087c9378ffae003b9a25bb0f1c2e4 -HTLC address (P2WSH): bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f - -Run `loop monitor` to monitor progress. +./regtest/regtest.sh loop in --amt 500000 --force +``` +Confirm the client-funded P2TR HTLC. The server then pays the real swap invoice +and publishes its success-path claim: -$ ./regtest.sh loop monitor -Note: offchain cost may report as 0 after loopd restart during swap -2021-03-19T17:17:13Z LOOP_IN HTLC_PUBLISHED 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f +```shell +./regtest/regtest.sh mine 1 +./regtest/regtest.sh mine 1 +./regtest/regtest.sh loop listswaps --loop_in_only ``` -In your second terminal window, go ahead and mine a block: +## Run a static-address deposit Loop In manually + +Request the authenticated static address: ```shell -$ ./regtest.sh mine 1 +./regtest/regtest.sh loop static new ``` -You should see your previous window with the `monitor` command update: +Send one or more deposits to the returned address and confirm them. For example: ```shell -2021-03-19T17:17:13Z LOOP_IN HTLC_PUBLISHED 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f -2021-03-19T17:18:38Z LOOP_IN INVOICE_SETTLED 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f (cost: server -499929, onchain 7650, offchain 0) +ADDRESS=bcrt1p... +./regtest/regtest.sh lndclient sendcoins \ + --addr "$ADDRESS" --amt 500000 --min_confs 0 --force +./regtest/regtest.sh mine 6 +./regtest/regtest.sh loop static listdeposits --filter deposited ``` -After mining yet another block, the process should complete: +Loop in every available deposit: ```shell -$ ./regtest.sh mine 1 +./regtest/regtest.sh loop static in --all --fast --force +./regtest/regtest.sh loop static listswaps ``` +The client only accepts the off-chain payment after it has three independently +fee-bumped, fully signed fallback transactions. The server then settles the +deposit and broadcasts the resulting transaction. Mine any transaction left in +the mempool: + ```shell -2021-03-19T17:17:13Z LOOP_IN HTLC_PUBLISHED 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f -2021-03-19T17:18:38Z LOOP_IN INVOICE_SETTLED 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f (cost: server -499929, onchain 7650, offchain 0) -2021-03-19T17:19:21Z LOOP_IN SUCCESS 0.005 BTC - P2WSH: bcrt1q4p0pead4tfepm683fff8fl8g3kpx8spjztuquu2sctfzed9rufls2z0g2f (cost: server 71, onchain 7650, offchain 0) +./regtest/regtest.sh bitcoin getrawmempool +./regtest/regtest.sh mine 1 ``` -# Static Address Loop In - -Static Address Loop In is a new loop-in mode that, like Loop In, allows you to -swap your on-chain balance for off-chain (channel) balance, but in contrast to -the legacy loop-in, you can receive the off-chain balance instantly (after funds -were deposited to a static address). -To do so a two-step process is required, the setup and the swap phase. +## Run the server against an existing regtest -To setup a static address and deposit funds to it, do the following: +Standard Loop In and Loop Out can use the binary directly: ```shell -./regtest.sh loop static new  ✔  11:59:12 +go build -o loopserver-regtest ./cmd/loopserver-regtest -WARNING: Be aware that loosing your l402.token file in .loop under your home directory will take your ability to spend funds sent to the static address via loop-ins or withdrawals. You will have to wait until the deposit expires and your loop client sweeps the funds back to your lnd wallet. The deposit expiry could be months in the future. - -CONTINUE WITH NEW ADDRESS? (y/n): y -Received a new static loop-in address from the server: bcrt1pzgdmxftg3t6wghl2t72ewqyf3jvy2t85ud6pqsagx0zm9mj8f3aq23v3t4 -``` -A static address has been created. You can send funds to it across different -transactions. Each transaction output to this address is considered a deposit -that can be used individually or in combination with other deposits to be -swapped for an off-chain payment. Let's create a few deposits and confirm them: -```shell -./regtest.sh lndclient sendcoins --addr bcrt1pzgdmxftg3t6wghl2t72ewqyf3jvy2t85ud6pqsagx0zm9mj8f3aq23v3t4 --amt 250000 --min_confs 0 -f -{ - "txid": "86ccc85449957c3472a259e447a9180aff49848f0b957a85c24819a5f432eda7" -} -./regtest.sh lndclient sendcoins --addr bcrt1pzgdmxftg3t6wghl2t72ewqyf3jvy2t85ud6pqsagx0zm9mj8f3aq23v3t4 --amt 250000 --min_confs 0 -f -{ - "txid": "09d72c89b2346dc068bb57621463e53637f3c0cca3ba8ebb7fcb2773d7f32ae3" -} -./regtest.sh lndclient sendcoins --addr bcrt1pzgdmxftg3t6wghl2t72ewqyf3jvy2t85ud6pqsagx0zm9mj8f3aq23v3t4 --amt 250000 --min_confs 0 -f -{ - "txid": "5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f" -} - -./regtest.sh mine 6 -``` -The loop client logs should show the deposits being confirmed: -```shell -[DBG] SADDR: Received deposit: 5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0 -[DBG] SADDR: Deposit 5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0: NextState: Deposited, PreviousState: , Event: OnStart -[DBG] SADDR: Received deposit: 86ccc85449957c3472a259e447a9180aff49848f0b957a85c24819a5f432eda7:1 -[DBG] SADDR: Deposit 86ccc85449957c3472a259e447a9180aff49848f0b957a85c24819a5f432eda7:1: NextState: Deposited, PreviousState: , Event: OnStart -[DBG] SADDR: Received deposit: 09d72c89b2346dc068bb57621463e53637f3c0cca3ba8ebb7fcb2773d7f32ae3:1 -[DBG] SADDR: Deposit 09d72c89b2346dc068bb57621463e53637f3c0cca3ba8ebb7fcb2773d7f32ae3:1: NextState: Deposited, PreviousState: , Event: OnStart -``` -Let's list the deposits in the loop client: -```shell -./regtest.sh loop static listdeposits --filter deposited  ✔  12:03:37 -{ - "filtered_deposits": [ - { - "id": "5afc81a72464881e66aa6a5d7476b75cae26ea4c74c70424e4a68e63b9708e0c", - "state": "DEPOSITED", - "outpoint": "5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0", - "value": "250000", - "confirmation_height": "126", - "blocks_until_expiry": "715" - }, - { - "id": "0bf419bb047922afc186021fc186970b0b71db83444b39b0dcad4550014e0ba3", - "state": "DEPOSITED", - "outpoint": "86ccc85449957c3472a259e447a9180aff49848f0b957a85c24819a5f432eda7:1", - "value": "250000", - "confirmation_height": "126", - "blocks_until_expiry": "715" - }, - { - "id": "ea969e2ca53b608d4cf9ca8def249d77ee1db444bd3384ae1150ea03bb03dbbd", - "state": "DEPOSITED", - "outpoint": "09d72c89b2346dc068bb57621463e53637f3c0cca3ba8ebb7fcb2773d7f32ae3:1", - "value": "250000", - "confirmation_height": "126", - "blocks_until_expiry": "715" - } - ] -} -``` -These deposits can now be instantly swapped. Let's use the first one: -```shell -./regtest.sh loop static in --utxo 5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0 -Previously deposited on-chain: 250000 sat -Receive off-chain: 249614 sat -Estimated total fee: 386 sat - -CONTINUE SWAP? (y/n): y -{ - "swap_hash": "43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e", - "state": "SignHtlcTx", - "amount": "250000", - "htlc_cltv": 431, - "quoted_swap_fee_satoshis": "386", - "max_swap_fee_satoshis": "386", - "initiation_height": 131, - "protocol_version": "V0", - "label": "", - "initiator": "loop-cli", - "payment_timeout_seconds": 60 -} +./loopserver-regtest \ + --listen=127.0.0.1:11009 \ + --lnd.host=127.0.0.1:10009 \ + --lnd.macaroonpath=/path/to/lnd/admin.macaroon \ + --lnd.tlspath=/path/to/lnd/tls.cert \ + --bitcoin.host=127.0.0.1:18443 \ + --bitcoin.user=lightning \ + --bitcoin.password=lightning ``` -We see in the client log that the swap instantly succeeds: -```shell -[INF] LOOPD: Loop in quote request received -[INF] LOOPD: Static loop-in request received -[INF] SADDR: StaticAddr loop-in 0000000000000000000000000000000000000000000000000000000000000000: Current: InitHtlcTx -[DBG] SADDR: Deposit 5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0: NextState: LoopingIn, PreviousState: Deposited, Event: OnLoopInInitiated -[INF] SADDR: StaticAddr loop-in 43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e: Current: SignHtlcTx -[INF] SADDR: StaticAddr loop-in 43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e: Current: MonitorInvoiceAndHtlcTx -[DBG] SADDR: StaticAddr loop-in 43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e: received off-chain payment update Settled -[INF] SADDR: StaticAddr loop-in 43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e: Current: PaymentReceived -[DBG] SADDR: Deposit 5405e5cae38f9e4e193f7b5442dd005273e2e1fab8687c42a505c1a333e8884f:0: NextState: LoopedIn, PreviousState: LoopingIn, Event: OnLoopedIn -[INF] SADDR: StaticAddr loop-in 43ed16958d5a5bf0e6cb9a36eefb1493f39741238f06d6c7b5365c1c5c22d29e: Current: Succeeded -``` -We can combine the remaining two deposits in another instant swap by specifying -the `--all` flag: -```shell -./regtest.sh loop static in --all  ✔  12:07:28 -Previously deposited on-chain: 500000 sat -Receive off-chain: 499302 sat -Estimated total fee: 698 sat - -CONTINUE SWAP? (y/n): y -{ - "swap_hash": "950a4c0017831dc9934e93faacde1819ed5801d1c7abf6b555dc36908a3b3ca8", - "state": "SignHtlcTx", - "amount": "500000", - "htlc_cltv": 431, - "quoted_swap_fee_satoshis": "698", - "max_swap_fee_satoshis": "698", - "initiation_height": 131, - "protocol_version": "V0", - "label": "", - "initiator": "loop-cli", - "payment_timeout_seconds": 60 -} -``` -For more information on the static address loop-in feature, see the -https://docs.lightning.engineering/lightning-network-tools/loop/static-loop-in-addresses -# Using the Loop server in an existing setup +The listener is plaintext by default. Supply both `--tls.certpath` and +`--tls.keypath` to serve TLS, as the Compose topology does for Aperture's gRPC +backend. -This `docker-compose` is only meant as a demo and quick start help. You can of -course also integrate the Loop server Docker image into your existing `regtest` -environment. - -Simply pull the image and run it by pointing it to an existing `lnd` node: +Point a regtest `loopd` at it with `--server.notls`: ```shell -$ docker pull lightninglabs/loopserver:latest -$ docker run -d \ - -p 11009:11009 \ - -v /some/dir/to/lnd:/root/.lnd \ - lightninglabs/loopserver:latest \ - daemon \ - --maxamt=5000000 \ - --lnd.host=some-lnd-node:10009 \ - --lnd.macaroondir=/root/.lnd/data/chain/bitcoin/regtest \ - --lnd.tlspath=/root/.lnd/tls.cert \ +loopd \ + --experimental \ + --network=regtest \ + --server.host=127.0.0.1:11009 \ + --server.notls \ + --lnd.host=127.0.0.1:10010 \ + --lnd.macaroonpath=/path/to/client-lnd/admin.macaroon \ + --lnd.tlspath=/path/to/client-lnd/tls.cert ``` -An existing Loop client (`loopd`) can then be pointed to this server with: - -```shell -$ loopd \ - --network=regtest \ - --debuglevel=debug \ - --server.host=localhost:11009 \ - --server.notls \ - --lnd.host=some-other-lnd-node:10009 \ - --lnd.macaroonpath=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon \ - --lnd.tlspath=/root/.lnd/tls.cert -``` +For static-address swaps, use the Compose topology or put the server behind an +L402-compatible Aperture instance. A direct connection has no paid token with +which the notification manager can authenticate static-address ownership. -The `--server.notls` is important here as the `regtest` version of the Loop -server only supports insecure, non-TLS connections. +## Scope and limitations -Also make sure you connect the Loop server and Loop client to a different `lnd` -node each since an `lnd` node cannot pay itself. Obviously there also need to be -some channels with enough liquidity between the server's and client's `lnd` -nodes (direct or indirect doesn't matter). +The server is intentionally a protocol test fixture, not a miniature production +service. It has no database, multi-tenant authorization, batching, dynamic fee +market, liquidity management, accounting, monitoring, or upgrade guarantees. +It validates contract keys, hashes, amounts, outpoints, scripts, invoices, and +signing requests needed to keep the real regtest funds safe for the lifetime of +the process. diff --git a/regtest/aperture.yaml b/regtest/aperture.yaml new file mode 100644 index 000000000..d6da7cab7 --- /dev/null +++ b/regtest/aperture.yaml @@ -0,0 +1,34 @@ +listenaddr: '0.0.0.0:11018' +staticroot: '/root/.aperture/static' +servestatic: true +debuglevel: trace +insecure: false +writetimeout: 0s + +servername: aperture +autocert: false + +authenticator: + lndhost: lndserver:10009 + tlspath: /root/.lnd/tls.cert + macdir: /root/.lnd/data/chain/bitcoin/regtest + network: regtest + +etcd: + host: 'etcd:2379' + user: + password: + +services: + - name: loop + hostregexp: '^.*$' + pathregexp: '^/looprpc.*$' + address: 'loopserver:11009' + protocol: https + tlscertpath: /root/.lnd/tls.cert + price: 1000 + authwhitelistpaths: + - '^/looprpc.SwapServer/LoopOutTerms.*$' + - '^/looprpc.SwapServer/LoopOutQuote.*$' + - '^/looprpc.SwapServer/LoopInTerms.*$' + - '^/looprpc.SwapServer/LoopInQuote.*$' diff --git a/regtest/docker-compose.yml b/regtest/docker-compose.yml index b6b6c5e0d..46165430f 100644 --- a/regtest/docker-compose.yml +++ b/regtest/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: bitcoind: image: ruimarinho/bitcoin-core:23 @@ -59,8 +58,10 @@ services: - "--tlsextradomain=lndserver" loopserver: - image: lightninglabs/loopserver container_name: loopserver + build: + context: ../ + dockerfile: regtest/Dockerfile restart: unless-stopped networks: regtest: @@ -68,24 +69,22 @@ services: - loopserver volumes: - "lndserver:/root/.lnd" - - "loopserver:/root/loopserver" depends_on: - lndserver command: - - "daemon" - "--maxamt=5000000" - "--lnd.host=lndserver:10009" - - "--lnd.macaroondir=/home/loopserver/" - - "--lnd.tlspath=/home/loopserver/tls.cert" + - "--lnd.macaroonpath=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon" + - "--lnd.tlspath=/root/.lnd/tls.cert" + - "--tls.certpath=/root/.lnd/tls.cert" + - "--tls.keypath=/root/.lnd/tls.key" - "--bitcoin.host=bitcoind:18443" - "--bitcoin.user=lightning" - "--bitcoin.password=lightning" - - "--bitcoin.zmqpubrawblock=tcp://bitcoind:28332" - - "--bitcoin.zmqpubrawtx=tcp://bitcoind:28333" aperture: build: - context: https://github.com/lightninglabs/aperture.git + context: https://github.com/lightninglabs/aperture.git#v0.3.8-beta dockerfile: Dockerfile args: checkout: v0.3.8-beta @@ -100,10 +99,10 @@ services: - aperture volumes: - "lndserver:/root/.lnd" - - "loopserver:/root/loopserver" - "aperture:/root/.aperture" + - "./aperture.yaml:/root/.aperture/aperture.yaml:ro" ports: - - "11018:11018" + - "127.0.0.1:11018:11018" entrypoint: [ "/bin/aperture" ] command: - "--configfile=/root/.aperture/aperture.yaml" @@ -151,7 +150,7 @@ services: - loopclient volumes: - "lndclient:/root/.lnd" - - "aperture:/root/.aperture" + - "aperture:/root/.aperture:ro" - "loopclient:/root/.loop" depends_on: - aperture @@ -161,14 +160,13 @@ services: - "--network=regtest" - "--debuglevel=debug" - "--server.host=aperture:11018" - - "--server.tlspath=/root/.loop/aperture-tls.cert" + - "--server.tlspath=/root/.aperture/tls.cert" - "--lnd.host=lndclient:10009" - "--lnd.macaroonpath=/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon" - "--lnd.tlspath=/root/.lnd/tls.cert" etcd: - image: bitnami/etcd:3.3.12 - platform: linux/amd64 + image: gcr.io/etcd-development/etcd:v3.5.21 container_name: etcd hostname: etcd networks: @@ -176,11 +174,14 @@ services: aliases: - etcd restart: unless-stopped - ports: - - "2379:2379" - - "2380:2380" - environment: - - ALLOW_NONE_AUTHENTICATION=yes + command: + - "/usr/local/bin/etcd" + - "--name=etcd" + - "--listen-client-urls=http://0.0.0.0:2379" + - "--advertise-client-urls=http://etcd:2379" + - "--listen-peer-urls=http://0.0.0.0:2380" + - "--initial-advertise-peer-urls=http://etcd:2380" + - "--initial-cluster=etcd=http://etcd:2380" networks: regtest: @@ -189,6 +190,5 @@ volumes: bitcoind: lndserver: lndclient: - loopserver: loopclient: aperture: diff --git a/regtest/e2e.sh b/regtest/e2e.sh new file mode 100755 index 000000000..50e0c16d3 --- /dev/null +++ b/regtest/e2e.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +# Exercises the source-built regtest server through the real loopd/loop CLI. +# Run regtest.sh start first. This script intentionally mines the confirmations +# needed by each protocol and fails if any client state machine reports failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGTEST="${SCRIPT_DIR}/regtest.sh" +TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-180}" + +wait_for() { + local description="$1" + shift + + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while true; do + local status + if "$@"; then + return + else + status=$? + fi + + # State predicates use status 1 for "not there yet" and status 2 for a + # terminal swap failure. Do not hide a real failure behind the timeout. + if [ "${status}" -gt 1 ]; then + return "${status}" + fi + + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Timed out waiting for ${description}" >&2 + return 1 + fi + sleep 1 + done +} + +mempool_nonempty() { + [ "$("${REGTEST}" bitcoin getrawmempool | jq 'length')" -gt 0 ] +} + +traditional_state_is() { + local hash="$1" + local expected="$2" + local state + + state="$("${REGTEST}" loop swapinfo "${hash}" | jq -r '.state')" + if [ "${state}" = "FAILED" ]; then + echo "Swap ${hash} failed" >&2 + "${REGTEST}" loop swapinfo "${hash}" >&2 + return 2 + fi + + [ "${state}" = "${expected}" ] +} + +deposit_available() { + [ "$("${REGTEST}" loop static listdeposits --filter deposited | \ + jq '.filtered_deposits | length')" -gt 0 ] +} + +static_state_is() { + local encoded_hash="$1" + local expected="$2" + local state + + state="$("${REGTEST}" loop static listswaps | jq -r \ + --arg hash "${encoded_hash}" \ + '.swaps[] | select(.swap_hash == $hash) | .state')" + if [ "${state}" = "FAILED_STATIC_ADDRESS_SWAP" ]; then + echo "Static-address swap ${encoded_hash} failed" >&2 + "${REGTEST}" loop static listswaps >&2 + return 2 + fi + + [ "${state}" = "${expected}" ] +} + +echo "Running a real regtest Loop Out" +loop_out_output="$("${REGTEST}" loop out --amt 500000 --fast --force)" +loop_out_hash="$(printf '%s\n' "${loop_out_output}" | \ + awk '/^ID:/ {print $2}')" +test -n "${loop_out_hash}" + +wait_for "Loop Out HTLC publication" mempool_nonempty +"${REGTEST}" mine 1 +wait_for "Loop Out client sweep" mempool_nonempty +"${REGTEST}" mine 3 +wait_for "Loop Out success" traditional_state_is \ + "${loop_out_hash}" SUCCESS + +echo "Running a real regtest Loop In" +loop_in_output="$("${REGTEST}" loop in --amt 500000 --force)" +loop_in_hash="$(printf '%s\n' "${loop_in_output}" | \ + awk '/^ID:/ {print $2}')" +test -n "${loop_in_hash}" + +wait_for "Loop In HTLC publication" mempool_nonempty +"${REGTEST}" mine 1 +wait_for "Loop In server claim" mempool_nonempty +"${REGTEST}" mine 1 +wait_for "Loop In success" traditional_state_is \ + "${loop_in_hash}" SUCCESS + +echo "Running a real static-address deposit Loop In" +static_output="$(printf 'y\n' | "${REGTEST}" loop static new)" +static_address="$(printf '%s\n' "${static_output}" | sed -n \ + 's/.*"address":[[:space:]]*"\([^"]*\)".*/\1/p')" +test -n "${static_address}" + +"${REGTEST}" lndclient sendcoins --addr "${static_address}" \ + --amt 500000 --min_confs 0 --force >/dev/null +"${REGTEST}" mine 6 +wait_for "static-address deposit discovery" deposit_available + +static_swap_output="$("${REGTEST}" loop static in --all --fast --force)" +static_swap_hash="$(printf '%s\n' "${static_swap_output}" | \ + jq -r '.swap_hash')" +test -n "${static_swap_hash}" && test "${static_swap_hash}" != "null" + +wait_for "static-address Loop In success" static_state_is \ + "${static_swap_hash}" SUCCEEDED +wait_for "static-address settlement transaction" mempool_nonempty +"${REGTEST}" mine 1 + +# A fallback HTLC sweep can become valid one block after its parent. If the +# server chose that path, confirm it too. The direct sweepless path is already +# complete and leaves the mempool empty here. +sleep 2 +if mempool_nonempty; then + "${REGTEST}" mine 1 +fi + +echo "All three real regtest swap flows succeeded" diff --git a/regtest/regtest.sh b/regtest/regtest.sh index 1de11f82f..311cdb621 100755 --- a/regtest/regtest.sh +++ b/regtest/regtest.sh @@ -1,202 +1,239 @@ -#!/bin/bash +#!/usr/bin/env bash -# The absolute directory this file is located in. -COMPOSE="docker-compose -p regtest" +set -euo pipefail -function bitcoin() { - docker exec -ti bitcoind bitcoin-cli -regtest "$@" -} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml" +START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-180}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose -p regtest -f "${COMPOSE_FILE}") +else + COMPOSE=(docker-compose -p regtest -f "${COMPOSE_FILE}") +fi -function lndserver() { - docker exec -ti lndserver lncli --network regtest "$@" +bitcoin() { + docker exec -i bitcoind bitcoin-cli -regtest "$@" } -function lndclient() { - docker exec -ti lndclient lncli --network regtest "$@" +lndserver() { + docker exec -i lndserver lncli --network regtest "$@" } -function loop() { - docker exec -ti loopclient loop --network regtest "$@" +lndclient() { + docker exec -i lndclient lncli --network regtest "$@" } -function start() { - $COMPOSE up --force-recreate -d - echo "Waiting for nodes to start" - waitnodestart - setup +loop() { + docker exec -i loopclient loop --network regtest "$@" } -function waitnodestart() { - while ! lndserver getinfo | grep -q identity_pubkey; do - sleep 1 - done - while ! lndclient getinfo | grep -q identity_pubkey; do +wait_for() { + local description="$1" + shift + + local deadline=$((SECONDS + START_TIMEOUT_SECONDS)) + until "$@" >/dev/null 2>&1; do + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Timed out waiting for ${description}" >&2 + return 1 + fi sleep 1 done } -function mine() { - NUMBLOCKS=6 - if [ ! -z "$1" ] - then - NUMBLOCKS=$1 +lnd_rpc_ready() { + local node="$1" + "${node}" getinfo | jq -e '.identity_pubkey != ""' +} + +lnd_synced() { + local node="$1" + "${node}" getinfo | jq -e \ + '.identity_pubkey != "" and .synced_to_chain == true' +} + +channels_active() { + local node="$1" + local expected="$2" + + [ "$("${node}" getinfo | jq -r '.num_active_channels')" \ + -ge "${expected}" ] +} + +loop_ready() { + loop getinfo +} + +payment_route_ready() { + local invoice="$1" + local decoded destination amount + + decoded="$(lndclient decodepayreq --pay_req "${invoice}")" || return 1 + destination="$(jq -r '.destination' <<<"${decoded}")" + amount="$(jq -r '.num_satoshis' <<<"${decoded}")" + + [ -n "${destination}" ] && [ "${destination}" != "null" ] && \ + [ "${amount}" -gt 0 ] && \ + lndclient queryroutes "${destination}" "${amount}" | \ + jq -e '.routes | length > 0' +} + +mine() { + local blocks="${1:-6}" + local address + address="$(bitcoin getnewaddress "" legacy)" + bitcoin generatetoaddress "${blocks}" "${address}" >/dev/null + wait_for_nodes +} + +wait_for_rpc() { + echo "Waiting for both lnd RPC servers" + wait_for "server lnd RPC" lnd_rpc_ready lndserver + wait_for "client lnd RPC" lnd_rpc_ready lndclient +} + +wait_for_nodes() { + echo "Waiting for both lnd nodes to synchronize" + wait_for "server lnd chain sync" lnd_synced lndserver + wait_for "client lnd chain sync" lnd_synced lndclient +} + +wait_for_channels() { + local expected="${1}" + echo "Waiting for ${expected} active channel(s) on both nodes" + wait_for "${expected} server channel(s)" \ + channels_active lndserver "${expected}" + wait_for "${expected} client channel(s)" \ + channels_active lndclient "${expected}" +} + +bootstrap_l402() { + echo "Fetching the regtest L402 token" + + local before_index fetch_error invoice latest_index + before_index="$(lndserver listinvoices --max_invoices 1 | \ + jq -r '.last_index_offset // 0')" + + # The current lndclient compatibility PayInvoice helper omits lnd's + # mandatory payment timeout. The first call still persists the pending + # challenge, so pay that exact newly-created invoice with lncli and let the + # interceptor resume it on the second call. + if fetch_error="$(loop fetchl402 2>&1)"; then + return + fi + + latest_index="$(lndserver listinvoices --max_invoices 1 | \ + jq -r '.last_index_offset // 0')" + if [ "${latest_index}" -le "${before_index}" ]; then + echo "${fetch_error}" >&2 + echo "Aperture did not create an L402 invoice" >&2 + return 1 + fi + + invoice="$(lndserver listinvoices --max_invoices 1 | \ + jq -r '.invoices[-1].payment_request')" + if [ -z "${invoice}" ] || [ "${invoice}" = "null" ]; then + echo "Aperture returned an empty L402 invoice" >&2 + return 1 + fi + + wait_for "a route to the Aperture L402 invoice" \ + payment_route_ready "${invoice}" + lndclient payinvoice --pay_req "${invoice}" --fee_limit 10 \ + --timeout 60s --force >/dev/null + loop fetchl402 >/dev/null +} + +setup() { + echo "Creating and funding the regtest topology" + if ! bitcoin listwallets | jq -e '.[] | select(. == "miner")' >/dev/null; then + bitcoin createwallet miner >/dev/null fi - bitcoin generatetoaddress $NUMBLOCKS $(bitcoin getnewaddress "" legacy) > /dev/null -} - -function setup() { - echo "Copying loopserver files" - copy_loopserver_files - - echo "Creating wallet" - bitcoin createwallet miner - - ADDR_BTC=$(bitcoin getnewaddress "" legacy) - echo "Generating blocks to $ADDR_BTC" - bitcoin generatetoaddress 106 "$ADDR_BTC" > /dev/null - - echo "Getting pubkeys" - LNDSERVER=$(lndserver getinfo | jq .identity_pubkey -r) - LNDCLIENT=$(lndclient getinfo | jq .identity_pubkey -r) - echo "Getting addresses" - - - echo "Sending funds" - ADDR_SERVER=$(lndserver newaddress p2wkh | jq .address -r) - ADDR_CLIENT=$(lndclient newaddress p2wkh | jq .address -r) - bitcoin sendtoaddress "$ADDR_SERVER" 5 - bitcoin sendtoaddress "$ADDR_CLIENT" 5 + + local miner_address server_address client_address + miner_address="$(bitcoin getnewaddress "" legacy)" + bitcoin generatetoaddress 106 "${miner_address}" >/dev/null + wait_for_nodes + + server_address="$(lndserver newaddress p2wkh | jq -r '.address')" + client_address="$(lndclient newaddress p2wkh | jq -r '.address')" + bitcoin sendtoaddress "${server_address}" 5 >/dev/null + bitcoin sendtoaddress "${client_address}" 5 >/dev/null mine 6 - sleep 30 - - lndserver openchannel --node_key $LNDCLIENT --connect lndclient:9735 --local_amt 16000000 + local server_pubkey client_pubkey + server_pubkey="$(lndserver getinfo | jq -r '.identity_pubkey')" + client_pubkey="$(lndclient getinfo | jq -r '.identity_pubkey')" + + lndserver openchannel --node_key "${client_pubkey}" \ + --connect lndclient:9735 --local_amt 16000000 >/dev/null mine 6 - - sleep 10 + wait_for_channels 1 - lndclient openchannel --node_key $LNDSERVER --local_amt 16000000 + lndclient openchannel --node_key "${server_pubkey}" \ + --local_amt 16000000 >/dev/null mine 6 + wait_for_channels 2 - docker cp aperture:/root/.aperture/tls.cert /tmp/aperture-tls.cert - chmod 644 /tmp/aperture-tls.cert - docker cp -a /tmp/aperture-tls.cert loopclient:/root/.loop/aperture-tls.cert + echo "Waiting for loopd and the source-built regtest server" + wait_for "loopd and regtest server readiness" loop_ready + bootstrap_l402 +} +start() { + # The server is intentionally in-memory, so keeping client or Aperture state + # across a server recreation would leave orphaned swaps and invalid tokens. + "${COMPOSE[@]}" down --volumes --remove-orphans + "${COMPOSE[@]}" up --build --force-recreate -d + wait_for_rpc + setup + info } -function stop() { - $COMPOSE down --volumes +stop() { + "${COMPOSE[@]}" down --volumes --remove-orphans } -function restart() { - stop +restart() { start } -function info() { - LNDSERVER=$(lndserver getinfo | jq -c '{pubkey: .identity_pubkey, channels: .num_active_channels, peers: .num_peers}') - LNDCLIENT=$(lndclient getinfo | jq -c '{pubkey: .identity_pubkey, channels: .num_active_channels, peers: .num_peers}') - echo "lnd server: $LNDSERVER" - echo "lnd client: $LNDCLIENT" -} - -function copy_loopserver_files() { - # copy cert to loopserver - docker cp lndserver:/root/.lnd/tls.cert /tmp/loopserver-tls.cert - chmod 644 /tmp/loopserver-tls.cert - docker cp -a /tmp/loopserver-tls.cert loopserver:/home/loopserver/tls.cert - - #copy readonly macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/readonly.macaroon /tmp/loopserver-read.macaroon - chmod 644 /tmp/loopserver-read.macaroon - docker cp -a /tmp/loopserver-read.macaroon loopserver:/home/loopserver/readonly.macaroon - - # copy admin macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon /tmp/loopserver-admin.macaroon - chmod 644 /tmp/loopserver-admin.macaroon - docker cp -a /tmp/loopserver-admin.macaroon loopserver:/home/loopserver/admin.macaroon - - # copy invoices macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/invoices.macaroon /tmp/loopserver-invoices.macaroon - chmod 644 /tmp/loopserver-invoices.macaroon - docker cp -a /tmp/loopserver-invoices.macaroon loopserver:/home/loopserver/invoices.macaroon - - # copy chainnotifier macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/chainnotifier.macaroon /tmp/loopserver-chainnotifier.macaroon - chmod 644 /tmp/loopserver-chainnotifier.macaroon - docker cp -a /tmp/loopserver-chainnotifier.macaroon loopserver:/home/loopserver/chainnotifier.macaroon - - # copy router macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/router.macaroon /tmp/loopserver-router.macaroon - chmod 644 /tmp/loopserver-router.macaroon - docker cp -a /tmp/loopserver-router.macaroon loopserver:/home/loopserver/router.macaroon - - # copy signer macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/signer.macaroon /tmp/loopserver-signer.macaroon - chmod 644 /tmp/loopserver-signer.macaroon - docker cp -a /tmp/loopserver-signer.macaroon loopserver:/home/loopserver/signer.macaroon - - # copy walletkit macaroon to loopserver - docker cp lndserver:/root/.lnd/data/chain/bitcoin/regtest/walletkit.macaroon /tmp/loopserver-walletkit.macaroon - chmod 644 /tmp/loopserver-walletkit.macaroon - docker cp -a /tmp/loopserver-walletkit.macaroon loopserver:/home/loopserver/walletkit.macaroon - - docker cp loopserver:/home/loopserver/tls.cert /tmp/loopserver-tls.cert - chmod 644 /tmp/loopserver-tls.cert - docker cp -a /tmp/loopserver-tls.cert aperture:/root/.aperture/loopserver-tls.cert - - # create the aperture config and copy it to the aperture container. - write_aperture_config - - docker cp /tmp/aperture.yaml aperture:/root/.aperture/aperture.yaml -} - - -function write_aperture_config() { - rm -rf /tmp/aperture.yaml - touch /tmp/aperture.yaml && cat > /tmp/aperture.yaml < s.cfg.MaxSwapAmount: + return status.Errorf( + codes.InvalidArgument, "amount %d above maximum %d", + amount, s.cfg.MaxSwapAmount, + ) + } + + if s.swapFee(amount) >= amount { + return status.Error(codes.InvalidArgument, "swap fee exceeds amount") + } + + return nil +} + +func (s *Server) currentHeight(ctx context.Context) (int32, error) { + info, err := s.cfg.Lnd.Client.GetInfo(ctx) + if err != nil { + return 0, err + } + + return int32(info.BlockHeight), nil +} + +func decodeInvoice(params *zpay32.Invoice) error { + if params.MilliSat == nil { + return errors.New("amountless invoices are not supported") + } + if params.PaymentHash == nil { + return errors.New("invoice has no payment hash") + } + + return nil +} + +func (s *Server) validateInvoice(invoice string, hash lntypes.Hash, + expectedAmount btcutil.Amount) (*zpay32.Invoice, error) { + + decoded, err := zpay32.Decode(invoice, s.cfg.Lnd.ChainParams) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "invalid invoice: %v", err, + ) + } + if err := decodeInvoice(decoded); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if *decoded.PaymentHash != hash { + return nil, status.Error( + codes.InvalidArgument, "invoice payment hash mismatch", + ) + } + + want := lnwire.NewMSatFromSatoshis(expectedAmount) + if *decoded.MilliSat != want { + return nil, status.Errorf( + codes.InvalidArgument, + "invoice amount %d msat does not match %d msat", + *decoded.MilliSat, want, + ) + } + + return decoded, nil +} + +func cloneHash(hash chainhash.Hash) *chainhash.Hash { + hashCopy := hash + return &hashCopy +} diff --git a/regtest/server/loopin.go b/regtest/server/loopin.go new file mode 100644 index 000000000..89649c9c5 --- /dev/null +++ b/regtest/server/loopin.go @@ -0,0 +1,938 @@ +package server + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "math" + "sync" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightninglabs/loop/sweep" + "github.com/lightninglabs/loop/utils" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const loopInSweepConfTarget = int32(2) + +// loopInSwap holds all server-side state for a standard Loop In. The initDone +// barrier makes NewLoopInSwap idempotent even when duplicate requests arrive +// while the first request is still carrying out the probe payment. +type loopInSwap struct { + mu sync.Mutex + + request loopInRequestFingerprint + hash lntypes.Hash + amount btcutil.Amount + swapInvoice string + lastHop *route.Vertex + initiationHeight int32 + expiry int32 + + senderScriptKey [btcec.PubKeyBytesLenCompressed]byte + senderInternalKey [btcec.PubKeyBytesLenCompressed]byte + receiverScriptKey *serverKey + receiverInternalKey *serverKey + htlc *swap.Htlc + + updates *updateHub + + initDone chan struct{} + initErr error + response *swapserverrpc.ServerLoopInResponse + + clientInternalKeyPushed bool +} + +// loopInRequestFingerprint records every request field that can change the +// swap contract or either Lightning payment. It is captured before the hash is +// reserved so concurrent replays can be checked without waiting for the first +// probe to finish. +type loopInRequestFingerprint struct { + amount uint64 + swapInvoice string + probeInvoice string + senderKey []byte + senderInternalKey []byte + lastHop []byte + protocol swapserverrpc.ProtocolVersion +} + +func newLoopInRequestFingerprint( + req *swapserverrpc.ServerLoopInRequest) loopInRequestFingerprint { + + return loopInRequestFingerprint{ + amount: req.GetAmt(), + swapInvoice: req.GetSwapInvoice(), + probeInvoice: req.GetProbeInvoice(), + senderKey: bytes.Clone(req.GetSenderKey()), + senderInternalKey: bytes.Clone(req.GetSenderInternalPubkey()), + lastHop: bytes.Clone(req.GetLastHop()), + protocol: req.GetProtocolVersion(), + } +} + +func (f loopInRequestFingerprint) matches( + req *swapserverrpc.ServerLoopInRequest) bool { + + return f.amount == req.GetAmt() && + f.swapInvoice == req.GetSwapInvoice() && + f.probeInvoice == req.GetProbeInvoice() && + bytes.Equal(f.senderKey, req.GetSenderKey()) && + bytes.Equal(f.senderInternalKey, req.GetSenderInternalPubkey()) && + bytes.Equal(f.lastHop, req.GetLastHop()) && + f.protocol == req.GetProtocolVersion() +} + +func validateLoopInProtocol(version swapserverrpc.ProtocolVersion) error { + if version != swapserverrpc.ProtocolVersion_MUSIG2 { + return status.Errorf( + codes.InvalidArgument, + "standard Loop In requires protocol %d, got %d", + swapserverrpc.ProtocolVersion_MUSIG2, version, + ) + } + + return nil +} + +func loopInAmount(raw uint64) (btcutil.Amount, error) { + if raw > math.MaxInt64 { + return 0, status.Error(codes.InvalidArgument, "amount overflows int64") + } + + return btcutil.Amount(raw), nil +} + +func cloneLoopInResponse( + response *swapserverrpc.ServerLoopInResponse) *swapserverrpc.ServerLoopInResponse { + + if response == nil { + return nil + } + + return &swapserverrpc.ServerLoopInResponse{ + ReceiverKey: bytes.Clone(response.ReceiverKey), + ReceiverInternalPubkey: bytes.Clone( + response.ReceiverInternalPubkey, + ), + Expiry: response.Expiry, + ServerMessage: response.ServerMessage, + } +} + +func (s *loopInSwap) completeInit( + response *swapserverrpc.ServerLoopInResponse, err error) { + + s.mu.Lock() + s.response = cloneLoopInResponse(response) + s.initErr = err + close(s.initDone) + s.mu.Unlock() +} + +func (s *loopInSwap) waitForInit(ctx context.Context) ( + *swapserverrpc.ServerLoopInResponse, error) { + + select { + case <-s.initDone: + case <-ctx.Done(): + return nil, ctx.Err() + } + + s.mu.Lock() + defer s.mu.Unlock() + + return cloneLoopInResponse(s.response), s.initErr +} + +// LoopInTerms returns the amount range supported by the disposable server. +func (s *Server) LoopInTerms(_ context.Context, + req *swapserverrpc.ServerLoopInTermsRequest) ( + *swapserverrpc.ServerLoopInTerms, error) { + + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + return nil, err + } + + return &swapserverrpc.ServerLoopInTerms{ + MinSwapAmount: uint64(s.cfg.MinSwapAmount), + MaxSwapAmount: uint64(s.cfg.MaxSwapAmount), + }, nil +} + +// Probe validates the proposed route endpoint. The authoritative reachability +// test is the hold-invoice payment performed by NewLoopInSwap, because this +// quote-time RPC does not carry an invoice that can be paid atomically. +func (s *Server) Probe(_ context.Context, + req *swapserverrpc.ServerProbeRequest) ( + *swapserverrpc.ServerProbeResponse, error) { + + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + return nil, err + } + + amount, err := loopInAmount(req.GetAmt()) + if err != nil { + return nil, err + } + if err := s.validateAmount(amount); err != nil { + return nil, err + } + if _, err := parseKey("probe target", req.GetTarget()); err != nil { + return nil, err + } + if len(req.GetLastHop()) != 0 { + if _, err := parseKey("last hop", req.GetLastHop()); err != nil { + return nil, err + } + } + + return &swapserverrpc.ServerProbeResponse{}, nil +} + +// LoopInQuote returns a deterministic fee. A zero amount requests a quote for +// the maximum supported amount, as specified by the server protocol. +func (s *Server) LoopInQuote(_ context.Context, + req *swapserverrpc.ServerLoopInQuoteRequest) ( + *swapserverrpc.ServerLoopInQuoteResponse, error) { + + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + return nil, err + } + + if _, err := parseKey("payment destination", req.GetPubkey()); err != nil { + return nil, err + } + if len(req.GetLastHop()) != 0 { + if _, err := parseKey("last hop", req.GetLastHop()); err != nil { + return nil, err + } + } + + amount := s.cfg.MaxSwapAmount + if req.GetAmt() != 0 { + var err error + amount, err = loopInAmount(req.GetAmt()) + if err != nil { + return nil, err + } + } + if err := s.validateAmount(amount); err != nil { + return nil, err + } + + return &swapserverrpc.ServerLoopInQuoteResponse{ + SwapFee: int64(s.swapFee(amount)), + CltvDelta: s.cfg.LoopInCltvDelta, + }, nil +} + +// NewLoopInSwap validates the contract and pays the client's hold invoice as +// a real Lightning payment. It only returns after lnd reports the payment in +// flight and the client cancels it, proving that the actual swap invoice is +// reachable without disclosing the swap preimage. +func (s *Server) NewLoopInSwap(ctx context.Context, + req *swapserverrpc.ServerLoopInRequest) ( + *swapserverrpc.ServerLoopInResponse, error) { + + hash, err := parseHash(req.GetSwapHash()) + if err != nil { + return nil, err + } + + // Reserve the hash before any blocking work. All duplicate calls wait on + // the same initialization result and never repeat the probe payment or + // start a second chain watcher. + s.mu.Lock() + if existing, ok := s.loopIns[hash]; ok { + s.mu.Unlock() + if !existing.request.matches(req) { + return nil, status.Error( + codes.AlreadyExists, + "Loop In swap hash already exists with different parameters", + ) + } + + return existing.waitForInit(ctx) + } + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + s.mu.Unlock() + return nil, err + } + + loopIn := &loopInSwap{ + request: newLoopInRequestFingerprint(req), + hash: hash, + updates: newUpdateHub(), + initDone: make(chan struct{}), + } + s.loopIns[hash] = loopIn + s.mu.Unlock() + + failInit := func(err error) (*swapserverrpc.ServerLoopInResponse, error) { + loopIn.updates.finish( + swapserverrpc.ServerSwapState_SERVER_FAILED_INITIALIZATION, + ) + loopIn.completeInit(nil, err) + return nil, err + } + + amount, err := loopInAmount(req.GetAmt()) + if err != nil { + return failInit(err) + } + if err := s.validateAmount(amount); err != nil { + return failInit(err) + } + + senderScriptPubKey, err := parseKey("sender key", req.GetSenderKey()) + if err != nil { + return failInit(err) + } + senderInternalPubKey, err := parseKey( + "sender internal pubkey", req.GetSenderInternalPubkey(), + ) + if err != nil { + return failInit(err) + } + + var lastHop *route.Vertex + if len(req.GetLastHop()) != 0 { + if _, err := parseKey("last hop", req.GetLastHop()); err != nil { + return failInit(err) + } + + vertex, err := route.NewVertexFromBytes(req.GetLastHop()) + if err != nil { + return failInit(status.Error( + codes.InvalidArgument, err.Error(), + )) + } + lastHop = &vertex + } + + invoiceAmount := amount - s.swapFee(amount) + swapInvoice, err := s.validateInvoice( + req.GetSwapInvoice(), hash, invoiceAmount, + ) + if err != nil { + return failInit(err) + } + + probeHash := lntypes.Hash(sha256.Sum256(hash[:])) + probeHash[0] ^= 1 + probeInvoice, err := s.validateInvoice( + req.GetProbeInvoice(), probeHash, invoiceAmount, + ) + if err != nil { + return failInit(status.Errorf( + codes.InvalidArgument, "invalid probe invoice: %v", err, + )) + } + if !bytes.Equal( + swapInvoice.Destination.SerializeCompressed(), + probeInvoice.Destination.SerializeCompressed(), + ) { + + return failInit(status.Error( + codes.InvalidArgument, + "swap and probe invoices have different destinations", + )) + } + + receiverScriptKey, err := s.deriveKey(ctx, swap.KeyFamily) + if err != nil { + return failInit(status.Errorf( + codes.Internal, "derive receiver script key: %v", err, + )) + } + receiverInternalKey, err := s.deriveKey(ctx, swap.KeyFamily) + if err != nil { + return failInit(status.Errorf( + codes.Internal, "derive receiver internal key: %v", err, + )) + } + + height, err := s.currentHeight(ctx) + if err != nil { + return failInit(status.Errorf( + codes.Unavailable, "get block height: %v", err, + )) + } + expiry := height + s.cfg.LoopInCltvDelta + + copy(loopIn.senderScriptKey[:], senderScriptPubKey.SerializeCompressed()) + copy(loopIn.senderInternalKey[:], senderInternalPubKey.SerializeCompressed()) + loopIn.amount = amount + loopIn.swapInvoice = req.GetSwapInvoice() + loopIn.lastHop = lastHop + loopIn.initiationHeight = height + loopIn.expiry = expiry + loopIn.receiverScriptKey = receiverScriptKey + loopIn.receiverInternalKey = receiverInternalKey + + contract := &loopdb.SwapContract{ + AmountRequested: amount, + HtlcKeys: loopdb.HtlcKeys{ + SenderScriptKey: loopIn.senderScriptKey, + SenderInternalPubKey: loopIn.senderInternalKey, + ReceiverScriptKey: keyBytes(receiverScriptKey.pubKey), + ReceiverInternalPubKey: keyBytes(receiverInternalKey.pubKey), + }, + CltvExpiry: expiry, + InitiationHeight: height, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + } + loopIn.htlc, err = utils.GetHtlc(hash, contract, s.cfg.Lnd.ChainParams) + if err != nil { + return failInit(status.Errorf( + codes.Internal, "construct Loop In HTLC: %v", err, + )) + } + + // This is deliberately part of the unary RPC. The client has already + // subscribed to the hold invoice and will cancel it after it becomes + // accepted; waiting for the final failed payment is the acknowledgement. + if err := s.probeLoopInInvoice( + ctx, req.GetProbeInvoice(), lastHop, + ); err != nil { + return failInit(status.Errorf( + codes.FailedPrecondition, "probe payment failed: %v", err, + )) + } + + response := &swapserverrpc.ServerLoopInResponse{ + ReceiverKey: receiverScriptKey.pubKey.SerializeCompressed(), + ReceiverInternalPubkey: receiverInternalKey.pubKey.SerializeCompressed(), + Expiry: expiry, + ServerMessage: "regtest Loop In accepted; waiting for the HTLC", + } + + loopIn.updates.publish(swapserverrpc.ServerSwapState_SERVER_INITIATED) + s.goSwap(func(runCtx context.Context) { + s.runLoopInSwap(runCtx, loopIn) + }) + loopIn.completeInit(response, nil) + + return cloneLoopInResponse(response), nil +} + +// probeLoopInInvoice performs the real hold-invoice/cancellation handshake. +func (s *Server) probeLoopInInvoice(ctx context.Context, invoice string, + lastHop *route.Vertex) error { + + statusChan, errChan, err := s.cfg.Lnd.Router.SendPayment( + ctx, lndclient.SendPaymentRequest{ + Invoice: invoice, + MaxFee: s.cfg.MaxSwapAmount, + Timeout: s.cfg.PaymentTimeout, + LastHopPubkey: lastHop, + MaxParts: 10, + Cancelable: true, + }, + ) + if err != nil { + return err + } + + accepted := false + for statusChan != nil || errChan != nil { + select { + case payment, ok := <-statusChan: + if !ok { + statusChan = nil + continue + } + + switch payment.State { + case lnrpc.Payment_IN_FLIGHT: + accepted = true + + case lnrpc.Payment_FAILED: + if !accepted { + return fmt.Errorf( + "probe failed before acceptance: %v", + payment.FailureReason, + ) + } + + // CancelInvoice at the receiving lnd fails the held + // HTLC with FailIncorrectDetails. Other terminal + // reasons (notably NO_ROUTE) do not prove that the + // receiver accepted and canceled this probe. + if payment.FailureReason != + lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS { //nolint:lll + + return fmt.Errorf( + "probe ended with unexpected failure reason: %v", + payment.FailureReason, + ) + } + + return nil + + case lnrpc.Payment_SUCCEEDED: + return errors.New("probe invoice unexpectedly settled") + } + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + return err + } + + case <-ctx.Done(): + return ctx.Err() + } + } + + return errors.New("probe payment stream closed before cancellation") +} + +func (s *Server) runLoopInSwap(ctx context.Context, loopIn *loopInSwap) { + confirmation, err := s.waitForLoopInHtlc(ctx, loopIn) + if err != nil { + s.failRunningLoopIn( + ctx, loopIn, + swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + "wait for HTLC confirmation", err, + ) + return + } + + outpoint, htlcAmount, terminalState, err := confirmedLoopInOutput( + confirmation, loopIn.htlc.PkScript, loopIn.amount, + ) + if err != nil { + s.failRunningLoopIn(ctx, loopIn, terminalState, + "validate confirmed HTLC", err) + return + } + + loopIn.updates.publish( + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, + ) + + paymentCtx, cancelPayment := context.WithTimeout( + ctx, s.cfg.PaymentTimeout, + ) + payment, err := s.payLoopInInvoice( + paymentCtx, loopIn.swapInvoice, loopIn.lastHop, + ) + cancelPayment() + if err != nil { + s.failRunningLoopIn( + ctx, loopIn, + swapserverrpc.ServerSwapState_SERVER_FAILED_OFF_CHAIN_TIMEOUT, + "pay swap invoice", err, + ) + return + } + if payment.Preimage.Hash() != loopIn.hash { + s.failRunningLoopIn( + ctx, loopIn, + swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + "pay swap invoice", errors.New("lnd returned the wrong preimage"), + ) + return + } + + sweepTx, err := s.createLoopInSuccessSweep( + ctx, loopIn, *outpoint, htlcAmount, payment.Preimage, + ) + if err != nil { + s.failRunningLoopIn( + ctx, loopIn, + swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + "create success sweep", err, + ) + return + } + + if err := s.publishAndConfirmLoopInSweep(ctx, loopIn, sweepTx); err != nil { + s.failRunningLoopIn( + ctx, loopIn, + swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + "publish success sweep", err, + ) + return + } + + loopIn.updates.finish(swapserverrpc.ServerSwapState_SERVER_SUCCESS) +} + +func (s *Server) waitForLoopInHtlc(ctx context.Context, + loopIn *loopInSwap) (*chainntnfs.TxConfirmation, error) { + + confChan, errChan, err := s.cfg.Lnd.ChainNotifier.RegisterConfirmationsNtfn( + ctx, nil, loopIn.htlc.PkScript, 1, loopIn.initiationHeight, + ) + if err != nil { + return nil, err + } + + for confChan != nil || errChan != nil { + select { + case confirmation, ok := <-confChan: + if !ok { + confChan = nil + continue + } + if confirmation == nil || confirmation.Tx == nil { + return nil, errors.New("empty HTLC confirmation") + } + + return confirmation, nil + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + return nil, err + } + + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, errors.New("HTLC confirmation stream closed") +} + +func confirmedLoopInOutput(confirmation *chainntnfs.TxConfirmation, + pkScript []byte, expectedAmount btcutil.Amount) (*wire.OutPoint, + btcutil.Amount, swapserverrpc.ServerSwapState, error) { + + var ( + outputIndex uint32 + outputValue btcutil.Amount + matches int + ) + for index, output := range confirmation.Tx.TxOut { + if !bytes.Equal(output.PkScript, pkScript) { + continue + } + + matches++ + outputIndex = uint32(index) + outputValue = btcutil.Amount(output.Value) + } + + switch { + case matches == 0: + return nil, 0, + swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + errors.New("confirmation does not contain the swap script") + + case matches > 1: + return nil, 0, + swapserverrpc.ServerSwapState_SERVER_FAILED_MULTIPLE_SWAP_SCRIPTS, + fmt.Errorf("confirmed transaction contains %d swap outputs", matches) + + case outputValue != expectedAmount: + return nil, 0, + swapserverrpc.ServerSwapState_SERVER_FAILED_INVALID_HTLC_AMOUNT, + fmt.Errorf("HTLC amount %d does not match %d", outputValue, + expectedAmount) + } + + return &wire.OutPoint{ + Hash: confirmation.Tx.TxHash(), + Index: outputIndex, + }, outputValue, swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, nil +} + +func (s *Server) payLoopInInvoice(ctx context.Context, invoice string, + lastHop *route.Vertex) (lndclient.PaymentStatus, error) { + + statusChan, errChan, err := s.cfg.Lnd.Router.SendPayment( + ctx, lndclient.SendPaymentRequest{ + Invoice: invoice, + MaxFee: s.cfg.MaxSwapAmount, + Timeout: s.cfg.PaymentTimeout, + LastHopPubkey: lastHop, + MaxParts: 10, + Cancelable: true, + }, + ) + if err != nil { + return lndclient.PaymentStatus{}, err + } + + for statusChan != nil || errChan != nil { + select { + case payment, ok := <-statusChan: + if !ok { + statusChan = nil + continue + } + + switch payment.State { + case lnrpc.Payment_SUCCEEDED: + return payment, nil + + case lnrpc.Payment_FAILED: + return payment, fmt.Errorf( + "payment failed: %v", payment.FailureReason, + ) + } + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + return lndclient.PaymentStatus{}, err + } + + case <-ctx.Done(): + return lndclient.PaymentStatus{}, ctx.Err() + } + } + + return lndclient.PaymentStatus{}, errors.New("payment stream closed") +} + +func (s *Server) createLoopInSuccessSweep(ctx context.Context, + loopIn *loopInSwap, outpoint wire.OutPoint, amount btcutil.Amount, + preimage lntypes.Preimage) (*wire.MsgTx, error) { + + destination, err := s.cfg.Lnd.WalletKit.NextAddr( + ctx, "", walletrpc.AddressType_WITNESS_PUBKEY_HASH, false, + ) + if err != nil { + return nil, err + } + + sweeper := sweep.Sweeper{Lnd: s.cfg.Lnd} + fee, err := sweeper.GetSweepFee( + ctx, loopIn.htlc.AddSuccessToEstimator, destination, + loopInSweepConfTarget, + fmt.Sprintf("regtest-loop-in-%s", swap.ShortHash(&loopIn.hash)), + ) + if err != nil { + return nil, err + } + if fee >= amount { + return nil, fmt.Errorf("success sweep fee %d exceeds HTLC amount %d", + fee, amount) + } + + height, err := s.currentHeight(ctx) + if err != nil { + return nil, err + } + witness := func(signature []byte) (wire.TxWitness, error) { + return loopIn.htlc.GenSuccessWitness(signature, preimage) + } + + return sweeper.CreateSweepTx( + ctx, height, loopIn.htlc.SuccessSequence(), loopIn.htlc, + outpoint, keyBytes(loopIn.receiverScriptKey.pubKey), + loopIn.htlc.SuccessScript(), witness, amount, fee, destination, + ) +} + +func (s *Server) publishAndConfirmLoopInSweep(ctx context.Context, + loopIn *loopInSwap, sweepTx *wire.MsgTx) error { + + if len(sweepTx.TxOut) != 1 { + return fmt.Errorf("success sweep has %d outputs", len(sweepTx.TxOut)) + } + + txid := sweepTx.TxHash() + height, err := s.currentHeight(ctx) + if err != nil { + return err + } + confChan, errChan, err := s.cfg.Lnd.ChainNotifier.RegisterConfirmationsNtfn( + ctx, cloneHash(txid), sweepTx.TxOut[0].PkScript, 1, height, + ) + if err != nil { + return err + } + + label := fmt.Sprintf( + "loopserver-regtest -- InSweepSuccess(swap=%s)", + swap.ShortHash(&loopIn.hash), + ) + if err := s.cfg.Lnd.WalletKit.PublishTransaction( + ctx, sweepTx, label, + ); err != nil { + return err + } + + for confChan != nil || errChan != nil { + select { + case confirmation, ok := <-confChan: + if !ok { + confChan = nil + continue + } + if confirmation == nil || confirmation.Tx == nil { + return errors.New("empty sweep confirmation") + } + if confirmation.Tx.TxHash() != txid { + return fmt.Errorf("confirmed unexpected sweep transaction %v", + confirmation.Tx.TxHash()) + } + + return nil + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + return err + } + + case <-ctx.Done(): + return ctx.Err() + } + } + + return errors.New("sweep confirmation stream closed") +} + +func (s *Server) failRunningLoopIn(ctx context.Context, loopIn *loopInSwap, + state swapserverrpc.ServerSwapState, action string, err error) { + + if ctx.Err() != nil { + return + } + + s.cfg.Logger.Printf("Loop In %s: %s: %v", loopIn.hash, action, err) + loopIn.updates.finish(state) +} + +// SubscribeLoopInUpdates replays all state history before forwarding live +// updates, so a client can reconnect without losing transitions. +func (s *Server) SubscribeLoopInUpdates( + req *swapserverrpc.SubscribeUpdatesRequest, + stream swapserverrpc.SwapServer_SubscribeLoopInUpdatesServer) error { + + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + return err + } + hash, err := parseHash(req.GetSwapHash()) + if err != nil { + return err + } + + s.mu.RLock() + loopIn, ok := s.loopIns[hash] + s.mu.RUnlock() + if !ok { + return status.Error(codes.NotFound, "Loop In swap not found") + } + + subscription := loopIn.updates.subscribe() + defer subscription.cancel() + + send := func(update serverUpdate) error { + return stream.Send(&swapserverrpc.SubscribeLoopInUpdatesResponse{ + TimestampNs: update.timestamp.UnixNano(), + State: update.state, + }) + } + + for _, update := range subscription.history { + if err := send(update); err != nil { + return err + } + } + if subscription.done { + return nil + } + + for { + select { + case update, ok := <-subscription.updates: + if !ok { + return nil + } + if err := send(update); err != nil { + return err + } + + case <-s.ctx.Done(): + return s.ctx.Err() + + case <-stream.Context().Done(): + return stream.Context().Err() + } + } +} + +// PushKey acknowledges the protocol-11 internal-key reveal. The unilateral +// success path does not require the key, but validating it keeps the fake +// server faithful and allows repeated acknowledgements safely. +func (s *Server) PushKey(ctx context.Context, + req *swapserverrpc.ServerPushKeyReq) (*swapserverrpc.ServerPushKeyRes, + error) { + + if err := validateLoopInProtocol(req.GetProtocolVersion()); err != nil { + return nil, err + } + hash, err := parseHash(req.GetSwapHash()) + if err != nil { + return nil, err + } + if len(req.GetInternalPrivkey()) != 32 { + return nil, status.Error( + codes.InvalidArgument, "internal private key must be 32 bytes", + ) + } + + s.mu.RLock() + loopIn, ok := s.loopIns[hash] + s.mu.RUnlock() + if !ok { + return nil, status.Error(codes.NotFound, "Loop In swap not found") + } + if _, err := loopIn.waitForInit(ctx); err != nil { + return nil, status.Error( + codes.FailedPrecondition, "Loop In swap initialization failed", + ) + } + + _, publicKey := btcec.PrivKeyFromBytes(req.GetInternalPrivkey()) + if !bytes.Equal( + publicKey.SerializeCompressed(), loopIn.senderInternalKey[:], + ) { + + return nil, status.Error( + codes.InvalidArgument, + "internal private key does not match the swap public key", + ) + } + + loopIn.mu.Lock() + loopIn.clientInternalKeyPushed = true + loopIn.mu.Unlock() + + return &swapserverrpc.ServerPushKeyRes{}, nil +} diff --git a/regtest/server/loopin_test.go b/regtest/server/loopin_test.go new file mode 100644 index 000000000..eae9335af --- /dev/null +++ b/regtest/server/loopin_test.go @@ -0,0 +1,487 @@ +package server + +import ( + "context" + "crypto/sha256" + "io" + "log" + "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/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swapserverrpc" + looptest "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func TestLoopInTermsAndQuote(t *testing.T) { + t.Parallel() + + _, destination := looptest.CreateKey(30) + server := &Server{cfg: Config{ + MinSwapAmount: 50_000, + MaxSwapAmount: 5_000_000, + FeeBaseSat: 100, + FeePPM: 1_000, + LoopInCltvDelta: 100, + }} + + terms, err := server.LoopInTerms( + context.Background(), &swapserverrpc.ServerLoopInTermsRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 50_000, terms.MinSwapAmount) + require.EqualValues(t, 5_000_000, terms.MaxSwapAmount) + + quote, err := server.LoopInQuote( + context.Background(), &swapserverrpc.ServerLoopInQuoteRequest{ + Amt: 100_000, + Pubkey: destination.SerializeCompressed(), + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 200, quote.SwapFee) + require.EqualValues(t, 100, quote.CltvDelta) + + _, err = server.LoopInTerms( + context.Background(), &swapserverrpc.ServerLoopInTermsRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_HTLC_V3, + }, + ) + require.Equal(t, codes.InvalidArgument, status.Code(err)) +} + +func TestLoopInFullFlowProbeAndDuplicate(t *testing.T) { + lnd := looptest.NewMockLnd() + lnd.ChainParams = &chaincfg.RegressionNetParams + + serverCtx, cancel := context.WithCancel(context.Background()) + server := &Server{ + cfg: Config{ + Lnd: &lnd.LndServices, + MinSwapAmount: 50_000, + MaxSwapAmount: 5_000_000, + LoopInCltvDelta: 100, + FeeBaseSat: 100, + FeePPM: 1_000, + PaymentTimeout: time.Minute, + Logger: log.New(io.Discard, "", 0), + }, + ctx: serverCtx, + cancel: cancel, + loopIns: make(map[lntypes.Hash]*loopInSwap), + } + + stopped := false + t.Cleanup(func() { + if !stopped { + server.Stop() + } + lnd.WaitForFinished() + }) + + var preimage lntypes.Preimage + preimage[0] = 1 + hash := preimage.Hash() + probeHash := lntypes.Hash(sha256.Sum256(hash[:])) + probeHash[0] ^= 1 + + const amount = btcutil.Amount(100_000) + invoiceAmount := amount - server.swapFee(amount) + invoiceSigner, err := btcec.NewPrivateKey() + require.NoError(t, err) + swapInvoice := encodeLoopInTestInvoice( + t, invoiceSigner, hash, invoiceAmount, + ) + probeInvoice := encodeLoopInTestInvoice( + t, invoiceSigner, probeHash, invoiceAmount, + ) + decodedSwapInvoice, err := zpay32.Decode( + swapInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + decodedProbeInvoice, err := zpay32.Decode( + probeInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + require.Equal( + t, decodedSwapInvoice.Destination.SerializeCompressed(), + decodedProbeInvoice.Destination.SerializeCompressed(), + ) + + _, senderScriptKey := looptest.CreateKey(31) + senderInternalPrivKey, senderInternalKey := looptest.CreateKey(32) + request := &swapserverrpc.ServerLoopInRequest{ + SenderKey: senderScriptKey.SerializeCompressed(), + SenderInternalPubkey: senderInternalKey.SerializeCompressed(), + SwapHash: hash[:], + Amt: uint64(amount), + SwapInvoice: swapInvoice, + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + ProbeInvoice: probeInvoice, + } + + type loopInResult struct { + response *swapserverrpc.ServerLoopInResponse + err error + } + resultChan := make(chan loopInResult, 1) + go func() { + response, err := server.NewLoopInSwap( + context.Background(), request, + ) + resultChan <- loopInResult{response: response, err: err} + }() + + // The RPC must remain blocked while the payment is merely in flight, + // and return only once the canceled hold invoice fails at the sender. + var probePayment looptest.RouterPaymentChannelMessage + select { + case probePayment = <-lnd.RouterSendPaymentChannel: + case result := <-resultChan: + t.Fatalf("NewLoopInSwap failed before probing: %v", result.err) + case <-time.After(looptest.Timeout): + t.Fatal("router payment was not initiated") + } + require.Equal(t, probeInvoice, probePayment.Invoice) + probePayment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_IN_FLIGHT, + } + select { + case result := <-resultChan: + t.Fatalf("NewLoopInSwap returned before probe cancellation: %v", + result.err) + case <-time.After(25 * time.Millisecond): + } + + // A conflicting replay must be rejected immediately even while the + // original request is still blocked on the probe handshake. + conflict := cloneLoopInRequest(request) + conflict.Amt++ + _, err = server.NewLoopInSwap(context.Background(), conflict) + require.Equal(t, codes.AlreadyExists, status.Code(err)) + + probePayment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_FAILED, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_INCORRECT_PAYMENT_DETAILS, + } + + var first loopInResult + select { + case first = <-resultChan: + case <-time.After(looptest.Timeout): + t.Fatal("NewLoopInSwap did not finish after probe cancellation") + } + require.NoError(t, first.err) + require.Len(t, first.response.ReceiverKey, 33) + require.Len(t, first.response.ReceiverInternalPubkey, 33) + require.EqualValues(t, 700, first.response.Expiry) + + // The background worker must register for the actual P2TR swap script. + var registration *looptest.ConfRegistration + select { + case registration = <-lnd.RegisterConfChannel: + case <-time.After(looptest.Timeout): + t.Fatal("Loop In HTLC confirmation was not registered") + } + require.Nil(t, registration.TxID) + require.NotEmpty(t, registration.PkScript) + require.EqualValues(t, 1, registration.NumConfs) + + server.mu.RLock() + loopIn := server.loopIns[hash] + server.mu.RUnlock() + require.NotNil(t, loopIn) + updates := loopIn.updates.subscribe() + defer updates.cancel() + + duplicate, err := server.NewLoopInSwap( + context.Background(), cloneLoopInRequest(request), + ) + require.NoError(t, err) + require.Equal(t, first.response, duplicate) + select { + case <-lnd.RouterSendPaymentChannel: + t.Fatal("duplicate swap started a second probe payment") + default: + } + + _, alternateKey := looptest.CreateKey(33) + _, alternateInternalKey := looptest.CreateKey(34) + _, alternateLastHop := looptest.CreateKey(35) + conflicts := []struct { + name string + mutate func(*swapserverrpc.ServerLoopInRequest) + }{ + { + name: "amount", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.Amt++ + }, + }, + { + name: "swap invoice", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.SwapInvoice += "-different" + }, + }, + { + name: "probe invoice", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.ProbeInvoice += "-different" + }, + }, + { + name: "sender key", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.SenderKey = alternateKey.SerializeCompressed() + }, + }, + { + name: "sender internal key", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.SenderInternalPubkey = + alternateInternalKey.SerializeCompressed() + }, + }, + { + name: "last hop", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.LastHop = alternateLastHop.SerializeCompressed() + }, + }, + { + name: "protocol", + mutate: func(req *swapserverrpc.ServerLoopInRequest) { + req.ProtocolVersion = swapserverrpc.ProtocolVersion_HTLC_V3 + }, + }, + } + for _, testCase := range conflicts { + t.Run("conflicting duplicate "+testCase.name, func(t *testing.T) { + conflictingRequest := cloneLoopInRequest(request) + testCase.mutate(conflictingRequest) + + _, err := server.NewLoopInSwap( + context.Background(), conflictingRequest, + ) + require.Equal(t, codes.AlreadyExists, status.Code(err)) + }) + } + + _, err = server.PushKey( + context.Background(), &swapserverrpc.ServerPushKeyReq{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + InternalPrivkey: senderInternalPrivKey.Serialize(), + }, + ) + require.NoError(t, err) + + // Confirm an exact-value output to the negotiated P2TR HTLC. This must + // trigger payment of the real (non-hold) invoice. + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: loopIn.htlc.PkScript, + }) + registration.ConfChan <- &chainntnfs.TxConfirmation{ + Tx: fundingTx, + BlockHeight: 601, + } + + var swapPayment looptest.RouterPaymentChannelMessage + select { + case swapPayment = <-lnd.RouterSendPaymentChannel: + case <-time.After(looptest.Timeout): + t.Fatal("swap invoice payment was not initiated") + } + require.Equal(t, swapInvoice, swapPayment.Invoice) + swapPayment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_SUCCEEDED, + Preimage: preimage, + } + + var signRequest looptest.SignOutputRawRequest + select { + case signRequest = <-lnd.SignOutputRawChannel: + case <-time.After(looptest.Timeout): + t.Fatal("success sweep was not signed") + } + require.Len(t, signRequest.SignDescriptors, 1) + require.Equal( + t, loopIn.htlc.SuccessScript(), + signRequest.SignDescriptors[0].WitnessScript, + ) + + var sweepRegistration *looptest.ConfRegistration + select { + case sweepRegistration = <-lnd.RegisterConfChannel: + case <-time.After(looptest.Timeout): + t.Fatal("success sweep confirmation was not registered") + } + require.NotNil(t, sweepRegistration.TxID) + + var sweepTx *wire.MsgTx + select { + case sweepTx = <-lnd.TxPublishChannel: + case <-time.After(looptest.Timeout): + t.Fatal("success sweep was not published") + } + require.Equal(t, fundingTx.TxHash(), + sweepTx.TxIn[0].PreviousOutPoint.Hash) + require.Equal(t, loopIn.htlc.SuccessSequence(), + sweepTx.TxIn[0].Sequence) + require.True(t, loopIn.htlc.IsSuccessWitness(sweepTx.TxIn[0].Witness)) + require.Equal(t, sweepTx.TxHash(), *sweepRegistration.TxID) + + sweepRegistration.ConfChan <- &chainntnfs.TxConfirmation{ + Tx: sweepTx, + BlockHeight: 602, + } + + states := make([]swapserverrpc.ServerSwapState, 0, 3) + for _, update := range updates.history { + states = append(states, update.state) + } + for len(states) < 3 { + select { + case update, ok := <-updates.updates: + require.True(t, ok) + states = append(states, update.state) + + case <-time.After(looptest.Timeout): + t.Fatal("server did not publish all Loop In states") + } + } + require.Equal(t, []swapserverrpc.ServerSwapState{ + swapserverrpc.ServerSwapState_SERVER_INITIATED, + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, + swapserverrpc.ServerSwapState_SERVER_SUCCESS, + }, states) + + server.Stop() + stopped = true +} + +func TestProbeLoopInInvoiceRejectsNoRouteAfterInFlight(t *testing.T) { + t.Parallel() + + lnd := looptest.NewMockLnd() + server := &Server{cfg: Config{ + Lnd: &lnd.LndServices, + MaxSwapAmount: 5_000_000, + PaymentTimeout: time.Minute, + }} + + result := make(chan error, 1) + go func() { + result <- server.probeLoopInInvoice( + context.Background(), "probe-invoice", nil, + ) + }() + + var payment looptest.RouterPaymentChannelMessage + select { + case payment = <-lnd.RouterSendPaymentChannel: + case <-time.After(looptest.Timeout): + t.Fatal("probe payment was not initiated") + } + payment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_IN_FLIGHT, + } + payment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_FAILED, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE, + } + + select { + case err := <-result: + require.ErrorContains(t, err, "unexpected failure reason") + case <-time.After(looptest.Timeout): + t.Fatal("probe did not return its terminal failure") + } +} + +func TestConfirmedLoopInOutput(t *testing.T) { + t.Parallel() + + pkScript := []byte{0x51, 0x20, 0x01} + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{Value: 100_000, PkScript: pkScript}) + confirmation := &chainntnfs.TxConfirmation{Tx: tx} + + outpoint, amount, state, err := confirmedLoopInOutput( + confirmation, pkScript, 100_000, + ) + require.NoError(t, err) + require.EqualValues(t, 100_000, amount) + require.Equal(t, tx.TxHash(), outpoint.Hash) + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, state) + + _, _, state, err = confirmedLoopInOutput( + confirmation, pkScript, 99_999, + ) + require.Error(t, err) + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_FAILED_INVALID_HTLC_AMOUNT, + state, + ) + + tx.AddTxOut(&wire.TxOut{Value: 100_000, PkScript: pkScript}) + _, _, state, err = confirmedLoopInOutput( + confirmation, pkScript, 100_000, + ) + require.Error(t, err) + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_FAILED_MULTIPLE_SWAP_SCRIPTS, + state, + ) +} + +func encodeLoopInTestInvoice(t *testing.T, signer *btcec.PrivateKey, + hash lntypes.Hash, amount btcutil.Amount) string { + + t.Helper() + + invoice, err := zpay32.NewInvoice( + &chaincfg.RegressionNetParams, hash, time.Unix(1_700_000_000, 0), + zpay32.Description("regtest Loop In"), + zpay32.Amount(lnwire.NewMSatFromSatoshis(amount)), + ) + require.NoError(t, err) + + encoded, err := invoice.Encode(zpay32.MessageSigner{ + SignCompact: func(message []byte) ([]byte, error) { + digest := chainhash.HashB(message) + return ecdsa.SignCompact(signer, digest, true), nil + }, + }) + require.NoError(t, err) + + return encoded +} + +func cloneLoopInRequest( + req *swapserverrpc.ServerLoopInRequest) *swapserverrpc.ServerLoopInRequest { + + return proto.Clone(req).(*swapserverrpc.ServerLoopInRequest) +} diff --git a/regtest/server/loopout.go b/regtest/server/loopout.go new file mode 100644 index 000000000..b7dc75d41 --- /dev/null +++ b/regtest/server/loopout.go @@ -0,0 +1,1514 @@ +package server + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "math" + "strings" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" + invpkg "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // loopOutInvoiceCltvDelta is the safety delta between the on-chain HTLC + // expiry and the final-hop CLTV used by the two hold invoices. + loopOutInvoiceCltvDelta = int32(50) + + // loopOutFundingConfTarget is deliberately conservative enough to be + // accepted by lnd's fee estimator while still confirming quickly on + // regtest once a block is mined. + loopOutFundingConfTarget = int32(6) + + loopOutInvoiceExpiry = int64((365 * 24 * time.Hour) / time.Second) +) + +type loopOutSwap struct { + mu sync.Mutex + + hash lntypes.Hash + amount btcutil.Amount + expiry int32 + initiationHeight int32 + publicationDeadline time.Time + + senderKey [33]byte + receiverKey [33]byte + senderLocator keychain.KeyLocator + + prepayPreimage lntypes.Preimage + prepayHash lntypes.Hash + + swapInvoice string + prepayInvoice string + paymentAddr [32]byte + + htlc *swap.Htlc + + state swapserverrpc.ServerSwapState + updates *updateHub + ctx context.Context + cancel context.CancelFunc + terminal bool + canceled bool + + cancelRequested bool + cancelState swapserverrpc.ServerSwapState + mainCancelAck bool + prepayCancelAck bool + + fundingStarted bool + fundingTx *wire.MsgTx + fundingOutpoint *wire.OutPoint + confirmed bool + prepaySettled bool + mainSettled bool +} + +func validateLoopOutProtocol(version swapserverrpc.ProtocolVersion) error { + if version != swapserverrpc.ProtocolVersion_MUSIG2 { + return status.Errorf( + codes.InvalidArgument, "protocol version %d unsupported; want %d", + version, swapserverrpc.ProtocolVersion_MUSIG2, + ) + } + + return nil +} + +func (s *Server) LoopOutTerms(_ context.Context, + req *swapserverrpc.ServerLoopOutTermsRequest) ( + *swapserverrpc.ServerLoopOutTerms, error) { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + + return &swapserverrpc.ServerLoopOutTerms{ + MinSwapAmount: uint64(s.cfg.MinSwapAmount), + MaxSwapAmount: uint64(s.cfg.MaxSwapAmount), + MinCltvDelta: s.cfg.LoopOutMinCltvDelta, + MaxCltvDelta: s.cfg.LoopOutMaxCltvDelta, + }, nil +} + +func (s *Server) LoopOutQuote(ctx context.Context, + req *swapserverrpc.ServerLoopOutQuoteRequest) ( + *swapserverrpc.ServerLoopOutQuote, error) { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + + amount, err := s.loopOutAmount(req.Amt, true) + if err != nil { + return nil, err + } + + height, err := s.currentHeight(ctx) + if err != nil { + return nil, status.Errorf( + codes.Unavailable, "get current height: %v", err, + ) + } + if err := s.validateLoopOutExpiry(height, req.Expiry); err != nil { + return nil, err + } + + dest := s.cfg.Lnd.NodePubkey + if dest == ([33]byte{}) { + info, err := s.cfg.Lnd.Client.GetInfo(ctx) + if err != nil { + return nil, status.Errorf( + codes.Unavailable, "get server identity: %v", err, + ) + } + dest = info.IdentityPubkey + } + + return &swapserverrpc.ServerLoopOutQuote{ + SwapPaymentDest: hex.EncodeToString(dest[:]), + SwapFee: int64(s.swapFee(amount)), + PrepayAmt: uint64(s.cfg.PrepaySat), + MinSwapAmount: uint64(s.cfg.MinSwapAmount), + MaxSwapAmount: uint64(s.cfg.MaxSwapAmount), + CltvDelta: req.Expiry - height, + }, nil +} + +func (s *Server) loopOutAmount(rpcAmount uint64, + allowZero bool) (btcutil.Amount, error) { + + if rpcAmount == 0 && allowZero { + return s.cfg.MaxSwapAmount, nil + } + if rpcAmount > math.MaxInt64 { + return 0, status.Error(codes.InvalidArgument, "amount overflows int64") + } + + amount := btcutil.Amount(rpcAmount) + if err := s.validateAmount(amount); err != nil { + return 0, err + } + if s.cfg.PrepaySat < 0 || s.cfg.PrepaySat > amount+s.swapFee(amount) { + return 0, status.Error( + codes.InvalidArgument, "invalid configured prepay amount", + ) + } + + return amount, nil +} + +func (s *Server) validateLoopOutExpiry(height, expiry int32) error { + delta := int64(expiry) - int64(height) + if delta < int64(s.cfg.LoopOutMinCltvDelta) || + delta > int64(s.cfg.LoopOutMaxCltvDelta) { + + return status.Errorf( + codes.OutOfRange, "CLTV delta %d outside range [%d,%d]", + delta, s.cfg.LoopOutMinCltvDelta, + s.cfg.LoopOutMaxCltvDelta, + ) + } + + return nil +} + +func (s *Server) NewLoopOutSwap(ctx context.Context, + req *swapserverrpc.ServerLoopOutRequest) ( + *swapserverrpc.ServerLoopOutResponse, error) { + + requestReceived := time.Now() + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, err + } + + // Serialize creation for this deliberately small server. Apart from + // making duplicate requests idempotent, this prevents two concurrent + // requests for the same hash from creating distinct invoice/key pairs. + s.mu.Lock() + defer s.mu.Unlock() + + if existing, ok := s.loopOuts[hash]; ok { + if err := existing.matchesRequest(req); err != nil { + return nil, err + } + + return existing.response(), nil + } + + // The client's payment recovery request intentionally contains only the + // hash. It can only succeed if the swap already exists. + if req.UserAgent == "resume_swap" && req.Amt == 0 && + len(req.ReceiverKey) == 0 { + + return nil, status.Error(codes.NotFound, "swap not found") + } + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + if len(strings.TrimSpace(req.UserAgent)) > math.MaxUint8 { + return nil, status.Error( + codes.InvalidArgument, "user agent exceeds 255 bytes", + ) + } + + amount, err := s.loopOutAmount(req.Amt, false) + if err != nil { + return nil, err + } + receiverPub, err := parseKey("receiver key", req.ReceiverKey) + if err != nil { + return nil, err + } + receiverKey := keyBytes(receiverPub) + + height, err := s.currentHeight(ctx) + if err != nil { + return nil, status.Errorf( + codes.Unavailable, "get current height: %v", err, + ) + } + if err := s.validateLoopOutExpiry(height, req.Expiry); err != nil { + return nil, err + } + + serverKey, err := s.deriveKey(ctx, swap.KeyFamily) + if err != nil { + return nil, status.Errorf( + codes.Unavailable, "derive sender key: %v", err, + ) + } + senderKey := keyBytes(serverKey.pubKey) + + htlc, err := swap.NewHtlcV3( + input.MuSig2Version100RC2, req.Expiry, senderKey, receiverKey, + senderKey, receiverKey, hash, s.cfg.Lnd.ChainParams, + ) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "construct HTLC: %v", err, + ) + } + + var prepayPreimage lntypes.Preimage + if _, err := rand.Read(prepayPreimage[:]); err != nil { + return nil, status.Errorf( + codes.Internal, "generate prepay preimage: %v", err, + ) + } + prepayHash := prepayPreimage.Hash() + + fee := s.swapFee(amount) + mainAmount := amount + fee - s.cfg.PrepaySat + invoiceCltv := int64(req.Expiry) - int64(height) + + int64(loopOutInvoiceCltvDelta) + if invoiceCltv <= 0 { + return nil, status.Error( + codes.InvalidArgument, "invalid invoice CLTV delta", + ) + } + + mainInvoice, err := s.cfg.Lnd.Invoices.AddHoldInvoice( + ctx, &invoicesrpc.AddInvoiceData{ + Hash: &hash, + Value: lnwire.NewMSatFromSatoshis(mainAmount), + CltvExpiry: uint64(invoiceCltv), + Memo: fmt.Sprintf("loop out - script: %x", htlc.PkScript), + Expiry: loopOutInvoiceExpiry, + Private: true, + }, + ) + if err != nil { + return nil, status.Errorf( + codes.Unavailable, "create swap hold invoice: %v", err, + ) + } + + prepayInvoice, err := s.cfg.Lnd.Invoices.AddHoldInvoice( + ctx, &invoicesrpc.AddInvoiceData{ + Hash: &prepayHash, + Value: lnwire.NewMSatFromSatoshis(s.cfg.PrepaySat), + CltvExpiry: uint64(invoiceCltv), + Memo: "loop out prepay", + Expiry: loopOutInvoiceExpiry, + Private: true, + }, + ) + if err != nil { + _ = s.cfg.Lnd.Invoices.CancelInvoice(ctx, hash) + return nil, status.Errorf( + codes.Unavailable, "create prepay hold invoice: %v", err, + ) + } + + paymentAddr, err := loopOutPaymentAddress( + mainInvoice, s.cfg.Lnd.ChainParams, + ) + if err != nil { + _ = s.cfg.Lnd.Invoices.CancelInvoice(ctx, hash) + _ = s.cfg.Lnd.Invoices.CancelInvoice(ctx, prepayHash) + return nil, status.Errorf( + codes.Internal, "decode swap payment address: %v", err, + ) + } + + publicationDeadline := loopOutPublicationDeadline( + req.SwapPublicationDeadline, requestReceived, + ) + swapCtx, cancel := context.WithCancel(s.ctx) + loopOut := &loopOutSwap{ + hash: hash, + amount: amount, + expiry: req.Expiry, + initiationHeight: height, + publicationDeadline: publicationDeadline, + senderKey: senderKey, + receiverKey: receiverKey, + senderLocator: serverKey.locator, + prepayPreimage: prepayPreimage, + prepayHash: prepayHash, + swapInvoice: mainInvoice, + prepayInvoice: prepayInvoice, + paymentAddr: paymentAddr, + htlc: htlc, + state: swapserverrpc.ServerSwapState_SERVER_INITIATED, + updates: newUpdateHub(), + ctx: swapCtx, + cancel: cancel, + } + loopOut.updates.publish( + swapserverrpc.ServerSwapState_SERVER_INITIATED, + ) + s.loopOuts[hash] = loopOut + + response := loopOut.response() + s.goSwap(func(context.Context) { + s.runLoopOut(loopOut) + }) + + return response, nil +} + +// loopOutPublicationDeadline converts the client's absolute publication +// deadline into an enforced deadline. A deadline at or before request receipt +// means "fast": both the CLI (now) and autolooper (zero time) use such values +// to request immediate publication once both hold invoices are accepted. +func loopOutPublicationDeadline(unixSeconds int64, + requestReceived time.Time) time.Time { + + deadline := time.Unix(unixSeconds, 0) + if !deadline.After(requestReceived) { + return time.Time{} + } + + return deadline +} + +func loopOutPaymentAddress(invoice string, + chainParams *chaincfg.Params) ([32]byte, error) { + + decoded, err := zpay32.Decode(invoice, chainParams) + if err != nil { + return [32]byte{}, err + } + paymentAddr, err := decoded.PaymentAddr.UnwrapOrErr( + errors.New("invoice has no payment address"), + ) + if err != nil { + return [32]byte{}, err + } + + return paymentAddr, nil +} + +func (o *loopOutSwap) matchesRequest( + req *swapserverrpc.ServerLoopOutRequest) error { + + // A payment recovery request deliberately only carries the hash. + if req.UserAgent == "resume_swap" && req.Amt == 0 && + len(req.ReceiverKey) == 0 { + + return nil + } + + if req.ProtocolVersion != swapserverrpc.ProtocolVersion_MUSIG2 || + req.Amt != uint64(o.amount) || req.Expiry != o.expiry || + !bytes.Equal(req.ReceiverKey, o.receiverKey[:]) { + + return status.Error( + codes.AlreadyExists, "swap hash already has a different contract", + ) + } + + return nil +} + +func (o *loopOutSwap) response() *swapserverrpc.ServerLoopOutResponse { + return &swapserverrpc.ServerLoopOutResponse{ + SwapInvoice: o.swapInvoice, + PrepayInvoice: o.prepayInvoice, + SenderKey: bytes.Clone(o.senderKey[:]), + Expiry: o.expiry, + } +} + +func (s *Server) runLoopOut(loopOut *loopOutSwap) { + ctx := loopOut.ctx + publicationCtx, cancelPublication := context.WithCancel(ctx) + if !loopOut.publicationDeadline.IsZero() { + publicationCtx, cancelPublication = context.WithDeadline( + ctx, loopOut.publicationDeadline, + ) + } + defer cancelPublication() + + mainUpdates, mainErrors, err := s.cfg.Lnd.Invoices.SubscribeSingleInvoice( + publicationCtx, loopOut.hash, + ) + if err != nil { + s.failLoopOutBeforeFunding( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_INITIALIZATION, + fmt.Errorf("subscribe swap invoice: %w", err), + ) + return + } + prepayUpdates, prepayErrors, err := + s.cfg.Lnd.Invoices.SubscribeSingleInvoice( + publicationCtx, loopOut.prepayHash, + ) + if err != nil { + s.failLoopOutBeforeFunding( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_INITIALIZATION, + fmt.Errorf("subscribe prepay invoice: %w", err), + ) + return + } + + if err := waitForLoopOutInvoices( + publicationCtx, mainUpdates, mainErrors, prepayUpdates, + prepayErrors, + ); err != nil { + switch { + case errors.Is(err, context.DeadlineExceeded): + s.failLoopOutBeforeFunding( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_HTLC_PUBLICATION, + fmt.Errorf("publication deadline while waiting for invoices: %w", + err), + ) + + case !errors.Is(err, context.Canceled): + s.failLoopOutBeforeFunding( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_OFF_CHAIN_TIMEOUT, + err, + ) + } + return + } + if err := publicationCtx.Err(); err != nil { + s.failLoopOutBeforeFunding( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_HTLC_PUBLICATION, + fmt.Errorf("publication deadline before funding: %w", err), + ) + return + } + + loopOut.mu.Lock() + if loopOut.terminal || loopOut.canceled || loopOut.cancelRequested { + loopOut.mu.Unlock() + return + } + loopOut.fundingStarted = true + loopOut.mu.Unlock() + + feeRate, err := s.cfg.Lnd.WalletKit.EstimateFeeRate( + publicationCtx, loopOutFundingConfTarget, + ) + if err != nil { + s.failLoopOutBeforeBroadcast(loopOut, fmt.Errorf( + "estimate HTLC fee: %w", err, + )) + return + } + if err := publicationCtx.Err(); err != nil { + s.failLoopOutBeforeBroadcast(loopOut, fmt.Errorf( + "publication deadline before broadcast: %w", err, + )) + return + } + + fundingTx, err := s.cfg.Lnd.WalletKit.SendOutputs( + publicationCtx, []*wire.TxOut{{ + Value: int64(loopOut.amount), + PkScript: bytes.Clone(loopOut.htlc.PkScript), + }}, feeRate, "loop-out-regtest-htlc", + ) + cancelPublication() + + var confirmationErr error + if err != nil || fundingTx == nil { + if err == nil { + err = errors.New("wallet returned no funding transaction") + } + s.cfg.Logger.Printf( + "Loop Out %x broadcast result is ambiguous: %v; "+ + "reconciling by HTLC script", loopOut.hash[:6], err, + ) + confirmationErr = s.waitForAmbiguousLoopOutConfirmation(loopOut) + } else { + outpoint, value, outputErr := swap.GetScriptOutput( + fundingTx, loopOut.htlc.PkScript, + ) + if outputErr != nil || value != loopOut.amount { + s.cfg.Logger.Printf( + "Loop Out %x wallet returned an ambiguous HTLC "+ + "transaction: outpoint=%v, value=%v, err=%v; "+ + "reconciling by script", loopOut.hash[:6], + outpoint, value, outputErr, + ) + confirmationErr = + s.waitForAmbiguousLoopOutConfirmation(loopOut) + } else { + s.recordPublishedLoopOut(loopOut, fundingTx, *outpoint) + confirmationErr = s.waitForLoopOutConfirmation(loopOut) + } + } + + if confirmationErr != nil { + if !errors.Is(confirmationErr, context.Canceled) { + s.failLoopOutAfterPublication(loopOut, confirmationErr) + } + return + } + + if err := s.settleLoopOutPrepay(loopOut); err != nil { + s.failLoopOutAfterPublication(loopOut, err) + return + } + + // LoopOutPushPreimage is a best-effort optimization. Also watch the exact + // HTLC outpoint so a valid unilateral success spend can recover the + // preimage and settle the main hold invoice if that RPC is lost. + if err := s.waitForLoopOutSuccessSpend(loopOut); err != nil && + !errors.Is(err, context.Canceled) { + + s.failLoopOutAfterPublication(loopOut, err) + } +} + +func waitForLoopOutInvoices(ctx context.Context, + mainUpdates <-chan lndclient.InvoiceUpdate, mainErrors <-chan error, + prepayUpdates <-chan lndclient.InvoiceUpdate, + prepayErrors <-chan error) error { + + var mainAccepted, prepayAccepted bool + for !mainAccepted || !prepayAccepted { + select { + case update, ok := <-mainUpdates: + if !ok { + mainUpdates = nil + if !mainAccepted { + return errors.New("swap invoice subscription closed") + } + continue + } + switch update.State { + case invpkg.ContractAccepted: + mainAccepted = true + case invpkg.ContractCanceled: + return errors.New("swap invoice canceled") + case invpkg.ContractSettled: + return errors.New("swap invoice settled before HTLC publication") + } + + case update, ok := <-prepayUpdates: + if !ok { + prepayUpdates = nil + if !prepayAccepted { + return errors.New("prepay invoice subscription closed") + } + continue + } + switch update.State { + case invpkg.ContractAccepted: + prepayAccepted = true + case invpkg.ContractCanceled: + return errors.New("prepay invoice canceled") + case invpkg.ContractSettled: + return errors.New("prepay invoice settled before HTLC publication") + } + + case err, ok := <-mainErrors: + if !ok { + mainErrors = nil + continue + } + if err != nil { + return fmt.Errorf("swap invoice subscription: %w", err) + } + + case err, ok := <-prepayErrors: + if !ok { + prepayErrors = nil + continue + } + if err != nil { + return fmt.Errorf("prepay invoice subscription: %w", err) + } + + case <-ctx.Done(): + return ctx.Err() + } + + if mainUpdates == nil && mainErrors == nil && !mainAccepted { + return errors.New("swap invoice subscription ended") + } + if prepayUpdates == nil && prepayErrors == nil && !prepayAccepted { + return errors.New("prepay invoice subscription ended") + } + } + + return nil +} + +func (s *Server) recordPublishedLoopOut(loopOut *loopOutSwap, + fundingTx *wire.MsgTx, outpoint wire.OutPoint) bool { + + loopOut.mu.Lock() + if loopOut.terminal { + loopOut.mu.Unlock() + return false + } + loopOut.fundingTx = fundingTx.Copy() + copyOutpoint := outpoint + loopOut.fundingOutpoint = ©Outpoint + loopOut.state = swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED + loopOut.mu.Unlock() + loopOut.updates.publish( + swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED, + ) + + return true +} + +func (s *Server) waitForLoopOutConfirmation(loopOut *loopOutSwap) error { + loopOut.mu.Lock() + if loopOut.fundingTx == nil || loopOut.fundingOutpoint == nil { + loopOut.mu.Unlock() + return errors.New("published HTLC transaction is missing") + } + fundingTx := loopOut.fundingTx.Copy() + outpoint := *loopOut.fundingOutpoint + loopOut.mu.Unlock() + + txid := fundingTx.TxHash() + return s.waitForLoopOutConfirmationMatch( + loopOut, &txid, &outpoint, false, + ) +} + +// waitForAmbiguousLoopOutConfirmation reconciles a SendOutputs call whose +// result cannot prove whether lnd broadcast the transaction. The invoice +// holds remain intact while a script-only notification discovers the exact +// HTLC. This avoids canceling accepted payments after a possibly successful +// publication. +func (s *Server) waitForAmbiguousLoopOutConfirmation( + loopOut *loopOutSwap) error { + + return s.waitForLoopOutConfirmationMatch(loopOut, nil, nil, true) +} + +func (s *Server) waitForLoopOutConfirmationMatch(loopOut *loopOutSwap, + expectedTxID *chainhash.Hash, expectedOutpoint *wire.OutPoint, + recordPublication bool) error { + + confirmations, confirmationErrors, err := + s.cfg.Lnd.ChainNotifier.RegisterConfirmationsNtfn( + loopOut.ctx, expectedTxID, loopOut.htlc.PkScript, 1, + loopOut.initiationHeight, + ) + if err != nil { + return fmt.Errorf("register HTLC confirmation: %w", err) + } + + select { + case confirmation, ok := <-confirmations: + if !ok || confirmation == nil || confirmation.Tx == nil { + return errors.New("HTLC confirmation stream closed") + } + if expectedTxID != nil && + confirmation.Tx.TxHash() != *expectedTxID { + + return errors.New("confirmed HTLC transaction hash mismatch") + } + confirmedOutpoint, value, err := swap.GetScriptOutput( + confirmation.Tx, loopOut.htlc.PkScript, + ) + if err != nil || value != loopOut.amount || + (expectedOutpoint != nil && + *confirmedOutpoint != *expectedOutpoint) { + + return fmt.Errorf( + "invalid confirmed HTLC: outpoint=%v, value=%v, err=%v", + confirmedOutpoint, value, err, + ) + } + if recordPublication && !s.recordPublishedLoopOut( + loopOut, confirmation.Tx, *confirmedOutpoint, + ) { + + return context.Canceled + } + + loopOut.mu.Lock() + if loopOut.terminal { + loopOut.mu.Unlock() + return context.Canceled + } + loopOut.confirmed = true + loopOut.state = swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED + loopOut.mu.Unlock() + loopOut.updates.publish( + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, + ) + + return nil + + case err, ok := <-confirmationErrors: + if !ok || err == nil { + return errors.New("HTLC confirmation error stream closed") + } + return fmt.Errorf("HTLC confirmation: %w", err) + + case <-loopOut.ctx.Done(): + return loopOut.ctx.Err() + } +} + +func (s *Server) settleLoopOutPrepay(loopOut *loopOutSwap) error { + loopOut.mu.Lock() + defer loopOut.mu.Unlock() + + if loopOut.terminal || loopOut.canceled { + return context.Canceled + } + if !loopOut.confirmed { + return errors.New("cannot settle prepay before HTLC confirmation") + } + if loopOut.prepaySettled { + return nil + } + + err := s.cfg.Lnd.Invoices.SettleInvoice( + loopOut.ctx, loopOut.prepayPreimage, + ) + if err != nil && !invoiceAlreadySettled(err) { + return fmt.Errorf("settle prepay invoice: %w", err) + } + loopOut.prepaySettled = true + + return nil +} + +func (s *Server) waitForLoopOutSuccessSpend(loopOut *loopOutSwap) error { + loopOut.mu.Lock() + if loopOut.fundingOutpoint == nil { + loopOut.mu.Unlock() + return errors.New("confirmed HTLC outpoint is missing") + } + outpoint := *loopOut.fundingOutpoint + loopOut.mu.Unlock() + + spends, spendErrors, err := s.cfg.Lnd.ChainNotifier.RegisterSpendNtfn( + loopOut.ctx, &outpoint, loopOut.htlc.PkScript, + loopOut.initiationHeight, + ) + if err != nil { + return fmt.Errorf("register HTLC spend: %w", err) + } + + select { + case spend, ok := <-spends: + if !ok || spend == nil { + return errors.New("HTLC spend stream closed") + } + preimage, err := s.validateLoopOutSuccessSpend(loopOut, spend) + if err != nil { + return fmt.Errorf("invalid HTLC success spend: %w", err) + } + + // A settlement RPC can fail after lnd has committed the invoice + // state. Retry the idempotent completion while the swap is active so + // the one-shot spend notification is not lost to a transient error. + for { + err := s.completeLoopOut( + loopOut.ctx, loopOut, preimage, + ) + if err == nil { + return nil + } + if status.Code(err) != codes.Unavailable { + return err + } + + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-timer.C: + case <-loopOut.ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return loopOut.ctx.Err() + } + } + + case err, ok := <-spendErrors: + if !ok || err == nil { + return errors.New("HTLC spend error stream closed") + } + return fmt.Errorf("HTLC spend: %w", err) + + case <-loopOut.ctx.Done(): + return loopOut.ctx.Err() + } +} + +// validateLoopOutSuccessSpend returns a preimage only after validating the +// exact notified outpoint, the tapscript success path and the complete Bitcoin +// script execution. This deliberately rejects key spends and timeout spends, +// neither of which reveal a preimage. +func (s *Server) validateLoopOutSuccessSpend(loopOut *loopOutSwap, + spend *chainntnfs.SpendDetail) (lntypes.Preimage, error) { + + loopOut.mu.Lock() + if loopOut.fundingOutpoint == nil { + loopOut.mu.Unlock() + return lntypes.Preimage{}, errors.New("funding outpoint missing") + } + fundingOutpoint := *loopOut.fundingOutpoint + htlcValue := loopOut.amount + htlcPkScript := bytes.Clone(loopOut.htlc.PkScript) + successScript := bytes.Clone(loopOut.htlc.SuccessScript()) + swapHash := loopOut.hash + loopOut.mu.Unlock() + + if spend.SpentOutPoint == nil || + *spend.SpentOutPoint != fundingOutpoint { + + return lntypes.Preimage{}, errors.New("spent outpoint mismatch") + } + if spend.SpendingTx == nil { + return lntypes.Preimage{}, errors.New("spending transaction missing") + } + spendingTx := spend.SpendingTx + if spend.SpenderTxHash != nil && + spendingTx.TxHash() != *spend.SpenderTxHash { + + return lntypes.Preimage{}, errors.New("spender transaction hash mismatch") + } + inputIndex := int(spend.SpenderInputIndex) + if inputIndex < 0 || inputIndex >= len(spendingTx.TxIn) { + return lntypes.Preimage{}, errors.New("spender input index out of range") + } + txIn := spendingTx.TxIn[inputIndex] + if txIn.PreviousOutPoint != fundingOutpoint { + return lntypes.Preimage{}, errors.New("spender input outpoint mismatch") + } + witness := txIn.Witness + if len(witness) != 4 { + return lntypes.Preimage{}, fmt.Errorf( + "success witness has %d elements", len(witness), + ) + } + if !bytes.Equal(witness[2], successScript) { + return lntypes.Preimage{}, errors.New("success tapscript mismatch") + } + + preimage, err := lntypes.MakePreimage(witness[0]) + if err != nil || preimage.Hash() != swapHash { + return lntypes.Preimage{}, errors.New("success preimage mismatch") + } + + witnessVersion, witnessProgram, err := + txscript.ExtractWitnessProgramInfo(htlcPkScript) + if err != nil || witnessVersion != 1 || len(witnessProgram) != 32 { + return lntypes.Preimage{}, errors.New("invalid HTLC taproot output") + } + controlBlock, err := txscript.ParseControlBlock(witness[3]) + if err != nil { + return lntypes.Preimage{}, fmt.Errorf("parse control block: %w", err) + } + if err := txscript.VerifyTaprootLeafCommitment( + controlBlock, witnessProgram, witness[2], + ); err != nil { + return lntypes.Preimage{}, fmt.Errorf( + "verify success tapleaf commitment: %w", err, + ) + } + + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(spendingTx.TxIn)) + for index, input := range spendingTx.TxIn { + outpoint := input.PreviousOutPoint + if _, duplicate := prevOuts[outpoint]; duplicate { + return lntypes.Preimage{}, fmt.Errorf( + "duplicate transaction input %d", index, + ) + } + if outpoint == fundingOutpoint { + prevOuts[outpoint] = &wire.TxOut{ + Value: int64(htlcValue), + PkScript: bytes.Clone(htlcPkScript), + } + continue + } + + prevTx, err := s.cfg.Bitcoin.GetRawTransaction(&outpoint.Hash) + if err != nil { + return lntypes.Preimage{}, fmt.Errorf( + "fetch prevout %d: %w", index, err, + ) + } + if prevTx == nil || int(outpoint.Index) >= len(prevTx.MsgTx().TxOut) { + return lntypes.Preimage{}, fmt.Errorf( + "prevout %d is missing", index, + ) + } + prevOutput := prevTx.MsgTx().TxOut[outpoint.Index] + prevOuts[outpoint] = &wire.TxOut{ + Value: prevOutput.Value, + PkScript: bytes.Clone(prevOutput.PkScript), + } + } + + prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(spendingTx, prevOutFetcher) + engine, err := txscript.NewEngine( + htlcPkScript, spendingTx, inputIndex, + txscript.StandardVerifyFlags, nil, sigHashes, int64(htlcValue), + prevOutFetcher, + ) + if err != nil { + return lntypes.Preimage{}, fmt.Errorf("create script engine: %w", err) + } + if err := engine.Execute(); err != nil { + return lntypes.Preimage{}, fmt.Errorf( + "execute success witness: %w", err, + ) + } + + return preimage, nil +} + +func invoiceAlreadySettled(err error) bool { + return err != nil && strings.Contains( + strings.ToLower(err.Error()), "already settled", + ) +} + +func (s *Server) failLoopOutBeforeFunding(loopOut *loopOutSwap, + state swapserverrpc.ServerSwapState, err error) { + + loopOut.mu.Lock() + cancelRequested := loopOut.cancelRequested + loopOut.mu.Unlock() + if cancelRequested { + return + } + + _ = s.cfg.Lnd.Invoices.CancelInvoice(s.ctx, loopOut.hash) + _ = s.cfg.Lnd.Invoices.CancelInvoice(s.ctx, loopOut.prepayHash) + s.finishLoopOut(loopOut, state, err) +} + +// failLoopOutBeforeBroadcast is only used before SendOutputs is invoked, when +// it is still certain that this server did not publish an HTLC transaction. +func (s *Server) failLoopOutBeforeBroadcast(loopOut *loopOutSwap, + err error) { + + _ = s.cfg.Lnd.Invoices.CancelInvoice(s.ctx, loopOut.hash) + _ = s.cfg.Lnd.Invoices.CancelInvoice(s.ctx, loopOut.prepayHash) + s.finishLoopOut( + loopOut, + swapserverrpc.ServerSwapState_SERVER_FAILED_HTLC_PUBLICATION, err, + ) +} + +func (s *Server) failLoopOutAfterPublication(loopOut *loopOutSwap, + err error) { + + // Once broadcasting was attempted, retain both accepted hold invoices: + // the client may already possess the preimage and an HTLC may exist even + // if lnd returned an error to SendOutputs. + s.finishLoopOut( + loopOut, swapserverrpc.ServerSwapState_SERVER_UNEXPECTED_FAILURE, + err, + ) +} + +func (s *Server) finishLoopOut(loopOut *loopOutSwap, + state swapserverrpc.ServerSwapState, err error) { + + loopOut.mu.Lock() + if loopOut.terminal || loopOut.cancelRequested { + loopOut.mu.Unlock() + return + } + loopOut.state = state + loopOut.terminal = true + loopOut.mu.Unlock() + + if err != nil { + s.cfg.Logger.Printf("Loop Out %x finished in %v: %v", + loopOut.hash[:6], state, err) + } + loopOut.updates.finish(state) + loopOut.cancel() +} + +func (s *Server) LoopOutPushPreimage(ctx context.Context, + req *swapserverrpc.ServerLoopOutPushPreimageRequest) ( + *swapserverrpc.ServerLoopOutPushPreimageResponse, error) { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + preimage, err := lntypes.MakePreimage(req.Preimage) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid preimage") + } + hash := preimage.Hash() + loopOut := s.lookupLoopOut(hash) + if loopOut == nil { + return nil, status.Error(codes.NotFound, "swap not found") + } + if err := s.completeLoopOut(ctx, loopOut, preimage); err != nil { + return nil, err + } + + return &swapserverrpc.ServerLoopOutPushPreimageResponse{}, nil +} + +// completeLoopOut atomically settles both invoices and terminalizes the swap. +// It is shared by the preimage RPC and spend-based recovery, so either path can +// win without double settlement or duplicate terminal updates. +func (s *Server) completeLoopOut(ctx context.Context, loopOut *loopOutSwap, + preimage lntypes.Preimage) error { + + loopOut.mu.Lock() + switch { + case preimage.Hash() != loopOut.hash: + loopOut.mu.Unlock() + return status.Error(codes.InvalidArgument, "preimage hash mismatch") + + case loopOut.mainSettled && loopOut.state == + swapserverrpc.ServerSwapState_SERVER_SUCCESS: + + loopOut.mu.Unlock() + return nil + + case loopOut.terminal: + loopOut.mu.Unlock() + return status.Error(codes.FailedPrecondition, "swap is terminal") + + case !loopOut.confirmed: + loopOut.mu.Unlock() + return status.Error( + codes.FailedPrecondition, "HTLC is not confirmed", + ) + } + + // Confirmation and the corresponding update are visible just before the + // swap worker settles the prepay invoice. Settle it here as well so that + // an immediate preimage push cannot terminalize the swap while leaving + // the accepted prepay invoice held. The operation is idempotent with the + // worker and with retries after an uncertain RPC result. + if !loopOut.prepaySettled { + err := s.cfg.Lnd.Invoices.SettleInvoice( + ctx, loopOut.prepayPreimage, + ) + if err != nil && !invoiceAlreadySettled(err) { + loopOut.mu.Unlock() + return status.Errorf( + codes.Unavailable, "settle prepay invoice: %v", err, + ) + } + loopOut.prepaySettled = true + } + + err := s.cfg.Lnd.Invoices.SettleInvoice(ctx, preimage) + if err != nil && !invoiceAlreadySettled(err) { + loopOut.mu.Unlock() + return status.Errorf( + codes.Unavailable, "settle swap invoice: %v", err, + ) + } + loopOut.mainSettled = true + loopOut.state = swapserverrpc.ServerSwapState_SERVER_SUCCESS + loopOut.terminal = true + loopOut.mu.Unlock() + + loopOut.updates.finish(swapserverrpc.ServerSwapState_SERVER_SUCCESS) + loopOut.cancel() + + return nil +} + +func (s *Server) lookupLoopOut(hash lntypes.Hash) *loopOutSwap { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.loopOuts[hash] +} + +func (s *Server) SubscribeLoopOutUpdates( + req *swapserverrpc.SubscribeUpdatesRequest, + stream swapserverrpc.SwapServer_SubscribeLoopOutUpdatesServer) error { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return err + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return err + } + loopOut := s.lookupLoopOut(hash) + if loopOut == nil { + return status.Error(codes.NotFound, "swap not found") + } + + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + + send := func(update serverUpdate) error { + return stream.Send(&swapserverrpc.SubscribeLoopOutUpdatesResponse{ + TimestampNs: update.timestamp.UnixNano(), + State: update.state, + }) + } + for _, update := range subscription.history { + if err := send(update); err != nil { + return err + } + } + if subscription.done { + return nil + } + + for { + select { + case update, ok := <-subscription.updates: + if !ok { + return nil + } + if err := send(update); err != nil { + return err + } + + case <-s.ctx.Done(): + return s.ctx.Err() + + case <-stream.Context().Done(): + return stream.Context().Err() + } + } +} + +func (s *Server) CancelLoopOutSwap(ctx context.Context, + req *swapserverrpc.CancelLoopOutSwapRequest) ( + *swapserverrpc.CancelLoopOutSwapResponse, error) { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, err + } + if len(req.PaymentAddress) != 32 { + return nil, status.Error(codes.PermissionDenied, "invalid swap owner") + } + loopOut := s.lookupLoopOut(hash) + if loopOut == nil { + return nil, status.Error(codes.NotFound, "swap not found") + } + + cancelInfo := req.GetRouteCancel() + if cancelInfo == nil { + return nil, status.Error( + codes.InvalidArgument, "route cancellation details required", + ) + } + var terminalState swapserverrpc.ServerSwapState + switch cancelInfo.RouteType { + case swapserverrpc.RoutePaymentType_PREPAY_ROUTE: + terminalState = + swapserverrpc.ServerSwapState_SERVER_CLIENT_PREPAY_CANCEL + case swapserverrpc.RoutePaymentType_INVOICE_ROUTE: + terminalState = + swapserverrpc.ServerSwapState_SERVER_CLIENT_INVOICE_CANCEL + default: + return nil, status.Error( + codes.InvalidArgument, "invalid cancellation route type", + ) + } + + loopOut.mu.Lock() + if subtle.ConstantTimeCompare( + req.PaymentAddress, loopOut.paymentAddr[:], + ) != 1 { + + loopOut.mu.Unlock() + return nil, status.Error(codes.PermissionDenied, "invalid swap owner") + } + if loopOut.canceled { + loopOut.mu.Unlock() + return &swapserverrpc.CancelLoopOutSwapResponse{}, nil + } + if loopOut.terminal || loopOut.fundingStarted { + loopOut.mu.Unlock() + return nil, status.Error( + codes.FailedPrecondition, "HTLC publication already started", + ) + } + if loopOut.cancelRequested && loopOut.cancelState != terminalState { + loopOut.mu.Unlock() + return nil, status.Error( + codes.FailedPrecondition, + "swap cancellation already requested for another route", + ) + } + loopOut.cancelRequested = true + loopOut.cancelState = terminalState + + // Keep the lock across the idempotent invoice RPCs so concurrent retries + // cannot issue duplicate requests or overwrite acknowledgement state. + // A partial result remains retryable: only invoices without a successful + // acknowledgement are called again. + var cancelErr error + if !loopOut.mainCancelAck { + err := s.cfg.Lnd.Invoices.CancelInvoice(ctx, loopOut.hash) + if err == nil { + loopOut.mainCancelAck = true + } else { + cancelErr = errors.Join( + cancelErr, fmt.Errorf("cancel swap invoice: %w", err), + ) + } + } + if !loopOut.prepayCancelAck { + err := s.cfg.Lnd.Invoices.CancelInvoice(ctx, loopOut.prepayHash) + if err == nil { + loopOut.prepayCancelAck = true + } else { + cancelErr = errors.Join( + cancelErr, fmt.Errorf("cancel prepay invoice: %w", err), + ) + } + } + if cancelErr != nil { + loopOut.mu.Unlock() + return nil, status.Error(codes.Unavailable, cancelErr.Error()) + } + + loopOut.canceled = true + loopOut.state = terminalState + loopOut.terminal = true + loopOut.mu.Unlock() + + loopOut.updates.finish(terminalState) + loopOut.cancel() + + return &swapserverrpc.CancelLoopOutSwapResponse{}, nil +} + +func (s *Server) MuSig2SignSweep(ctx context.Context, + req *swapserverrpc.MuSig2SignSweepReq) ( + *swapserverrpc.MuSig2SignSweepRes, error) { + + if err := validateLoopOutProtocol(req.ProtocolVersion); err != nil { + return nil, err + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, errMuSig2Sweep() + } + if len(req.PaymentAddress) != 32 || len(req.Nonce) != musig2.PubNonceSize { + return nil, errMuSig2Sweep() + } + + loopOut := s.lookupLoopOut(hash) + if loopOut == nil { + return nil, errMuSig2Sweep() + } + + loopOut.mu.Lock() + if subtle.ConstantTimeCompare( + req.PaymentAddress, loopOut.paymentAddr[:], + ) != 1 || !loopOut.confirmed || !loopOut.mainSettled || + loopOut.state != swapserverrpc.ServerSwapState_SERVER_SUCCESS || + loopOut.fundingOutpoint == nil { + + loopOut.mu.Unlock() + return nil, errMuSig2Sweep() + } + + fundingOutpoint := *loopOut.fundingOutpoint + senderKey := loopOut.senderKey + receiverKey := loopOut.receiverKey + senderLocator := loopOut.senderLocator + htlc := loopOut.htlc + amount := loopOut.amount + loopOut.mu.Unlock() + + packet, inputIndex, prevOutputFetcher, err := validateLoopOutSweepPSBT( + req, fundingOutpoint, htlc.PkScript, amount, + ) + if err != nil { + s.cfg.Logger.Printf("reject MuSig2 sweep for %x: %v", hash[:6], err) + return nil, errMuSig2Sweep() + } + + htlcV3, ok := htlc.HtlcScript.(*swap.HtlcScriptV3) + if !ok { + return nil, errMuSig2Sweep() + } + sigHashes := txscript.NewTxSigHashes( + packet.UnsignedTx, prevOutputFetcher, + ) + sigHash, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, packet.UnsignedTx, inputIndex, + prevOutputFetcher, + ) + if err != nil { + return nil, errMuSig2Sweep() + } + + var clientNonce [musig2.PubNonceSize]byte + copy(clientNonce[:], req.Nonce) + session, err := s.cfg.Lnd.Signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, &senderLocator, + [][]byte{senderKey[:], receiverKey[:]}, + lndclient.MuSig2TaprootTweakOpt(htlcV3.RootHash[:], false), + lndclient.MuSig2NonceOpt( + [][musig2.PubNonceSize]byte{clientNonce}, + ), + ) + if err != nil { + return nil, errMuSig2Sweep() + } + if !session.HaveAllNonces { + _ = s.cfg.Lnd.Signer.MuSig2Cleanup(ctx, session.SessionID) + return nil, errMuSig2Sweep() + } + + var digest [32]byte + copy(digest[:], sigHash) + partialSignature, err := s.cfg.Lnd.Signer.MuSig2Sign( + ctx, session.SessionID, digest, true, + ) + if err != nil || len(partialSignature) != input.MuSig2PartialSigSize { + if err == nil { + err = fmt.Errorf( + "partial signature has length %d", len(partialSignature), + ) + } + s.cfg.Logger.Printf("MuSig2 sign failed for %x: %v", hash[:6], err) + return nil, errMuSig2Sweep() + } + + return &swapserverrpc.MuSig2SignSweepRes{ + Nonce: bytes.Clone(session.PublicNonce[:]), + PartialSignature: bytes.Clone(partialSignature), + }, nil +} + +func errMuSig2Sweep() error { + return status.Error(codes.PermissionDenied, "MuSig2 sweep rejected") +} + +func validateLoopOutSweepPSBT(req *swapserverrpc.MuSig2SignSweepReq, + fundingOutpoint wire.OutPoint, htlcPkScript []byte, + amount btcutil.Amount) (*psbt.Packet, int, txscript.PrevOutputFetcher, + error) { + + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(req.SweepTxPsbt), false, + ) + if err != nil { + return nil, 0, nil, fmt.Errorf("decode PSBT: %w", err) + } + tx := packet.UnsignedTx + if len(tx.TxIn) == 0 || len(tx.TxOut) == 0 || + len(tx.TxIn) != len(packet.Inputs) { + + return nil, 0, nil, errors.New("invalid PSBT shape") + } + + selectedInput := -1 + for index := range tx.TxIn { + witnessUtxo := packet.Inputs[index].WitnessUtxo + if witnessUtxo == nil || witnessUtxo.Value <= 0 { + return nil, 0, nil, fmt.Errorf( + "input %d missing valid witness UTXO", index, + ) + } + if tx.TxIn[index].PreviousOutPoint == fundingOutpoint && + witnessUtxo.Value == int64(amount) && + bytes.Equal(witnessUtxo.PkScript, htlcPkScript) { + + if selectedInput != -1 { + return nil, 0, nil, errors.New("duplicate HTLC input") + } + selectedInput = index + } + } + if selectedInput == -1 { + return nil, 0, nil, errors.New("recorded HTLC input not found") + } + + if len(req.PrevoutInfo) == 0 { + if len(tx.TxIn) != 1 { + return nil, 0, nil, errors.New( + "multi-input sweep requires every prevout", + ) + } + prevOut := packet.Inputs[selectedInput].WitnessUtxo + + return packet, selectedInput, + txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ), nil + } + + if len(req.PrevoutInfo) != len(tx.TxIn) { + return nil, 0, nil, errors.New("prevout count mismatch") + } + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(req.PrevoutInfo)) + for _, rpcPrevOut := range req.PrevoutInfo { + txid, err := chainhash.NewHash(rpcPrevOut.TxidBytes) + if err != nil || rpcPrevOut.Value > math.MaxInt64 { + return nil, 0, nil, errors.New("invalid prevout") + } + outpoint := wire.OutPoint{ + Hash: *txid, + Index: rpcPrevOut.OutputIndex, + } + if _, duplicate := prevOuts[outpoint]; duplicate { + return nil, 0, nil, errors.New("duplicate prevout") + } + prevOuts[outpoint] = &wire.TxOut{ + Value: int64(rpcPrevOut.Value), + PkScript: bytes.Clone(rpcPrevOut.PkScript), + } + } + if len(prevOuts) != len(tx.TxIn) { + return nil, 0, nil, errors.New("prevout map mismatch") + } + for index, txIn := range tx.TxIn { + prevOut, ok := prevOuts[txIn.PreviousOutPoint] + if !ok { + return nil, 0, nil, errors.New("missing transaction prevout") + } + witnessUtxo := packet.Inputs[index].WitnessUtxo + if witnessUtxo.Value != prevOut.Value || + !bytes.Equal(witnessUtxo.PkScript, prevOut.PkScript) { + + return nil, 0, nil, errors.New("PSBT prevout mismatch") + } + } + + return packet, selectedInput, + txscript.NewMultiPrevOutFetcher(prevOuts), nil +} diff --git a/regtest/server/loopout_test.go b/regtest/server/loopout_test.go new file mode 100644 index 000000000..c2a194c78 --- /dev/null +++ b/regtest/server/loopout_test.go @@ -0,0 +1,1215 @@ +package server + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" + invpkg "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const loopOutTestHeight = int32(600) + +type loopOutTestInvoiceSubscription struct { + updates chan lndclient.InvoiceUpdate + errors chan error +} + +type loopOutTestInvoices struct { + lndclient.InvoicesClient + + mu sync.Mutex + invoiceKey *btcec.PrivateKey + addRequests []*invoicesrpc.AddInvoiceData + subscriptions map[lntypes.Hash]*loopOutTestInvoiceSubscription + settled map[lntypes.Hash]struct{} + cancelErrors map[lntypes.Hash][]error + cancelCalls map[lntypes.Hash]int + + subscribed chan lntypes.Hash + settles chan lntypes.Preimage + cancels chan lntypes.Hash +} + +func newLoopOutTestInvoices(t *testing.T) *loopOutTestInvoices { + t.Helper() + + privateKey, _ := btcec.PrivKeyFromBytes([]byte{1}) + return &loopOutTestInvoices{ + invoiceKey: privateKey, + subscriptions: make( + map[lntypes.Hash]*loopOutTestInvoiceSubscription, + ), + settled: make(map[lntypes.Hash]struct{}), + cancelErrors: make(map[lntypes.Hash][]error), + cancelCalls: make(map[lntypes.Hash]int), + subscribed: make(chan lntypes.Hash, 4), + settles: make(chan lntypes.Preimage, 4), + cancels: make(chan lntypes.Hash, 8), + } +} + +func (i *loopOutTestInvoices) AddHoldInvoice(_ context.Context, + request *invoicesrpc.AddInvoiceData) (string, error) { + + i.mu.Lock() + requestCopy := *request + hashCopy := *request.Hash + requestCopy.Hash = &hashCopy + i.addRequests = append(i.addRequests, &requestCopy) + i.mu.Unlock() + + paymentAddr := sha256.Sum256(append( + []byte("payment-address"), request.Hash[:]..., + )) + invoice, err := zpay32.NewInvoice( + &chaincfg.RegressionNetParams, *request.Hash, time.Now(), + zpay32.Description(request.Memo), + zpay32.Amount(request.Value), + zpay32.CLTVExpiry(request.CltvExpiry), + zpay32.PaymentAddr(paymentAddr), + ) + if err != nil { + return "", err + } + + return invoice.Encode(zpay32.MessageSigner{ + SignCompact: func(digest []byte) ([]byte, error) { + return ecdsa.SignCompact(i.invoiceKey, digest, true), nil + }, + }) +} + +func (i *loopOutTestInvoices) SubscribeSingleInvoice(_ context.Context, + hash lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) { + + subscription := &loopOutTestInvoiceSubscription{ + updates: make(chan lndclient.InvoiceUpdate, 4), + errors: make(chan error, 1), + } + i.mu.Lock() + i.subscriptions[hash] = subscription + i.mu.Unlock() + i.subscribed <- hash + + return subscription.updates, subscription.errors, nil +} + +func (i *loopOutTestInvoices) SettleInvoice(_ context.Context, + preimage lntypes.Preimage) error { + + i.mu.Lock() + if _, ok := i.settled[preimage.Hash()]; ok { + i.mu.Unlock() + return status.Error(codes.AlreadyExists, "invoice already settled") + } + i.settled[preimage.Hash()] = struct{}{} + i.mu.Unlock() + i.settles <- preimage + + return nil +} + +func (i *loopOutTestInvoices) CancelInvoice(_ context.Context, + hash lntypes.Hash) error { + + i.mu.Lock() + i.cancelCalls[hash]++ + var cancelErr error + if queued := i.cancelErrors[hash]; len(queued) != 0 { + cancelErr = queued[0] + i.cancelErrors[hash] = queued[1:] + } + i.mu.Unlock() + i.cancels <- hash + + return cancelErr +} + +func (i *loopOutTestInvoices) setCancelErrors(hash lntypes.Hash, + errors ...error) { + + i.mu.Lock() + i.cancelErrors[hash] = append([]error(nil), errors...) + i.mu.Unlock() +} + +func (i *loopOutTestInvoices) cancelCount(hash lntypes.Hash) int { + i.mu.Lock() + defer i.mu.Unlock() + + return i.cancelCalls[hash] +} + +func (i *loopOutTestInvoices) sendState(t *testing.T, hash lntypes.Hash, + state invpkg.ContractState) { + + t.Helper() + i.mu.Lock() + subscription := i.subscriptions[hash] + i.mu.Unlock() + require.NotNil(t, subscription) + subscription.updates <- lndclient.InvoiceUpdate{ + Invoice: lndclient.Invoice{ + Hash: hash, + State: state, + }, + } +} + +func (i *loopOutTestInvoices) addCount() int { + i.mu.Lock() + defer i.mu.Unlock() + + return len(i.addRequests) +} + +type loopOutTestLightning struct { + lndclient.LightningClient + + height int32 + pubKey [33]byte +} + +func (l *loopOutTestLightning) GetInfo(context.Context) (*lndclient.Info, + error) { + + return &lndclient.Info{ + BlockHeight: uint32(l.height), + IdentityPubkey: l.pubKey, + }, nil +} + +type loopOutTestWallet struct { + lndclient.WalletKitClient + + mu sync.Mutex + keyIndex uint32 + sendErr error + estimateUntilContextDone bool + attempts chan *wire.MsgTx + sent chan *wire.MsgTx +} + +func (w *loopOutTestWallet) DeriveNextKey(_ context.Context, + family int32) (*keychain.KeyDescriptor, error) { + + w.mu.Lock() + w.keyIndex++ + index := w.keyIndex + w.mu.Unlock() + + keyMaterial := make([]byte, 32) + keyMaterial[28] = byte(index >> 24) + keyMaterial[29] = byte(index >> 16) + keyMaterial[30] = byte(index >> 8) + keyMaterial[31] = byte(index) + privateKey, publicKey := btcec.PrivKeyFromBytes(keyMaterial) + _ = privateKey + + return &keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(family), + Index: index, + }, + PubKey: publicKey, + }, nil +} + +func (w *loopOutTestWallet) EstimateFeeRate(ctx context.Context, + _ int32) (chainfee.SatPerKWeight, error) { + + w.mu.Lock() + waitForContext := w.estimateUntilContextDone + w.mu.Unlock() + if waitForContext { + <-ctx.Done() + return 0, ctx.Err() + } + + return chainfee.SatPerKWeight(1_000), nil +} + +func (w *loopOutTestWallet) SendOutputs(_ context.Context, + outputs []*wire.TxOut, _ chainfee.SatPerKWeight, + _ string) (*wire.MsgTx, error) { + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + }) + for _, output := range outputs { + tx.AddTxOut(&wire.TxOut{ + Value: output.Value, + PkScript: bytes.Clone(output.PkScript), + }) + } + w.attempts <- tx.Copy() + w.mu.Lock() + sendErr := w.sendErr + w.mu.Unlock() + if sendErr != nil { + return nil, sendErr + } + w.sent <- tx.Copy() + + return tx, nil +} + +func (w *loopOutTestWallet) failSend(err error) { + w.mu.Lock() + w.sendErr = err + w.mu.Unlock() +} + +func (w *loopOutTestWallet) waitForEstimateDeadline() { + w.mu.Lock() + w.estimateUntilContextDone = true + w.mu.Unlock() +} + +type loopOutTestConfRegistration struct { + txid *chainhash.Hash + pkScript []byte + confirmations int32 + heightHint int32 + confirmed chan *chainntnfs.TxConfirmation + errors chan error +} + +type loopOutTestSpendRegistration struct { + outpoint *wire.OutPoint + pkScript []byte + heightHint int32 + spends chan *chainntnfs.SpendDetail + errors chan error +} + +type loopOutTestNotifier struct { + lndclient.ChainNotifierClient + + registrations chan *loopOutTestConfRegistration + spendRegistrations chan *loopOutTestSpendRegistration +} + +func (n *loopOutTestNotifier) RegisterConfirmationsNtfn(_ context.Context, + txid *chainhash.Hash, pkScript []byte, confirmations, + heightHint int32, _ ...lndclient.NotifierOption) ( + chan *chainntnfs.TxConfirmation, chan error, error) { + + registration := &loopOutTestConfRegistration{ + pkScript: bytes.Clone(pkScript), + confirmations: confirmations, + heightHint: heightHint, + confirmed: make(chan *chainntnfs.TxConfirmation, 1), + errors: make(chan error, 1), + } + if txid != nil { + registration.txid = cloneHash(*txid) + } + n.registrations <- registration + + return registration.confirmed, registration.errors, nil +} + +func (n *loopOutTestNotifier) RegisterSpendNtfn(_ context.Context, + outpoint *wire.OutPoint, pkScript []byte, heightHint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail, chan error, + error) { + + registration := &loopOutTestSpendRegistration{ + pkScript: bytes.Clone(pkScript), + heightHint: heightHint, + spends: make(chan *chainntnfs.SpendDetail, 1), + errors: make(chan error, 1), + } + if outpoint != nil { + copyOutpoint := *outpoint + registration.outpoint = ©Outpoint + } + n.spendRegistrations <- registration + + return registration.spends, registration.errors, nil +} + +type loopOutTestMuSigCall struct { + version input.MuSig2Version + locator keychain.KeyLocator + signers [][]byte +} + +type loopOutTestSigner struct { + lndclient.SignerClient + + mu sync.Mutex + createCalls []loopOutTestMuSigCall + signed chan [32]byte +} + +func (s *loopOutTestSigner) MuSig2CreateSession(_ context.Context, + version input.MuSig2Version, locator *keychain.KeyLocator, + signers [][]byte, _ ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) { + + signerCopies := make([][]byte, len(signers)) + for index := range signers { + signerCopies[index] = bytes.Clone(signers[index]) + } + s.mu.Lock() + s.createCalls = append(s.createCalls, loopOutTestMuSigCall{ + version: version, + locator: *locator, + signers: signerCopies, + }) + s.mu.Unlock() + + var publicNonce [musig2.PubNonceSize]byte + publicNonce[0] = 2 + return &input.MuSig2SessionInfo{ + SessionID: [32]byte{1}, + Version: version, + PublicNonce: publicNonce, + HaveAllNonces: true, + }, nil +} + +func (s *loopOutTestSigner) MuSig2Sign(_ context.Context, _ [32]byte, + digest [32]byte, _ bool) ([]byte, error) { + + s.signed <- digest + return make([]byte, input.MuSig2PartialSigSize), nil +} + +func (s *loopOutTestSigner) MuSig2Cleanup(context.Context, [32]byte) error { + return nil +} + +func (s *loopOutTestSigner) createCount() int { + s.mu.Lock() + defer s.mu.Unlock() + + return len(s.createCalls) +} + +type loopOutTestBitcoin struct{} + +func (loopOutTestBitcoin) GetTxOut(*chainhash.Hash, uint32, + bool) (*btcjson.GetTxOutResult, error) { + + return nil, nil +} + +func (loopOutTestBitcoin) GetRawTransaction( + *chainhash.Hash) (*btcutil.Tx, error) { + + return nil, nil +} + +type loopOutTestHarness struct { + server *Server + invoices *loopOutTestInvoices + wallet *loopOutTestWallet + notifier *loopOutTestNotifier + signer *loopOutTestSigner +} + +func newLoopOutTestHarness(t *testing.T) *loopOutTestHarness { + t.Helper() + + _, identityKey := btcec.PrivKeyFromBytes([]byte{9}) + var identity [33]byte + copy(identity[:], identityKey.SerializeCompressed()) + + invoices := newLoopOutTestInvoices(t) + wallet := &loopOutTestWallet{ + attempts: make(chan *wire.MsgTx, 4), + sent: make(chan *wire.MsgTx, 2), + } + notifier := &loopOutTestNotifier{ + registrations: make(chan *loopOutTestConfRegistration, 2), + spendRegistrations: make(chan *loopOutTestSpendRegistration, 2), + } + signer := &loopOutTestSigner{ + signed: make(chan [32]byte, 2), + } + lnd := &lndclient.LndServices{ + Client: &loopOutTestLightning{ + height: loopOutTestHeight, + pubKey: identity, + }, + WalletKit: wallet, + ChainNotifier: notifier, + Signer: signer, + Invoices: invoices, + ChainParams: &chaincfg.RegressionNetParams, + NodePubkey: identity, + } + server, err := New(context.Background(), Config{ + Lnd: lnd, + Bitcoin: loopOutTestBitcoin{}, + }) + require.NoError(t, err) + + return &loopOutTestHarness{ + server: server, + invoices: invoices, + wallet: wallet, + notifier: notifier, + signer: signer, + } +} + +func loopOutTestRequest(t *testing.T) (*swapserverrpc.ServerLoopOutRequest, + lntypes.Preimage) { + + t.Helper() + var preimage lntypes.Preimage + preimage[0] = 42 + hash := preimage.Hash() + _, receiverPubKey := btcec.PrivKeyFromBytes([]byte{7}) + + return &swapserverrpc.ServerLoopOutRequest{ + ReceiverKey: receiverPubKey.SerializeCompressed(), + SwapHash: hash[:], + Amt: 500_000, + SwapPublicationDeadline: time.Now().Add(time.Minute).Unix(), + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Expiry: loopOutTestHeight + 40, + UserAgent: "loopd/test", + }, preimage +} + +func receiveWithTimeout[T any](t *testing.T, channel <-chan T) T { + t.Helper() + + select { + case value := <-channel: + return value + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for test event") + var zero T + return zero + } +} + +func TestLoopOutFullHappyPathAndMuSig2(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + terms, err := harness.server.LoopOutTerms( + context.Background(), &swapserverrpc.ServerLoopOutTermsRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + }, + ) + require.NoError(t, err) + require.Equal(t, uint64(defaultMinSwapAmount), terms.MinSwapAmount) + require.Equal(t, uint64(defaultMaxSwapAmount), terms.MaxSwapAmount) + + quote, err := harness.server.LoopOutQuote( + context.Background(), &swapserverrpc.ServerLoopOutQuoteRequest{ + Amt: 500_000, + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Expiry: loopOutTestHeight + 40, + }, + ) + require.NoError(t, err) + require.Equal(t, int64(600), quote.SwapFee) + require.Len(t, quote.SwapPaymentDest, 66) + + request, preimage := loopOutTestRequest(t) + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + require.Len(t, response.SenderKey, 33) + require.Equal(t, 2, harness.invoices.addCount()) + + mainInvoice, err := zpay32.Decode( + response.SwapInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + require.Equal(t, [32]byte(preimage.Hash()), *mainInvoice.PaymentHash) + require.Equal(t, int64(500_500_000), int64(*mainInvoice.MilliSat)) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + require.Equal(t, int64(100_000), int64(*prepayInvoice.MilliSat)) + + duplicate, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + require.Equal(t, response, duplicate) + require.Equal(t, 2, harness.invoices.addCount()) + + hash := preimage.Hash() + resume, err := harness.server.NewLoopOutSwap( + context.Background(), &swapserverrpc.ServerLoopOutRequest{ + SwapHash: hash[:], + UserAgent: "resume_swap", + }, + ) + require.NoError(t, err) + require.Equal(t, response, resume) + require.Equal(t, 2, harness.invoices.addCount()) + + subscribed := map[lntypes.Hash]bool{} + for range 2 { + subscribed[receiveWithTimeout(t, harness.invoices.subscribed)] = true + } + require.True(t, subscribed[hash]) + require.True(t, subscribed[*prepayInvoice.PaymentHash]) + + // A preimage cannot settle the main invoice before the exact HTLC is + // confirmed. + _, err = harness.server.LoopOutPushPreimage( + context.Background(), &swapserverrpc.ServerLoopOutPushPreimageRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Preimage: preimage[:], + }, + ) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + + harness.invoices.sendState(t, hash, invpkg.ContractAccepted) + select { + case <-harness.wallet.sent: + t.Fatal("HTLC published before prepay invoice was accepted") + case <-time.After(50 * time.Millisecond): + } + harness.invoices.sendState( + t, *prepayInvoice.PaymentHash, invpkg.ContractAccepted, + ) + + fundingTx := receiveWithTimeout(t, harness.wallet.sent) + require.Len(t, fundingTx.TxOut, 1) + require.Equal(t, int64(500_000), fundingTx.TxOut[0].Value) + + loopOut := harness.server.lookupLoopOut(hash) + require.NotNil(t, loopOut) + require.Equal(t, loopOut.htlc.PkScript, fundingTx.TxOut[0].PkScript) + + confirmation := receiveWithTimeout(t, harness.notifier.registrations) + require.Equal(t, fundingTx.TxHash(), *confirmation.txid) + require.Equal(t, int32(1), confirmation.confirmations) + require.Equal(t, loopOutTestHeight, confirmation.heightHint) + confirmation.confirmed <- &chainntnfs.TxConfirmation{ + Tx: fundingTx, + BlockHeight: uint32(loopOutTestHeight + 1), + } + + prepaySettle := receiveWithTimeout(t, harness.invoices.settles) + settledPrepayHash := prepaySettle.Hash() + require.Equal(t, prepayInvoice.PaymentHash[:], settledPrepayHash[:]) + + _, err = harness.server.LoopOutPushPreimage( + context.Background(), &swapserverrpc.ServerLoopOutPushPreimageRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Preimage: preimage[:], + }, + ) + require.NoError(t, err) + require.Equal(t, preimage, receiveWithTimeout(t, harness.invoices.settles)) + + // A duplicate preimage push is an idempotent acknowledgement. + _, err = harness.server.LoopOutPushPreimage( + context.Background(), &swapserverrpc.ServerLoopOutPushPreimageRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Preimage: preimage[:], + }, + ) + require.NoError(t, err) + + loopOut.mu.Lock() + fundingOutpoint := *loopOut.fundingOutpoint + loopOut.mu.Unlock() + + sweepTx := wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{PreviousOutPoint: fundingOutpoint}) + sweepTx.AddTxOut(&wire.TxOut{ + Value: 499_000, + PkScript: []byte{0x51}, + }) + packet, err := psbt.NewFromUnsignedTx(sweepTx) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 500_000, + PkScript: bytes.Clone(loopOut.htlc.PkScript), + } + var packetBytes bytes.Buffer + require.NoError(t, packet.Serialize(&packetBytes)) + + paymentAddr, err := mainInvoice.PaymentAddr.UnwrapOrErr( + context.Canceled, + ) + require.NoError(t, err) + clientNonce := make([]byte, musig2.PubNonceSize) + clientNonce[0] = 3 + + badPacket, err := psbt.NewFromUnsignedTx(sweepTx) + require.NoError(t, err) + badPacket.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 500_000, + PkScript: []byte{0x51}, + } + var badPacketBytes bytes.Buffer + require.NoError(t, badPacket.Serialize(&badPacketBytes)) + _, err = harness.server.MuSig2SignSweep( + context.Background(), &swapserverrpc.MuSig2SignSweepReq{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + PaymentAddress: paymentAddr[:], + Nonce: clientNonce, + SweepTxPsbt: badPacketBytes.Bytes(), + }, + ) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Zero(t, harness.signer.createCount()) + + signature, err := harness.server.MuSig2SignSweep( + context.Background(), &swapserverrpc.MuSig2SignSweepReq{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + PaymentAddress: paymentAddr[:], + Nonce: clientNonce, + SweepTxPsbt: packetBytes.Bytes(), + }, + ) + require.NoError(t, err) + require.Len(t, signature.Nonce, musig2.PubNonceSize) + require.Len(t, signature.PartialSignature, input.MuSig2PartialSigSize) + require.NotEqual(t, [32]byte{}, receiveWithTimeout(t, harness.signer.signed)) + require.Equal(t, 1, harness.signer.createCount()) + + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + require.True(t, subscription.done) + require.Equal(t, []swapserverrpc.ServerSwapState{ + swapserverrpc.ServerSwapState_SERVER_INITIATED, + swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED, + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, + swapserverrpc.ServerSwapState_SERVER_SUCCESS, + }, loopOutUpdateStates(subscription.history)) +} + +func TestLoopOutAmbiguousBroadcastReconciles(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + harness.wallet.failSend(errors.New("response lost after broadcast")) + + request, preimage := loopOutTestRequest(t) + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + hash := preimage.Hash() + for range 2 { + receiveWithTimeout(t, harness.invoices.subscribed) + } + harness.invoices.sendState(t, hash, invpkg.ContractAccepted) + harness.invoices.sendState( + t, *prepayInvoice.PaymentHash, invpkg.ContractAccepted, + ) + + // SendOutputs returned an error, but the transaction may already have + // been broadcast. The server must retain both accepted invoices and use + // a script-only confirmation to reconcile the ambiguous result. + fundingTx := receiveWithTimeout(t, harness.wallet.attempts) + confirmation := receiveWithTimeout(t, harness.notifier.registrations) + require.Nil(t, confirmation.txid) + select { + case canceled := <-harness.invoices.cancels: + t.Fatalf("invoice %x canceled after ambiguous broadcast", canceled) + case <-time.After(50 * time.Millisecond): + } + + confirmation.confirmed <- &chainntnfs.TxConfirmation{ + Tx: fundingTx, + BlockHeight: uint32(loopOutTestHeight + 1), + } + prepaySettle := receiveWithTimeout(t, harness.invoices.settles) + settledPrepayHash := prepaySettle.Hash() + require.Equal(t, prepayInvoice.PaymentHash[:], settledPrepayHash[:]) + + _, err = harness.server.LoopOutPushPreimage( + context.Background(), &swapserverrpc.ServerLoopOutPushPreimageRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Preimage: preimage[:], + }, + ) + require.NoError(t, err) + require.Equal(t, preimage, receiveWithTimeout(t, harness.invoices.settles)) + require.Zero(t, harness.invoices.cancelCount(hash)) + require.Zero(t, harness.invoices.cancelCount(*prepayInvoice.PaymentHash)) + + loopOut := harness.server.lookupLoopOut(hash) + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + require.True(t, subscription.done) + require.Equal(t, []swapserverrpc.ServerSwapState{ + swapserverrpc.ServerSwapState_SERVER_INITIATED, + swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED, + swapserverrpc.ServerSwapState_SERVER_HTLC_CONFIRMED, + swapserverrpc.ServerSwapState_SERVER_SUCCESS, + }, loopOutUpdateStates(subscription.history)) +} + +func TestLoopOutRecoversPreimageFromSuccessSpend(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + request, preimage := loopOutTestRequest(t) + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + hash := preimage.Hash() + for range 2 { + receiveWithTimeout(t, harness.invoices.subscribed) + } + harness.invoices.sendState(t, hash, invpkg.ContractAccepted) + harness.invoices.sendState( + t, *prepayInvoice.PaymentHash, invpkg.ContractAccepted, + ) + + fundingTx := receiveWithTimeout(t, harness.wallet.sent) + loopOut := harness.server.lookupLoopOut(hash) + confirmation := receiveWithTimeout(t, harness.notifier.registrations) + confirmation.confirmed <- &chainntnfs.TxConfirmation{ + Tx: fundingTx, + BlockHeight: uint32(loopOutTestHeight + 1), + } + prepaySettle := receiveWithTimeout(t, harness.invoices.settles) + settledPrepayHash := prepaySettle.Hash() + require.Equal(t, prepayInvoice.PaymentHash[:], settledPrepayHash[:]) + + spendRegistration := receiveWithTimeout( + t, harness.notifier.spendRegistrations, + ) + require.NotNil(t, spendRegistration.outpoint) + require.Equal(t, loopOutTestHeight, spendRegistration.heightHint) + require.Equal(t, loopOut.htlc.PkScript, spendRegistration.pkScript) + + sweepTx := loopOutTestSuccessSweep(t, loopOut, preimage) + spendHash := sweepTx.TxHash() + spend := &chainntnfs.SpendDetail{ + SpentOutPoint: spendRegistration.outpoint, + SpenderTxHash: &spendHash, + SpendingTx: sweepTx, + SpenderInputIndex: 0, + SpendingHeight: loopOutTestHeight + 2, + } + + // A matching preimage alone isn't enough: the revealed script and its + // complete witness must spend the exact committed success path. + wrongScriptSpend := *spend + wrongScriptSpend.SpendingTx = sweepTx.Copy() + wrongScriptSpend.SpendingTx.TxIn[0].Witness[2] = []byte{txscript.OP_TRUE} + _, err = harness.server.validateLoopOutSuccessSpend( + loopOut, &wrongScriptSpend, + ) + require.Error(t, err) + + wrongPreimageSpend := *spend + wrongPreimageSpend.SpendingTx = sweepTx.Copy() + wrongPreimageSpend.SpendingTx.TxIn[0].Witness[0] = make([]byte, 32) + _, err = harness.server.validateLoopOutSuccessSpend( + loopOut, &wrongPreimageSpend, + ) + require.Error(t, err) + + // Never call LoopOutPushPreimage. The valid unilateral spend is the only + // delivery mechanism for the main invoice preimage. + spendRegistration.spends <- spend + require.Equal(t, preimage, receiveWithTimeout(t, harness.invoices.settles)) + assertLoopOutTerminalState( + t, loopOut, swapserverrpc.ServerSwapState_SERVER_SUCCESS, + ) + + // A late best-effort push observes the already completed result without + // attempting another invoice settlement. + _, err = harness.server.LoopOutPushPreimage( + context.Background(), &swapserverrpc.ServerLoopOutPushPreimageRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + Preimage: preimage[:], + }, + ) + require.NoError(t, err) + select { + case duplicate := <-harness.invoices.settles: + t.Fatalf("duplicate settlement with preimage %x", duplicate) + case <-time.After(50 * time.Millisecond): + } +} + +func TestLoopOutCancellationRetriesPartialAcknowledgement(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + request, preimage := loopOutTestRequest(t) + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + mainInvoice, err := zpay32.Decode( + response.SwapInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + paymentAddr, err := mainInvoice.PaymentAddr.UnwrapOrErr(context.Canceled) + require.NoError(t, err) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + hash := preimage.Hash() + prepayHash := lntypes.Hash(*prepayInvoice.PaymentHash) + harness.invoices.setCancelErrors( + prepayHash, errors.New("temporary cancellation failure"), + ) + cancelRequest := &swapserverrpc.CancelLoopOutSwapRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + PaymentAddress: paymentAddr[:], + CancelInfo: &swapserverrpc.CancelLoopOutSwapRequest_RouteCancel{ + RouteCancel: &swapserverrpc.RouteCancel{ + RouteType: swapserverrpc.RoutePaymentType_INVOICE_ROUTE, + }, + }, + } + + _, err = harness.server.CancelLoopOutSwap( + context.Background(), cancelRequest, + ) + require.Equal(t, codes.Unavailable, status.Code(err)) + firstCalls := map[lntypes.Hash]bool{} + for range 2 { + firstCalls[receiveWithTimeout(t, harness.invoices.cancels)] = true + } + require.True(t, firstCalls[hash]) + require.True(t, firstCalls[prepayHash]) + + loopOut := harness.server.lookupLoopOut(hash) + loopOut.mu.Lock() + require.True(t, loopOut.cancelRequested) + require.True(t, loopOut.mainCancelAck) + require.False(t, loopOut.prepayCancelAck) + require.False(t, loopOut.canceled) + require.False(t, loopOut.terminal) + loopOut.mu.Unlock() + + _, err = harness.server.CancelLoopOutSwap( + context.Background(), cancelRequest, + ) + require.NoError(t, err) + require.Equal(t, prepayHash, receiveWithTimeout(t, harness.invoices.cancels)) + require.Equal(t, 1, harness.invoices.cancelCount(hash)) + require.Equal(t, 2, harness.invoices.cancelCount(prepayHash)) + + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + require.True(t, subscription.done) + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_CLIENT_INVOICE_CANCEL, + subscription.history[len(subscription.history)-1].state, + ) +} + +func TestLoopOutPublicationDeadline(t *testing.T) { + fastDeadlines := map[string]int64{ + "cli-now": time.Now().Unix(), + "zero-time": time.Time{}.Unix(), + } + for name, deadline := range fastDeadlines { + t.Run(name, func(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + request, preimage := loopOutTestRequest(t) + request.SwapPublicationDeadline = deadline + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + for range 2 { + receiveWithTimeout(t, harness.invoices.subscribed) + } + + loopOut := harness.server.lookupLoopOut(preimage.Hash()) + require.True(t, loopOut.publicationDeadline.IsZero()) + select { + case canceled := <-harness.invoices.cancels: + t.Fatalf("fast swap invoice %x canceled", canceled) + case <-time.After(25 * time.Millisecond): + } + + harness.invoices.sendState( + t, preimage.Hash(), invpkg.ContractAccepted, + ) + harness.invoices.sendState( + t, *prepayInvoice.PaymentHash, + invpkg.ContractAccepted, + ) + receiveWithTimeout(t, harness.wallet.sent) + }) + } + + t.Run("expires-while-waiting-for-invoices", func(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + request, preimage := loopOutTestRequest(t) + request.SwapPublicationDeadline = time.Now().Add(2 * time.Second).Unix() + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + for range 2 { + receiveWithTimeout(t, harness.invoices.subscribed) + } + + canceled := map[lntypes.Hash]bool{} + for range 2 { + canceled[receiveWithTimeout(t, harness.invoices.cancels)] = true + } + require.True(t, canceled[preimage.Hash()]) + require.True(t, canceled[*prepayInvoice.PaymentHash]) + select { + case <-harness.wallet.attempts: + t.Fatal("funding attempted after invoice-wait deadline") + default: + } + assertLoopOutTerminalState( + t, harness.server.lookupLoopOut(preimage.Hash()), + swapserverrpc.ServerSwapState_SERVER_FAILED_HTLC_PUBLICATION, + ) + }) + + t.Run("expires-during-fee-estimation", func(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + harness.wallet.waitForEstimateDeadline() + + request, preimage := loopOutTestRequest(t) + request.SwapPublicationDeadline = time.Now().Add(2 * time.Second).Unix() + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + for range 2 { + receiveWithTimeout(t, harness.invoices.subscribed) + } + harness.invoices.sendState( + t, preimage.Hash(), invpkg.ContractAccepted, + ) + harness.invoices.sendState( + t, *prepayInvoice.PaymentHash, invpkg.ContractAccepted, + ) + + for range 2 { + receiveWithTimeout(t, harness.invoices.cancels) + } + select { + case <-harness.wallet.attempts: + t.Fatal("SendOutputs called after fee-estimation deadline") + default: + } + assertLoopOutTerminalState( + t, harness.server.lookupLoopOut(preimage.Hash()), + swapserverrpc.ServerSwapState_SERVER_FAILED_HTLC_PUBLICATION, + ) + }) +} + +func assertLoopOutTerminalState(t *testing.T, loopOut *loopOutSwap, + want swapserverrpc.ServerSwapState) { + + t.Helper() + require.Eventually(t, func() bool { + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + if !subscription.done || len(subscription.history) == 0 { + return false + } + + return subscription.history[len(subscription.history)-1].state == want + }, time.Second, 10*time.Millisecond) + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + require.True(t, subscription.done) + require.Equal(t, want, + subscription.history[len(subscription.history)-1].state, + ) +} + +func TestLoopOutCancelOwnershipAndGating(t *testing.T) { + harness := newLoopOutTestHarness(t) + defer harness.server.Stop() + + request, preimage := loopOutTestRequest(t) + response, err := harness.server.NewLoopOutSwap( + context.Background(), request, + ) + require.NoError(t, err) + + mainInvoice, err := zpay32.Decode( + response.SwapInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + paymentAddr, err := mainInvoice.PaymentAddr.UnwrapOrErr( + context.Canceled, + ) + require.NoError(t, err) + + hash := preimage.Hash() + badPaymentAddr := paymentAddr + badPaymentAddr[0] ^= 1 + _, err = harness.server.CancelLoopOutSwap( + context.Background(), &swapserverrpc.CancelLoopOutSwapRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + PaymentAddress: badPaymentAddr[:], + CancelInfo: &swapserverrpc.CancelLoopOutSwapRequest_RouteCancel{ + RouteCancel: &swapserverrpc.RouteCancel{ + RouteType: swapserverrpc.RoutePaymentType_INVOICE_ROUTE, + }, + }, + }, + ) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + + _, err = harness.server.CancelLoopOutSwap( + context.Background(), &swapserverrpc.CancelLoopOutSwapRequest{ + ProtocolVersion: swapserverrpc.ProtocolVersion_MUSIG2, + SwapHash: hash[:], + PaymentAddress: paymentAddr[:], + CancelInfo: &swapserverrpc.CancelLoopOutSwapRequest_RouteCancel{ + RouteCancel: &swapserverrpc.RouteCancel{ + RouteType: swapserverrpc.RoutePaymentType_INVOICE_ROUTE, + }, + }, + }, + ) + require.NoError(t, err) + + canceled := map[lntypes.Hash]bool{} + for range 2 { + canceled[receiveWithTimeout(t, harness.invoices.cancels)] = true + } + require.True(t, canceled[hash]) + prepayInvoice, err := zpay32.Decode( + response.PrepayInvoice, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + require.True(t, canceled[*prepayInvoice.PaymentHash]) + + loopOut := harness.server.lookupLoopOut(hash) + subscription := loopOut.updates.subscribe() + defer subscription.cancel() + require.True(t, subscription.done) + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_CLIENT_INVOICE_CANCEL, + subscription.history[len(subscription.history)-1].state, + ) +} + +func loopOutUpdateStates(updates []serverUpdate) []swapserverrpc.ServerSwapState { + states := make([]swapserverrpc.ServerSwapState, len(updates)) + for index := range updates { + states[index] = updates[index].state + } + + return states +} + +func loopOutTestSuccessSweep(t *testing.T, loopOut *loopOutSwap, + preimage lntypes.Preimage) *wire.MsgTx { + + t.Helper() + loopOut.mu.Lock() + require.NotNil(t, loopOut.fundingOutpoint) + outpoint := *loopOut.fundingOutpoint + amount := loopOut.amount + htlc := loopOut.htlc + loopOut.mu.Unlock() + + sweepTx := wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + Sequence: htlc.SuccessSequence(), + }) + sweepTx.AddTxOut(&wire.TxOut{ + Value: int64(amount - 1_000), + PkScript: []byte{txscript.OP_TRUE}, + }) + prevOutFetcher := txscript.NewCannedPrevOutputFetcher( + htlc.PkScript, int64(amount), + ) + sigHashes := txscript.NewTxSigHashes(sweepTx, prevOutFetcher) + htlcV3, ok := htlc.HtlcScript.(*swap.HtlcScriptV3) + require.True(t, ok) + receiverPrivateKey, _ := btcec.PrivKeyFromBytes([]byte{7}) + signature, err := txscript.RawTxInTapscriptSignature( + sweepTx, sigHashes, 0, int64(amount), htlc.PkScript, + txscript.NewBaseTapLeaf(htlcV3.SuccessScript()), htlc.SigHash(), + receiverPrivateKey, + ) + require.NoError(t, err) + sweepTx.TxIn[0].Witness, err = htlc.GenSuccessWitness( + signature, preimage, + ) + require.NoError(t, err) + + return sweepTx +} diff --git a/regtest/server/rpc.go b/regtest/server/rpc.go new file mode 100644 index 000000000..f9dfa1c1d --- /dev/null +++ b/regtest/server/rpc.go @@ -0,0 +1,61 @@ +package server + +import ( + "context" + + "github.com/lightninglabs/loop/swapserverrpc" +) + +// FetchL402 intentionally has no application-level payload. When this server +// is placed behind Aperture, the proxy turns this call into the L402 challenge +// that binds a static address to the regtest client. +func (s *Server) FetchL402(context.Context, + *swapserverrpc.FetchL402Request) (*swapserverrpc.FetchL402Response, error) { + + return &swapserverrpc.FetchL402Response{}, nil +} + +// RecommendRoutingPlugin keeps the demo independent from optional routing +// plugins. lnd's normal payment router is sufficient for the two-node regtest +// topology. +func (s *Server) RecommendRoutingPlugin(context.Context, + *swapserverrpc.RecommendRoutingPluginReq) ( + *swapserverrpc.RecommendRoutingPluginRes, error) { + + return &swapserverrpc.RecommendRoutingPluginRes{ + Plugin: swapserverrpc.RoutingPlugin_NONE, + }, nil +} + +func (s *Server) ReportRoutingResult(context.Context, + *swapserverrpc.ReportRoutingResultReq) ( + *swapserverrpc.ReportRoutingResultRes, error) { + + return &swapserverrpc.ReportRoutingResultRes{}, nil +} + +// SubscribeNotifications is the long-lived control stream used by the static +// address managers. Aperture authenticates the stream before it reaches us. +func (s *Server) SubscribeNotifications( + _ *swapserverrpc.SubscribeNotificationsRequest, + stream swapserverrpc.SwapServer_SubscribeNotificationsServer) error { + + updates := s.notifications.subscribe(stream.Context()) + for { + select { + case update, ok := <-updates: + if !ok { + return stream.Context().Err() + } + if err := stream.Send(update); err != nil { + return err + } + + case <-s.ctx.Done(): + return s.ctx.Err() + + case <-stream.Context().Done(): + return stream.Context().Err() + } + } +} diff --git a/regtest/server/server.go b/regtest/server/server.go new file mode 100644 index 000000000..6219cd1ce --- /dev/null +++ b/regtest/server/server.go @@ -0,0 +1,193 @@ +// Package server implements a deliberately small Loop server for regtest. +// +// Unlike the unit-test mocks in the loop package, this server creates real +// Lightning invoices, publishes real Bitcoin transactions and performs the +// MuSig2 exchange required by static-address Loop In. It is not intended for +// any public network. +package server + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/rpcclient" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lntypes" +) + +const ( + defaultMinSwapAmount = btcutil.Amount(50_000) + defaultMaxSwapAmount = btcutil.Amount(5_000_000) + + defaultLoopOutMinCltvDelta = int32(30) + defaultLoopOutMaxCltvDelta = int32(250) + defaultLoopInCltvDelta = int32(100) + minLoopInCltvDelta = int32(100) + maxLoopInCltvDelta = int32(1_500) + + defaultFeeBaseSat = btcutil.Amount(100) + defaultFeePPM = uint64(1_000) + defaultPrepaySat = btcutil.Amount(100) + + defaultStaticAddressExpiry = uint32(4_320) + defaultPaymentTimeout = time.Minute +) + +// BitcoinClient is the subset of Bitcoin Core's RPC client used by the +// regtest server. Keeping it narrow makes transaction validation testable. +type BitcoinClient interface { + GetTxOut(txHash *chainhash.Hash, index uint32, + mempool bool) (*btcjson.GetTxOutResult, error) + + GetRawTransaction(txHash *chainhash.Hash) (*btcutil.Tx, error) +} + +var _ BitcoinClient = (*rpcclient.Client)(nil) + +// Config contains the dependencies and policy knobs for a regtest server. +type Config struct { + Lnd *lndclient.LndServices + Bitcoin BitcoinClient + + MinSwapAmount btcutil.Amount + MaxSwapAmount btcutil.Amount + + LoopOutMinCltvDelta int32 + LoopOutMaxCltvDelta int32 + LoopInCltvDelta int32 + + FeeBaseSat btcutil.Amount + FeePPM uint64 + PrepaySat btcutil.Amount + + StaticAddressExpiry uint32 + PaymentTimeout time.Duration + + Logger *log.Logger +} + +// Server implements the public Loop swap and static-address services. Swap +// state is intentionally in-memory: the binary is a disposable regtest tool. +// Static address keys remain valid for the lifetime of the process. +type Server struct { + swapserverrpc.UnimplementedSwapServerServer + swapserverrpc.UnimplementedStaticAddressServerServer + + cfg Config + + ctx context.Context + cancel context.CancelFunc + + mu sync.RWMutex + loopOuts map[lntypes.Hash]*loopOutSwap + loopIns map[lntypes.Hash]*loopInSwap + staticSwaps map[lntypes.Hash]*staticLoopInSwap + addresses map[string]*staticAddress + lockedUTXOs map[string]lntypes.Hash + + notifications *notificationHub + wg sync.WaitGroup +} + +// New constructs a regtest server and applies safe demo defaults for all +// omitted policy values. +func New(parent context.Context, cfg Config) (*Server, error) { + if cfg.Lnd == nil { + return nil, errors.New("lnd services are required") + } + if cfg.Bitcoin == nil { + return nil, errors.New("bitcoin client is required") + } + if cfg.Lnd.ChainParams == nil || cfg.Lnd.ChainParams.Name != "regtest" { + return nil, fmt.Errorf("regtest chain required, got %v", + cfg.Lnd.ChainParams) + } + + if cfg.MinSwapAmount == 0 { + cfg.MinSwapAmount = defaultMinSwapAmount + } + if cfg.MaxSwapAmount == 0 { + cfg.MaxSwapAmount = defaultMaxSwapAmount + } + if cfg.MinSwapAmount <= 0 || cfg.MaxSwapAmount < cfg.MinSwapAmount { + return nil, errors.New("invalid swap amount range") + } + + if cfg.LoopOutMinCltvDelta == 0 { + cfg.LoopOutMinCltvDelta = defaultLoopOutMinCltvDelta + } + if cfg.LoopOutMaxCltvDelta == 0 { + cfg.LoopOutMaxCltvDelta = defaultLoopOutMaxCltvDelta + } + if cfg.LoopInCltvDelta == 0 { + cfg.LoopInCltvDelta = defaultLoopInCltvDelta + } + if cfg.LoopOutMinCltvDelta <= 0 || + cfg.LoopOutMaxCltvDelta < cfg.LoopOutMinCltvDelta { + + return nil, errors.New("invalid Loop Out CLTV range") + } + if cfg.LoopInCltvDelta < minLoopInCltvDelta || + cfg.LoopInCltvDelta > maxLoopInCltvDelta { + + return nil, fmt.Errorf( + "Loop In CLTV delta must be within [%d,%d]", + minLoopInCltvDelta, maxLoopInCltvDelta, + ) + } + + if cfg.FeeBaseSat == 0 { + cfg.FeeBaseSat = defaultFeeBaseSat + } + if cfg.FeePPM == 0 { + cfg.FeePPM = defaultFeePPM + } + if cfg.PrepaySat == 0 { + cfg.PrepaySat = defaultPrepaySat + } + if cfg.StaticAddressExpiry == 0 { + cfg.StaticAddressExpiry = defaultStaticAddressExpiry + } + if cfg.PaymentTimeout == 0 { + cfg.PaymentTimeout = defaultPaymentTimeout + } + if cfg.Logger == nil { + cfg.Logger = log.New(os.Stdout, "loopserver-regtest: ", + log.LstdFlags|log.Lmicroseconds) + } + + ctx, cancel := context.WithCancel(parent) + + return &Server{ + cfg: cfg, + ctx: ctx, + cancel: cancel, + loopOuts: make(map[lntypes.Hash]*loopOutSwap), + loopIns: make(map[lntypes.Hash]*loopInSwap), + staticSwaps: make(map[lntypes.Hash]*staticLoopInSwap), + addresses: make(map[string]*staticAddress), + lockedUTXOs: make(map[string]lntypes.Hash), + notifications: newNotificationHub(), + }, nil +} + +// Stop cancels all active swaps and waits for their goroutines to exit. +func (s *Server) Stop() { + s.cancel() + s.wg.Wait() +} + +func (s *Server) goSwap(run func(context.Context)) { + s.wg.Go(func() { + run(s.ctx) + }) +} diff --git a/regtest/server/server_test.go b/regtest/server/server_test.go new file mode 100644 index 000000000..ae0e291e5 --- /dev/null +++ b/regtest/server/server_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/lndclient" + "github.com/stretchr/testify/require" +) + +func TestLoopInCltvDeltaValidation(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + delta int32 + valid bool + }{ + {name: "default", delta: 0, valid: true}, + {name: "minimum", delta: minLoopInCltvDelta, valid: true}, + {name: "maximum", delta: maxLoopInCltvDelta, valid: true}, + {name: "too short", delta: minLoopInCltvDelta - 1}, + {name: "too long", delta: maxLoopInCltvDelta + 1}, + {name: "negative", delta: -1}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + server, err := New(context.Background(), Config{ + Lnd: &lndclient.LndServices{ + ChainParams: &chaincfg.RegressionNetParams, + }, + Bitcoin: &loopOutTestBitcoin{}, + LoopInCltvDelta: testCase.delta, + }) + if !testCase.valid { + require.Error(t, err) + return + } + + require.NoError(t, err) + server.Stop() + }) + } +} diff --git a/regtest/server/staticaddr.go b/regtest/server/staticaddr.go new file mode 100644 index 000000000..27e54b73c --- /dev/null +++ b/regtest/server/staticaddr.go @@ -0,0 +1,1828 @@ +package server + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "slices" + "strings" + "sync" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + staticloopin "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/routing/route" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // A static Loop In uses a substantially longer safety window than a + // regular Loop In. Keep this in sync with + // staticaddr/loopin.DefaultLoopInOnChainCltvDelta. + staticLoopInCltvDelta = int32( + staticloopin.DefaultLoopInOnChainCltvDelta, + ) + + // The server accepts deposits after their first confirmation. The CSV + // lifetime policy mirrors staticaddr/loopin.IsSwappable: the deposit + // must outlive the HTLC by DepositHtlcDelta blocks. + staticDepositMinConfirmations = int64(1) + staticDepositMinLifetime = int64( + staticloopin.DefaultLoopInOnChainCltvDelta + + staticloopin.DepositHtlcDelta, + ) + + // The three transactions are pre-signed before the server pays the + // invoice. The latter two are fee-bump fallbacks for the first one. + staticStandardFeeRate = chainfee.SatPerKWeight(253) + staticHighFeeRate = chainfee.SatPerKWeight(500) + staticExtremeFeeRate = chainfee.SatPerKWeight(1_000) + + staticSweepConfTarget = int32(3) + staticSweeplessWait = 10 * time.Second + staticSweeplessRetry = 500 * time.Millisecond +) + +var staticFundingFeeRates = [...]chainfee.SatPerKWeight{ + staticStandardFeeRate, + staticHighFeeRate, + staticExtremeFeeRate, +} + +// staticAddress contains both participants' public data and the locator of the +// server key in lnd. The private key never leaves lnd's signer. +type staticAddress struct { + clientKey *btcec.PublicKey + serverKey *serverKey + expiry uint32 + contract *script.StaticAddress + pkScript []byte +} + +// staticFundingRound is one fully deterministic static-address-to-HTLC +// transaction and the server MuSig2 sessions used to authorize its inputs. +type staticFundingRound struct { + feeRate chainfee.SatPerKWeight + tx *wire.MsgTx + sessions []*input.MuSig2SessionInfo + finalTx *wire.MsgTx +} + +// staticSweeplessRound is the preferred cooperative spend of the original +// static-address deposits. The server sends its PSBT and nonces only after the +// Lightning payment succeeds. If the client does not co-sign it promptly, the +// already finalized HTLC funding transactions remain the safe fallback. +type staticSweeplessRound struct { + tx *wire.MsgTx + psbt []byte + sessions map[string]*input.MuSig2SessionInfo + result chan error + finalTx *wire.MsgTx + closed bool +} + +// staticLoopInSwap contains everything required to complete the safe fallback +// flow. The client and server first authorize three funding transactions. Only +// then does the server pay the invoice, publish one funding transaction and +// claim its HTLC output with the payment preimage. +type staticLoopInSwap struct { + mu sync.Mutex + + hash lntypes.Hash + depositStrings []string + deposits []wire.OutPoint + prevOuts map[wire.OutPoint]*wire.TxOut + address *staticAddress + changePkScript []byte + changeDescriptor bool + totalDepositAmount btcutil.Amount + swapAmount btcutil.Amount + requestedAmount uint64 + invoice string + lastHop *route.Vertex + lastHopBytes []byte + paymentTimeout time.Duration + paymentTimeoutSecs uint32 + fast bool + htlcClientKey *btcec.PublicKey + htlcServerKey *serverKey + htlc *swap.Htlc + htlcExpiry int32 + initiationHeight int32 + fundingRounds [len(staticFundingFeeRates)]*staticFundingRound + sweepless *staticSweeplessRound + backupFinalized bool + workerStarted bool + abandoned bool + signingFailed error + paymentPreimage lntypes.Preimage + fundingTxHash *[32]byte + successSweepTxHash *[32]byte +} + +// ServerNewAddress creates the server half of a static address. Repeating the +// call with the same client key is idempotent for the lifetime of this +// disposable regtest process. +func (s *Server) ServerNewAddress(ctx context.Context, + req *swapserverrpc.ServerNewAddressRequest) ( + *swapserverrpc.ServerNewAddressResponse, error) { + + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + if req.ProtocolVersion != swapserverrpc.StaticAddressProtocolVersion_V0 { + return nil, status.Errorf( + codes.InvalidArgument, + "unsupported static address protocol version %d", + req.ProtocolVersion, + ) + } + clientKey, err := parseKey("static address client key", req.ClientKey) + if err != nil { + return nil, err + } + + addressID := string(clientKey.SerializeCompressed()) + s.mu.RLock() + existing := s.addresses[addressID] + s.mu.RUnlock() + if existing != nil { + return existing.addressResponse(), nil + } + + serverKey, err := s.deriveKey(ctx, swap.StaticAddressKeyFamily) + if err != nil { + return nil, status.Errorf( + codes.Internal, "derive static address server key: %v", err, + ) + } + contract, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(s.cfg.StaticAddressExpiry), + clientKey, serverKey.pubKey, + ) + if err != nil { + return nil, status.Errorf( + codes.Internal, "create static address contract: %v", err, + ) + } + pkScript, err := contract.StaticAddressScript() + if err != nil { + return nil, status.Errorf( + codes.Internal, "create static address script: %v", err, + ) + } + + address := &staticAddress{ + clientKey: clientKey, + serverKey: serverKey, + expiry: s.cfg.StaticAddressExpiry, + contract: contract, + pkScript: pkScript, + } + + // Resolve a concurrent duplicate in favor of the first completed call. + s.mu.Lock() + if existing = s.addresses[addressID]; existing == nil { + s.addresses[addressID] = address + existing = address + } + s.mu.Unlock() + + return existing.addressResponse(), nil +} + +func (a *staticAddress) addressResponse() *swapserverrpc. + ServerNewAddressResponse { + + return &swapserverrpc.ServerNewAddressResponse{ + Params: &swapserverrpc.ServerAddressParameters{ + ServerKey: bytes.Clone(a.serverKey.pubKey.SerializeCompressed()), + Expiry: a.expiry, + }, + } +} + +// ServerStaticAddressLoopIn validates the selected deposits and creates three +// real server-side MuSig2 signing sessions for each deposit. No off-chain +// payment is attempted until PushStaticAddressHtlcSigs has produced complete, +// executable funding transactions. +func (s *Server) ServerStaticAddressLoopIn(ctx context.Context, + req *swapserverrpc.ServerStaticAddressLoopInRequest) ( + *swapserverrpc.ServerStaticAddressLoopInResponse, error) { + + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + if req.ProtocolVersion != swapserverrpc.StaticAddressProtocolVersion_V0 { + return nil, status.Errorf( + codes.InvalidArgument, + "unsupported static address protocol version %d", + req.ProtocolVersion, + ) + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, err + } + + s.mu.RLock() + existing := s.staticSwaps[hash] + s.mu.RUnlock() + if existing != nil { + if !existing.matchesRequest(req) { + return nil, status.Error( + codes.AlreadyExists, + "swap hash already exists with different parameters", + ) + } + + return existing.initiationResponse(), nil + } + + htlcClientKey, err := parseKey("HTLC client key", req.HtlcClientPubKey) + if err != nil { + return nil, err + } + if len(req.DepositOutpoints) == 0 { + return nil, status.Error( + codes.InvalidArgument, "at least one deposit is required", + ) + } + height, err := s.currentHeight(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "get height: %v", err) + } + + depositStrings := slices.Clone(req.DepositOutpoints) + deposits := make([]wire.OutPoint, len(depositStrings)) + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(depositStrings)) + seen := make(map[wire.OutPoint]struct{}, len(depositStrings)) + var ( + address *staticAddress + total btcutil.Amount + ) + for i, outpointString := range depositStrings { + outpoint, err := wire.NewOutPointFromString(outpointString) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "invalid deposit outpoint %q: %v", + outpointString, err, + ) + } + if _, ok := seen[*outpoint]; ok { + return nil, status.Errorf( + codes.InvalidArgument, "duplicate deposit %v", outpoint, + ) + } + seen[*outpoint] = struct{}{} + depositStrings[i] = outpoint.String() + + prevOut, confirmations, err := s.fetchStaticDeposit(*outpoint) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "fetch deposit %v: %v", outpoint, + err, + ) + } + if err := validateStaticDepositPolicy( + *outpoint, height, confirmations, s.cfg.StaticAddressExpiry, + ); err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + depositAddress := s.addressForPkScript(prevOut.PkScript) + if depositAddress == nil { + return nil, status.Errorf( + codes.InvalidArgument, + "deposit %v does not pay a registered static address", + outpoint, + ) + } + if address != nil && address != depositAddress { + return nil, status.Error( + codes.InvalidArgument, + "all deposits must use the same static address", + ) + } + address = depositAddress + deposits[i] = *outpoint + prevOuts[*outpoint] = prevOut + if prevOut.Value < 0 || int64(total) > math.MaxInt64-prevOut.Value { + return nil, status.Error( + codes.InvalidArgument, "deposit total overflows int64", + ) + } + total += btcutil.Amount(prevOut.Value) + } + + changePkScript, err := s.validateStaticDescriptors(req, address, seen) + if err != nil { + return nil, err + } + + swapAmount := total + if req.Amount != 0 { + if req.Amount > math.MaxInt64 { + return nil, status.Error(codes.InvalidArgument, "amount overflows") + } + swapAmount = btcutil.Amount(req.Amount) + } + if err := s.validateAmount(swapAmount); err != nil { + return nil, err + } + if swapAmount > total { + return nil, status.Error( + codes.InvalidArgument, "swap amount exceeds selected deposits", + ) + } + changeAmount := total - swapAmount + if changeAmount > 0 && + changeAmount < lnwallet.DustLimitForSize(input.P2TRSize) { + + return nil, status.Error( + codes.InvalidArgument, "swap leaves a dust change output", + ) + } + if req.ChangeOutput != nil && + req.ChangeOutput.Amount != int64(changeAmount) { + + return nil, status.Errorf( + codes.InvalidArgument, + "change output amount %d does not match expected %d", + req.ChangeOutput.Amount, changeAmount, + ) + } + + expectedInvoiceAmount := swapAmount - s.swapFee(swapAmount) + if _, err := s.validateInvoice( + req.SwapInvoice, hash, expectedInvoiceAmount, + ); err != nil { + return nil, err + } + + var lastHop *route.Vertex + if len(req.LastHop) != 0 { + vertex, err := route.NewVertexFromBytes(req.LastHop) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "invalid last hop: %v", err, + ) + } + lastHop = &vertex + } + + htlcServerKey, err := s.deriveKey(ctx, swap.StaticAddressKeyFamily) + if err != nil { + return nil, status.Errorf( + codes.Internal, "derive HTLC server key: %v", err, + ) + } + htlcExpiry := height + staticLoopInCltvDelta + htlc, err := swap.NewHtlcV2( + htlcExpiry, keyBytes(htlcClientKey), keyBytes(htlcServerKey.pubKey), + hash, s.cfg.Lnd.ChainParams, + ) + if err != nil { + return nil, status.Errorf(codes.Internal, "create HTLC: %v", err) + } + + paymentTimeout := s.cfg.PaymentTimeout + if req.PaymentTimeoutSeconds != 0 { + paymentTimeout = time.Duration(req.PaymentTimeoutSeconds) * time.Second + } + staticSwap := &staticLoopInSwap{ + hash: hash, + depositStrings: depositStrings, + deposits: deposits, + prevOuts: prevOuts, + address: address, + changePkScript: changePkScript, + changeDescriptor: req.ChangeOutput != nil, + totalDepositAmount: total, + swapAmount: swapAmount, + requestedAmount: req.Amount, + invoice: req.SwapInvoice, + lastHop: lastHop, + lastHopBytes: bytes.Clone(req.LastHop), + paymentTimeout: paymentTimeout, + paymentTimeoutSecs: req.PaymentTimeoutSeconds, + fast: req.Fast, + htlcClientKey: htlcClientKey, + htlcServerKey: htlcServerKey, + htlc: htlc, + htlcExpiry: htlcExpiry, + initiationHeight: height, + } + + for i, feeRate := range staticFundingFeeRates { + round, err := s.newStaticFundingRound(ctx, staticSwap, feeRate) + if err != nil { + s.cleanupStaticSessions(context.WithoutCancel(ctx), staticSwap) + return nil, status.Errorf( + codes.Internal, "create funding signing round: %v", err, + ) + } + staticSwap.fundingRounds[i] = round + } + + // Lock the selected UTXOs atomically with insertion. A concurrent + // duplicate hash receives the first response; a different swap cannot + // reserve an already selected deposit. + s.mu.Lock() + if existing = s.staticSwaps[hash]; existing != nil { + s.mu.Unlock() + s.cleanupStaticSessions(context.WithoutCancel(ctx), staticSwap) + if !existing.matchesRequest(req) { + return nil, status.Error( + codes.AlreadyExists, + "swap hash already exists with different parameters", + ) + } + + return existing.initiationResponse(), nil + } + for _, outpointString := range depositStrings { + if owner, ok := s.lockedUTXOs[outpointString]; ok { + s.mu.Unlock() + s.cleanupStaticSessions(context.WithoutCancel(ctx), staticSwap) + return nil, status.Errorf( + codes.Aborted, "deposit %s is locked by swap %v", + outpointString, owner, + ) + } + } + for _, outpointString := range depositStrings { + s.lockedUTXOs[outpointString] = hash + } + s.staticSwaps[hash] = staticSwap + s.mu.Unlock() + + return staticSwap.initiationResponse(), nil +} + +func (s *Server) addressForPkScript(pkScript []byte) *staticAddress { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, address := range s.addresses { + if bytes.Equal(address.pkScript, pkScript) { + return address + } + } + + return nil +} + +// fetchStaticDeposit returns an exact prevout plus Bitcoin Core's current +// confirmation count. Looking up the UTXO and then the raw transaction lets +// the server both reject spent deposits and avoid deriving satoshi values from +// the JSON-RPC floating-point amount. +func (s *Server) fetchStaticDeposit(outpoint wire.OutPoint) (*wire.TxOut, + int64, error) { + + unspent, err := s.cfg.Bitcoin.GetTxOut( + &outpoint.Hash, outpoint.Index, true, + ) + if err != nil { + return nil, 0, err + } + if unspent == nil { + return nil, 0, fmt.Errorf("outpoint %v is spent or unknown", outpoint) + } + + rawTx, err := s.cfg.Bitcoin.GetRawTransaction(&outpoint.Hash) + if err != nil { + return nil, 0, err + } + tx := rawTx.MsgTx() + if int(outpoint.Index) >= len(tx.TxOut) { + return nil, 0, fmt.Errorf( + "outpoint %v has invalid output index", outpoint, + ) + } + prevOut := tx.TxOut[outpoint.Index] + if unspent.ScriptPubKey.Hex != fmt.Sprintf("%x", prevOut.PkScript) { + return nil, 0, fmt.Errorf("outpoint %v script mismatch", outpoint) + } + + return &wire.TxOut{ + Value: prevOut.Value, + PkScript: bytes.Clone(prevOut.PkScript), + }, unspent.Confirmations, nil +} + +func validateStaticDepositPolicy(outpoint wire.OutPoint, currentHeight int32, + confirmations int64, csvExpiry uint32) error { + + if confirmations < staticDepositMinConfirmations { + return fmt.Errorf( + "deposit %v is unconfirmed; at least %d confirmation is required", + outpoint, staticDepositMinConfirmations, + ) + } + confirmationHeight := int64(currentHeight) - confirmations + 1 + if confirmationHeight <= 0 { + return fmt.Errorf( + "deposit %v has invalid confirmation height %d", outpoint, + confirmationHeight, + ) + } + remainingLifetime := confirmationHeight + int64(csvExpiry) - + int64(currentHeight) + if remainingLifetime < staticDepositMinLifetime { + return fmt.Errorf( + "deposit %v has %d blocks of residual CSV lifetime; at least %d are required", + outpoint, remainingLifetime, staticDepositMinLifetime, + ) + } + + return nil +} + +// revalidateStaticDeposits verifies the admission snapshot immediately before +// the server takes irreversible action. The script and value comparison also +// guards against a backend inconsistency returning a different output for the +// same outpoint. +func (s *Server) revalidateStaticDeposits(l *staticLoopInSwap) error { + for _, outpoint := range l.deposits { + current, _, err := s.fetchStaticDeposit(outpoint) + if err != nil { + return fmt.Errorf("revalidate deposit %v: %w", outpoint, err) + } + expected := l.prevOuts[outpoint] + if expected == nil || current.Value != expected.Value || + !bytes.Equal(current.PkScript, expected.PkScript) { + + return fmt.Errorf( + "revalidate deposit %v: prevout changed", outpoint, + ) + } + } + + return nil +} + +func (s *Server) validateStaticDescriptors(req *swapserverrpc. + ServerStaticAddressLoopInRequest, address *staticAddress, + deposits map[wire.OutPoint]struct{}) ([]byte, error) { + + for outpoint, descriptor := range req.DepositToClientPubkeys { + parsedOutpoint, err := wire.NewOutPointFromString(outpoint) + if err != nil { + return nil, status.Errorf( + codes.InvalidArgument, "invalid descriptor outpoint %q: %v", + outpoint, err, + ) + } + if _, ok := deposits[*parsedOutpoint]; !ok { + return nil, status.Errorf( + codes.InvalidArgument, + "descriptor outpoint %s is not a selected deposit", outpoint, + ) + } + if descriptor == nil { + return nil, status.Errorf( + codes.InvalidArgument, "nil descriptor for %s", outpoint, + ) + } + if !bytes.Equal( + descriptor.Pubkey, address.clientKey.SerializeCompressed(), + ) || !bytes.Equal(descriptor.PkScript, address.pkScript) { + + return nil, status.Errorf( + codes.InvalidArgument, "descriptor mismatch for %s", outpoint, + ) + } + } + if req.ChangeOutput != nil { + change := req.ChangeOutput + if change.StaticAddress == nil { + return nil, status.Error( + codes.InvalidArgument, "change output descriptor mismatch", + ) + } + changeAddress := s.addressForPkScript(change.StaticAddress.PkScript) + if changeAddress == nil || !bytes.Equal( + change.StaticAddress.Pubkey, + changeAddress.clientKey.SerializeCompressed(), + ) { + + return nil, status.Error( + codes.InvalidArgument, "change output descriptor mismatch", + ) + } + + return bytes.Clone(changeAddress.pkScript), nil + } + + return bytes.Clone(address.pkScript), nil +} + +func (l *staticLoopInSwap) matchesRequest( + req *swapserverrpc.ServerStaticAddressLoopInRequest) bool { + + if req == nil { + return false + } + if !bytes.Equal(req.SwapHash, l.hash[:]) || + !bytes.Equal( + req.HtlcClientPubKey, l.htlcClientKey.SerializeCompressed(), + ) || req.SwapInvoice != l.invoice || + !bytes.Equal(req.LastHop, l.lastHopBytes) || + req.PaymentTimeoutSeconds != l.paymentTimeoutSecs || + req.Fast != l.fast || + (req.ChangeOutput != nil) != l.changeDescriptor { + + return false + } + if len(req.DepositOutpoints) != len(l.depositStrings) { + return false + } + for i, serialized := range req.DepositOutpoints { + outpoint, err := wire.NewOutPointFromString(serialized) + if err != nil || outpoint.String() != l.depositStrings[i] { + return false + } + } + + if req.Amount != l.requestedAmount { + return false + } + if req.ChangeOutput != nil { + descriptor := req.ChangeOutput.GetStaticAddress() + if descriptor == nil || !bytes.Equal( + descriptor.PkScript, l.changePkScript, + ) || req.ChangeOutput.Amount != int64( + l.totalDepositAmount-l.swapAmount, + ) { + + return false + } + } + + return true +} + +func (l *staticLoopInSwap) initiationResponse() *swapserverrpc. + ServerStaticAddressLoopInResponse { + + infos := make([]*swapserverrpc.ServerHtlcSigningInfo, 0, + len(l.fundingRounds)) + for _, round := range l.fundingRounds { + nonces := make([][]byte, len(round.sessions)) + for i, session := range round.sessions { + nonces[i] = bytes.Clone(session.PublicNonce[:]) + } + infos = append(infos, &swapserverrpc.ServerHtlcSigningInfo{ + Nonces: nonces, + FeeRate: uint64(round.feeRate), + }) + } + + return &swapserverrpc.ServerStaticAddressLoopInResponse{ + HtlcServerPubKey: bytes.Clone( + l.htlcServerKey.pubKey.SerializeCompressed(), + ), + HtlcExpiry: l.htlcExpiry, + StandardHtlcInfo: infos[0], + HighFeeHtlcInfo: infos[1], + ExtremeFeeHtlcInfo: infos[2], + } +} + +func (s *Server) newStaticFundingRound(ctx context.Context, + l *staticLoopInSwap, feeRate chainfee.SatPerKWeight) ( + *staticFundingRound, error) { + + tx, err := createStaticFundingTx(l, feeRate) + if err != nil { + return nil, err + } + + round := &staticFundingRound{ + feeRate: feeRate, + tx: tx, + sessions: make([]*input.MuSig2SessionInfo, len(l.deposits)), + } + signers := [][]byte{ + l.address.clientKey.SerializeCompressed(), + l.address.serverKey.pubKey.SerializeCompressed(), + } + rootHash := l.address.contract.RootHash + for i := range l.deposits { + session, err := s.cfg.Lnd.Signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, + &l.address.serverKey.locator, signers, + lndclient.MuSig2TaprootTweakOpt(rootHash[:], false), + ) + if err != nil { + for _, created := range round.sessions { + if created != nil { + _ = s.cfg.Lnd.Signer.MuSig2Cleanup( + context.WithoutCancel(ctx), created.SessionID, + ) + } + } + + return nil, err + } + round.sessions[i] = session + } + + return round, nil +} + +func createStaticFundingTx(l *staticLoopInSwap, + feeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) { + + tx := wire.NewMsgTx(2) + for _, outpoint := range l.deposits { + // Keep this literal in sync with the client. In particular, its + // zero sequence is part of the signed transaction. + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + + changeAmount := l.totalDepositAmount - l.swapAmount + var weight input.TxWeightEstimator + for range l.deposits { + weight.AddTaprootKeySpendInput(txscript.SigHashDefault) + } + weight.AddP2WSHOutput() + if changeAmount > 0 { + weight.AddP2TROutput() + } + fee := feeRate.FeeForWeight(weight.Weight()) + if fee <= 0 || fee >= l.swapAmount { + return nil, fmt.Errorf("invalid funding fee %d", fee) + } + + htlcValue := l.swapAmount - fee + if htlcValue < lnwallet.DustLimitForSize(input.P2WSHSize) { + return nil, fmt.Errorf("HTLC output is dust: %d", htlcValue) + } + tx.AddTxOut(&wire.TxOut{ + Value: int64(htlcValue), + PkScript: bytes.Clone(l.htlc.PkScript), + }) + if changeAmount > 0 { + tx.AddTxOut(&wire.TxOut{ + Value: int64(changeAmount), + PkScript: bytes.Clone(l.changePkScript), + }) + } + + return tx, nil +} + +// PushStaticAddressHtlcSigs completes all three server MuSig2 signing rounds. +// A duplicate call after successful finalization is idempotent. An otherwise +// empty request is the protocol's abandonment signal. +func (s *Server) PushStaticAddressHtlcSigs(ctx context.Context, + req *swapserverrpc.PushStaticAddressHtlcSigsRequest) ( + *swapserverrpc.PushStaticAddressHtlcSigsResponse, error) { + + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, err + } + s.mu.RLock() + staticSwap := s.staticSwaps[hash] + s.mu.RUnlock() + if staticSwap == nil { + return nil, status.Error(codes.NotFound, "static swap not found") + } + + staticSwap.mu.Lock() + defer staticSwap.mu.Unlock() + + if staticSwap.backupFinalized { + return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil + } + infos := []*swapserverrpc.ClientHtlcSigningInfo{ + req.StandardHtlcInfo, + req.HighFeeHtlcInfo, + req.ExtremeFeeHtlcInfo, + } + if staticSwap.abandoned { + if emptyStaticFundingInfos(infos) { + return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil + } + + return nil, status.Error(codes.FailedPrecondition, "swap abandoned") + } + if staticSwap.signingFailed != nil { + return nil, status.Errorf( + codes.FailedPrecondition, "previous signing failed: %v", + staticSwap.signingFailed, + ) + } + + if emptyStaticFundingInfos(infos) { + staticSwap.abandoned = true + s.cleanupStaticSessions(context.WithoutCancel(ctx), staticSwap) + s.releaseStaticLocks(staticSwap) + + return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil + } + + for i, info := range infos { + finalTx, err := s.finalizeStaticFundingRound( + ctx, staticSwap, staticSwap.fundingRounds[i], info, + ) + if err != nil { + staticSwap.signingFailed = err + s.cleanupStaticSessions(context.WithoutCancel(ctx), staticSwap) + s.releaseStaticLocks(staticSwap) + + return nil, status.Errorf( + codes.InvalidArgument, + "finalize funding signatures at fee tier %d: %v", i, err, + ) + } + staticSwap.fundingRounds[i].finalTx = finalTx + } + + staticSwap.backupFinalized = true + if !staticSwap.workerStarted { + staticSwap.workerStarted = true + s.goSwap(func(runCtx context.Context) { + s.runStaticLoopIn(runCtx, staticSwap) + }) + } + + return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil +} + +func emptyStaticFundingInfos( + infos []*swapserverrpc.ClientHtlcSigningInfo) bool { + + for _, info := range infos { + if info != nil && (len(info.Nonces) != 0 || len(info.Sigs) != 0) { + return false + } + } + + return true +} + +func (s *Server) finalizeStaticFundingRound(ctx context.Context, + l *staticLoopInSwap, round *staticFundingRound, + info *swapserverrpc.ClientHtlcSigningInfo) (*wire.MsgTx, error) { + + if info == nil { + return nil, errors.New("missing signing info") + } + if len(info.Nonces) != len(l.deposits) || + len(info.Sigs) != len(l.deposits) { + + return nil, fmt.Errorf( + "got %d nonces and %d signatures for %d deposits", + len(info.Nonces), len(info.Sigs), len(l.deposits), + ) + } + + tx := round.tx.Copy() + prevFetcher := txscript.NewMultiPrevOutFetcher(l.prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) + for i := range l.deposits { + if len(info.Nonces[i]) != musig2.PubNonceSize { + return nil, fmt.Errorf("nonce %d must be %d bytes", i, + musig2.PubNonceSize) + } + if len(info.Sigs[i]) != input.MuSig2PartialSigSize { + return nil, fmt.Errorf("partial signature %d must be %d bytes", + i, input.MuSig2PartialSigSize) + } + + var clientNonce [musig2.PubNonceSize]byte + copy(clientNonce[:], info.Nonces[i]) + haveAllNonces, err := s.cfg.Lnd.Signer.MuSig2RegisterNonces( + ctx, round.sessions[i].SessionID, + [][musig2.PubNonceSize]byte{clientNonce}, + ) + if err != nil { + return nil, err + } + if !haveAllNonces { + return nil, errors.New("MuSig2 session is missing nonces") + } + + digestBytes, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, tx, i, prevFetcher, + ) + if err != nil { + return nil, err + } + var digest [32]byte + copy(digest[:], digestBytes) + + if _, err := s.cfg.Lnd.Signer.MuSig2Sign( + ctx, round.sessions[i].SessionID, digest, false, + ); err != nil { + return nil, err + } + haveAllSigs, finalSig, err := + s.cfg.Lnd.Signer.MuSig2CombineSig( + ctx, round.sessions[i].SessionID, + [][]byte{info.Sigs[i]}, + ) + if err != nil { + return nil, err + } + if !haveAllSigs || len(finalSig) != 64 { + return nil, errors.New("MuSig2 signature did not finalize") + } + tx.TxIn[i].Witness = wire.TxWitness{finalSig} + } + + if err := validateStaticFundingTx(tx, l.prevOuts); err != nil { + return nil, fmt.Errorf("funding transaction validation failed: %w", err) + } + + return tx, nil +} + +func validateStaticFundingTx(tx *wire.MsgTx, + prevOuts map[wire.OutPoint]*wire.TxOut) error { + + prevFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) + for i, txIn := range tx.TxIn { + prevOut := prevOuts[txIn.PreviousOutPoint] + if prevOut == nil { + return fmt.Errorf("missing prevout for input %d", i) + } + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, i, txscript.StandardVerifyFlags, + nil, sigHashes, prevOut.Value, prevFetcher, + ) + if err != nil { + return err + } + if err := vm.Execute(); err != nil { + return err + } + } + + return nil +} + +func (s *Server) cleanupStaticSessions(ctx context.Context, + l *staticLoopInSwap) { + + for _, round := range l.fundingRounds { + if round == nil || round.finalTx != nil { + continue + } + for _, session := range round.sessions { + if session != nil { + _ = s.cfg.Lnd.Signer.MuSig2Cleanup(ctx, session.SessionID) + } + } + } +} + +func (s *Server) releaseStaticLocks(l *staticLoopInSwap) { + s.mu.Lock() + for _, outpoint := range l.depositStrings { + if s.lockedUTXOs[outpoint] == l.hash { + delete(s.lockedUTXOs, outpoint) + } + } + s.mu.Unlock() +} + +func (s *Server) publishStaticRiskAccepted(hash lntypes.Hash) { + s.notifications.publish(&swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: bytes.Clone(hash[:]), + }, + }, + }) +} + +func (s *Server) publishStaticRiskRejected(hash lntypes.Hash) { + s.notifications.publish(&swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: bytes.Clone(hash[:]), + }, + }, + }) +} + +func (s *Server) newStaticSweeplessRound(ctx context.Context, + l *staticLoopInSwap) (*staticSweeplessRound, error) { + + sweepAddress, err := s.cfg.Lnd.WalletKit.NextAddr( + ctx, lnwallet.DefaultAccountName, + walletrpc.AddressType_TAPROOT_PUBKEY, false, + ) + if err != nil { + return nil, fmt.Errorf("derive sweepless destination: %w", err) + } + sweepPkScript, err := txscript.PayToAddrScript(sweepAddress) + if err != nil { + return nil, fmt.Errorf("create sweepless destination script: %w", err) + } + feeRate, err := s.cfg.Lnd.WalletKit.EstimateFeeRate( + ctx, staticSweepConfTarget, + ) + if err != nil { + return nil, fmt.Errorf("estimate sweepless fee: %w", err) + } + if feeRate < staticStandardFeeRate { + feeRate = staticStandardFeeRate + } + + changeAmount := l.totalDepositAmount - l.swapAmount + var weight input.TxWeightEstimator + for range l.deposits { + weight.AddTaprootKeySpendInput(txscript.SigHashDefault) + } + weight.AddP2TROutput() + if changeAmount > 0 { + weight.AddP2TROutput() + } + fee := feeRate.FeeForWeight(weight.Weight()) + serverAmount := l.swapAmount - fee + if fee <= 0 || serverAmount <= 0 || + serverAmount < lnwallet.DustLimitForSize(input.P2TRSize) { + + return nil, fmt.Errorf( + "invalid sweepless fee/output: fee=%d output=%d", fee, + serverAmount, + ) + } + + tx := wire.NewMsgTx(2) + for _, outpoint := range l.deposits { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outpoint}) + } + tx.AddTxOut(&wire.TxOut{ + Value: int64(serverAmount), + PkScript: sweepPkScript, + }) + if changeAmount > 0 { + tx.AddTxOut(&wire.TxOut{ + Value: int64(changeAmount), + PkScript: bytes.Clone(l.changePkScript), + }) + } + + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return nil, fmt.Errorf("create sweepless PSBT: %w", err) + } + for i, outpoint := range l.deposits { + prevOut := l.prevOuts[outpoint] + if prevOut == nil { + return nil, fmt.Errorf("missing prevout for %v", outpoint) + } + packet.Inputs[i].WitnessUtxo = &wire.TxOut{ + Value: prevOut.Value, + PkScript: bytes.Clone(prevOut.PkScript), + } + } + var serialized bytes.Buffer + if err := packet.Serialize(&serialized); err != nil { + return nil, fmt.Errorf("serialize sweepless PSBT: %w", err) + } + + round := &staticSweeplessRound{ + tx: tx, + psbt: serialized.Bytes(), + sessions: make(map[string]*input.MuSig2SessionInfo, len(l.deposits)), + result: make(chan error, 1), + } + signers := [][]byte{ + l.address.clientKey.SerializeCompressed(), + l.address.serverKey.pubKey.SerializeCompressed(), + } + rootHash := l.address.contract.RootHash + for _, outpoint := range l.deposits { + session, err := s.cfg.Lnd.Signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, + &l.address.serverKey.locator, signers, + lndclient.MuSig2TaprootTweakOpt(rootHash[:], false), + ) + if err != nil { + s.cleanupStaticSweeplessSessions( + context.WithoutCancel(ctx), round, + ) + + return nil, fmt.Errorf("create sweepless session: %w", err) + } + round.sessions[outpoint.String()] = session + } + + return round, nil +} + +func (s *Server) cleanupStaticSweeplessSessions(ctx context.Context, + round *staticSweeplessRound) { + + if round == nil { + return + } + for _, session := range round.sessions { + _ = s.cfg.Lnd.Signer.MuSig2Cleanup(ctx, session.SessionID) + } +} + +func (s *Server) publishStaticSweeplessRequest(l *staticLoopInSwap, + round *staticSweeplessRound) { + + nonces := make(map[string][]byte, len(round.sessions)) + for outpoint, session := range round.sessions { + nonces[outpoint] = bytes.Clone(session.PublicNonce[:]) + } + prevOuts := make([]*swapserverrpc.PrevoutInfo, 0, len(l.deposits)) + for _, outpoint := range l.deposits { + prevOut := l.prevOuts[outpoint] + prevOuts = append(prevOuts, &swapserverrpc.PrevoutInfo{ + TxidBytes: bytes.Clone(outpoint.Hash[:]), + OutputIndex: outpoint.Index, + Value: uint64(prevOut.Value), + PkScript: bytes.Clone(prevOut.PkScript), + }) + } + + s.notifications.publish(&swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInSweep{ + StaticLoopInSweep: &swapserverrpc. + ServerStaticLoopInSweepNotification{ + SweepTxPsbt: bytes.Clone(round.psbt), + SwapHash: bytes.Clone(l.hash[:]), + DepositToNonces: nonces, + PrevoutInfo: prevOuts, + }, + }, + }) +} + +// tryStaticSweepless asks the now-paid client to co-sign a direct spend of the +// original deposits. Notifications are retried because the client's invoice +// subscription and its manager-level notification stream advance +// independently. Any timeout or signing/publication failure falls through to +// the fully signed HTLC path. +func (s *Server) tryStaticSweepless(ctx context.Context, + l *staticLoopInSwap) bool { + + if err := s.revalidateStaticDeposits(l); err != nil { + s.cfg.Logger.Printf( + "static Loop In %v direct sweep preflight failed: %v", + l.hash, err, + ) + + return false + } + + round, err := s.newStaticSweeplessRound(ctx, l) + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v direct sweep setup failed: %v", l.hash, err, + ) + + return false + } + + l.mu.Lock() + l.sweepless = round + l.mu.Unlock() + + timeout := time.NewTimer(staticSweeplessWait) + defer timeout.Stop() + retry := time.NewTicker(staticSweeplessRetry) + defer retry.Stop() + s.publishStaticSweeplessRequest(l, round) + + for { + select { + case err := <-round.result: + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v direct signing failed: %v", + l.hash, err, + ) + s.cleanupStaticSweeplessSessions( + context.WithoutCancel(ctx), round, + ) + + return false + } + + l.mu.Lock() + finalTx := round.finalTx + l.mu.Unlock() + if finalTx == nil { + return false + } + if err := s.revalidateStaticDeposits(l); err != nil { + l.mu.Lock() + round.closed = true + l.mu.Unlock() + s.cfg.Logger.Printf( + "static Loop In %v direct settlement preflight "+ + "failed: %v", l.hash, err, + ) + + return false + } + err = s.cfg.Lnd.WalletKit.PublishTransaction( + ctx, finalTx, + fmt.Sprintf( + "regtest-static-loop-in-direct-%x", l.hash[:6], + ), + ) + if err != nil && !strings.Contains(err.Error(), "already") { + s.cfg.Logger.Printf( + "static Loop In %v direct publication failed: %v", + l.hash, err, + ) + + return false + } + + txHash := finalTx.TxHash() + l.mu.Lock() + var serializedHash [32]byte + copy(serializedHash[:], txHash[:]) + l.successSweepTxHash = &serializedHash + round.closed = true + l.mu.Unlock() + s.releaseStaticLocks(l) + s.cfg.Logger.Printf( + "static Loop In %v direct settlement complete: sweep=%v", + l.hash, txHash, + ) + + return true + + case <-retry.C: + s.publishStaticSweeplessRequest(l, round) + + case <-timeout.C: + l.mu.Lock() + round.closed = true + l.mu.Unlock() + s.cleanupStaticSweeplessSessions( + context.WithoutCancel(ctx), round, + ) + s.cfg.Logger.Printf( + "static Loop In %v direct signing timed out; using HTLC", + l.hash, + ) + + return false + + case <-ctx.Done(): + l.mu.Lock() + round.closed = true + l.mu.Unlock() + s.cleanupStaticSweeplessSessions( + context.WithoutCancel(ctx), round, + ) + + return false + } + } +} + +// runStaticLoopIn pays only after fully signed funding safety transactions +// exist. It first attempts the cooperative direct spend. If the client cannot +// co-sign it, the server publishes one funding transaction, waits for its +// relative-lock prerequisite and claims the HTLC with the payment preimage. +func (s *Server) runStaticLoopIn(ctx context.Context, + l *staticLoopInSwap) { + + // The funding signatures can arrive well after initiation. Check the + // selected deposits again before announcing risk acceptance or paying the + // invoice so a conflicting spend never turns into an off-chain loss. + if err := s.revalidateStaticDeposits(l); err != nil { + s.cfg.Logger.Printf( + "static Loop In %v deposit preflight failed: %v", l.hash, err, + ) + s.publishStaticRiskRejected(l.hash) + s.releaseStaticLocks(l) + + return + } + + s.publishStaticRiskAccepted(l.hash) + payment, err := s.sendStaticPayment( + ctx, l.invoice, l.paymentTimeout, l.lastHop, + ) + if err != nil { + s.cfg.Logger.Printf("static Loop In %v payment failed: %v", l.hash, err) + s.publishStaticRiskRejected(l.hash) + s.releaseStaticLocks(l) + + return + } + if payment.Preimage.Hash() != l.hash { + s.cfg.Logger.Printf( + "static Loop In %v payment returned wrong preimage", l.hash, + ) + s.publishStaticRiskRejected(l.hash) + + return + } + + l.mu.Lock() + l.paymentPreimage = payment.Preimage + l.mu.Unlock() + if s.tryStaticSweepless(ctx, l) { + return + } + if err := s.revalidateStaticDeposits(l); err != nil { + s.cfg.Logger.Printf( + "static Loop In %v fallback funding preflight failed: %v", + l.hash, err, + ) + s.releaseStaticLocks(l) + + return + } + + fundingTx, err := s.publishStaticFundingTx(ctx, l) + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v funding publication failed after payment: %v", + l.hash, err, + ) + return + } + fundingHash := fundingTx.TxHash() + l.mu.Lock() + var serializedFundingHash [32]byte + copy(serializedFundingHash[:], fundingHash[:]) + l.fundingTxHash = &serializedFundingHash + l.mu.Unlock() + + confChan, errChan, err := s.cfg.Lnd.ChainNotifier. + RegisterConfirmationsNtfn( + ctx, &fundingHash, l.htlc.PkScript, 1, l.initiationHeight, + ) + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v funding confirmation registration failed: %v", + l.hash, err, + ) + return + } + + confirmed := false + for !confirmed && (confChan != nil || errChan != nil) { + select { + case _, ok := <-confChan: + if !ok { + confChan = nil + continue + } + confirmed = true + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v funding confirmation failed: %v", + l.hash, err, + ) + + return + } + + case <-ctx.Done(): + return + } + } + if !confirmed { + s.cfg.Logger.Printf( + "static Loop In %v funding confirmation stream closed", l.hash, + ) + + return + } + + sweepTx, err := s.createStaticSuccessSweep(ctx, l, fundingTx) + if err != nil { + s.cfg.Logger.Printf( + "static Loop In %v success sweep creation failed: %v", l.hash, err, + ) + return + } + if err := s.cfg.Lnd.WalletKit.PublishTransaction( + ctx, sweepTx, fmt.Sprintf("regtest-static-loop-in-success-%x", l.hash[:6]), + ); err != nil && !strings.Contains(err.Error(), "already") { + s.cfg.Logger.Printf( + "static Loop In %v success sweep publication failed: %v", l.hash, + err, + ) + return + } + + sweepHash := sweepTx.TxHash() + l.mu.Lock() + var serializedSweepHash [32]byte + copy(serializedSweepHash[:], sweepHash[:]) + l.successSweepTxHash = &serializedSweepHash + l.mu.Unlock() + s.releaseStaticLocks(l) + s.cfg.Logger.Printf( + "static Loop In %v fallback complete: funding=%v sweep=%v", + l.hash, fundingHash, sweepHash, + ) +} + +func (s *Server) sendStaticPayment(ctx context.Context, invoice string, + timeout time.Duration, lastHop *route.Vertex) (lndclient.PaymentStatus, + error) { + + statusChan, errChan, err := s.cfg.Lnd.Router.SendPayment( + ctx, lndclient.SendPaymentRequest{ + Invoice: invoice, + MaxFee: s.cfg.MaxSwapAmount, + Timeout: timeout, + LastHopPubkey: lastHop, + MaxParts: 10, + Cancelable: true, + }, + ) + if err != nil { + return lndclient.PaymentStatus{}, err + } + + for statusChan != nil || errChan != nil { + select { + case payment, ok := <-statusChan: + if !ok { + statusChan = nil + continue + } + switch payment.State { + case lnrpc.Payment_SUCCEEDED: + return payment, nil + + case lnrpc.Payment_FAILED: + return payment, fmt.Errorf( + "payment failed: %v", payment.FailureReason, + ) + } + + case err, ok := <-errChan: + if !ok { + errChan = nil + continue + } + if err != nil { + return lndclient.PaymentStatus{}, err + } + + case <-ctx.Done(): + return lndclient.PaymentStatus{}, ctx.Err() + } + } + + return lndclient.PaymentStatus{}, errors.New("payment stream closed") +} + +func (s *Server) publishStaticFundingTx(ctx context.Context, + l *staticLoopInSwap) (*wire.MsgTx, error) { + + var publicationErrors []error + for i, round := range l.fundingRounds { + if round == nil || round.finalTx == nil { + continue + } + err := s.cfg.Lnd.WalletKit.PublishTransaction( + ctx, round.finalTx, + fmt.Sprintf("regtest-static-loop-in-funding-%x-%d", l.hash[:6], i), + ) + if err == nil || strings.Contains(err.Error(), "already") { + return round.finalTx, nil + } + publicationErrors = append(publicationErrors, err) + } + if len(publicationErrors) == 0 { + return nil, errors.New("no finalized static funding transaction") + } + + return nil, errors.Join(publicationErrors...) +} + +func (s *Server) createStaticSuccessSweep(ctx context.Context, + l *staticLoopInSwap, fundingTx *wire.MsgTx) (*wire.MsgTx, error) { + + if len(fundingTx.TxOut) == 0 { + return nil, errors.New("funding transaction has no HTLC output") + } + sweepAddress, err := s.cfg.Lnd.WalletKit.NextAddr( + ctx, lnwallet.DefaultAccountName, + walletrpc.AddressType_TAPROOT_PUBKEY, false, + ) + if err != nil { + return nil, err + } + sweepPkScript, err := txscript.PayToAddrScript(sweepAddress) + if err != nil { + return nil, err + } + feeRate, err := s.cfg.Lnd.WalletKit.EstimateFeeRate( + ctx, staticSweepConfTarget, + ) + if err != nil { + return nil, err + } + if feeRate < staticStandardFeeRate { + feeRate = staticStandardFeeRate + } + var weight input.TxWeightEstimator + if err := l.htlc.AddSuccessToEstimator(&weight); err != nil { + return nil, err + } + weight.AddP2TROutput() + fee := feeRate.FeeForWeight(weight.Weight()) + outputValue := btcutil.Amount(fundingTx.TxOut[0].Value) - fee + if outputValue < lnwallet.DustLimitForSize(input.P2TRSize) { + return nil, fmt.Errorf("success sweep output is dust: %d", outputValue) + } + + fundingHash := fundingTx.TxHash() + sweepTx := wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: fundingHash, Index: 0}, + SignatureScript: bytes.Clone(l.htlc.SigScript), + Sequence: l.htlc.SuccessSequence(), + }) + sweepTx.AddTxOut(&wire.TxOut{ + Value: int64(outputValue), + PkScript: sweepPkScript, + }) + + signDesc := &lndclient.SignDescriptor{ + WitnessScript: l.htlc.SuccessScript(), + Output: fundingTx.TxOut[0], + HashType: l.htlc.SigHash(), + InputIndex: 0, + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: l.htlcServerKey.locator, + PubKey: l.htlcServerKey.pubKey, + }, + SignMethod: input.WitnessV0SignMethod, + } + rawSigs, err := s.cfg.Lnd.Signer.SignOutputRawKeyLocator( + ctx, sweepTx, []*lndclient.SignDescriptor{signDesc}, + []*wire.TxOut{fundingTx.TxOut[0]}, + ) + if err != nil { + return nil, err + } + if len(rawSigs) != 1 { + return nil, fmt.Errorf("expected one HTLC signature, got %d", + len(rawSigs)) + } + sweepTx.TxIn[0].Witness, err = l.htlc.GenSuccessWitness( + rawSigs[0], l.paymentPreimage, + ) + if err != nil { + return nil, err + } + + prevFetcher := txscript.NewCannedPrevOutputFetcher( + fundingTx.TxOut[0].PkScript, fundingTx.TxOut[0].Value, + ) + sigHashes := txscript.NewTxSigHashes(sweepTx, prevFetcher) + vm, err := txscript.NewEngine( + fundingTx.TxOut[0].PkScript, sweepTx, 0, + txscript.StandardVerifyFlags, nil, sigHashes, + fundingTx.TxOut[0].Value, prevFetcher, + ) + if err != nil { + return nil, err + } + if err := vm.Execute(); err != nil { + return nil, fmt.Errorf("success sweep validation failed: %w", err) + } + + return sweepTx, nil +} + +// PushStaticAddressSweeplessSigs finalizes the preferred direct spend of the +// deposits. The transaction id, exact outpoint set and all resulting Taproot +// witnesses are validated before the worker is allowed to publish it. +func (s *Server) PushStaticAddressSweeplessSigs(ctx context.Context, + req *swapserverrpc.PushStaticAddressSweeplessSigsRequest) ( + *swapserverrpc.PushStaticAddressSweeplessSigsResponse, error) { + + if req == nil { + return nil, status.Error(codes.InvalidArgument, "request is required") + } + hash, err := parseHash(req.SwapHash) + if err != nil { + return nil, err + } + s.mu.RLock() + staticSwap := s.staticSwaps[hash] + s.mu.RUnlock() + if staticSwap == nil { + return nil, status.Error(codes.NotFound, "static swap not found") + } + + staticSwap.mu.Lock() + defer staticSwap.mu.Unlock() + + round := staticSwap.sweepless + if round == nil { + return nil, status.Error( + codes.FailedPrecondition, "sweepless signing was not requested", + ) + } + if err := validateStaticSweeplessTxID(req.Txid, round.tx); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if round.finalTx != nil { + return &swapserverrpc.PushStaticAddressSweeplessSigsResponse{}, nil + } + if round.closed { + return nil, status.Error( + codes.FailedPrecondition, "sweepless signing round is closed", + ) + } + if req.ErrorMessage != "" { + if len(req.SigningInfo) != 0 { + return nil, status.Error( + codes.InvalidArgument, + "error acknowledgement must not contain signatures", + ) + } + + // "not finished" is expected while the client processes its + // invoice settlement. The worker will retry the notification. + return &swapserverrpc.PushStaticAddressSweeplessSigsResponse{}, nil + } + if len(req.SigningInfo) == 0 { + return nil, status.Error( + codes.InvalidArgument, "sweepless signatures are required", + ) + } + + finalTx, err := s.finalizeStaticSweeplessRound( + ctx, staticSwap, round, req.SigningInfo, + ) + if err != nil { + round.closed = true + s.cleanupStaticSweeplessSessions(context.WithoutCancel(ctx), round) + select { + case round.result <- err: + default: + } + + return nil, status.Errorf( + codes.InvalidArgument, "finalize sweepless signatures: %v", err, + ) + } + round.finalTx = finalTx + select { + case round.result <- nil: + default: + } + + return &swapserverrpc.PushStaticAddressSweeplessSigsResponse{}, nil +} + +func validateStaticSweeplessTxID(serialized []byte, tx *wire.MsgTx) error { + if len(serialized) != 32 { + return errors.New("sweepless txid must be 32 bytes") + } + want := tx.TxHash() + if !bytes.Equal(serialized, want[:]) { + return errors.New("sweepless txid mismatch") + } + + return nil +} + +func (s *Server) finalizeStaticSweeplessRound(ctx context.Context, + l *staticLoopInSwap, round *staticSweeplessRound, + infos map[string]*swapserverrpc.ClientSweeplessSigningInfo) ( + *wire.MsgTx, error) { + + if len(infos) != len(l.deposits) { + return nil, fmt.Errorf( + "got signatures for %d deposits, expected %d", len(infos), + len(l.deposits), + ) + } + for outpoint := range infos { + if _, ok := round.sessions[outpoint]; !ok { + return nil, fmt.Errorf( + "signature supplied for unknown deposit %s", outpoint, + ) + } + } + + tx := round.tx.Copy() + prevFetcher := txscript.NewMultiPrevOutFetcher(l.prevOuts) + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) + for i, outpoint := range l.deposits { + outpointString := outpoint.String() + info := infos[outpointString] + if info == nil { + return nil, fmt.Errorf( + "missing signing info for %s", outpointString, + ) + } + if len(info.Nonce) != musig2.PubNonceSize { + return nil, fmt.Errorf( + "nonce for %s must be %d bytes", outpointString, + musig2.PubNonceSize, + ) + } + if len(info.Sig) != input.MuSig2PartialSigSize { + return nil, fmt.Errorf( + "partial signature for %s must be %d bytes", outpointString, + input.MuSig2PartialSigSize, + ) + } + + var clientNonce [musig2.PubNonceSize]byte + copy(clientNonce[:], info.Nonce) + session := round.sessions[outpointString] + haveAllNonces, err := s.cfg.Lnd.Signer.MuSig2RegisterNonces( + ctx, session.SessionID, + [][musig2.PubNonceSize]byte{clientNonce}, + ) + if err != nil { + return nil, err + } + if !haveAllNonces { + return nil, fmt.Errorf( + "MuSig2 session for %s is missing nonces", outpointString, + ) + } + + digestBytes, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, tx, i, prevFetcher, + ) + if err != nil { + return nil, err + } + var digest [32]byte + copy(digest[:], digestBytes) + if _, err := s.cfg.Lnd.Signer.MuSig2Sign( + ctx, session.SessionID, digest, false, + ); err != nil { + return nil, err + } + haveAllSigs, finalSig, err := s.cfg.Lnd.Signer.MuSig2CombineSig( + ctx, session.SessionID, [][]byte{info.Sig}, + ) + if err != nil { + return nil, err + } + if !haveAllSigs || len(finalSig) != 64 { + return nil, fmt.Errorf( + "MuSig2 signature for %s did not finalize", outpointString, + ) + } + tx.TxIn[i].Witness = wire.TxWitness{finalSig} + } + + if err := validateStaticFundingTx(tx, l.prevOuts); err != nil { + return nil, fmt.Errorf("sweepless transaction validation failed: %w", + err) + } + + return tx, nil +} diff --git a/regtest/server/staticaddr_test.go b/regtest/server/staticaddr_test.go new file mode 100644 index 000000000..016ea476e --- /dev/null +++ b/regtest/server/staticaddr_test.go @@ -0,0 +1,992 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc/signrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// testMuSig2Signer adapts lnd's real in-memory MuSig2 session manager to the +// lndclient interface. Embedding the interface supplies methods irrelevant to +// this focused test; every MuSig2 method exercised by the server is real. +type testMuSig2Signer struct { + lndclient.SignerClient + + manager *input.MusigSessionManager + rawKeys []*btcec.PrivateKey +} + +type testStaticWallet struct { + lndclient.WalletKitClient + + address btcutil.Address + feeRate chainfee.SatPerKWeight + derivedKey *keychain.KeyDescriptor + deriveCalls int +} + +// testStaticBitcoin provides an exact raw transaction and a mutable UTXO view +// so tests can model a deposit being spent between admission and payment. +type testStaticBitcoin struct { + mu sync.Mutex + + tx *wire.MsgTx + confirmations int64 + spent bool +} + +func (b *testStaticBitcoin) GetTxOut(hash *chainhash.Hash, index uint32, + _ bool) (*btcjson.GetTxOutResult, error) { + + b.mu.Lock() + defer b.mu.Unlock() + + if b.spent || hash == nil || b.tx == nil || b.tx.TxHash() != *hash || + int(index) >= len(b.tx.TxOut) { + + return nil, nil + } + + return &btcjson.GetTxOutResult{ + Confirmations: b.confirmations, + ScriptPubKey: btcjson.ScriptPubKeyResult{ + Hex: fmt.Sprintf("%x", b.tx.TxOut[index].PkScript), + }, + }, nil +} + +func (b *testStaticBitcoin) GetRawTransaction( + hash *chainhash.Hash) (*btcutil.Tx, error) { + + b.mu.Lock() + defer b.mu.Unlock() + + if hash == nil || b.tx == nil || b.tx.TxHash() != *hash { + return nil, fmt.Errorf("transaction %v not found", hash) + } + + return btcutil.NewTx(b.tx.Copy()), nil +} + +func (b *testStaticBitcoin) setSpent(spent bool) { + b.mu.Lock() + b.spent = spent + b.mu.Unlock() +} + +type testStaticLightning struct { + lndclient.LightningClient + + height uint32 +} + +func (l *testStaticLightning) GetInfo(context.Context) (*lndclient.Info, + error) { + + return &lndclient.Info{BlockHeight: l.height}, nil +} + +type testStaticRouter struct { + lndclient.RouterClient + + mu sync.Mutex + sendCalls int +} + +func (r *testStaticRouter) SendPayment(context.Context, + lndclient.SendPaymentRequest) (chan lndclient.PaymentStatus, chan error, + error) { + + r.mu.Lock() + r.sendCalls++ + r.mu.Unlock() + + return nil, nil, fmt.Errorf("unexpected payment") +} + +func (r *testStaticRouter) calls() int { + r.mu.Lock() + defer r.mu.Unlock() + + return r.sendCalls +} + +func encodeStaticTestInvoice(t *testing.T, signer *btcec.PrivateKey, + hash lntypes.Hash, amount btcutil.Amount) string { + + t.Helper() + + invoice, err := zpay32.NewInvoice( + &chaincfg.RegressionNetParams, hash, time.Unix(1_700_000_000, 0), + zpay32.Description("regtest static Loop In"), + zpay32.Amount(lnwire.NewMSatFromSatoshis(amount)), + ) + require.NoError(t, err) + + encoded, err := invoice.Encode(zpay32.MessageSigner{ + SignCompact: func(message []byte) ([]byte, error) { + digest := chainhash.HashB(message) + + return ecdsa.SignCompact(signer, digest, true), nil + }, + }) + require.NoError(t, err) + + return encoded +} + +func (w *testStaticWallet) DeriveNextKey(context.Context, int32) ( + *keychain.KeyDescriptor, error) { + + w.deriveCalls++ + + return w.derivedKey, nil +} + +func (w *testStaticWallet) NextAddr(context.Context, string, + walletrpc.AddressType, bool) (btcutil.Address, error) { + + return w.address, nil +} + +func (w *testStaticWallet) EstimateFeeRate(context.Context, + int32) (chainfee.SatPerKWeight, error) { + + return w.feeRate, nil +} + +func newTestMuSig2Signer(privateKey *btcec.PrivateKey, + locator keychain.KeyLocator) *testMuSig2Signer { + + keyFetcher := func(keyDesc *keychain.KeyDescriptor) ( + *btcec.PrivateKey, error) { + + if keyDesc.KeyLocator != locator { + return nil, fmt.Errorf("unexpected key locator: %v", + keyDesc.KeyLocator) + } + + return privateKey, nil + } + + return &testMuSig2Signer{ + manager: input.NewMusigSessionManager(keyFetcher), + rawKeys: []*btcec.PrivateKey{privateKey}, + } +} + +func (s *testMuSig2Signer) SignOutputRaw(_ context.Context, tx *wire.MsgTx, + descriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) { + + return s.signOutputRaw(tx, descriptors, prevOutputs) +} + +func (s *testMuSig2Signer) SignOutputRawKeyLocator(_ context.Context, + tx *wire.MsgTx, descriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) { + + return s.signOutputRaw(tx, descriptors, prevOutputs) +} + +func (s *testMuSig2Signer) signOutputRaw(tx *wire.MsgTx, + descriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) { + + if len(prevOutputs) != len(tx.TxIn) { + return nil, fmt.Errorf("got %d prevouts for %d inputs", + len(prevOutputs), len(tx.TxIn)) + } + prevFetcher := txscript.NewMultiPrevOutFetcher(nil) + for i, txIn := range tx.TxIn { + prevFetcher.AddPrevOut(txIn.PreviousOutPoint, prevOutputs[i]) + } + sigHashes := txscript.NewTxSigHashes(tx, prevFetcher) + signatures := make([][]byte, len(descriptors)) + for i, descriptor := range descriptors { + if descriptor.KeyDesc.PubKey == nil { + return nil, fmt.Errorf("descriptor %d has no public key", i) + } + var privateKey *btcec.PrivateKey + for _, candidate := range s.rawKeys { + if candidate.PubKey().IsEqual(descriptor.KeyDesc.PubKey) { + privateKey = candidate + break + } + } + if privateKey == nil { + return nil, fmt.Errorf("signing key %x not found", + descriptor.KeyDesc.PubKey.SerializeCompressed()) + } + + signer := input.NewMockSigner( + []*btcec.PrivateKey{privateKey}, + &chaincfg.RegressionNetParams, + ) + signature, err := signer.SignOutputRaw(tx, &input.SignDescriptor{ + KeyDesc: descriptor.KeyDesc, + SingleTweak: descriptor.SingleTweak, + DoubleTweak: descriptor.DoubleTweak, + TapTweak: descriptor.TapTweak, + WitnessScript: descriptor.WitnessScript, + SignMethod: descriptor.SignMethod, + Output: descriptor.Output, + HashType: descriptor.HashType, + SigHashes: sigHashes, + PrevOutputFetcher: prevFetcher, + InputIndex: descriptor.InputIndex, + }) + if err != nil { + return nil, err + } + signatures[i] = signature.Serialize() + } + + return signatures, nil +} + +func (s *testMuSig2Signer) MuSig2CreateSession(_ context.Context, + version input.MuSig2Version, signerLoc *keychain.KeyLocator, + signers [][]byte, opts ...lndclient.MuSig2SessionOpts) ( + *input.MuSig2SessionInfo, error) { + + parsedSigners, err := input.MuSig2ParsePubKeys(version, signers) + if err != nil { + return nil, err + } + + request := &signrpc.MuSig2SessionRequest{} + for _, opt := range opts { + opt(request) + } + + tweaks := &input.MuSig2Tweaks{} + if request.TaprootTweak != nil { + if request.TaprootTweak.KeySpendOnly { + tweaks.TaprootBIP0086Tweak = true + } else { + tweaks.TaprootTweak = request.TaprootTweak.ScriptRoot + } + } + + nonces := make( + [][musig2.PubNonceSize]byte, + len(request.OtherSignerPublicNonces), + ) + for i, rawNonce := range request.OtherSignerPublicNonces { + if len(rawNonce) != musig2.PubNonceSize { + return nil, fmt.Errorf("invalid nonce length: %d", + len(rawNonce)) + } + copy(nonces[i][:], rawNonce) + } + + return s.manager.MuSig2CreateSession( + version, *signerLoc, parsedSigners, tweaks, nonces, nil, + ) +} + +func (s *testMuSig2Signer) MuSig2RegisterNonces(_ context.Context, + sessionID [32]byte, nonces [][musig2.PubNonceSize]byte) ( + bool, error) { + + return s.manager.MuSig2RegisterNonces( + input.MuSig2SessionID(sessionID), nonces, + ) +} + +func (s *testMuSig2Signer) MuSig2Sign(_ context.Context, + sessionID [32]byte, message [32]byte, cleanup bool) ([]byte, error) { + + partialSig, err := s.manager.MuSig2Sign( + input.MuSig2SessionID(sessionID), message, cleanup, + ) + if err != nil { + return nil, err + } + + serialized, err := input.SerializePartialSignature(partialSig) + if err != nil { + return nil, err + } + + return serialized[:], nil +} + +func (s *testMuSig2Signer) MuSig2CombineSig(_ context.Context, + sessionID [32]byte, otherPartialSigs [][]byte) (bool, []byte, error) { + + partialSigs := make( + []*musig2.PartialSignature, len(otherPartialSigs), + ) + for i, serialized := range otherPartialSigs { + partialSig, err := input.DeserializePartialSignature(serialized) + if err != nil { + return false, nil, err + } + partialSigs[i] = partialSig + } + + finalSig, haveAllSigs, err := s.manager.MuSig2CombineSig( + input.MuSig2SessionID(sessionID), partialSigs, + ) + if err != nil || finalSig == nil { + return haveAllSigs, nil, err + } + + return haveAllSigs, finalSig.Serialize(), nil +} + +func (s *testMuSig2Signer) MuSig2Cleanup(_ context.Context, + sessionID [32]byte) error { + + return s.manager.MuSig2Cleanup(input.MuSig2SessionID(sessionID)) +} + +func TestServerNewAddressIdempotent(t *testing.T) { + t.Parallel() + + _, clientPubKey := btcec.PrivKeyFromBytes([]byte{11}) + _, serverPubKey := btcec.PrivKeyFromBytes([]byte{12}) + serverLocator := keychain.KeyLocator{Family: 80, Index: 3} + wallet := &testStaticWallet{derivedKey: &keychain.KeyDescriptor{ + KeyLocator: serverLocator, + PubKey: serverPubKey, + }} + server := &Server{ + cfg: Config{ + Lnd: &lndclient.LndServices{ + WalletKit: wallet, + ChainParams: &chaincfg.RegressionNetParams, + }, + StaticAddressExpiry: 4_320, + }, + addresses: make(map[string]*staticAddress), + } + request := &swapserverrpc.ServerNewAddressRequest{ + ProtocolVersion: swapserverrpc.StaticAddressProtocolVersion_V0, + ClientKey: clientPubKey.SerializeCompressed(), + } + + first, err := server.ServerNewAddress(t.Context(), request) + require.NoError(t, err) + second, err := server.ServerNewAddress(t.Context(), request) + require.NoError(t, err) + require.Equal(t, first, second) + require.Equal(t, 1, wallet.deriveCalls) + require.Equal(t, serverPubKey.SerializeCompressed(), + first.Params.ServerKey) + require.Equal(t, uint32(4_320), first.Params.Expiry) + + _, err = server.ServerNewAddress(t.Context(), + &swapserverrpc.ServerNewAddressRequest{ + ProtocolVersion: swapserverrpc.StaticAddressProtocolVersion(1), + ClientKey: clientPubKey.SerializeCompressed(), + }, + ) + require.ErrorContains(t, err, "unsupported static address protocol") + require.Equal(t, 1, wallet.deriveCalls) +} + +type staticAdmissionHarness struct { + server *Server + request *swapserverrpc.ServerStaticAddressLoopInRequest + bitcoin *testStaticBitcoin + router *testStaticRouter + clientSigner *testMuSig2Signer + clientLocator keychain.KeyLocator +} + +func newStaticAdmissionHarness(t *testing.T, + confirmations int64) *staticAdmissionHarness { + + t.Helper() + + const ( + currentHeight = uint32(5_000) + depositValue = btcutil.Amount(500_000) + addressExpiry = uint32(4_320) + ) + + clientAddressPriv, clientAddressPub := btcec.PrivKeyFromBytes( + []byte{21}, + ) + serverAddressPriv, serverAddressPub := btcec.PrivKeyFromBytes( + []byte{22}, + ) + _, htlcClientPub := btcec.PrivKeyFromBytes([]byte{23}) + invoicePriv, _ := btcec.PrivKeyFromBytes([]byte{24}) + clientLocator := keychain.KeyLocator{Family: 80, Index: 21} + serverLocator := keychain.KeyLocator{Family: 80, Index: 22} + + contract, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(addressExpiry), + clientAddressPub, serverAddressPub, + ) + require.NoError(t, err) + pkScript, err := contract.StaticAddressScript() + require.NoError(t, err) + + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.HashH([]byte("static admission source")), + Index: 1, + }, + }) + fundingTx.AddTxOut(&wire.TxOut{ + Value: int64(depositValue), + PkScript: pkScript, + }) + outpoint := wire.OutPoint{Hash: fundingTx.TxHash(), Index: 0} + bitcoin := &testStaticBitcoin{ + tx: fundingTx, + confirmations: confirmations, + } + router := &testStaticRouter{} + serverSigner := newTestMuSig2Signer( + serverAddressPriv, serverLocator, + ) + wallet := &testStaticWallet{derivedKey: &keychain.KeyDescriptor{ + KeyLocator: serverLocator, + PubKey: serverAddressPub, + }} + server := &Server{ + cfg: Config{ + Lnd: &lndclient.LndServices{ + Client: &testStaticLightning{height: currentHeight}, + WalletKit: wallet, + Signer: serverSigner, + Router: router, + ChainParams: &chaincfg.RegressionNetParams, + }, + Bitcoin: bitcoin, + MinSwapAmount: 50_000, + MaxSwapAmount: 5_000_000, + FeeBaseSat: 100, + FeePPM: 1_000, + StaticAddressExpiry: addressExpiry, + PaymentTimeout: time.Minute, + Logger: log.New(io.Discard, "", 0), + }, + ctx: context.Background(), + staticSwaps: make(map[lntypes.Hash]*staticLoopInSwap), + addresses: make(map[string]*staticAddress), + lockedUTXOs: make(map[string]lntypes.Hash), + notifications: newNotificationHub(), + } + server.addresses[string(clientAddressPub.SerializeCompressed())] = + &staticAddress{ + clientKey: clientAddressPub, + serverKey: &serverKey{ + pubKey: serverAddressPub, + locator: serverLocator, + }, + expiry: addressExpiry, + contract: contract, + pkScript: pkScript, + } + + var preimage lntypes.Preimage + preimage[0] = 25 + hash := preimage.Hash() + invoiceAmount := depositValue - server.swapFee(depositValue) + request := &swapserverrpc.ServerStaticAddressLoopInRequest{ + ProtocolVersion: swapserverrpc.StaticAddressProtocolVersion_V0, + SwapHash: hash[:], + HtlcClientPubKey: htlcClientPub.SerializeCompressed(), + SwapInvoice: encodeStaticTestInvoice( + t, invoicePriv, hash, invoiceAmount, + ), + DepositOutpoints: []string{outpoint.String()}, + DepositToClientPubkeys: map[string]*swapserverrpc. + StaticAddressDescriptor{ + outpoint.String(): { + Pubkey: clientAddressPub.SerializeCompressed(), + PkScript: pkScript, + }, + }, + } + + return &staticAdmissionHarness{ + server: server, + request: request, + bitcoin: bitcoin, + router: router, + clientSigner: newTestMuSig2Signer(clientAddressPriv, clientLocator), + clientLocator: clientLocator, + } +} + +func TestStaticDepositAdmissionPolicy(t *testing.T) { + t.Parallel() + + // At height 5,000 with a 4,320-block CSV, 3,271 confirmations + // leave exactly 1,050 blocks. One additional confirmation makes the + // deposit one block too old. + testCases := []struct { + name string + confirmations int64 + errorCode codes.Code + errorContains string + }{ + { + name: "unconfirmed", + confirmations: 0, + errorCode: codes.FailedPrecondition, + errorContains: "unconfirmed", + }, + { + name: "near expiry", + confirmations: 3_272, + errorCode: codes.FailedPrecondition, + errorContains: "residual CSV lifetime", + }, + { + name: "exact lifetime boundary", + confirmations: 3_271, + errorCode: codes.OK, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + harness := newStaticAdmissionHarness( + t, testCase.confirmations, + ) + response, err := harness.server.ServerStaticAddressLoopIn( + t.Context(), harness.request, + ) + if testCase.errorCode != codes.OK { + require.Equal(t, testCase.errorCode, status.Code(err)) + require.ErrorContains(t, err, testCase.errorContains) + require.Nil(t, response) + + return + } + + require.NoError(t, err) + require.NotNil(t, response) + hash, err := parseHash(harness.request.SwapHash) + require.NoError(t, err) + staticSwap := harness.server.staticSwaps[hash] + require.NotNil(t, staticSwap) + require.EqualValues( + t, staticDepositMinLifetime, + int64(staticSwap.address.expiry)-testCase.confirmations+1, + ) + harness.server.cleanupStaticSessions( + context.Background(), staticSwap, + ) + harness.server.releaseStaticLocks(staticSwap) + }) + } +} + +func TestStaticDepositSpentBeforePayment(t *testing.T) { + t.Parallel() + + harness := newStaticAdmissionHarness(t, 1) + _, err := harness.server.ServerStaticAddressLoopIn( + t.Context(), harness.request, + ) + require.NoError(t, err) + hash, err := parseHash(harness.request.SwapHash) + require.NoError(t, err) + staticSwap := harness.server.staticSwaps[hash] + require.NotNil(t, staticSwap) + + clientInfos := make( + []*swapserverrpc.ClientHtlcSigningInfo, + len(staticFundingFeeRates), + ) + for i, round := range staticSwap.fundingRounds { + clientInfos[i] = signStaticFundingRound( + t, t.Context(), harness.clientSigner, + harness.clientLocator, staticSwap, round, + ) + } + + notifications := harness.server.notifications.subscribe(t.Context()) + + // Admission took a valid UTXO snapshot. Model a conflicting spend after + // the backup signatures have been prepared but before the payment worker + // is allowed to accept risk or dispatch the invoice. + harness.bitcoin.setSpent(true) + _, err = harness.server.PushStaticAddressHtlcSigs( + t.Context(), &swapserverrpc.PushStaticAddressHtlcSigsRequest{ + SwapHash: hash[:], + StandardHtlcInfo: clientInfos[0], + HighFeeHtlcInfo: clientInfos[1], + ExtremeFeeHtlcInfo: clientInfos[2], + }, + ) + require.NoError(t, err) + harness.server.wg.Wait() + + select { + case notification := <-notifications: + rejected := notification.GetStaticLoopInRiskRejected() + require.NotNil(t, rejected) + require.Equal(t, hash[:], rejected.SwapHash) + require.Nil(t, notification.GetStaticLoopInRiskAccepted()) + + case <-time.After(time.Second): + t.Fatal("risk rejection notification not received") + } + + require.Zero(t, harness.router.calls()) + harness.server.mu.RLock() + _, locked := harness.server.lockedUTXOs[staticSwap.depositStrings[0]] + harness.server.mu.RUnlock() + require.False(t, locked) +} + +func TestStaticFundingRoundsFinalize(t *testing.T) { + t.Parallel() + + ctx := context.Background() + clientAddressPriv, clientAddressPub := btcec.PrivKeyFromBytes( + []byte{1}, + ) + serverAddressPriv, serverAddressPub := btcec.PrivKeyFromBytes( + []byte{2}, + ) + _, clientHtlcPub := btcec.PrivKeyFromBytes([]byte{3}) + serverHtlcPriv, serverHtlcPub := btcec.PrivKeyFromBytes([]byte{4}) + clientLocator := keychain.KeyLocator{Family: 80, Index: 1} + serverLocator := keychain.KeyLocator{Family: 80, Index: 2} + + contract, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 4_320, clientAddressPub, + serverAddressPub, + ) + require.NoError(t, err) + pkScript, err := contract.StaticAddressScript() + require.NoError(t, err) + + var preimage lntypes.Preimage + preimage[0] = 5 + htlc, err := swap.NewHtlcV2( + 1_000, keyBytes(clientHtlcPub), keyBytes(serverHtlcPub), + preimage.Hash(), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + outpointOne := wire.OutPoint{ + Hash: chainhash.HashH([]byte("static-deposit-one")), + Index: 0, + } + outpointTwo := wire.OutPoint{ + Hash: chainhash.HashH([]byte("static-deposit-two")), + Index: 1, + } + prevOuts := map[wire.OutPoint]*wire.TxOut{ + outpointOne: { + Value: 300_000, + PkScript: pkScript, + }, + outpointTwo: { + Value: 250_000, + PkScript: pkScript, + }, + } + + serverSigner := newTestMuSig2Signer( + serverAddressPriv, serverLocator, + ) + serverSigner.rawKeys = append(serverSigner.rawKeys, serverHtlcPriv) + clientSigner := newTestMuSig2Signer( + clientAddressPriv, clientLocator, + ) + directAddress, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(serverHtlcPub), + &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + server := &Server{ + cfg: Config{Lnd: &lndclient.LndServices{ + Signer: serverSigner, + WalletKit: &testStaticWallet{ + address: directAddress, + feeRate: chainfee.SatPerKWeight(500), + }, + ChainParams: &chaincfg.RegressionNetParams, + }}, + staticSwaps: make(map[lntypes.Hash]*staticLoopInSwap), + lockedUTXOs: make(map[string]lntypes.Hash), + } + staticSwap := &staticLoopInSwap{ + hash: preimage.Hash(), + deposits: []wire.OutPoint{outpointOne, outpointTwo}, + prevOuts: prevOuts, + changePkScript: pkScript, + address: &staticAddress{ + clientKey: clientAddressPub, + serverKey: &serverKey{ + pubKey: serverAddressPub, + locator: serverLocator, + }, + contract: contract, + pkScript: pkScript, + }, + totalDepositAmount: 550_000, + swapAmount: 400_000, + htlcClientKey: clientHtlcPub, + htlcServerKey: &serverKey{ + pubKey: serverHtlcPub, + locator: keychain.KeyLocator{Family: 80, Index: 4}, + }, + htlc: htlc, + workerStarted: true, + } + + clientInfos := make( + []*swapserverrpc.ClientHtlcSigningInfo, + len(staticFundingFeeRates), + ) + for i, feeRate := range staticFundingFeeRates { + round, err := server.newStaticFundingRound( + ctx, staticSwap, feeRate, + ) + require.NoError(t, err) + staticSwap.fundingRounds[i] = round + clientInfos[i] = signStaticFundingRound( + t, ctx, clientSigner, clientLocator, staticSwap, round, + ) + } + + server.staticSwaps[staticSwap.hash] = staticSwap + request := &swapserverrpc.PushStaticAddressHtlcSigsRequest{ + SwapHash: staticSwap.hash[:], + StandardHtlcInfo: clientInfos[0], + HighFeeHtlcInfo: clientInfos[1], + ExtremeFeeHtlcInfo: clientInfos[2], + } + _, err = server.PushStaticAddressHtlcSigs(ctx, request) + require.NoError(t, err) + require.True(t, staticSwap.backupFinalized) + + txids := make(map[chainhash.Hash]struct{}, len(staticFundingFeeRates)) + for _, round := range staticSwap.fundingRounds { + require.NotNil(t, round.finalTx) + require.NoError(t, validateStaticFundingTx( + round.finalTx, prevOuts, + )) + require.Len(t, round.finalTx.TxIn, len(staticSwap.deposits)) + for _, txIn := range round.finalTx.TxIn { + require.Len(t, txIn.Witness, 1) + require.Len(t, txIn.Witness[0], 64) + } + txids[round.finalTx.TxHash()] = struct{}{} + } + require.Len(t, txids, len(staticFundingFeeRates)) + + // A retry must not attempt to register a nonce or combine a signature + // into an already consumed session. + _, err = server.PushStaticAddressHtlcSigs(ctx, request) + require.NoError(t, err) + + // The fallback claim is a real, script-validated P2WSH success spend. + staticSwap.paymentPreimage = preimage + successSweep, err := server.createStaticSuccessSweep( + ctx, staticSwap, staticSwap.fundingRounds[0].finalTx, + ) + require.NoError(t, err) + require.Equal(t, htlc.SuccessSequence(), successSweep.TxIn[0].Sequence) + require.True(t, htlc.IsSuccessWitness(successSweep.TxIn[0].Witness)) + + directRound, err := server.newStaticSweeplessRound(ctx, staticSwap) + require.NoError(t, err) + staticSwap.sweepless = directRound + packet, err := psbt.NewFromRawBytes( + bytes.NewReader(directRound.psbt), false, + ) + require.NoError(t, err) + require.Len(t, packet.Inputs, len(staticSwap.deposits)) + for i, packetInput := range packet.Inputs { + require.Equal(t, staticSwap.prevOuts[staticSwap.deposits[i]], + packetInput.WitnessUtxo) + } + directInfos := signStaticSweeplessRound( + t, ctx, clientSigner, clientLocator, staticSwap, directRound, + ) + directTxID := directRound.tx.TxHash() + directRequest := &swapserverrpc.PushStaticAddressSweeplessSigsRequest{ + SwapHash: staticSwap.hash[:], + Txid: directTxID[:], + SigningInfo: directInfos, + } + _, err = server.PushStaticAddressSweeplessSigs( + ctx, &swapserverrpc.PushStaticAddressSweeplessSigsRequest{ + SwapHash: staticSwap.hash[:], + Txid: directTxID[:], + ErrorMessage: "swap not finished", + }, + ) + require.NoError(t, err) + _, err = server.PushStaticAddressSweeplessSigs(ctx, directRequest) + require.NoError(t, err) + require.NotNil(t, directRound.finalTx) + require.NoError(t, validateStaticFundingTx( + directRound.finalTx, staticSwap.prevOuts, + )) + + // The direct-signature submission is idempotent too. + _, err = server.PushStaticAddressSweeplessSigs(ctx, directRequest) + require.NoError(t, err) + + // Retrying the protocol's empty-signature abandonment signal is also + // harmless if the first response was lost. + abandonedHash := lntypes.Hash{9} + server.staticSwaps[abandonedHash] = &staticLoopInSwap{abandoned: true} + _, err = server.PushStaticAddressHtlcSigs( + ctx, &swapserverrpc.PushStaticAddressHtlcSigsRequest{ + SwapHash: abandonedHash[:], + }, + ) + require.NoError(t, err) +} + +func signStaticFundingRound(t *testing.T, ctx context.Context, + signer *testMuSig2Signer, locator keychain.KeyLocator, + staticSwap *staticLoopInSwap, + round *staticFundingRound) *swapserverrpc.ClientHtlcSigningInfo { + + t.Helper() + + signers := [][]byte{ + staticSwap.address.clientKey.SerializeCompressed(), + staticSwap.address.serverKey.pubKey.SerializeCompressed(), + } + prevFetcher := txscript.NewMultiPrevOutFetcher(staticSwap.prevOuts) + sigHashes := txscript.NewTxSigHashes(round.tx, prevFetcher) + info := &swapserverrpc.ClientHtlcSigningInfo{ + Nonces: make([][]byte, len(staticSwap.deposits)), + Sigs: make([][]byte, len(staticSwap.deposits)), + } + + for i := range staticSwap.deposits { + session, err := signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, &locator, signers, + lndclient.MuSig2TaprootTweakOpt( + staticSwap.address.contract.RootHash[:], false, + ), + ) + require.NoError(t, err) + info.Nonces[i] = append([]byte(nil), session.PublicNonce[:]...) + + haveAllNonces, err := signer.MuSig2RegisterNonces( + ctx, session.SessionID, + [][musig2.PubNonceSize]byte{ + round.sessions[i].PublicNonce, + }, + ) + require.NoError(t, err) + require.True(t, haveAllNonces) + + digestBytes, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, round.tx, i, + prevFetcher, + ) + require.NoError(t, err) + var digest [32]byte + copy(digest[:], digestBytes) + info.Sigs[i], err = signer.MuSig2Sign( + ctx, session.SessionID, digest, true, + ) + require.NoError(t, err) + } + + return info +} + +func signStaticSweeplessRound(t *testing.T, ctx context.Context, + signer *testMuSig2Signer, locator keychain.KeyLocator, + staticSwap *staticLoopInSwap, + round *staticSweeplessRound) map[string]*swapserverrpc. + ClientSweeplessSigningInfo { + + t.Helper() + + signers := [][]byte{ + staticSwap.address.clientKey.SerializeCompressed(), + staticSwap.address.serverKey.pubKey.SerializeCompressed(), + } + prevFetcher := txscript.NewMultiPrevOutFetcher(staticSwap.prevOuts) + sigHashes := txscript.NewTxSigHashes(round.tx, prevFetcher) + infos := make( + map[string]*swapserverrpc.ClientSweeplessSigningInfo, + len(staticSwap.deposits), + ) + for i, outpoint := range staticSwap.deposits { + session, err := signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, &locator, signers, + lndclient.MuSig2TaprootTweakOpt( + staticSwap.address.contract.RootHash[:], false, + ), + ) + require.NoError(t, err) + haveAllNonces, err := signer.MuSig2RegisterNonces( + ctx, session.SessionID, + [][musig2.PubNonceSize]byte{ + round.sessions[outpoint.String()].PublicNonce, + }, + ) + require.NoError(t, err) + require.True(t, haveAllNonces) + + digestBytes, err := txscript.CalcTaprootSignatureHash( + sigHashes, txscript.SigHashDefault, round.tx, i, + prevFetcher, + ) + require.NoError(t, err) + var digest [32]byte + copy(digest[:], digestBytes) + partialSig, err := signer.MuSig2Sign( + ctx, session.SessionID, digest, true, + ) + require.NoError(t, err) + infos[outpoint.String()] = &swapserverrpc. + ClientSweeplessSigningInfo{ + Nonce: append([]byte(nil), session.PublicNonce[:]...), + Sig: partialSig, + } + } + + return infos +} diff --git a/regtest/server/updates.go b/regtest/server/updates.go new file mode 100644 index 000000000..e6d663ab8 --- /dev/null +++ b/regtest/server/updates.go @@ -0,0 +1,172 @@ +package server + +import ( + "context" + "sync" + "time" + + "github.com/lightninglabs/loop/swapserverrpc" +) + +type serverUpdate struct { + state swapserverrpc.ServerSwapState + timestamp time.Time +} + +type updateSubscription struct { + history []serverUpdate + updates <-chan serverUpdate + done bool + cancel func() +} + +type updateHub struct { + mu sync.Mutex + history []serverUpdate + subscribers map[uint64]chan serverUpdate + nextID uint64 + done bool +} + +func newUpdateHub() *updateHub { + return &updateHub{ + subscribers: make(map[uint64]chan serverUpdate), + } +} + +func (h *updateHub) publish(state swapserverrpc.ServerSwapState) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.done { + return + } + + update := serverUpdate{ + state: state, + timestamp: time.Now(), + } + h.history = append(h.history, update) + + for _, subscriber := range h.subscribers { + select { + case subscriber <- update: + default: + } + } +} + +func (h *updateHub) finish(state swapserverrpc.ServerSwapState) { + h.publish(state) + + h.mu.Lock() + h.done = true + for id, subscriber := range h.subscribers { + close(subscriber) + delete(h.subscribers, id) + } + h.mu.Unlock() +} + +func (h *updateHub) subscribe() updateSubscription { + h.mu.Lock() + defer h.mu.Unlock() + + history := append([]serverUpdate(nil), h.history...) + if h.done { + return updateSubscription{ + history: history, + done: true, + cancel: func() {}, + } + } + + id := h.nextID + h.nextID++ + updates := make(chan serverUpdate, 16) + h.subscribers[id] = updates + + var once sync.Once + return updateSubscription{ + history: history, + updates: updates, + cancel: func() { + once.Do(func() { + h.mu.Lock() + if subscriber, ok := h.subscribers[id]; ok { + delete(h.subscribers, id) + close(subscriber) + } + h.mu.Unlock() + }) + }, + } +} + +type notificationHub struct { + mu sync.Mutex + history []*swapserverrpc.SubscribeNotificationsResponse + subscribers map[uint64]chan *swapserverrpc.SubscribeNotificationsResponse + nextID uint64 +} + +const notificationHistoryLimit = 128 + +func newNotificationHub() *notificationHub { + return ¬ificationHub{ + subscribers: make( + map[uint64]chan *swapserverrpc.SubscribeNotificationsResponse, + ), + } +} + +func (h *notificationHub) publish( + notification *swapserverrpc.SubscribeNotificationsResponse) { + + h.mu.Lock() + defer h.mu.Unlock() + + h.history = append(h.history, notification) + if len(h.history) > notificationHistoryLimit { + h.history = append( + []*swapserverrpc.SubscribeNotificationsResponse(nil), + h.history[len(h.history)-notificationHistoryLimit:]..., + ) + } + + for _, subscriber := range h.subscribers { + select { + case subscriber <- notification: + default: + } + } +} + +func (h *notificationHub) subscribe( + ctx context.Context) <-chan *swapserverrpc.SubscribeNotificationsResponse { + + h.mu.Lock() + id := h.nextID + h.nextID++ + updates := make( + chan *swapserverrpc.SubscribeNotificationsResponse, + len(h.history)+16, + ) + for _, notification := range h.history { + updates <- notification + } + h.subscribers[id] = updates + h.mu.Unlock() + + go func() { + <-ctx.Done() + h.mu.Lock() + if subscriber, ok := h.subscribers[id]; ok { + delete(h.subscribers, id) + close(subscriber) + } + h.mu.Unlock() + }() + + return updates +} diff --git a/regtest/server/updates_test.go b/regtest/server/updates_test.go new file mode 100644 index 000000000..463bed4b8 --- /dev/null +++ b/regtest/server/updates_test.go @@ -0,0 +1,75 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/stretchr/testify/require" +) + +func TestUpdateHubReplayAndFinish(t *testing.T) { + t.Parallel() + + hub := newUpdateHub() + hub.publish(swapserverrpc.ServerSwapState_SERVER_INITIATED) + + subscriber := hub.subscribe() + require.Len(t, subscriber.history, 1) + require.Equal(t, swapserverrpc.ServerSwapState_SERVER_INITIATED, + subscriber.history[0].state) + + hub.publish(swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED) + select { + case update := <-subscriber.updates: + require.Equal(t, + swapserverrpc.ServerSwapState_SERVER_HTLC_PUBLISHED, + update.state) + case <-time.After(time.Second): + t.Fatal("live update not delivered") + } + + hub.finish(swapserverrpc.ServerSwapState_SERVER_SUCCESS) + select { + case update := <-subscriber.updates: + require.Equal(t, swapserverrpc.ServerSwapState_SERVER_SUCCESS, + update.state) + case <-time.After(time.Second): + t.Fatal("terminal update not delivered") + } + + _, ok := <-subscriber.updates + require.False(t, ok) + + late := hub.subscribe() + require.True(t, late.done) + require.Len(t, late.history, 3) + require.Equal(t, swapserverrpc.ServerSwapState_SERVER_SUCCESS, + late.history[2].state) +} + +func TestNotificationHubCancellation(t *testing.T) { + t.Parallel() + + hub := newNotificationHub() + want := &swapserverrpc.SubscribeNotificationsResponse{} + hub.publish(want) + + ctx, cancel := context.WithCancel(context.Background()) + updates := hub.subscribe(ctx) + select { + case got := <-updates: + require.Same(t, want, got) + case <-time.After(time.Second): + t.Fatal("notification not delivered") + } + + cancel() + select { + case _, ok := <-updates: + require.False(t, ok) + case <-time.After(time.Second): + t.Fatal("notification subscription not closed") + } +}