Skip to content

Commit afff0cc

Browse files
committed
recover L402 and static address
1 parent d4e3dcc commit afff0cc

32 files changed

Lines changed: 5395 additions & 384 deletions

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ To execute a Loop In:
6464
loop in <amt_in_satoshis>
6565
```
6666

67+
### Static Address Recovery
68+
Loop now keeps at most one encrypted immutable recovery backup per paid L402
69+
generation in the active network data directory. A backup is written only after
70+
Loop has both the paid `l402.token` and the concrete static-address parameters
71+
for that generation.
72+
73+
The backup is encrypted with a key derived by the backing `lnd` wallet. It is
74+
therefore only useful with that same `lnd` instance, or with an `lnd` restored
75+
from the same seed/key material. The Loop backup file alone is not enough to
76+
recover static-address access.
77+
78+
Existing static-address users get this backup backfilled on the next startup
79+
with the upgraded client. A fresh install that has no local L402 or
80+
static-address state first checks for an existing recovery backup in the active
81+
network directory. If none is restored, startup materializes the initial
82+
paid-L402/static-address generation and writes its backup.
83+
84+
The follow-up multi-address work is expected to keep this one-backup-per-L402
85+
model and use the deterministic receive/change key-family metadata already
86+
stored in the backup. See [recovery/README.md](./recovery/README.md) for the
87+
full recovery model and the planned multi-address outlook.
88+
6789
### More info
6890

6991
- [Loop FAQs](./docs/faqs.md)

cmd/loop/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ var (
8888
monitorCommand, quoteCommand, listAuthCommand, fetchL402Command,
8989
listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand,
9090
setLiquidityRuleCommand, suggestSwapCommand, setParamsCommand,
91-
getInfoCommand, abandonSwapCommand, reservationsCommands,
91+
getInfoCommand, abandonSwapCommand, recoverCommand,
92+
reservationsCommands,
9293
instantOutCommand, listInstantOutsCommand, stopCommand,
9394
printManCommand, printMarkdownCommand,
9495
}

cmd/loop/recover.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package main
2+
3+
import (
4+
"context"
5+
6+
"github.com/lightninglabs/loop/looprpc"
7+
"github.com/urfave/cli/v3"
8+
)
9+
10+
var recoverCommand = &cli.Command{
11+
Name: "recover",
12+
Usage: "restore static address and L402 state from a local backup file",
13+
Description: "Restores the local static-address state and L402 token " +
14+
"from an encrypted backup file. If --backup_file is omitted, " +
15+
"loopd selects the latest decryptable active-network backup " +
16+
"candidate and fully validates it before restoring state.",
17+
Flags: []cli.Flag{
18+
&cli.StringFlag{
19+
Name: "backup_file",
20+
Usage: "path to an encrypted backup file; if omitted, " +
21+
"loopd selects and validates the latest active-network " +
22+
"backup candidate",
23+
},
24+
},
25+
Action: runRecover,
26+
}
27+
28+
func runRecover(ctx context.Context, cmd *cli.Command) error {
29+
if cmd.NArg() > 0 {
30+
return showCommandHelp(ctx, cmd)
31+
}
32+
33+
client, cleanup, err := getClient(cmd)
34+
if err != nil {
35+
return err
36+
}
37+
defer cleanup()
38+
39+
resp, err := client.Recover(
40+
ctx, &looprpc.RecoverRequest{
41+
BackupFile: cmd.String("backup_file"),
42+
},
43+
)
44+
if err != nil {
45+
return err
46+
}
47+
48+
printRespJSON(resp)
49+
return nil
50+
}

cmd/loop/staticaddr.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/lightninglabs/loop/labels"
1111
"github.com/lightninglabs/loop/looprpc"
12+
"github.com/lightninglabs/loop/staticaddr/address"
1213
"github.com/lightninglabs/loop/staticaddr/loopin"
1314
"github.com/lightninglabs/loop/swapserverrpc"
1415
lndcommands "github.com/lightningnetwork/lnd/cmd/commands"
@@ -43,13 +44,15 @@ var staticAddressCommands = &cli.Command{
4344
var newStaticAddressCommand = &cli.Command{
4445
Name: "new",
4546
Aliases: []string{"n"},
46-
Usage: "Create a new static loop in address.",
47+
Usage: "Return the static loop in address.",
4748
Description: `
48-
Requests a new static loop in address from the server. Funds that are
49-
sent to this address will be locked by a 2:2 multisig between us and the
50-
loop server, or a timeout path that we can sweep once it opens up. The
51-
funds can either be cooperatively spent with a signature from the server
52-
or looped in.
49+
Returns the current static loop in address. On a fresh installation loopd
50+
initializes the current static-address generation during startup. If the
51+
address is still missing, this call will create it on demand. Funds sent
52+
to the address will be locked by a 2:2 multisig between us and the loop
53+
server, or a timeout path that we can sweep once it opens up. The funds
54+
can either be cooperatively spent with a signature from the server or
55+
looped in.
5356
`,
5457
Action: newStaticAddress,
5558
}
@@ -59,16 +62,16 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error {
5962
return showCommandHelp(ctx, cmd)
6063
}
6164

62-
err := displayNewAddressWarning()
65+
client, cleanup, err := getClient(cmd)
6366
if err != nil {
6467
return err
6568
}
69+
defer cleanup()
6670

67-
client, cleanup, err := getClient(cmd)
71+
err = maybeDisplayNewAddressWarning(ctx, client)
6872
if err != nil {
6973
return err
7074
}
71-
defer cleanup()
7275

7376
resp, err := client.NewStaticAddress(
7477
ctx, &looprpc.NewStaticAddressRequest{},
@@ -841,8 +844,26 @@ func lowConfDepositWarning(allDeposits []*looprpc.Deposit,
841844
)
842845
}
843846

847+
func maybeDisplayNewAddressWarning(ctx context.Context,
848+
client looprpc.SwapClientClient) error {
849+
850+
_, err := client.GetStaticAddressSummary(
851+
ctx, &looprpc.StaticAddressSummaryRequest{},
852+
)
853+
switch {
854+
case err == nil:
855+
return nil
856+
857+
case strings.Contains(err.Error(), address.ErrNoStaticAddress.Error()):
858+
return displayNewAddressWarning()
859+
860+
default:
861+
return nil
862+
}
863+
}
864+
844865
func displayNewAddressWarning() error {
845-
fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " +
866+
fmt.Printf("\nWARNING: Be aware that losing your l402.token file in " +
846867
".loop under your home directory will take your ability to " +
847868
"spend funds sent to the static address via loop-ins or " +
848869
"withdrawals. You will have to wait until the deposit " +

docs/loop.1

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,16 @@ abandon a swap with a given swap hash
403403
.PP
404404
\fB--i_know_what_i_am_doing\fP: Specify this flag if you made sure that you read and understood the following consequence of applying this command.
405405

406+
.SH recover
407+
.PP
408+
restore static address and L402 state from a local backup file
409+
410+
.PP
411+
\fB--backup_file\fP="": path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate
412+
413+
.PP
414+
\fB--help, -h\fP: show help
415+
406416
.SH reservations, r
407417
.PP
408418
manage reservations
@@ -456,7 +466,7 @@ perform on-chain to off-chain swaps using static addresses.
456466

457467
.SS new, n
458468
.PP
459-
Create a new static loop in address.
469+
Return the static loop in address.
460470

461471
.PP
462472
\fB--help, -h\fP: show help

docs/loop.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,25 @@ The following flags are supported:
418418
| `--i_know_what_i_am_doing` | Specify this flag if you made sure that you read and understood the following consequence of applying this command | bool | `false` |
419419
| `--help` (`-h`) | show help | bool | `false` |
420420

421+
### `recover` command
422+
423+
restore static address and L402 state from a local backup file.
424+
425+
Restores the local static-address state and L402 token from an encrypted backup file. If --backup_file is omitted, loopd selects the latest decryptable active-network backup candidate and fully validates it before restoring state.
426+
427+
Usage:
428+
429+
```bash
430+
$ loop [GLOBAL FLAGS] recover [COMMAND FLAGS] [ARGUMENTS...]
431+
```
432+
433+
The following flags are supported:
434+
435+
| Name | Description | Type | Default value |
436+
|---------------------|----------------------------------------------------------------------------------------------------------------------|--------|:-------------:|
437+
| `--backup_file="…"` | path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate | string |
438+
| `--help` (`-h`) | show help | bool | `false` |
439+
421440
### `reservations` command (aliases: `r`)
422441

423442
manage reservations.
@@ -529,9 +548,9 @@ The following flags are supported:
529548

530549
### `static new` subcommand (aliases: `n`)
531550

532-
Create a new static loop in address.
551+
Return the static loop in address.
533552

534-
Requests a new static loop in address from the server. Funds that are sent to this address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in.
553+
Returns the current static loop in address. On a fresh installation loopd initializes the current static-address generation during startup. If the address is still missing, this call will create it on demand. Funds sent to the address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in.
535554

536555
Usage:
537556

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ require (
3737
github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6
3838
github.com/urfave/cli/v3 v3.4.1
3939
go.etcd.io/bbolt v1.4.3
40+
golang.org/x/crypto v0.46.0
4041
golang.org/x/sync v0.19.0
4142
google.golang.org/grpc v1.79.3
4243
google.golang.org/protobuf v1.36.11
@@ -194,7 +195,6 @@ require (
194195
go.uber.org/multierr v1.6.0 // indirect
195196
go.uber.org/zap v1.24.0 // indirect
196197
go.yaml.in/yaml/v3 v3.0.4 // indirect
197-
golang.org/x/crypto v0.46.0 // indirect
198198
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
199199
golang.org/x/mod v0.30.0 // indirect
200200
golang.org/x/net v0.48.0 // indirect

loopd/daemon.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/lightninglabs/loop/loopdb"
2323
loop_looprpc "github.com/lightninglabs/loop/looprpc"
2424
"github.com/lightninglabs/loop/notifications"
25+
"github.com/lightninglabs/loop/recovery"
2526
"github.com/lightninglabs/loop/staticaddr/address"
2627
"github.com/lightninglabs/loop/staticaddr/deposit"
2728
"github.com/lightninglabs/loop/staticaddr/loopin"
@@ -597,13 +598,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
597598
withdrawalManager *withdraw.Manager
598599
openChannelManager *openchannel.Manager
599600
staticLoopInManager *loopin.Manager
601+
recoveryService *recovery.Service
600602
)
601603

602604
// Static address manager setup.
603605
staticAddressStore := address.NewSqlStore(baseDb)
604606
addrCfg := &address.ManagerConfig{
605607
AddressClient: staticAddressClient,
606-
FetchL402: swapClient.Server.FetchL402,
608+
FetchL402: func(ctx context.Context) error {
609+
return swapClient.Server.FetchL402(ctx)
610+
},
607611
Store: staticAddressStore,
608612
WalletKit: d.lnd.WalletKit,
609613
ChainParams: d.lnd.ChainParams,
@@ -705,6 +709,46 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
705709
return fmt.Errorf("unable to create loop-in manager: %w", err)
706710
}
707711

712+
// Keep startup restore/write-backup free of deposit reconciliation so we
713+
// don't create deposit FSMs before the deposit manager is running.
714+
startupRecoveryService := recovery.NewService(
715+
d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit,
716+
staticAddressManager, nil,
717+
)
718+
719+
restoreResult, restoredFromBackup, err :=
720+
startupRecoveryService.RestoreLatestOnFreshInstall(d.mainCtx)
721+
if err != nil {
722+
return fmt.Errorf("unable to restore latest recovery "+
723+
"backup on fresh install: %w", err)
724+
}
725+
if restoredFromBackup {
726+
infof("Restored fresh install from encrypted recovery "+
727+
"backup %s", restoreResult.BackupFile)
728+
} else {
729+
_, _, err = staticAddressManager.NewAddress(d.mainCtx)
730+
if err != nil {
731+
warnf("Unable to initialize static address generation "+
732+
"during startup: %v", err)
733+
}
734+
}
735+
736+
backupFile, err := startupRecoveryService.WriteBackup(d.mainCtx)
737+
if err != nil {
738+
warnf("Unable to write startup recovery backup: %v", err)
739+
}
740+
if backupFile != "" {
741+
infof("Wrote encrypted recovery backup to %s after "+
742+
"initializing the current L402 generation", backupFile)
743+
}
744+
745+
// Runtime recovery is wired with the deposit manager so explicit
746+
// recovery RPCs can reconcile restored static-address deposits.
747+
recoveryService = recovery.NewService(
748+
d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit,
749+
staticAddressManager, depositManager,
750+
)
751+
708752
var (
709753
reservationManager *reservation.Manager
710754
instantOutManager *instantout.Manager
@@ -769,6 +813,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
769813
staticLoopInManager: staticLoopInManager,
770814
openChannelManager: openChannelManager,
771815
assetClient: d.assetClient,
816+
recoveryService: recoveryService,
772817
stopDaemon: d.Stop,
773818
}
774819

loopd/swapclient_server.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/lightninglabs/loop/liquidity"
3030
"github.com/lightninglabs/loop/loopdb"
3131
"github.com/lightninglabs/loop/looprpc"
32+
"github.com/lightninglabs/loop/recovery"
3233
"github.com/lightninglabs/loop/staticaddr/address"
3334
"github.com/lightninglabs/loop/staticaddr/deposit"
3435
"github.com/lightninglabs/loop/staticaddr/loopin"
@@ -101,6 +102,7 @@ type swapClientServer struct {
101102
staticLoopInManager *loopin.Manager
102103
openChannelManager *openchannel.Manager
103104
assetClient *assets.TapdClient
105+
recoveryService *recovery.Service
104106
swaps map[lntypes.Hash]loop.SwapInfo
105107
subscribers map[int]chan<- any
106108
statusChan chan loop.SwapInfo
@@ -1277,6 +1279,32 @@ func (s *swapClientServer) FetchL402Token(ctx context.Context,
12771279
return &looprpc.FetchL402TokenResponse{}, nil
12781280
}
12791281

1282+
// Recover restores the local paid L402 token material and static-address state
1283+
// from an encrypted backup file.
1284+
func (s *swapClientServer) Recover(ctx context.Context,
1285+
req *looprpc.RecoverRequest) (*looprpc.RecoverResponse, error) {
1286+
1287+
if s.recoveryService == nil {
1288+
return nil, status.Error(
1289+
codes.Unavailable, "recovery service not configured",
1290+
)
1291+
}
1292+
1293+
result, err := s.recoveryService.Restore(ctx, req.GetBackupFile())
1294+
if err != nil {
1295+
return nil, err
1296+
}
1297+
1298+
return &looprpc.RecoverResponse{
1299+
BackupFile: result.BackupFile,
1300+
RestoredL402: result.RestoredL402,
1301+
RestoredStaticAddress: result.RestoredStaticAddress,
1302+
StaticAddress: result.StaticAddress,
1303+
NumDepositsFound: uint32(result.NumDepositsFound),
1304+
DepositReconciliationError: result.DepositReconciliationError,
1305+
}, nil
1306+
}
1307+
12801308
// GetInfo returns basic information about the loop daemon and details to swaps
12811309
// from the swap store.
12821310
func (s *swapClientServer) GetInfo(ctx context.Context,
@@ -1646,6 +1674,17 @@ func (s *swapClientServer) NewStaticAddress(ctx context.Context,
16461674
return nil, err
16471675
}
16481676

1677+
if s.recoveryService != nil {
1678+
backupFile, backupErr := s.recoveryService.WriteBackup(ctx)
1679+
if backupErr != nil {
1680+
warnf("Unable to write recovery backup after static "+
1681+
"address request: %v", backupErr)
1682+
} else if backupFile != "" {
1683+
infof("Wrote encrypted recovery backup to %s after "+
1684+
"static address request", backupFile)
1685+
}
1686+
}
1687+
16491688
return &looprpc.NewStaticAddressResponse{
16501689
Address: staticAddress.String(),
16511690
Expiry: uint32(expiry),

0 commit comments

Comments
 (0)