diff --git a/README.md b/README.md index b33440443..743674efb 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,13 @@ The `arkd` server can be configured using environment variables and the admin se | `ARKD_REDIS_NUM_OF_RETRIES` | Maximum number of retries for Redis write operations in case of conflicts | - | | `ARKD_ESPLORA_URL` | Esplora API URL | `https://blockstream.info/api` | | `ARKD_WALLET_ADDR` | The arkd wallet address to connect to in the form `host:port` | - | +| `ARKD_WALLET_FALLBACK_ADDRS` | Additional arkd-wallet addresses (other LPs), comma-separated `host:port` list | - | | `ARKD_SIGNER_ADDR` | The signer address to connect to in the form `host:port` | value of `ARKD_WALLET_ADDR` | | `ARKD_NO_MACAROONS` | Disable macaroon authentication | `false` | | `ARKD_NO_TLS` | Disable TLS | `true` | -| `ARKD_UNLOCKER_TYPE` | Wallet unlocker type (env, file) to enable auto-unlock | - | +| `ARKD_UNLOCKER_TYPE` | Macaroon (admin auth) unlocker type (env, file) to enable auto-unlock | - | | `ARKD_UNLOCKER_FILE_PATH` | Path to unlocker file | - | -| `ARKD_UNLOCKER_PASSWORD` | Wallet unlocker password | - | +| `ARKD_UNLOCKER_PASSWORD` | Macaroon (admin auth) unlocker password | - | | `ARKD_SCHEDULER_TYPE` | Scheduler type (gocron, block) | `gocron` | | `ARKD_TLS_EXTRA_IP` | Extra IP addresses for TLS (comma-separated) | - | | `ARKD_TLS_EXTRA_DOMAIN` | Extra domains for TLS (comma-separated) | - | @@ -180,6 +181,16 @@ To connect `arkd` to `arkd-wallet` use this environment variable: export ARKD_WALLET_ADDR=localhost:6060 ``` +### Configuring multiple LP wallets + +`arkd` can be backed by a primary `arkd-wallet` plus additional wallets belonging to other liquidity providers. List the additional wallets with `ARKD_WALLET_FALLBACK_ADDRS`, a comma-separated list of `host:port` addresses: + +```sh +export ARKD_WALLET_FALLBACK_ADDRS=localhost:6061,localhost:6062 +``` + +Every wallet, primary and fallback, must be initialized and unlocked out of band (see [Setup arkd](#setup-arkd)) and must be on the same network as the primary; `arkd` validates this at startup and refuses to start otherwise. The primary wallet remains the sole source of the forfeit address, connector address, scanning and signing. The additional wallets are used only as sweep fallbacks: when a batch cannot be signed by the primary wallet (for example a batch created before this wallet became the primary), `arkd` tries each fallback wallet in turn until one can sign it. The swept funds always go to the primary wallet's address, whichever wallet signs. + ### Connect to signer By default, `arkd` makes use of the provided `arkd-wallet` also as signer, but you can customize its url either via environment variable or via API. @@ -195,26 +206,31 @@ export ARKD_SIGNER_ADDR=localhost:7071 ### Setup arkd +`arkd` does not manage the wallet lifecycle. Each `arkd-wallet` must be initialized and unlocked out of band before `arkd` is started: if the wallet is not initialized and unlocked, `arkd` refuses to start. This is what lets a single `arkd` be backed by more than one `arkd-wallet`. + 1. Start the wallet: ```sh arkd-wallet ``` -2. Start arkd: +2. Initialize and unlock the wallet. `arkd` does not manage the wallet lifecycle, so this is done out of band against the `arkd-wallet`: ```sh - arkd + # Create a new wallet and unlock it (prints the seed, back it up) + arkd-wallet create --password ``` -3. Create a new wallet: + Or restore from mnemonic: ```sh - arkd wallet create --password + arkd-wallet create --mnemonic "your twelve word mnemonic phrase here" --password ``` + By default these target `http://localhost:6060`; use `--url` to reach a wallet on another host. This wallet password is independent of the macaroon-service password used in step 5. After a wallet restart, unlock it again with `arkd-wallet unlock --password `. - Or restore from mnemonic: +3. Start arkd: ```sh - arkd wallet create --mnemonic "your twelve word mnemonic phrase here" --password + arkd ``` -4. Only if you didn't configure either the wallet as signer, or a custom signer, you must load the signer before unlocking the wallet, or `arkd` will fail to start: + +4. Only if you didn't configure either the wallet as signer, or a custom signer, you must load the signer, or `arkd` will be unable to start the ark service: ```sh # If you configured a custom signer arkd signer load --signer-url localhost:7071 @@ -223,7 +239,7 @@ export ARKD_SIGNER_ADDR=localhost:7071 ``` Remember, if you use this command, you must use it at every restart unless you export the required environment variable(s). -5. Unlock the wallet: +5. If macaroon authentication is enabled and you did not configure an auto-unlocker, unlock the macaroon (admin auth) service so `arkd` can serve authenticated admin requests: ```sh arkd wallet unlock --password ``` diff --git a/api-spec/openapi/swagger/ark/v1/admin.openapi.json b/api-spec/openapi/swagger/ark/v1/admin.openapi.json index a28810696..4af605d4c 100644 --- a/api-spec/openapi/swagger/ark/v1/admin.openapi.json +++ b/api-spec/openapi/swagger/ark/v1/admin.openapi.json @@ -2145,6 +2145,19 @@ "null" ], "format": "int64" + }, + "walletAddr": { + "type": [ + "string", + "null" + ], + "description": "Primary and fallback arkd-wallet connection addresses. Seeded from env on\nfirst boot; changes take effect on the next restart." + }, + "walletFallbackAddrs": { + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/api-spec/protobuf/ark/v1/admin.proto b/api-spec/protobuf/ark/v1/admin.proto index ae22d180e..739b2467c 100644 --- a/api-spec/protobuf/ark/v1/admin.proto +++ b/api-spec/protobuf/ark/v1/admin.proto @@ -447,6 +447,8 @@ message Settings { optional bool build_version_header_required = 23; optional string updated_at = 24; optional bool digest_header_required = 25; + optional string wallet_addr = 26; + repeated string wallet_fallback_addrs = 27; } message GetSettingsRequest {} diff --git a/api-spec/protobuf/gen/ark/v1/admin.pb.go b/api-spec/protobuf/gen/ark/v1/admin.pb.go index 83c55ca37..961ecfe5a 100644 --- a/api-spec/protobuf/gen/ark/v1/admin.pb.go +++ b/api-spec/protobuf/gen/ark/v1/admin.pb.go @@ -3025,8 +3025,12 @@ type Settings struct { BuildVersionHeaderRequired *bool `protobuf:"varint,23,opt,name=build_version_header_required,json=buildVersionHeaderRequired,proto3,oneof" json:"build_version_header_required,omitempty"` UpdatedAt *string `protobuf:"bytes,24,opt,name=updated_at,json=updatedAt,proto3,oneof" json:"updated_at,omitempty"` DigestHeaderRequired *bool `protobuf:"varint,25,opt,name=digest_header_required,json=digestHeaderRequired,proto3,oneof" json:"digest_header_required,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Primary and fallback arkd-wallet connection addresses. Seeded from env on + // first boot; changes take effect on the next restart. + WalletAddr *string `protobuf:"bytes,26,opt,name=wallet_addr,json=walletAddr,proto3,oneof" json:"wallet_addr,omitempty"` + WalletFallbackAddrs []string `protobuf:"bytes,27,rep,name=wallet_fallback_addrs,json=walletFallbackAddrs,proto3" json:"wallet_fallback_addrs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Settings) Reset() { @@ -3234,6 +3238,20 @@ func (x *Settings) GetDigestHeaderRequired() bool { return false } +func (x *Settings) GetWalletAddr() string { + if x != nil && x.WalletAddr != nil { + return *x.WalletAddr + } + return "" +} + +func (x *Settings) GetWalletFallbackAddrs() []string { + if x != nil { + return x.WalletFallbackAddrs + } + return nil +} + type GetSettingsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4035,7 +4053,7 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x10commitment_txids\x18\x02 \x03(\tR\x0fcommitmentTxids\"5\n" + "\rSweepResponse\x12\x12\n" + "\x04txid\x18\x01 \x01(\tR\x04txid\x12\x10\n" + - "\x03hex\x18\x02 \x01(\tR\x03hex\"\xe6\x0f\n" + + "\x03hex\x18\x02 \x01(\tR\x03hex\"\xd0\x10\n" + "\bSettings\x12.\n" + "\x10session_duration\x18\x01 \x01(\x03H\x00R\x0fsessionDuration\x88\x01\x01\x12I\n" + "\x1funrolled_vtxo_min_expiry_margin\x18\x02 \x01(\x03H\x01R\x1bunrolledVtxoMinExpiryMargin\x88\x01\x01\x12(\n" + @@ -4064,7 +4082,10 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x1dbuild_version_header_required\x18\x17 \x01(\bH\x16R\x1abuildVersionHeaderRequired\x88\x01\x01\x12\"\n" + "\n" + "updated_at\x18\x18 \x01(\tH\x17R\tupdatedAt\x88\x01\x01\x129\n" + - "\x16digest_header_required\x18\x19 \x01(\bH\x18R\x14digestHeaderRequired\x88\x01\x01B\x13\n" + + "\x16digest_header_required\x18\x19 \x01(\bH\x18R\x14digestHeaderRequired\x88\x01\x01\x12$\n" + + "\vwallet_addr\x18\x1a \x01(\tH\x19R\n" + + "walletAddr\x88\x01\x01\x122\n" + + "\x15wallet_fallback_addrs\x18\x1b \x03(\tR\x13walletFallbackAddrsB\x13\n" + "\x11_session_durationB\"\n" + " _unrolled_vtxo_min_expiry_marginB\x10\n" + "\x0e_ban_thresholdB\x0f\n" + @@ -4089,7 +4110,8 @@ const file_ark_v1_admin_proto_rawDesc = "" + "\x15_build_version_headerB \n" + "\x1e_build_version_header_requiredB\r\n" + "\v_updated_atB\x19\n" + - "\x17_digest_header_required\"\x14\n" + + "\x17_digest_header_requiredB\x0e\n" + + "\f_wallet_addr\"\x14\n" + "\x12GetSettingsRequest\"C\n" + "\x13GetSettingsResponse\x12,\n" + "\bsettings\x18\x01 \x01(\v2\x10.ark.v1.SettingsR\bsettings\"E\n" + diff --git a/cmd/arkd-forfeit-backfill/main.go b/cmd/arkd-forfeit-backfill/main.go new file mode 100644 index 000000000..744fd0efc --- /dev/null +++ b/cmd/arkd-forfeit-backfill/main.go @@ -0,0 +1,54 @@ +// Command arkd-forfeit-backfill signs the operator's half of forfeit transactions +// that were persisted before arkd started signing forfeit txs at collection time. +// +// It connects to the same database and signer as arkd (via the standard arkd +// configuration / environment), so the arkd-wallet signer must be running and +// unlocked. It scans every unswept forfeited vtxo, signs the operator's half of +// its forfeit tx when missing, and persists the result. It is safe to run +// repeatedly: forfeit txs that already carry the operator signature are skipped. +package main + +import ( + "context" + "os" + + "github.com/arkade-os/arkd/internal/backfill" + "github.com/arkade-os/arkd/internal/config" + log "github.com/sirupsen/logrus" +) + +func main() { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("invalid config: %s", err) + } + log.SetLevel(log.Level(cfg.LogLevel)) + + repo, err := cfg.RepoManager() + if err != nil { + log.Fatalf("failed to init repositories: %s", err) + } + defer repo.Close() + + signer, err := cfg.SignerService() + if err != nil { + log.Fatalf("failed to init signer: %s", err) + } + + log.Info("starting forfeit-tx backfill...") + res, err := backfill.Run(context.Background(), repo.Vtxos(), repo.Rounds(), signer) + if err != nil { + log.Fatalf("forfeit-tx backfill failed: %s", err) + } + + log.Infof( + "forfeit-tx backfill done: scanned=%d signed=%d already_signed=%d failed=%d", + res.Scanned, res.Signed, res.AlreadySigned, res.Failed, + ) + + // Non-zero exit when some forfeits could not be signed/persisted, so the + // operator (or a wrapping script) notices and re-runs after fixing the cause. + if res.Failed > 0 { + os.Exit(1) + } +} diff --git a/cmd/arkd-wallet/main.go b/cmd/arkd-wallet/main.go index a338ec652..1998b2a46 100644 --- a/cmd/arkd-wallet/main.go +++ b/cmd/arkd-wallet/main.go @@ -1,20 +1,110 @@ package main import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" "os" "os/signal" + "sync" "syscall" + "time" "github.com/arkade-os/arkd/pkg/arkd-wallet/config" grpcservice "github.com/arkade-os/arkd/pkg/arkd-wallet/interface/grpc" "github.com/arkade-os/arkd/pkg/arkd-wallet/telemetry" log "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" ) +// Version will be set during build time +var Version string + +const defaultWalletURL = "http://localhost:6060" + func main() { + app := cli.NewApp() + app.Version = Version + app.Name = "arkd-wallet" + app.Usage = "run or manage the Ark Server wallet" + app.UsageText = "Run the wallet with:\n\tarkd-wallet\n" + + "Manage the wallet with:\n\tarkd-wallet command [command options]" + app.Commands = append( + app.Commands, + startCmd, + createCmd, + restoreCmd, + unlockCmd, + statusCmd, + ) + app.DefaultCommand = startCmd.Name + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +var ( + walletUrlFlag = &cli.StringFlag{ + Name: "url", + Usage: "the url where to reach the arkd-wallet gateway", + Value: defaultWalletURL, + } + walletPasswordFlag = &cli.StringFlag{ + Name: "password", + Usage: "wallet password", + Required: true, + } + walletMnemonicFlag = &cli.StringFlag{ + Name: "mnemonic", + Usage: "mnemonic from which to restore the wallet", + Required: true, + } + walletGapLimitFlag = &cli.Uint64Flag{ + Name: "addr-gap-limit", + Usage: "address gap limit for wallet restoration", + Value: 100, + } + + startCmd = &cli.Command{ + Name: "start", + Usage: "Run the arkd-wallet service", + Action: startAction, + } + createCmd = &cli.Command{ + Name: "create", + Usage: "Create a new wallet and unlock it", + Action: createAction, + Flags: []cli.Flag{walletUrlFlag, walletPasswordFlag}, + } + restoreCmd = &cli.Command{ + Name: "restore", + Usage: "Restore the wallet from a mnemonic and unlock it", + Action: restoreAction, + Flags: []cli.Flag{ + walletUrlFlag, walletPasswordFlag, walletMnemonicFlag, walletGapLimitFlag, + }, + } + unlockCmd = &cli.Command{ + Name: "unlock", + Usage: "Unlock the wallet", + Action: unlockAction, + Flags: []cli.Flag{walletUrlFlag, walletPasswordFlag}, + } + statusCmd = &cli.Command{ + Name: "status", + Usage: "Get the status of the wallet", + Action: statusAction, + Flags: []cli.Flag{walletUrlFlag}, + } +) + +func startAction(_ *cli.Context) error { cfg, err := config.LoadConfig() if err != nil { - log.Fatalf("invalid config: %s", err) + return fmt.Errorf("invalid config: %s", err) } log.SetLevel(log.Level(cfg.LogLevel)) @@ -24,18 +114,22 @@ func main() { svc, err := grpcservice.NewService(cfg) if err != nil { - log.Fatalf("failed to create service: %s", err) + return fmt.Errorf("failed to create service: %s", err) } log.Infof("arkd wallet config: %+v", cfg) log.Info("starting service...") if err := svc.Start(); err != nil { - log.Fatalf("failed to start service: %s", err) + return fmt.Errorf("failed to start service: %s", err) } log.Infof("arkd wallet listens on: %v", cfg.Port) - log.RegisterExitHandler(svc.Stop) + // Stop the service at most once, whether triggered by the signal handler + // below or by log.Exit/Fatal firing the registered exit handler. + var stopOnce sync.Once + stop := func() { stopOnce.Do(svc.Stop) } + log.RegisterExitHandler(stop) sigChan := make(chan os.Signal, 1) signal.Notify( @@ -44,5 +138,141 @@ func main() { <-sigChan log.Info("shutting down service...") - log.Exit(0) + stop() + return nil +} + +// createAction generates a brand new wallet and unlocks it, talking directly to +// the arkd-wallet gateway. arkd does not manage the wallet lifecycle: each +// arkd-wallet must be set up this way out of band. +func createAction(ctx *cli.Context) error { + baseURL := ctx.String("url") + password := ctx.String("password") + + var seed struct { + Seed string `json:"seed"` + } + if err := walletGet(baseURL, "/v1/wallet/seed", &seed); err != nil { + return err + } + body, err := jsonBody(map[string]any{"seed": seed.Seed, "password": password}) + if err != nil { + return err + } + if err := walletPost(baseURL, "/v1/wallet/create", body, nil); err != nil { + return err + } + fmt.Println("IMPORTANT: store the following seed phrase securely and offline.") + fmt.Println("Anyone with access to it can control this wallet and spend its funds.") + fmt.Println(seed.Seed) + + return unlockWallet(baseURL, password) +} + +// restoreAction restores a wallet from a mnemonic and unlocks it, talking +// directly to the arkd-wallet gateway. arkd does not manage the wallet +// lifecycle: each arkd-wallet must be set up this way out of band. +func restoreAction(ctx *cli.Context) error { + baseURL := ctx.String("url") + password := ctx.String("password") + + body, err := jsonBody(map[string]any{ + "seed": ctx.String("mnemonic"), + "password": password, + "gap_limit": ctx.Uint64("addr-gap-limit"), + }) + if err != nil { + return err + } + if err := walletPost(baseURL, "/v1/wallet/restore", body, nil); err != nil { + return err + } + fmt.Println("wallet restored") + + return unlockWallet(baseURL, password) +} + +func unlockAction(ctx *cli.Context) error { + return unlockWallet(ctx.String("url"), ctx.String("password")) +} + +// unlockWallet unlocks the wallet via the arkd-wallet gateway and prints +// confirmation. It is shared by the create, restore and unlock commands. +func unlockWallet(baseURL, password string) error { + body, err := jsonBody(map[string]any{"password": password}) + if err != nil { + return err + } + if err := walletPost(baseURL, "/v1/wallet/unlock", body, nil); err != nil { + return err + } + fmt.Println("wallet unlocked") + return nil +} + +// jsonBody marshals a request body to JSON, escaping the values so a password or +// mnemonic containing quotes or backslashes can't corrupt the payload or inject +// extra fields. +func jsonBody(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("failed to encode request body: %w", err) + } + return string(b), nil +} + +func statusAction(ctx *cli.Context) error { + var status struct { + Initialized bool `json:"initialized"` + Unlocked bool `json:"unlocked"` + Synced bool `json:"synced"` + } + if err := walletGet(ctx.String("url"), "/v1/wallet/status", &status); err != nil { + return err + } + fmt.Printf( + "initialized: %t\nunlocked: %t\nsynced: %t\n", + status.Initialized, status.Unlocked, status.Synced, + ) + return nil +} + +func walletGet(baseURL, path string, out any) error { + resp, err := walletHTTPClient().Get(baseURL + path) + if err != nil { + return err + } + // nolint + defer resp.Body.Close() + return parseWalletResponse(resp, out) +} + +func walletPost(baseURL, path, body string, out any) error { + resp, err := walletHTTPClient().Post( + baseURL+path, "application/json", bytes.NewReader([]byte(body)), + ) + if err != nil { + return err + } + // nolint + defer resp.Body.Close() + return parseWalletResponse(resp, out) +} + +func walletHTTPClient() *http.Client { + return &http.Client{Timeout: 15 * time.Second} +} + +func parseWalletResponse(resp *http.Response, out any) error { + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (%d): %s", resp.StatusCode, string(data)) + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) } diff --git a/cmd/arkd/commands.go b/cmd/arkd/commands.go index b933b981d..1b7987816 100644 --- a/cmd/arkd/commands.go +++ b/cmd/arkd/commands.go @@ -28,7 +28,6 @@ var ( Usage: "Manage the Ark Server wallet", Subcommands: cli.Commands{ walletStatusCmd, - walletCreateOrRestoreCmd, walletUnlockCmd, walletAddressCmd, walletBalanceCmd, @@ -57,15 +56,9 @@ var ( Usage: "Get info about the status of the wallet", Action: walletStatusAction, } - walletCreateOrRestoreCmd = &cli.Command{ - Name: "create", - Usage: "Create or restore the wallet", - Action: walletCreateOrRestoreAction, - Flags: []cli.Flag{passwordFlag, mnemonicFlag, gapLimitFlag}, - } walletUnlockCmd = &cli.Command{ Name: "unlock", - Usage: "Unlock the wallet", + Usage: "Unlock the macaroon (admin auth) service", Action: walletUnlockAction, Flags: []cli.Flag{passwordFlag}, } @@ -304,49 +297,6 @@ func walletStatusAction(ctx *cli.Context) error { return nil } -func walletCreateOrRestoreAction(ctx *cli.Context) error { - baseURL := ctx.String(urlFlagName) - _, tlsConfig, err := getCredentials(ctx) - if err != nil { - return err - } - - password := ctx.String(passwordFlagName) - mnemonic := ctx.String(mnemonicFlagName) - gapLimit := ctx.Uint64(gapLimitFlagName) - - if len(mnemonic) > 0 { - url := fmt.Sprintf("%s/v1/admin/wallet/restore", baseURL) - body := fmt.Sprintf( - `{"seed": "%s", "password": "%s", "gap_limit": %d}`, - mnemonic, password, gapLimit, - ) - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { - return err - } - - fmt.Println("wallet restored") - return nil - } - - url := fmt.Sprintf("%s/v1/admin/wallet/seed", baseURL) - seed, err := get[string](url, "seed", "", tlsConfig) - if err != nil { - return err - } - - url = fmt.Sprintf("%s/v1/admin/wallet/create", baseURL) - body := fmt.Sprintf( - `{"seed": "%s", "password": "%s"}`, seed, password, - ) - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { - return err - } - - fmt.Println(seed) - return nil -} - func walletUnlockAction(ctx *cli.Context) error { baseURL := ctx.String(urlFlagName) _, tlsConfig, err := getCredentials(ctx) @@ -356,13 +306,16 @@ func walletUnlockAction(ctx *cli.Context) error { password := ctx.String(passwordFlagName) url := fmt.Sprintf("%s/v1/admin/wallet/unlock", baseURL) - body := fmt.Sprintf(`{"password": "%s"}`, password) + body, err := json.Marshal(map[string]string{"password": password}) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } - if _, err := post[struct{}](url, body, "", "", tlsConfig); err != nil { + if _, err := post[struct{}](url, string(body), "", "", tlsConfig); err != nil { return err } - fmt.Println("wallet unlocked") + fmt.Println("macaroon service unlocked") return nil } diff --git a/cmd/arkd/flags.go b/cmd/arkd/flags.go index 1ef5e6303..aec5885aa 100644 --- a/cmd/arkd/flags.go +++ b/cmd/arkd/flags.go @@ -14,8 +14,6 @@ const ( datadirFlagName = "datadir" macaroonFlagName = "macaroon" passwordFlagName = "password" - mnemonicFlagName = "mnemonic" - gapLimitFlagName = "addr-gap-limit" amountFlagName = "amount" withdrawAllFlagName = "all" quantityFlagName = "quantity" @@ -93,18 +91,9 @@ var ( } passwordFlag = &cli.StringFlag{ Name: passwordFlagName, - Usage: "wallet password", + Usage: "password to unlock the macaroon (admin auth) service", Required: true, } - mnemonicFlag = &cli.StringFlag{ - Name: mnemonicFlagName, - Usage: "mnemonic from which restore the wallet", - } - gapLimitFlag = &cli.Uint64Flag{ - Name: gapLimitFlagName, - Usage: "address gap limit for wallet restoration", - Value: 100, - } amountFlag = &cli.UintFlag{ Name: amountFlagName, Usage: "amount of the note in satoshis", diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml index 746b0aede..0d7fad4d5 100644 --- a/docker-compose.regtest.yml +++ b/docker-compose.regtest.yml @@ -61,6 +61,27 @@ services: - ARKD_WALLET_DEPRECATED_SIGNER_KEYS=${ARKD_WALLET_DEPRECATED_SIGNER_KEYS:-} volumes: - arkd-wallet-volume:/app/data + # A second arkd-wallet acting as an additional LP wallet, wired into arkd as a + # sweep fallback via ARKD_WALLET_FALLBACK_ADDRS: sweep signing is attempted with + # the primary first, then each fallback. + arkd-wallet-2: + restart: unless-stopped + build: + context: . + dockerfile: arkdwallet.Dockerfile + container_name: arkd-wallet-2 + depends_on: + - nbxplorer + ports: + - "6061:6060" + environment: + - ARKD_WALLET_LOG_LEVEL=5 + - ARKD_WALLET_NBXPLORER_URL=http://nbxplorer:32838 + - ARKD_WALLET_DATADIR=./data/regtest-2 + - ARKD_WALLET_NETWORK=regtest + - ARKD_WALLET_SIGNER_KEY=19422b10efd05403820ff6a3365422be2fc5f07f34a6d1603f7298328f0f80f6 + volumes: + - arkd-wallet-2-volume:/app/data redis: restart: unless-stopped image: redis:7-alpine @@ -77,9 +98,13 @@ services: context: . dockerfile: Dockerfile container_name: arkd + # arkd no longer initializes or unlocks the wallet: the arkd-wallet must be + # initialized and unlocked out of band first, otherwise arkd fails to start + # and is restarted by this policy until the wallet is ready. restart: unless-stopped depends_on: - arkd-wallet + - arkd-wallet-2 - pg - redis ports: @@ -104,6 +129,7 @@ services: - ARKD_BAN_THRESHOLD=1 - ARKD_DATADIR=./data/regtest - ARKD_WALLET_ADDR=arkd-wallet:6060 + - ARKD_WALLET_FALLBACK_ADDRS=arkd-wallet-2:6060 - ARKD_ESPLORA_URL=http://chopsticks:3000 - ARKD_DB_TYPE=${ARKD_DB_TYPE:-sqlite} - ARKD_PG_DB_URL=${ARKD_PG_DB_URL:-} @@ -114,12 +140,13 @@ services: - ARKD_REDIS_URL=${ARKD_REDIS_URL:-} - ARKD_SESSION_DURATION=${ARKD_SESSION_DURATION:-10} - ARKD_ROUND_REPORT_ENABLED=${ARKD_ROUND_REPORT_ENABLED:-true} - - ARKD_BAN_THRESHOLD=1 - ARKD_UNROLLED_VTXO_MIN_EXPIRY_MARGIN=10 volumes: arkd-wallet-volume: name: arkd-wallet-volume + arkd-wallet-2-volume: + name: arkd-wallet-2-volume arkd-volume: name: arkd-volume diff --git a/docs/settings.md b/docs/settings.md index c419cba94..215184f76 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -39,8 +39,15 @@ The seed runs exactly once, gated on the settings table being empty. It never re-runs and never overwrites a value an admin changed later. > **Note:** only the *settings* environment variables below follow this first-boot-only rule. -> Infrastructure variables (database, wallet/signer addresses, ports, TLS, unlocker, …) are read -> on **every** boot as usual. +> Infrastructure variables (database, signer address, ports, TLS, unlocker, …) are read on +> **every** boot as usual. +> +> The wallet addresses (`ARKD_WALLET_ADDR` / `ARKD_WALLET_FALLBACK_ADDRS`) are a hybrid. Like +> settings, they're seeded on first boot, persisted, and normally read from the stored row (and +> changed via the admin API). But unlike other settings — whose env vars are ignored after first +> boot — the wallet env vars are still honored on **every** boot as a fallback whenever the stored +> value is empty. This keeps upgrades safe: the migration adds the new columns empty, so a node +> falls back to its env-configured address until an admin sets it. ## Seed environment variables @@ -73,6 +80,12 @@ is unset on first boot. | `ARKD_MIN_BUILD_VERSION_HEADER` | `build_version_header` | min accepted client build version, semver (e.g. `v2.3.4`); empty = no minimum | `""` | | `ARKD_MIN_BUILD_VERSION_HEADER_REQUIRED` | `build_version_header_required` | bool; if `true`, clients must send a valid `X-Build-Version` header (requires `build_version_header`) | `false` | | `ARKD_DIGEST_HEADER_REQUIRED` | `digest_header_required` | bool; if `true`, clients must send a matching `X-Digest` header | `false` | +| `ARKD_WALLET_ADDR` | `wallet_addr` | primary arkd-wallet `host:port` | - | +| `ARKD_WALLET_FALLBACK_ADDRS` | `wallet_fallback_addrs` | fallback arkd-wallet addresses, comma-separated `host:port` | - | + +The wallet addresses follow the same first-boot-only rule as the other settings: +they are read from these env vars on first boot, then the stored values win and +are changed via the admin Settings API (taking effect on the next restart). ### Notes on specific values diff --git a/internal/backfill/backfill.go b/internal/backfill/backfill.go new file mode 100644 index 000000000..586a0dd9c --- /dev/null +++ b/internal/backfill/backfill.go @@ -0,0 +1,206 @@ +// Package backfill signs the operator's half of forfeit transactions that were +// persisted before arkd started signing forfeit txs at collection time. +// +// It is meant to be run on demand by the operator (see cmd/arkd-forfeit-backfill). +// It only touches forfeit txs of vtxos that still require a forfeit (unswept, +// unexpired, not notes, not unrolled): those are the only forfeits that could +// ever still be broadcast. Forfeit txs that already carry the operator signature +// are left untouched, so the backfill is safe to run repeatedly. +package backfill + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/arkade-os/arkd/internal/core/domain" + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + log "github.com/sirupsen/logrus" +) + +// VtxoSource exposes the vtxos to scan. Satisfied by domain.VtxoRepository. +type VtxoSource interface { + GetAllVtxos(ctx context.Context) ([]domain.Vtxo, error) +} + +// ForfeitStore reads rounds and patches forfeit txs. Satisfied by +// domain.RoundRepository. +type ForfeitStore interface { + GetRoundWithCommitmentTxid(ctx context.Context, txid string) (*domain.Round, error) + PatchForfeitTxs(ctx context.Context, txByTxid map[string]string) error +} + +// Signer adds the operator signature to a forfeit tx. Satisfied by +// ports.SignerService. +type Signer interface { + GetPubkey(ctx context.Context) (*btcec.PublicKey, error) + GetDeprecatedPubkeys(ctx context.Context) ([]ports.DeprecatedSignerPubkey, error) + SignTransactionTapscript( + ctx context.Context, + partialTx string, + inputIndexes []int, + ) (string, error) +} + +// Result is a summary of a backfill run. +type Result struct { + Scanned int // unswept forfeited vtxos considered + Signed int // forfeit txs newly signed and persisted + AlreadySigned int // forfeit txs already operator-signed (skipped) + Failed int // forfeit txs that could not be signed or persisted +} + +// Run scans all unswept forfeited vtxos, signs the operator's half of their +// forfeit txs when missing, and persists the result. A per-forfeit failure is +// logged and counted but does not abort the run, so re-running retries only the +// forfeits that are still unsigned. +func Run( + ctx context.Context, + vtxos VtxoSource, + rounds ForfeitStore, + signer Signer, +) (Result, error) { + pubkey, err := signer.GetPubkey(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to get operator pubkey: %w", err) + } + operatorKeys := [][]byte{schnorr.SerializePubKey(pubkey)} + + // Include deprecated keys so forfeits signed before a key rotation are + // recognized as already signed and not re-signed with the current key. + deprecated, err := signer.GetDeprecatedPubkeys(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to get deprecated operator pubkeys: %w", err) + } + for _, d := range deprecated { + if d.PubKey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(d.PubKey)) + } + } + + allVtxos, err := vtxos.GetAllVtxos(ctx) + if err != nil { + return Result{}, fmt.Errorf("failed to list vtxos: %w", err) + } + + // Group the unswept forfeited vtxos by the commitment tx of the round that + // holds their forfeit tx, so each round is loaded once. + byCommitment := make(map[string][]domain.Vtxo) + for _, v := range allVtxos { + if !v.IsSettled() || !v.RequiresForfeit() { + continue + } + byCommitment[v.SettledBy] = append(byCommitment[v.SettledBy], v) + } + + var res Result + for commitmentTxid, group := range byCommitment { + round, err := rounds.GetRoundWithCommitmentTxid(ctx, commitmentTxid) + if err != nil { + res.Failed += len(group) + log.WithError(err).Errorf( + "failed to load round %s, skipping %d forfeit(s)", commitmentTxid, len(group), + ) + continue + } + + patch := make(map[string]string) + for _, v := range group { + res.Scanned++ + + forfeitTx, err := findForfeitTx(round.ForfeitTxs, v.Outpoint) + if err != nil { + res.Failed++ + log.WithError(err). + Errorf("failed to find forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + if forfeitOperatorSigned(forfeitTx, operatorKeys) { + res.AlreadySigned++ + continue + } + + b64, err := forfeitTx.B64Encode() + if err != nil { + res.Failed++ + log.WithError(err). + Errorf("failed to encode forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + signedTx, err := signer.SignTransactionTapscript(ctx, b64, nil) + if err != nil { + res.Failed++ + log.WithError(err). + Errorf("failed to sign forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + signedPtx, err := psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + res.Failed++ + log.WithError(err). + Errorf("failed to parse signed forfeit tx for vtxo %s", v.Outpoint.String()) + continue + } + + patch[signedPtx.UnsignedTx.TxID()] = signedTx + } + + if len(patch) == 0 { + continue + } + + if err := rounds.PatchForfeitTxs(ctx, patch); err != nil { + res.Failed += len(patch) + log.WithError(err).Errorf( + "failed to persist %d signed forfeit tx(s) for round %s", len(patch), commitmentTxid, + ) + continue + } + res.Signed += len(patch) + } + + return res, nil +} + +// findForfeitTx returns the forfeit tx whose input spends the given vtxo. Mirrors +// the lookup in internal/core/application/fraud.go (findForfeitTx), kept local so +// the tool does not depend on the application package. +func findForfeitTx(forfeits []domain.ForfeitTx, vtxo domain.Outpoint) (*psbt.Packet, error) { + for _, forfeit := range forfeits { + forfeitTx, err := psbt.NewFromRawBytes(strings.NewReader(forfeit.Tx), true) + if err != nil { + return nil, err + } + for _, in := range forfeitTx.UnsignedTx.TxIn { + if in.PreviousOutPoint.Hash.String() == vtxo.Txid && + in.PreviousOutPoint.Index == vtxo.VOut { + return forfeitTx, nil + } + } + } + return nil, fmt.Errorf("forfeit tx not found for vtxo %s", vtxo.String()) +} + +// forfeitOperatorSigned reports whether the forfeit tx already carries a tapscript +// signature from one of the operator's signer keys (the current key or any +// deprecated one). Deprecated keys are included so a forfeit signed before a key +// rotation is recognized as already signed and not re-signed with the current key. +func forfeitOperatorSigned(ptx *psbt.Packet, operatorXOnlyKeys [][]byte) bool { + for _, in := range ptx.Inputs { + for _, sig := range in.TaprootScriptSpendSig { + for _, key := range operatorXOnlyKeys { + if bytes.Equal(sig.XOnlyPubKey, key) { + return true + } + } + } + } + return false +} diff --git a/internal/backfill/backfill_test.go b/internal/backfill/backfill_test.go new file mode 100644 index 000000000..5769c6844 --- /dev/null +++ b/internal/backfill/backfill_test.go @@ -0,0 +1,320 @@ +package backfill_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/arkade-os/arkd/internal/backfill" + "github.com/arkade-os/arkd/internal/core/domain" + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// --- test doubles --- + +type fakeVtxos struct { + vtxos []domain.Vtxo + err error +} + +func (f *fakeVtxos) GetAllVtxos(_ context.Context) ([]domain.Vtxo, error) { + return f.vtxos, f.err +} + +type fakeRounds struct { + rounds map[string]*domain.Round + patches []map[string]string + getErr error + patchErr error +} + +func (f *fakeRounds) GetRoundWithCommitmentTxid( + _ context.Context, txid string, +) (*domain.Round, error) { + if f.getErr != nil { + return nil, f.getErr + } + r, ok := f.rounds[txid] + if !ok { + return nil, fmt.Errorf("round %s not found", txid) + } + return r, nil +} + +func (f *fakeRounds) PatchForfeitTxs(_ context.Context, txByTxid map[string]string) error { + if f.patchErr != nil { + return f.patchErr + } + f.patches = append(f.patches, txByTxid) + // Apply the patch to the stored rounds so re-runs observe signed forfeits. + for _, r := range f.rounds { + for i := range r.ForfeitTxs { + if newTx, ok := txByTxid[r.ForfeitTxs[i].Txid]; ok { + r.ForfeitTxs[i].Tx = newTx + } + } + } + return nil +} + +type fakeSigner struct { + pubkey *btcec.PublicKey + operatorXOnly []byte + deprecated []ports.DeprecatedSignerPubkey + signErr error + calls int +} + +func (f *fakeSigner) GetPubkey(_ context.Context) (*btcec.PublicKey, error) { + return f.pubkey, nil +} + +func (f *fakeSigner) GetDeprecatedPubkeys( + _ context.Context, +) ([]ports.DeprecatedSignerPubkey, error) { + return f.deprecated, nil +} + +func (f *fakeSigner) SignTransactionTapscript( + _ context.Context, partialTx string, _ []int, +) (string, error) { + f.calls++ + if f.signErr != nil { + return "", f.signErr + } + p, err := psbt.NewFromRawBytes(strings.NewReader(partialTx), true) + if err != nil { + return "", err + } + p.Inputs[0].TaprootScriptSpendSig = append( + p.Inputs[0].TaprootScriptSpendSig, + operatorSig(f.operatorXOnly), + ) + return p.B64Encode() +} + +// --- helpers --- + +func txid(seed byte) string { + return strings.Repeat(fmt.Sprintf("%02x", seed), 32) +} + +func operatorSig(xOnly []byte) *psbt.TaprootScriptSpendSig { + return &psbt.TaprootScriptSpendSig{ + XOnlyPubKey: xOnly, + LeafHash: make([]byte, 32), + Signature: make([]byte, 64), + SigHash: txscript.SigHashDefault, + } +} + +// buildForfeit builds a forfeit psbt spending vtxoOp at input 0 and a connector at +// input 1. When signed is true, it carries the operator's tapscript signature. +func buildForfeit(t *testing.T, vtxoOp domain.Outpoint, operatorXOnly []byte, signed bool) domain.ForfeitTx { + t.Helper() + vh, err := chainhash.NewHashFromStr(vtxoOp.Txid) + require.NoError(t, err) + ch, err := chainhash.NewHashFromStr(txid(0xcc)) + require.NoError(t, err) + + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: *vh, Index: vtxoOp.VOut}, nil, nil)) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: *ch, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscript.OP_TRUE})) + + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + if signed { + p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{operatorSig(operatorXOnly)} + } + b64, err := p.B64Encode() + require.NoError(t, err) + return domain.ForfeitTx{Txid: p.UnsignedTx.TxID(), Tx: b64} +} + +// forfeitableVtxo builds a settled vtxo that still requires a forfeit. +func forfeitableVtxo(op domain.Outpoint, commitmentTxid string) domain.Vtxo { + return domain.Vtxo{ + Outpoint: op, + CommitmentTxids: []string{commitmentTxid}, + SettledBy: commitmentTxid, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } +} + +func newOperator(t *testing.T) (*btcec.PublicKey, []byte) { + t.Helper() + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + return key.PubKey(), schnorr.SerializePubKey(key.PubKey()) +} + +// --- tests --- + +func TestBackfillSignsUnsignedForfeits(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 1, res.Signed) + require.Equal(t, 0, res.AlreadySigned) + require.Equal(t, 0, res.Failed) + require.Equal(t, 1, signer.calls) + require.Len(t, rounds.patches, 1) + // the patched tx keeps the same txid and is now operator-signed + require.Contains(t, rounds.patches[0], forfeit.Txid) +} + +func TestBackfillSkipsAlreadySignedForfeits(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, true) // already operator-signed + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 1, res.AlreadySigned) + require.Equal(t, 0, signer.calls, "must not call signer for already-signed forfeits") + require.Empty(t, rounds.patches) +} + +func TestBackfillSkipsNonForfeitableVtxos(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + + swept := forfeitableVtxo(domain.Outpoint{Txid: txid(0x01)}, commitment) + swept.Swept = true + expired := forfeitableVtxo(domain.Outpoint{Txid: txid(0x02)}, commitment) + expired.ExpiresAt = time.Now().Add(-time.Hour).Unix() + unrolled := forfeitableVtxo(domain.Outpoint{Txid: txid(0x03)}, commitment) + unrolled.Unrolled = true + note := domain.Vtxo{ // no commitment txids -> note + Outpoint: domain.Outpoint{Txid: txid(0x04)}, + SettledBy: commitment, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + unsettled := domain.Vtxo{ // never settled + Outpoint: domain.Outpoint{Txid: txid(0x05)}, + CommitmentTxids: []string{commitment}, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + + rounds := &fakeRounds{rounds: map[string]*domain.Round{}} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{swept, expired, unrolled, note, unsettled}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 0, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 0, signer.calls) +} + +func TestBackfillIsIdempotent(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly} + + first, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + require.Equal(t, 1, first.Signed) + + second, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + require.Equal(t, 0, second.Signed, "second run must sign nothing") + require.Equal(t, 1, second.AlreadySigned) + require.Equal(t, 1, signer.calls, "signer must not be called again on re-run") +} + +func TestBackfillSignerErrorCountsAsFailed(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + forfeit := buildForfeit(t, vtxoOp, xOnly, false) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{pubkey: pub, operatorXOnly: xOnly, signErr: errors.New("signer down")} + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err, "a per-forfeit signer error must not abort the whole run") + + require.Equal(t, 1, res.Failed) + require.Equal(t, 0, res.Signed) + require.Empty(t, rounds.patches, "nothing persisted when signing failed") +} + +func TestBackfillSkipsForfeitsSignedWithDeprecatedKey(t *testing.T) { + ctx := context.Background() + pub, xOnly := newOperator(t) // current operator key + depPub, depXOnly := newOperator(t) // a now-deprecated operator key + commitment := txid(0x11) + vtxoOp := domain.Outpoint{Txid: txid(0xaa), VOut: 0} + + // The forfeit was signed with the operator key that was active before a key + // rotation; it must be recognized as already signed, not re-signed. + forfeit := buildForfeit(t, vtxoOp, depXOnly, true) + rounds := &fakeRounds{rounds: map[string]*domain.Round{ + commitment: {CommitmentTxid: commitment, ForfeitTxs: []domain.ForfeitTx{forfeit}}, + }} + vtxos := &fakeVtxos{vtxos: []domain.Vtxo{forfeitableVtxo(vtxoOp, commitment)}} + signer := &fakeSigner{ + pubkey: pub, + operatorXOnly: xOnly, + deprecated: []ports.DeprecatedSignerPubkey{{PubKey: depPub}}, + } + + res, err := backfill.Run(ctx, vtxos, rounds, signer) + require.NoError(t, err) + + require.Equal(t, 1, res.Scanned) + require.Equal(t, 0, res.Signed) + require.Equal(t, 1, res.AlreadySigned) + require.Equal(t, 0, signer.calls, "must not re-sign a forfeit signed with a deprecated key") + require.Empty(t, rounds.patches) +} diff --git a/internal/config/config.go b/internal/config/config.go index b7576ecc3..56d5e3470 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,6 +95,7 @@ type Config struct { RedisUrl string RedisTxNumOfRetries int WalletAddr string + WalletFallbackAddrs []string SignerAddr string VtxoTreeExpiry arklib.RelativeLocktime UnilateralExitDelay arklib.RelativeLocktime @@ -145,21 +146,22 @@ type Config struct { MaxConcurrentStreams uint32 StreamConnPoolSize uint32 - fee ports.FeeManager - repo ports.RepoManager - svc application.Service - adminSvc application.AdminService - wallet ports.WalletService - signer ports.SignerService - txBuilder ports.TxBuilder - scanner ports.BlockchainScanner - scheduler ports.SchedulerService - unlocker ports.Unlocker - liveStore ports.LiveStore - network *arklib.Network - roundReportSvc application.RoundReportService - alerts ports.Alerts - settings *domain.Settings + fee ports.FeeManager + repo ports.RepoManager + svc application.Service + adminSvc application.AdminService + wallet ports.WalletService + walletFallbacks []FallbackWallet + signer ports.SignerService + txBuilder ports.TxBuilder + scanner ports.BlockchainScanner + scheduler ports.SchedulerService + unlocker ports.Unlocker + liveStore ports.LiveStore + network *arklib.Network + roundReportSvc application.RoundReportService + alerts ports.Alerts + settings *domain.Settings } func (c *Config) String() string { @@ -180,6 +182,7 @@ func (c *Config) String() string { var ( Datadir = "DATADIR" WalletAddr = "WALLET_ADDR" + WalletFallbackAddrs = "WALLET_FALLBACK_ADDRS" SignerAddr = "SIGNER_ADDR" SessionDuration = "SESSION_DURATION" BanDuration = "BAN_DURATION" @@ -442,6 +445,7 @@ func LoadConfig() (*Config, error) { return &Config{ Datadir: viper.GetString(Datadir), WalletAddr: viper.GetString(WalletAddr), + WalletFallbackAddrs: parseWalletFallbackAddrs(viper.GetString(WalletFallbackAddrs)), SignerAddr: signerAddr, SessionDuration: viper.GetInt64(SessionDuration), BanDuration: viper.GetInt64(BanDuration), @@ -664,6 +668,29 @@ func (c *Config) WalletService() ports.WalletService { return c.wallet } +// FallbackWallet pairs a dialed fallback wallet client with the address it was +// dialed at, so failures can name the specific wallet (host:port) rather than a +// positional index. +type FallbackWallet struct { + Addr string + Service ports.WalletService +} + +func (c *Config) FallbackWallets() []FallbackWallet { + return c.walletFallbacks +} + +// fallbackWalletServices returns just the dialed fallback wallet clients, for +// consumers (the app and admin services) that sign with them but don't need the +// dial address. +func (c *Config) fallbackWalletServices() []ports.WalletService { + svcs := make([]ports.WalletService, 0, len(c.walletFallbacks)) + for _, fb := range c.walletFallbacks { + svcs = append(svcs, fb.Service) + } + return svcs +} + func (c *Config) UnlockerService() ports.Unlocker { return c.unlocker } @@ -712,6 +739,15 @@ func (c *Config) SignerService() (ports.SignerService, error) { return c.signer, nil } +func (c *Config) RepoManager() (ports.RepoManager, error) { + if c.repo == nil { + if err := c.repoManager(); err != nil { + return nil, err + } + } + return c.repo, nil +} + func (c *Config) CacheService() ports.LiveStore { return c.liveStore } @@ -799,22 +835,119 @@ func (c *Config) repoManager() error { return nil } +// newWalletClient is the wallet client constructor, indirected so tests can +// stub out the gRPC dial. +var newWalletClient = walletclient.New + func (c *Config) walletService() error { + // Wallet addresses are sourced from the persisted settings (seeded from + // env on first boot) so admin changes take effect on the next restart. Fall + // back to env if settings aren't available yet. arkWallet := c.WalletAddr + fallbackAddrs := c.WalletFallbackAddrs + if c.repo != nil { + settings, err := c.repo.Settings().Get(context.Background()) + switch { + case err != nil: + log.Warnf("failed to read settings for wallet addresses, using env: %v", err) + case settings != nil: + if settings.WalletAddr != "" { + arkWallet = settings.WalletAddr + } + if len(settings.WalletFallbackAddrs) > 0 { + fallbackAddrs = settings.WalletFallbackAddrs + } + } + } + if arkWallet == "" { return fmt.Errorf("missing ark wallet address") } - walletSvc, network, err := walletclient.New(arkWallet, c.OtelCollectorEndpoint) + walletSvc, network, err := newWalletClient(arkWallet, c.OtelCollectorEndpoint) if err != nil { return err } c.wallet = walletSvc c.network = network + + log.Infof("dialed primary arkd-wallet at %q on network %s", arkWallet, network.Name) + + fallbacks, err := c.dialFallbackWallets(arkWallet, fallbackAddrs) + if err != nil { + return err + } + c.walletFallbacks = fallbacks + return nil } +// dialFallbackWallets dials the given fallback arkd-wallets (sourced from +// settings or env), used as sweep signers, validating each is reachable and on +// the primary's network. Any failure is fatal so misconfiguration surfaces at +// startup. +func (c *Config) dialFallbackWallets( + primaryAddr string, fallbackAddrs []string, +) ([]FallbackWallet, error) { + fallbacks := make([]FallbackWallet, 0, len(fallbackAddrs)) + seen := make(map[string]struct{}, len(fallbackAddrs)) + for _, addr := range fallbackAddrs { + if addr == "" { + continue + } + // Reject a fallback that duplicates the primary or another fallback. + if addr == primaryAddr { + closeWallets(fallbacks) + return nil, fmt.Errorf("fallback wallet %q is the same as the primary wallet", addr) + } + if _, dup := seen[addr]; dup { + closeWallets(fallbacks) + return nil, fmt.Errorf("duplicate fallback wallet %q", addr) + } + seen[addr] = struct{}{} + + fbSvc, fbNetwork, err := newWalletClient(addr, c.OtelCollectorEndpoint) + if err != nil { + closeWallets(fallbacks) + return nil, fmt.Errorf("failed to dial fallback wallet %q: %w", addr, err) + } + if fbNetwork.Name != c.network.Name { + fbSvc.Close() + closeWallets(fallbacks) + return nil, fmt.Errorf( + "fallback wallet %q is on network %q, expected %q (same as primary)", + addr, fbNetwork.Name, c.network.Name, + ) + } + log.Infof("dialed fallback wallet %q on network %s", addr, fbNetwork.Name) + fallbacks = append(fallbacks, FallbackWallet{Addr: addr, Service: fbSvc}) + } + return fallbacks, nil +} + +func closeWallets(wallets []FallbackWallet) { + for _, w := range wallets { + w.Service.Close() + } +} + +// parseWalletFallbackAddrs splits a comma-separated list of wallet addresses, +// trimming whitespace and dropping empty entries. +func parseWalletFallbackAddrs(raw string) []string { + parts := strings.Split(raw, ",") + addrs := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + addrs = append(addrs, p) + } + } + if len(addrs) == 0 { + return nil + } + return addrs +} + func (c *Config) signerService() error { signer := c.SignerAddr if signer == "" { @@ -915,7 +1048,7 @@ func (c *Config) appService() error { } svc, err := application.NewService( - c.wallet, c.signer, c.repo, c.txBuilder, c.scanner, + c.wallet, c.fallbackWalletServices(), c.signer, c.repo, c.txBuilder, c.scanner, c.scheduler, c.liveStore, roundReportSvc, c.alerts, c.fee, ) if err != nil { @@ -933,7 +1066,7 @@ func (c *Config) adminService() error { } c.adminSvc = application.NewAdminService( - c.wallet, c.repo, c.txBuilder, c.liveStore, unit, c.fee, + c.wallet, c.fallbackWalletServices(), c.repo, c.txBuilder, c.liveStore, unit, c.fee, ) return nil } @@ -996,6 +1129,7 @@ func (c *Config) getSettings() (*domain.Settings, error) { c.BoardingExitDelay, c.VtxoTreeExpiry, c.MaxTxWeight, c.MaxOpReturnOutputs, c.AssetTxMaxWeightRatio, c.NoteUriPrefix, c.BuildVersionHeader, c.BuildVersionHeaderRequired, c.DigestHeaderRequired, + c.WalletAddr, c.WalletFallbackAddrs, ) if err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a5357063..7a6df93a2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,9 +1,11 @@ package config import ( + "fmt" "testing" "time" + "github.com/arkade-os/arkd/internal/core/ports" arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/stretchr/testify/require" ) @@ -270,3 +272,142 @@ func TestConfigStringRedactsSecrets(t *testing.T) { }) } } + +func TestParseWalletFallbackAddrs(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + {"empty", "", nil}, + {"single", "localhost:6061", []string{"localhost:6061"}}, + {"multiple", "a:6060,b:6060,c:6060", []string{"a:6060", "b:6060", "c:6060"}}, + {"trims whitespace", "a:6060, b:6060 ,c:6060", []string{"a:6060", "b:6060", "c:6060"}}, + {"drops empty entries", "a:6060,,b:6060,", []string{"a:6060", "b:6060"}}, + {"only separators", " , , ", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, parseWalletFallbackAddrs(tt.raw)) + }) + } +} + +// fakeFallbackWallet is a ports.WalletService that only implements Close; via +// the embedded nil interface every other method is unused by these tests. +type fakeFallbackWallet struct { + ports.WalletService + closed *int +} + +func (f *fakeFallbackWallet) Close() { *f.closed++ } + +func TestDialFallbackWallets(t *testing.T) { + orig := newWalletClient + t.Cleanup(func() { newWalletClient = orig }) + + regtest := &arklib.Network{Name: "regtest"} + testnet := &arklib.Network{Name: "testnet"} + + t.Run("all on the same network", func(t *testing.T) { + var closes int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets(c.WalletAddr, c.WalletFallbackAddrs) + + require.NoError(t, err) + require.Len(t, fbs, 2) + require.Zero(t, closes) + }) + + t.Run("network mismatch hard-fails and closes dialed", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + net := regtest + if calls == 2 { + net = testnet + } + return &fakeFallbackWallet{closed: &closes}, net, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets(c.WalletAddr, c.WalletFallbackAddrs) + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "b:6060") + require.Contains(t, err.Error(), "testnet") + require.Contains(t, err.Error(), "regtest") + // The mismatched wallet and the previously dialed one are both closed. + require.Equal(t, 2, closes) + }) + + t.Run("dial error hard-fails and closes dialed", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + if calls == 2 { + return nil, nil, fmt.Errorf("connection refused") + } + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "b:6060"}} + fbs, err := c.dialFallbackWallets(c.WalletAddr, c.WalletFallbackAddrs) + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "b:6060") + // The first, successfully dialed fallback is closed. + require.Equal(t, 1, closes) + }) + + t.Run("fallback equal to primary hard-fails", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{ + network: regtest, + WalletAddr: "primary:6060", + WalletFallbackAddrs: []string{"a:6060", "primary:6060"}, + } + fbs, err := c.dialFallbackWallets(c.WalletAddr, c.WalletFallbackAddrs) + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "primary:6060") + require.Contains(t, err.Error(), "same as the primary") + // The first, successfully dialed fallback is closed; the primary-equal + // entry is rejected before dialing. + require.Equal(t, 1, closes) + require.Equal(t, 1, calls) + }) + + t.Run("duplicate fallback hard-fails", func(t *testing.T) { + var closes, calls int + newWalletClient = func(_, _ string) (ports.WalletService, *arklib.Network, error) { + calls++ + return &fakeFallbackWallet{closed: &closes}, regtest, nil + } + + c := &Config{network: regtest, WalletFallbackAddrs: []string{"a:6060", "a:6060"}} + fbs, err := c.dialFallbackWallets(c.WalletAddr, c.WalletFallbackAddrs) + + require.Error(t, err) + require.Nil(t, fbs) + require.Contains(t, err.Error(), "duplicate fallback wallet") + require.Contains(t, err.Error(), "a:6060") + // The first dial succeeded and is closed; the duplicate is rejected + // before dialing. + require.Equal(t, 1, closes) + require.Equal(t, 1, calls) + }) +} diff --git a/internal/core/application/admin.go b/internal/core/application/admin.go index cd7e1a4ee..34f0d88d7 100644 --- a/internal/core/application/admin.go +++ b/internal/core/application/admin.go @@ -59,7 +59,10 @@ type AdminService interface { } type adminService struct { - walletSvc ports.WalletService + walletSvc ports.WalletService + // walletFallbacks are additional arkd-wallets whose batches admin.Sweep may also + // sign; signing is attempted with the primary wallet first, then each fallback. + walletFallbacks []ports.WalletService repoManager ports.RepoManager txBuilder ports.TxBuilder sweeperTimeUnit ports.TimeUnit @@ -72,11 +75,13 @@ type adminService struct { } func NewAdminService( - walletSvc ports.WalletService, repoManager ports.RepoManager, txBuilder ports.TxBuilder, + walletSvc ports.WalletService, walletFallbacks []ports.WalletService, + repoManager ports.RepoManager, txBuilder ports.TxBuilder, liveStoreSvc ports.LiveStore, timeUnit ports.TimeUnit, feeManager ports.FeeManager, ) AdminService { return &adminService{ walletSvc: walletSvc, + walletFallbacks: walletFallbacks, repoManager: repoManager, txBuilder: txBuilder, sweeperTimeUnit: timeUnit, @@ -85,6 +90,12 @@ func NewAdminService( } } +// signingWallets returns the wallets to try when signing a sweep, in order: the +// primary wallet first, then any configured fallbacks. +func (a *adminService) signingWallets() []ports.WalletService { + return append([]ports.WalletService{a.walletSvc}, a.walletFallbacks...) +} + func (a *adminService) Wallet() ports.WalletService { return a.walletSvc } @@ -583,7 +594,7 @@ func (a *adminService) Sweep( ) } - txid, txhex, err = a.txBuilder.BuildSweepTx(inputs) + txid, txhex, err = buildAndSignSweepTx(a.txBuilder, a.signingWallets(), inputs) if err != nil { return } @@ -631,9 +642,8 @@ func (a *adminService) UpdateSettings( a.settingsMu.Lock() defer a.settingsMu.Unlock() - // Partial update: only the fields set on the request (non-nil pointers) are - // applied to the stored settings; omitted fields are left unchanged. The - // returned changelog lists exactly the fields that were updated. + // Partial updates allowed. Only the fields set on the request (non-nil pointers) are + // applied to the stored settings and omitted fields are left unchanged. settings, err := a.repoManager.Settings().Get(ctx) if err != nil { return nil, fmt.Errorf("failed to get current settings: %w", err) diff --git a/internal/core/application/admin_test.go b/internal/core/application/admin_test.go index dc1dda34f..2253c5c6c 100644 --- a/internal/core/application/admin_test.go +++ b/internal/core/application/admin_test.go @@ -56,7 +56,7 @@ func TestAdminService_Settings(t *testing.T) { if seed != nil { require.NoError(t, repo.settingsRepo.Upsert(ctx, *seed, nil)) } - return application.NewAdminService(nil, repo, nil, nil, ports.UnixTime, nil) + return application.NewAdminService(nil, nil, repo, nil, nil, ports.UnixTime, nil) } t.Run("settings", func(t *testing.T) { @@ -246,7 +246,7 @@ func TestAdminService_SettingsSerialization(t *testing.T) { seed := validSettings() probe := &serializeProbeRepo{settings: &seed, delay: 10 * time.Millisecond} svc := application.NewAdminService( - nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil, + nil, nil, &mockRepoManager{settingsRepo: probe}, nil, nil, ports.UnixTime, nil, ) const workers = 8 diff --git a/internal/core/application/fraud.go b/internal/core/application/fraud.go index 9c7706b81..e642d7ee5 100644 --- a/internal/core/application/fraud.go +++ b/internal/core/application/fraud.go @@ -11,6 +11,7 @@ import ( "github.com/arkade-os/arkd/internal/core/domain" "github.com/arkade-os/arkd/pkg/ark-lib/tree" "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -164,9 +165,40 @@ func (s *service) broadcastForfeitTx(ctx context.Context, vtxo domain.Vtxo) erro return fmt.Errorf("failed to encode forfeit tx: %s", err) } - signedForfeitTx, err := s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) + // Forfeit txs are signed by the operator at collection time, so the stored tx + // is usually already broadcast-ready. Re-signing would append a duplicate + // operator signature and produce an invalid PSBT (duplicate key), so we only + // sign here when the operator signature is still missing (e.g. forfeit txs + // collected before collection-time signing was introduced). + // + // The operator key set is read from the cached settings, not the live signer: + // a pre-signed forfeit must stay broadcastable even when the signer is down, + // which is the whole point of signing at collection time. Deprecated keys are + // included so a forfeit signed before a key rotation is still recognized as + // signed and not re-signed with the current (wrong-for-its-tapscript) key. + settings, err := s.cache.Settings().Get(ctx) if err != nil { - return fmt.Errorf("failed to sign forfeit tx: %s", err) + return fmt.Errorf("failed to get settings: %s", err) + } + if settings == nil { + return fmt.Errorf("settings not available") + } + operatorKeys := make([][]byte, 0, 1+len(settings.DeprecatedSignerPubkeys)) + if settings.SignerPubkey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(settings.SignerPubkey)) + } + for _, deprecated := range settings.DeprecatedSignerPubkeys { + if deprecated.PubKey != nil { + operatorKeys = append(operatorKeys, schnorr.SerializePubKey(deprecated.PubKey)) + } + } + + signedForfeitTx := forfeitTxB64 + if !forfeitTxOperatorSigned(forfeitTx, operatorKeys) { + signedForfeitTx, err = s.signer.SignTransactionTapscript(ctx, forfeitTxB64, nil) + if err != nil { + return fmt.Errorf("failed to sign forfeit tx: %s", err) + } } forfeitTxHex, err := s.builder.FinalizeAndExtract(signedForfeitTx) @@ -417,6 +449,25 @@ func findForfeitTx( return nil, domain.Outpoint{}, fmt.Errorf("forfeit tx not found") } +// forfeitTxOperatorSigned reports whether the forfeit tx already carries a +// tapscript signature from one of the operator's signer keys (the current key or +// any deprecated one), i.e. it was signed at collection time and must not be +// signed again. Deprecated keys are included because a forfeit signed before a +// key rotation is still valid for its own (old-key) tapscript and must not be +// re-signed with the current key. +func forfeitTxOperatorSigned(ptx *psbt.Packet, operatorXOnlyKeys [][]byte) bool { + for _, in := range ptx.Inputs { + for _, sig := range in.TaprootScriptSpendSig { + for _, key := range operatorXOnlyKeys { + if bytes.Equal(sig.XOnlyPubKey, key) { + return true + } + } + } + } + return false +} + // computeVSize calculates the virtual size (vsize) of a Bitcoin transaction // in virtual bytes (vbytes). It takes into account both the stripped size // (base size without witness data) and the total size (including witness data), diff --git a/internal/core/application/service.go b/internal/core/application/service.go index bfbde4c9e..27152e738 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -69,6 +69,7 @@ type service struct { func NewService( wallet ports.WalletService, + walletFallbacks []ports.WalletService, signer ports.SignerService, repoManager ports.RepoManager, builder ports.TxBuilder, @@ -142,13 +143,19 @@ func NewService( ctx, cancel := context.WithCancel(ctx) svc := &service{ - wallet: wallet, - signer: signer, - repoManager: repoManager, - builder: builder, - cache: cache, - scanner: scanner, - sweeper: newSweeper(wallet, repoManager, builder, scheduler), + wallet: wallet, + signer: signer, + repoManager: repoManager, + builder: builder, + cache: cache, + scanner: scanner, + sweeper: newSweeper( + wallet, + walletFallbacks, + repoManager, + builder, + scheduler, + ), operatorPrvkey: operatorSigningKey, operatorPubkey: operatorSigningKey.PubKey(), forfeitsBoardingSigsChan: make(chan struct{}, 1), @@ -396,9 +403,8 @@ func (s *service) Stop() { } } - // nolint - s.wallet.Lock(ctx) - log.Debug("locked wallet") + // arkd does not own the wallet lifecycle: leave the (possibly shared) + // arkd-wallet unlocked so it stays usable across restarts and by other LPs. s.wallet.Close() log.Debug("closed connection to wallet") s.repoManager.Close() @@ -2059,6 +2065,31 @@ func (s *service) ConfirmRegistration(ctx context.Context, intentId string) erro return nil } +// signForfeitTxs adds the operator signature to each collected forfeit tx and +// returns them as domain.ForfeitTx ready to be persisted. Signing only adds +// witness data, so the txid is read from the signed psbt's unsigned tx and is +// identical to the txid of the user-submitted forfeit tx. +func (s *service) signForfeitTxs( + ctx context.Context, forfeitTxs []string, +) ([]domain.ForfeitTx, error) { + signed := make([]domain.ForfeitTx, 0, len(forfeitTxs)) + for _, tx := range forfeitTxs { + signedTx, err := s.signer.SignTransactionTapscript(ctx, tx, nil) + if err != nil { + return nil, fmt.Errorf("failed to sign forfeit tx: %w", err) + } + ptx, err := psbt.NewFromRawBytes(strings.NewReader(signedTx), true) + if err != nil { + return nil, fmt.Errorf("failed to parse signed forfeit tx: %w", err) + } + signed = append(signed, domain.ForfeitTx{ + Txid: ptx.UnsignedTx.TxID(), + Tx: signedTx, + }) + } + return signed, nil +} + func (s *service) SubmitForfeitTxs(ctx context.Context, forfeitTxs []string) errors.Error { if len(forfeitTxs) <= 0 { return nil @@ -3308,14 +3339,15 @@ func (s *service) finalizeRound(roundId string, roundTiming roundTiming, setting s.roundReportSvc.OpEnded(VerifyBoardingInputsSignaturesOp) } - for _, tx := range forfeitTxList { - // nolint - ptx, _ := psbt.NewFromRawBytes(strings.NewReader(tx), true) - forfeitTxid := ptx.UnsignedTx.TxID() - forfeitTxs = append(forfeitTxs, domain.ForfeitTx{ - Txid: forfeitTxid, - Tx: tx, - }) + // Add the operator signature to each forfeit tx at collection time, so the + // stored forfeit tx is broadcast-ready without needing to be signed later + // at fraud-reaction time. + forfeitTxs, err = s.signForfeitTxs(ctx, forfeitTxList) + if err != nil { + changes = round.Fail(errors.INTERNAL_ERROR.New( + "failed to sign forfeit txs: %s", err, + )) + return } } diff --git a/internal/core/application/service_forfeit_test.go b/internal/core/application/service_forfeit_test.go new file mode 100644 index 000000000..ff7d1db9b --- /dev/null +++ b/internal/core/application/service_forfeit_test.go @@ -0,0 +1,133 @@ +package application + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// fakeForfeitSigner is a minimal ports.SignerService double: it records calls to +// SignTransactionTapscript and returns a canned signed tx (or error). Other +// SignerService methods are inherited from the embedded interface and unused. +type fakeForfeitSigner struct { + ports.SignerService + returnTx string + err error + calls int + lastTx string +} + +func (f *fakeForfeitSigner) SignTransactionTapscript( + _ context.Context, partialTx string, _ []int, +) (string, error) { + f.calls++ + f.lastTx = partialTx + if f.err != nil { + return "", f.err + } + return f.returnTx, nil +} + +func unsignedPsbt(t *testing.T, inputIndex byte) string { + t.Helper() + var hash chainhash.Hash + hash[0] = inputIndex + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: hash, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscriptOpTrue})) + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + b64, err := p.B64Encode() + require.NoError(t, err) + return b64 +} + +const txscriptOpTrue = 0x51 + +func TestSignForfeitTxs(t *testing.T) { + ctx := context.Background() + + // The user-signed forfeit tx submitted at collection time. + userSigned := unsignedPsbt(t, 0xaa) + // What the operator signer returns: a distinct, fully signed forfeit tx. + operatorSigned := unsignedPsbt(t, 0xbb) + wantPtx, err := psbt.NewFromRawBytes(strings.NewReader(operatorSigned), true) + require.NoError(t, err) + wantTxid := wantPtx.UnsignedTx.TxID() + + t.Run("signs each forfeit tx via the operator signer", func(t *testing.T) { + signer := &fakeForfeitSigner{returnTx: operatorSigned} + s := &service{signer: signer} + + got, err := s.signForfeitTxs(ctx, []string{userSigned}) + + require.NoError(t, err) + require.Equal(t, 1, signer.calls, "signer must be invoked once per forfeit tx") + require.Equal(t, userSigned, signer.lastTx, "signer must receive the user-signed tx") + require.Len(t, got, 1) + require.Equal(t, operatorSigned, got[0].Tx, "stored tx must be the operator-signed tx") + require.Equal(t, wantTxid, got[0].Txid, "txid must come from the signed psbt") + }) + + t.Run("signs every forfeit tx in the batch", func(t *testing.T) { + signer := &fakeForfeitSigner{returnTx: operatorSigned} + s := &service{signer: signer} + + got, err := s.signForfeitTxs(ctx, []string{userSigned, userSigned, userSigned}) + + require.NoError(t, err) + require.Equal(t, 3, signer.calls) + require.Len(t, got, 3) + }) + + t.Run("returns error when the signer fails", func(t *testing.T) { + signer := &fakeForfeitSigner{err: errors.New("signer unavailable")} + s := &service{signer: signer} + + _, err := s.signForfeitTxs(ctx, []string{userSigned}) + + require.Error(t, err) + }) +} + +func TestForfeitTxOperatorSigned(t *testing.T) { + operatorXOnly := make([]byte, 32) + operatorXOnly[0] = 0x07 + + build := func(withOperatorSig bool) *psbt.Packet { + var hash chainhash.Hash + hash[0] = 0xaa + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Hash: hash, Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1000, []byte{txscriptOpTrue})) + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + if withOperatorSig { + p.Inputs[0].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{{ + XOnlyPubKey: operatorXOnly, + LeafHash: make([]byte, 32), + Signature: make([]byte, 64), + }} + } + return p + } + + require.True(t, forfeitTxOperatorSigned(build(true), [][]byte{operatorXOnly}), + "must detect the operator signature") + require.False(t, forfeitTxOperatorSigned(build(false), [][]byte{operatorXOnly}), + "must report unsigned when the operator sig is absent") + + otherXOnly := make([]byte, 32) + otherXOnly[0] = 0x09 + require.False(t, forfeitTxOperatorSigned(build(true), [][]byte{otherXOnly}), + "must not match a different pubkey") + require.True(t, forfeitTxOperatorSigned(build(true), [][]byte{otherXOnly, operatorXOnly}), + "must match when any key in the set is present (e.g. a deprecated key)") +} diff --git a/internal/core/application/sweeper.go b/internal/core/application/sweeper.go index 60b82930f..380591eed 100644 --- a/internal/core/application/sweeper.go +++ b/internal/core/application/sweeper.go @@ -31,10 +31,13 @@ type sweeperTask struct { // it is responsible for sweeping batch outputs that reached the expiration date. // it also handles delaying the sweep events in case some parts of the tree are broadcasted type sweeper struct { - wallet ports.WalletService - repoManager ports.RepoManager - builder ports.TxBuilder - scheduler ports.SchedulerService + wallet ports.WalletService + // walletFallbacks are additional arkd-wallets whose batches this arkd may also + // sweep; signing is attempted with the primary wallet first, then each fallback. + walletFallbacks []ports.WalletService + repoManager ports.RepoManager + builder ports.TxBuilder + scheduler ports.SchedulerService // cache of scheduled tasks, avoid scheduling the same sweep event multiple times locker *sync.Mutex @@ -44,14 +47,64 @@ type sweeper struct { } func newSweeper( - wallet ports.WalletService, repoManager ports.RepoManager, builder ports.TxBuilder, + wallet ports.WalletService, walletFallbacks []ports.WalletService, + repoManager ports.RepoManager, builder ports.TxBuilder, scheduler ports.SchedulerService, ) *sweeper { return &sweeper{ - wallet, repoManager, builder, scheduler, &sync.Mutex{}, make(map[string]struct{}), nil, + wallet: wallet, + walletFallbacks: walletFallbacks, + repoManager: repoManager, + builder: builder, + scheduler: scheduler, + locker: &sync.Mutex{}, + scheduledTasks: make(map[string]struct{}), } } +// signingWallets returns the wallets to try when signing a sweep, in order: the +// primary wallet first, then any configured fallbacks. +func (s *sweeper) signingWallets() []ports.WalletService { + return append([]ports.WalletService{s.wallet}, s.walletFallbacks...) +} + +// buildAndSignSweepTx builds the sweep transaction once (its destination and fees +// come from the primary wallet) and then attempts to sign it with each wallet in +// order, returning (txid, signedTx, nil) as soon as one succeeds. This lets a +// single arkd sweep batches signed by any of its primary/fallback arkd-wallets. +// Broadcasting is left to the caller. +// +// On signing failure it still returns the txid (with a non-nil error and an empty +// signed tx) so a reconcile-only caller can use the txid without rebuilding the +// tx. Callers that broadcast must treat a non-nil error as fatal. A build failure +// returns an empty txid. +func buildAndSignSweepTx( + builder ports.TxBuilder, wallets []ports.WalletService, inputs []ports.TxInput, +) (string, string, error) { + txid, unsignedTx, err := builder.BuildSweepTx(inputs) + if err != nil { + return "", "", err + } + + if len(wallets) == 0 { + return txid, "", fmt.Errorf("no signing wallets configured for sweep tx %s", txid) + } + + signErrs := make([]error, 0, len(wallets)) + for i, wallet := range wallets { + signed, signErr := builder.SignSweepTx(wallet, unsignedTx) + if signErr == nil { + return txid, signed, nil + } + // name the failing wallet so a multi-wallet operator can tell which rejected it + signErrs = append(signErrs, fmt.Errorf("wallet[%d]: %w", i, signErr)) + } + + return txid, "", fmt.Errorf( + "no wallet could sign sweep tx %s: %w", txid, errors.Join(signErrs...), + ) +} + func (s *sweeper) start(ctx context.Context) error { s.scheduledTasks = make(map[string]struct{}) s.scheduler.Start() @@ -627,7 +680,9 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) ) // build the sweep transaction with all the expired non-swept batch outputs - sweepTxId, sweepTx, err = s.builder.BuildSweepTx(unspentOutputsToSweep) + sweepTxId, sweepTx, err = buildAndSignSweepTx( + s.builder, s.signingWallets(), unspentOutputsToSweep, + ) if err != nil { return err } @@ -661,11 +716,26 @@ func (s *sweeper) createBatchSweepTask(commitmentTxid, vtxoTreeRootTxid string) } log.Debugf("sweeper: batch %s swept by: %s", commitmentTxid, txid) } else { - // if all outputs are spent, it means we missed to mark the batch as swept, - // build a sweep transaction without broadcasting it. we'll use it rebuild sweepEvent. - sweepTxId, sweepTx, err = s.builder.BuildSweepTx(outputsToSweep) + // All outputs are already spent on-chain — we just missed marking the + // batch swept. We rebuild the sweepEvent from the txid; the tx is never + // broadcast here, so a signing failure (e.g. the wallet that swept them + // is no longer primary or fallback) must not block reconciliation. + // buildAndSignSweepTx still returns the txid on signing failure, so we + // reconcile with the txid and store no tx bytes (an unsigned PSBT here + // would masquerade as a signed sweep tx in SweepTxs / the swept event). + sweepTxId, sweepTx, err = buildAndSignSweepTx( + s.builder, s.signingWallets(), outputsToSweep, + ) if err != nil { - return err + if sweepTxId == "" { + // the build itself failed; nothing to reconcile with + return err + } + log.WithError(err).Warnf( + "sweeper: could not sign sweep tx for already-spent batch %s, "+ + "reconciling with txid only", commitmentTxid, + ) + sweepTx = "" } } @@ -749,7 +819,9 @@ func (s *sweeper) createCheckpointSweepTask( checkpointTxid := toSweep.Txid log.Debugf("sweeper: start sweeping checkpoint %s", checkpointTxid) - _, sweepTx, err := s.builder.BuildSweepTx([]ports.TxInput{toSweep}) + _, sweepTx, err := buildAndSignSweepTx( + s.builder, s.signingWallets(), []ports.TxInput{toSweep}, + ) if err != nil { return err } diff --git a/internal/core/application/sweeper_test.go b/internal/core/application/sweeper_test.go new file mode 100644 index 000000000..ae0ba22c2 --- /dev/null +++ b/internal/core/application/sweeper_test.go @@ -0,0 +1,102 @@ +package application + +import ( + "fmt" + "testing" + + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/stretchr/testify/require" +) + +// fakeWallet is a distinct, comparable ports.WalletService identity. Only its +// pointer identity matters for the fallback-iteration tests. +type fakeWallet struct { + ports.WalletService + name string +} + +// fakeSweepBuilder implements just the two sweep methods of ports.TxBuilder that +// buildAndSignSweepTx uses. SignSweepTx succeeds only for goodWallet, recording the +// order in which wallets are tried. +type fakeSweepBuilder struct { + ports.TxBuilder + buildErr error + unsignedTx string + txid string + goodWallet ports.WalletService + signCalls []ports.WalletService +} + +func (b *fakeSweepBuilder) BuildSweepTx(inputs []ports.TxInput) (string, string, error) { + if b.buildErr != nil { + return "", "", b.buildErr + } + return b.txid, b.unsignedTx, nil +} + +func (b *fakeSweepBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (string, error) { + b.signCalls = append(b.signCalls, wallet) + if b.goodWallet != nil && wallet == b.goodWallet { + return "signed:" + unsignedTx, nil + } + return "", fmt.Errorf("wallet %v cannot sign", wallet) +} + +func TestBuildAndSignSweepTx(t *testing.T) { + inputs := []ports.TxInput{{Txid: "aa", Index: 0}} + primary := &fakeWallet{name: "primary"} + fb1 := &fakeWallet{name: "fb1"} + fb2 := &fakeWallet{name: "fb2"} + wallets := []ports.WalletService{primary, fb1, fb2} + + t.Run("primary signs, fallbacks not tried", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: primary} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.NoError(t, err) + require.Equal(t, "txid123", txid) + require.Equal(t, "signed:unsigned", signed) + require.Equal(t, []ports.WalletService{primary}, b.signCalls) + }) + + t.Run("falls back to a later wallet in order", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123", goodWallet: fb2} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.NoError(t, err) + require.Equal(t, "txid123", txid) + require.Equal(t, "signed:unsigned", signed) + require.Equal(t, []ports.WalletService{primary, fb1, fb2}, b.signCalls) + }) + + t.Run("no wallet can sign returns aggregated error naming each wallet", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"} + + txid, signed, err := buildAndSignSweepTx(b, wallets, inputs) + require.Error(t, err) + require.Equal(t, "txid123", txid) + require.Empty(t, signed) + require.Contains(t, err.Error(), "no wallet could sign sweep tx txid123") + require.Contains(t, err.Error(), "wallet[0]") + require.Contains(t, err.Error(), "wallet[2]") + require.Len(t, b.signCalls, 3) + }) + + t.Run("no signing wallets configured", func(t *testing.T) { + b := &fakeSweepBuilder{unsignedTx: "unsigned", txid: "txid123"} + + _, _, err := buildAndSignSweepTx(b, nil, inputs) + require.ErrorContains(t, err, "no signing wallets configured for sweep tx txid123") + require.Empty(t, b.signCalls) + }) + + t.Run("build error short-circuits before signing", func(t *testing.T) { + b := &fakeSweepBuilder{buildErr: fmt.Errorf("boom")} + + _, _, err := buildAndSignSweepTx(b, wallets, inputs) + require.ErrorContains(t, err, "boom") + require.Empty(t, b.signCalls) + }) +} diff --git a/internal/core/domain/round_repo.go b/internal/core/domain/round_repo.go index 7c8d7a877..05459c947 100644 --- a/internal/core/domain/round_repo.go +++ b/internal/core/domain/round_repo.go @@ -33,6 +33,11 @@ type RoundRepository interface { // used to lazily persist fees recomputed for rounds finalized before fee // persistence was introduced (https://github.com/arkade-os/arkd/pull/933). PatchCollectedFees(ctx context.Context, feesByRoundId map[string]uint64) error + // PatchForfeitTxs replaces the stored tx (PSBT) of the given forfeit txs, + // keyed by txid. Used to backfill the operator signature on forfeit txs that + // were persisted before collection-time signing was introduced. Signing only + // adds witness data, so the txid is unchanged and safely keys the update. + PatchForfeitTxs(ctx context.Context, txByTxid map[string]string) error Close() } diff --git a/internal/core/domain/settings.go b/internal/core/domain/settings.go index eb5cd1b0e..260530067 100644 --- a/internal/core/domain/settings.go +++ b/internal/core/domain/settings.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "strings" "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" @@ -62,6 +63,8 @@ type Settings struct { BuildVersionHeader string BuildVersionHeaderRequired bool DigestHeaderRequired bool + WalletAddr string + WalletFallbackAddrs []string UpdatedAt time.Time } @@ -75,6 +78,7 @@ func NewSettings( maxTxWeight, maxOpReturnOutputs uint64, assetTxMaxWeightRatio float32, noteUriPrefix, minVersionAccepted string, minVersionRequired, digestHeaderRequired bool, + walletAddr string, walletFallbackAddrs []string, ) (*Settings, error) { settings := &Settings{ SessionDuration: time.Duration(sessionDuration) * time.Second, @@ -101,6 +105,8 @@ func NewSettings( BuildVersionHeader: minVersionAccepted, BuildVersionHeaderRequired: minVersionRequired, DigestHeaderRequired: digestHeaderRequired, + WalletAddr: walletAddr, + WalletFallbackAddrs: walletFallbackAddrs, UpdatedAt: time.Now(), } if err := settings.Validate(); err != nil { @@ -237,9 +243,41 @@ func (s Settings) Validate() error { if s.BuildVersionHeaderRequired && len(s.BuildVersionHeader) <= 0 { return fmt.Errorf("build version header is required but no version is set") } + + // Fallback addrs are persisted comma-separated by the SQL backends, so a comma + // in an address would corrupt the column on the next read. + if strings.Contains(s.WalletAddr, ",") { + return fmt.Errorf("wallet addr must not contain a comma") + } + for _, addr := range s.WalletFallbackAddrs { + if strings.Contains(addr, ",") { + return fmt.Errorf("wallet fallback addr %q must not contain a comma", addr) + } + } return nil } +// EncodeFallbackAddrs and DecodeFallbackAddrs convert WalletFallbackAddrs to and +// from the comma-separated form the SQL backends persist it in. Decoding trims +// whitespace and drops empty entries; an empty result is returned as nil. +func EncodeFallbackAddrs(addrs []string) string { + return strings.Join(addrs, ",") +} + +func DecodeFallbackAddrs(s string) []string { + parts := strings.Split(s, ",") + addrs := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + addrs = append(addrs, p) + } + } + if len(addrs) == 0 { + return nil + } + return addrs +} + // SettingsUpdate is a copy of the Settings repo struct, but with optional fields to easily handle // changes. type SettingsUpdate struct { @@ -267,6 +305,8 @@ type SettingsUpdate struct { BuildVersionHeader *string BuildVersionHeaderRequired *bool DigestHeaderRequired *bool + WalletAddr *string + WalletFallbackAddrs *[]string } // Update updates any field of Settings but ScheduledSession and BatchFees and returns a changelog @@ -372,6 +412,14 @@ func (s *Settings) Update(u SettingsUpdate) ([]string, error) { updated.DigestHeaderRequired = *u.DigestHeaderRequired changelog = append(changelog, "digest_header_required") } + if u.WalletAddr != nil { + updated.WalletAddr = *u.WalletAddr + changelog = append(changelog, "wallet_addr") + } + if u.WalletFallbackAddrs != nil { + updated.WalletFallbackAddrs = *u.WalletFallbackAddrs + changelog = append(changelog, "wallet_fallback_addrs") + } if err := updated.Validate(); err != nil { return nil, err diff --git a/internal/core/domain/settings_test.go b/internal/core/domain/settings_test.go index 97683de97..cbc0a10d4 100644 --- a/internal/core/domain/settings_test.go +++ b/internal/core/domain/settings_test.go @@ -145,6 +145,12 @@ func testValidateSettings(t *testing.T) { requiredVersionWithoutHeader.BuildVersionHeaderRequired = true requiredVersionWithoutHeader.BuildVersionHeader = "" + commaWalletAddr := validSettings + commaWalletAddr.WalletAddr = "host:6060,evil" + + commaFallbackAddr := validSettings + commaFallbackAddr.WalletFallbackAddrs = []string{"host:6060,evil"} + fixtures := []struct { settings domain.Settings expectedErr string @@ -250,6 +256,15 @@ func testValidateSettings(t *testing.T) { settings: requiredVersionWithoutHeader, expectedErr: "build version header is required but no version is set", }, + { + settings: commaWalletAddr, + expectedErr: "wallet addr must not contain a comma", + }, + { + settings: commaFallbackAddr, + expectedErr: `wallet fallback addr "host:6060,evil" ` + + "must not contain a comma", + }, } for _, f := range fixtures { @@ -313,6 +328,24 @@ func testUpdateSettings(t *testing.T) { require.Equal(t, validSettings, settings) }) + t.Run("updates wallet addresses", func(t *testing.T) { + settings := validSettings + + walletAddr := "wallet:7070" + fallbacks := []string{"wallet-2:7070", "wallet-3:7070"} + + changelog, err := settings.Update(domain.SettingsUpdate{ + WalletAddr: &walletAddr, + WalletFallbackAddrs: &fallbacks, + }) + require.NoError(t, err) + require.ElementsMatch( + t, []string{"wallet_addr", "wallet_fallback_addrs"}, changelog, + ) + require.Equal(t, "wallet:7070", settings.WalletAddr) + require.Equal(t, fallbacks, settings.WalletFallbackAddrs) + }) + t.Run("invalid update leaves settings untouched", func(t *testing.T) { settings := validSettings @@ -374,6 +407,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, buildVersionHeader, buildVersionHeaderRequired, digestHeaderRequired, + "localhost:6060", nil, ) require.NoError(t, err) require.NotNil(t, settings) @@ -396,6 +430,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, buildVersionHeader, buildVersionHeaderRequired, digestHeaderRequired, + "localhost:6060", nil, ) require.ErrorContains(t, err, "invalid session duration") require.Nil(t, settings) @@ -411,6 +446,7 @@ func testNewSettings(t *testing.T) { boardingExitDelay, vtxoTreeExpiry, maxTxWeight, maxOpReturnOutputs, assetTxMaxWeightRatio, noteUriPrefix, "", true, true, + "localhost:6060", nil, ) require.ErrorContains(t, err, "build version header is required but no version is set") require.Nil(t, settings) diff --git a/internal/core/ports/tx_builder.go b/internal/core/ports/tx_builder.go index f0f392448..670b7e3bd 100644 --- a/internal/core/ports/tx_builder.go +++ b/internal/core/ports/tx_builder.go @@ -60,7 +60,12 @@ type TxBuilder interface { VerifyForfeitTxs( vtxos []domain.Vtxo, connectors tree.FlatTxTree, txs []string, ) (valid map[domain.Outpoint]ValidForfeitTx, err error) - BuildSweepTx(inputs []TxInput) (txid string, signedSweepTx string, err error) + // BuildSweepTx builds the unsigned sweep tx, using the primary wallet for the + // destination address and fees. + BuildSweepTx(inputs []TxInput) (txid string, unsignedTx string, err error) + // SignSweepTx signs an unsigned sweep tx with the given wallet, so it can be + // signed by any primary/fallback wallet without rebuilding it. + SignSweepTx(wallet WalletService, unsignedTx string) (signedTx string, err error) GetSweepableBatchOutputs(vtxoTree *tree.TxTree) ( vtxoTreeExpiry *arklib.RelativeLocktime, batchOutputs *TxInput, err error, ) diff --git a/internal/infrastructure/db/badger/ark_repo.go b/internal/infrastructure/db/badger/ark_repo.go index a0417be54..0781db825 100644 --- a/internal/infrastructure/db/badger/ark_repo.go +++ b/internal/infrastructure/db/badger/ark_repo.go @@ -99,6 +99,27 @@ func (r *arkRepository) PatchCollectedFees( return nil } +// PatchForfeitTxs replaces the stored tx bytes of the given forfeit txs, keyed by +// txid. Forfeit txs are persisted as standalone Tx records (see addTxs), so the +// patch is a direct upsert under the same txid. +func (r *arkRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + for txid, tx := range txByTxid { + // Update (not Upsert) so a missing txid fails loudly instead of inserting a + // stray record, matching the SQL backends' "UPDATE ... WHERE txid = ?" guard. + // Badger keys txs by txid alone (no type column), so unlike SQL this can't + // also assert type = 'forfeit'; that's safe because txids are globally unique. + if err := r.store.Update(txid, Tx{Txid: txid, Tx: tx}); err != nil { + if errors.Is(err, badgerhold.ErrNotFound) { + return fmt.Errorf("forfeit tx %s not found", txid) + } + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + } + return nil +} + func (r *arkRepository) GetRoundWithCommitmentTxid( ctx context.Context, txid string, ) (*domain.Round, error) { diff --git a/internal/infrastructure/db/badger/ark_repo_test.go b/internal/infrastructure/db/badger/ark_repo_test.go new file mode 100644 index 000000000..63630c857 --- /dev/null +++ b/internal/infrastructure/db/badger/ark_repo_test.go @@ -0,0 +1,39 @@ +package badgerdb + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/timshannon/badgerhold/v4" +) + +func TestArkRepositoryPatchForfeitTxs(t *testing.T) { + repo, err := NewArkRepository(t.TempDir(), nil) + require.NoError(t, err) + + r, ok := repo.(*arkRepository) + require.True(t, ok) + t.Cleanup(r.Close) + + ctx := context.Background() + const ( + txid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + missing = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + + // Seed an existing forfeit tx. + require.NoError(t, r.store.Upsert(txid, Tx{Txid: txid, Tx: "old"})) + + // Patching an existing txid updates it in place. + require.NoError(t, repo.PatchForfeitTxs(ctx, map[string]string{txid: "new"})) + var got Tx + require.NoError(t, r.store.Get(txid, &got)) + require.Equal(t, "new", got.Tx) + + // Patching an unknown txid fails loudly instead of inserting a stray record, + // matching the SQL backends' "UPDATE ... WHERE txid = ?" no-match behavior. + err = repo.PatchForfeitTxs(ctx, map[string]string{missing: "new"}) + require.ErrorContains(t, err, "not found") + require.ErrorIs(t, r.store.Get(missing, &got), badgerhold.ErrNotFound) +} diff --git a/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql new file mode 100644 index 000000000..14dd1a38d --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + DROP COLUMN wallet_addr, + DROP COLUMN wallet_fallback_addrs; diff --git a/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql new file mode 100644 index 000000000..b43cc673c --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260612000000_add_wallet_addrs_to_settings.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + ADD COLUMN wallet_addr TEXT NOT NULL DEFAULT '', + ADD COLUMN wallet_fallback_addrs TEXT NOT NULL DEFAULT ''; diff --git a/internal/infrastructure/db/postgres/round_repo.go b/internal/infrastructure/db/postgres/round_repo.go index b66279a40..a242a4393 100644 --- a/internal/infrastructure/db/postgres/round_repo.go +++ b/internal/infrastructure/db/postgres/round_repo.go @@ -499,6 +499,27 @@ func (r *roundRepository) PatchCollectedFees( return execTx(ctx, r.db, txBody) } +func (r *roundRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + txBody := func(querierWithTx *queries.Queries) error { + for txid, tx := range txByTxid { + affectedRows, err := querierWithTx.UpdateForfeitTx( + ctx, + queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, + ) + if err != nil { + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + if affectedRows == 0 { + return fmt.Errorf("forfeit tx %s not found", txid) + } + } + return nil + } + return execTx(ctx, r.db, txBody) +} + func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ Amount: uint64(row.Amount.Int64), diff --git a/internal/infrastructure/db/postgres/settings_repo.go b/internal/infrastructure/db/postgres/settings_repo.go index 14ec05b35..68817907d 100644 --- a/internal/infrastructure/db/postgres/settings_repo.go +++ b/internal/infrastructure/db/postgres/settings_repo.go @@ -98,6 +98,8 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeader: row.BuildVersionHeader, BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, + WalletAddr: row.WalletAddr, + WalletFallbackAddrs: domain.DecodeFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -140,6 +142,8 @@ func (r *settingsRepository) Upsert( BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/postgres/settings_seed.go b/internal/infrastructure/db/postgres/settings_seed.go index 307d9eb28..fee6e5ebe 100644 --- a/internal/infrastructure/db/postgres/settings_seed.go +++ b/internal/infrastructure/db/postgres/settings_seed.go @@ -144,6 +144,8 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/postgres/settings_seed_test.go b/internal/infrastructure/db/postgres/settings_seed_test.go index f98156aaa..a696b07c7 100644 --- a/internal/infrastructure/db/postgres/settings_seed_test.go +++ b/internal/infrastructure/db/postgres/settings_seed_test.go @@ -78,6 +78,8 @@ CREATE TABLE settings ( max_op_return_outputs BIGINT NOT NULL DEFAULT 0, asset_tx_max_weight_ratio REAL NOT NULL DEFAULT 0, note_uri_prefix TEXT NOT NULL DEFAULT '', + wallet_addr TEXT NOT NULL DEFAULT '', + wallet_fallback_addrs TEXT NOT NULL DEFAULT '', scheduled_session_start_time BIGINT NOT NULL DEFAULT 0, scheduled_session_end_time BIGINT NOT NULL DEFAULT 0, scheduled_session_period BIGINT NOT NULL DEFAULT 0, diff --git a/internal/infrastructure/db/postgres/sqlc/queries/models.go b/internal/infrastructure/db/postgres/sqlc/queries/models.go index 52eddb932..1befc6af0 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/models.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/models.go @@ -245,6 +245,8 @@ type Setting struct { BuildVersionHeaderRequired bool DigestHeaderRequired bool UpdatedAt int64 + WalletAddr string + WalletFallbackAddrs string } type SettingsHistory struct { diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index a744dc04f..644950a95 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -1393,7 +1393,7 @@ func (q *Queries) SelectRoundsWithTxids(ctx context.Context, dollar_1 []string) } const selectSettings = `-- name: SelectSettings :one -SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at FROM settings WHERE id = 1 +SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at, wallet_addr, wallet_fallback_addrs FROM settings WHERE id = 1 ` func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { @@ -1436,6 +1436,8 @@ func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { &i.BuildVersionHeaderRequired, &i.DigestHeaderRequired, &i.UpdatedAt, + &i.WalletAddr, + &i.WalletFallbackAddrs, ) return i, err } @@ -1892,6 +1894,23 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } +const updateForfeitTx = `-- name: UpdateForfeitTx :execrows +UPDATE tx SET tx = $1 WHERE txid = $2 AND type = 'forfeit' +` + +type UpdateForfeitTxParams struct { + Tx string + Txid string +} + +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = $1 WHERE id = $2 ` @@ -2233,6 +2252,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -2254,12 +2274,13 @@ INSERT INTO settings ( $21, $22, $23, $24, $25, - $26, - $27, - $28, $29, + $26, $27, + $28, + $29, $30, $31, - $32, $33, $34, - $35 + $32, $33, + $34, $35, $36, + $37 ) ON CONFLICT(id) DO UPDATE SET session_duration = EXCLUDED.session_duration, @@ -2283,6 +2304,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, @@ -2323,6 +2346,8 @@ type UpsertSettingsParams struct { MaxOpReturnOutputs int64 AssetTxMaxWeightRatio float32 NoteUriPrefix string + WalletAddr string + WalletFallbackAddrs string ScheduledSessionStartTime int64 ScheduledSessionEndTime int64 ScheduledSessionPeriod int64 @@ -2362,6 +2387,8 @@ func (q *Queries) UpsertSettings(ctx context.Context, arg UpsertSettingsParams) arg.MaxOpReturnOutputs, arg.AssetTxMaxWeightRatio, arg.NoteUriPrefix, + arg.WalletAddr, + arg.WalletFallbackAddrs, arg.ScheduledSessionStartTime, arg.ScheduledSessionEndTime, arg.ScheduledSessionPeriod, diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index d319e665a..27ea07b49 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -411,6 +411,9 @@ SELECT * FROM asset WHERE asset.id = ANY($1::varchar[]); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = @fees WHERE id = @id; +-- name: UpdateForfeitTx :execrows +UPDATE tx SET tx = @tx WHERE txid = @txid AND type = 'forfeit'; + -- name: SelectAssetsWithUnspentAmountsByIds :many SELECT a.id, @@ -451,6 +454,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -470,6 +474,7 @@ INSERT INTO settings ( @settlement_min_expiry_gap, @vtxo_no_csv_validation_cutoff_date, @max_tx_weight, @max_op_return_outputs, @asset_tx_max_weight_ratio, @note_uri_prefix, + @wallet_addr, @wallet_fallback_addrs, @scheduled_session_start_time, @scheduled_session_end_time, @scheduled_session_period, @scheduled_session_duration, @scheduled_session_round_min_participants_count, @@ -501,6 +506,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, diff --git a/internal/infrastructure/db/service_test.go b/internal/infrastructure/db/service_test.go index 6867e9095..8bc86fd3d 100644 --- a/internal/infrastructure/db/service_test.go +++ b/internal/infrastructure/db/service_test.go @@ -721,6 +721,55 @@ func testRoundRepository(t *testing.T, svc ports.RepoManager) { } }) + t.Run("test_patch_forfeit_txs", func(t *testing.T) { + ctx := context.Background() + repo := svc.Rounds() + + // Finalize a round with four forfeit txs (as stored at collection time). + id := uuid.New().String() + commitmentTxid := randomString(32) + forfeits := []domain.ForfeitTx{f1Tx(), f2Tx(), f3Tx(), f4Tx()} + untouchedTxid := forfeits[2].Txid // f3Tx has a random txid; keep a reference + round := domain.NewRoundFromEvents([]domain.Event{ + domain.RoundStarted{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundStarted}, + Timestamp: 100, + }, + domain.RoundFinalizationStarted{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundFinalizationStarted}, + CommitmentTxid: commitmentTxid, + CommitmentTx: emptyTx, + }, + domain.RoundFinalized{ + RoundEvent: domain.RoundEvent{Id: id, Type: domain.EventTypeRoundFinalized}, + ForfeitTxs: forfeits, + FinalCommitmentTx: emptyTx, + Timestamp: 110, + }, + }) + require.NoError(t, repo.AddOrUpdateRound(ctx, *round)) + + // Patch f1 (txida) and f2 (txidb) with new operator-signed tx bytes, leaving + // f3/f4 untouched. The txid is unchanged by signing, so it keys the update. + patches := map[string]string{txida: f3, txidb: f4} + require.NoError(t, repo.PatchForfeitTxs(ctx, patches)) + + got, err := repo.GetRoundForfeitTxs(ctx, commitmentTxid) + require.NoError(t, err) + byTxid := make(map[string]string, len(got)) + for _, ftx := range got { + byTxid[ftx.Txid] = ftx.Tx + } + require.Equal(t, f3, byTxid[txida], "txida forfeit tx should be patched") + require.Equal(t, f4, byTxid[txidb], "txidb forfeit tx should be patched") + require.Equal(t, f3, byTxid[untouchedTxid], "unpatched forfeit tx must be unchanged") + + // Patching an unknown txid must fail loudly rather than silently no-op, + // so the backfill never reports a forfeit as signed when nothing was written. + err = repo.PatchForfeitTxs(ctx, map[string]string{randomString(32): f3}) + require.ErrorContains(t, err, "not found") + }) + } func testVtxoRepository(t *testing.T, svc ports.RepoManager) { @@ -1903,6 +1952,8 @@ func validSettings() domain.Settings { BuildVersionHeader: "v1.0.0", BuildVersionHeaderRequired: true, DigestHeaderRequired: true, + WalletAddr: "wallet:6060", + WalletFallbackAddrs: []string{"wallet-2:6060", "wallet-3:6060"}, UpdatedAt: time.Unix(1700000000, 0), } } @@ -2010,6 +2061,8 @@ func assertSettingsEqual(t *testing.T, expected, actual domain.Settings) { assert.Equal(t, expected.MaxTxWeight, actual.MaxTxWeight, "MaxTxWeight not equal") assert.True(t, expected.UpdatedAt.Equal(actual.UpdatedAt), "UpdatedAt not equal") assert.Equal(t, expected.AssetTxMaxWeightRatio, actual.AssetTxMaxWeightRatio, "AssetTxMaxWeightRatio not equal") + assert.Equal(t, expected.WalletAddr, actual.WalletAddr, "WalletAddr not equal") + assert.Equal(t, expected.WalletFallbackAddrs, actual.WalletFallbackAddrs, "WalletFallbackAddrs not equal") assert.Equal(t, expected.MaxOpReturnOutputs, actual.MaxOpReturnOutputs, "MaxOpReturnOutputs not equal") assert.Equal(t, expected.NoteUriPrefix, actual.NoteUriPrefix, "NoteUriPrefix not equal") assert.Equal(t, expected.BuildVersionHeader, actual.BuildVersionHeader, "BuildVersionHeader not equal") diff --git a/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql new file mode 100644 index 000000000..0034d967e --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings DROP COLUMN wallet_addr; +ALTER TABLE settings DROP COLUMN wallet_fallback_addrs; diff --git a/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql new file mode 100644 index 000000000..3165333b0 --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260612000000_add_wallet_addrs_to_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings ADD COLUMN wallet_addr TEXT NOT NULL DEFAULT ''; +ALTER TABLE settings ADD COLUMN wallet_fallback_addrs TEXT NOT NULL DEFAULT ''; diff --git a/internal/infrastructure/db/sqlite/round_repo.go b/internal/infrastructure/db/sqlite/round_repo.go index 6c31f71cd..4a10b6408 100644 --- a/internal/infrastructure/db/sqlite/round_repo.go +++ b/internal/infrastructure/db/sqlite/round_repo.go @@ -626,6 +626,27 @@ func (r *roundRepository) PatchCollectedFees( return execTx(ctx, r.db.Write(), txBody) } +func (r *roundRepository) PatchForfeitTxs( + ctx context.Context, txByTxid map[string]string, +) error { + txBody := func(querierWithTx *queries.Queries) error { + for txid, tx := range txByTxid { + affectedRows, err := querierWithTx.UpdateForfeitTx( + ctx, + queries.UpdateForfeitTxParams{Tx: tx, Txid: txid}, + ) + if err != nil { + return fmt.Errorf("failed to patch forfeit tx %s: %w", txid, err) + } + if affectedRows == 0 { + return fmt.Errorf("forfeit tx %s not found", txid) + } + } + return nil + } + return execTx(ctx, r.db.Write(), txBody) +} + func rowToReceiver(row queries.IntentWithReceiversVw) domain.Receiver { return domain.Receiver{ Amount: uint64(row.Amount.Int64), diff --git a/internal/infrastructure/db/sqlite/settings_repo.go b/internal/infrastructure/db/sqlite/settings_repo.go index a0251c428..9af703af6 100644 --- a/internal/infrastructure/db/sqlite/settings_repo.go +++ b/internal/infrastructure/db/sqlite/settings_repo.go @@ -103,6 +103,8 @@ func (r *settingsRepository) Get(ctx context.Context) (*domain.Settings, error) BuildVersionHeader: row.BuildVersionHeader, BuildVersionHeaderRequired: row.BuildVersionHeaderRequired, DigestHeaderRequired: row.DigestHeaderRequired, + WalletAddr: row.WalletAddr, + WalletFallbackAddrs: domain.DecodeFallbackAddrs(row.WalletFallbackAddrs), ScheduledSession: scheduledSession, BatchFees: domain.BatchFees{ OnchainInputFee: row.BatchOnchainInputFee, @@ -144,6 +146,8 @@ func (r *settingsRepository) Upsert( BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_seed.go b/internal/infrastructure/db/sqlite/settings_seed.go index 0f039e78d..fc23c6c36 100644 --- a/internal/infrastructure/db/sqlite/settings_seed.go +++ b/internal/infrastructure/db/sqlite/settings_seed.go @@ -144,6 +144,8 @@ func seedParams(settings domain.Settings) queries.UpsertSettingsParams { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: domain.EncodeFallbackAddrs(settings.WalletFallbackAddrs), BatchOnchainInputFee: settings.BatchFees.OnchainInputFee, BatchOffchainInputFee: settings.BatchFees.OffchainInputFee, BatchOnchainOutputFee: settings.BatchFees.OnchainOutputFee, diff --git a/internal/infrastructure/db/sqlite/settings_seed_test.go b/internal/infrastructure/db/sqlite/settings_seed_test.go index e8e656e0e..301e1964e 100644 --- a/internal/infrastructure/db/sqlite/settings_seed_test.go +++ b/internal/infrastructure/db/sqlite/settings_seed_test.go @@ -212,6 +212,8 @@ CREATE TABLE IF NOT EXISTS settings ( max_op_return_outputs BIGINT NOT NULL DEFAULT 0, asset_tx_max_weight_ratio REAL NOT NULL DEFAULT 0, note_uri_prefix TEXT NOT NULL DEFAULT '', + wallet_addr TEXT NOT NULL DEFAULT '', + wallet_fallback_addrs TEXT NOT NULL DEFAULT '', scheduled_session_start_time BIGINT NOT NULL DEFAULT 0, scheduled_session_end_time BIGINT NOT NULL DEFAULT 0, scheduled_session_period BIGINT NOT NULL DEFAULT 0, diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/models.go b/internal/infrastructure/db/sqlite/sqlc/queries/models.go index 4a42b2ec9..32263a61a 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/models.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/models.go @@ -231,6 +231,8 @@ type Setting struct { BuildVersionHeaderRequired bool DigestHeaderRequired bool UpdatedAt int64 + WalletAddr string + WalletFallbackAddrs string } type Tx struct { diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index 12212dc4a..b3cd03619 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -1405,7 +1405,7 @@ func (q *Queries) SelectRoundsWithTxids(ctx context.Context, txids []string) ([] } const selectSettings = `-- name: SelectSettings :one -SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at FROM settings WHERE id = 1 +SELECT id, session_duration, unrolled_vtxo_min_expiry_margin, ban_threshold, ban_duration, unilateral_exit_delay, public_unilateral_exit_delay, checkpoint_exit_delay, boarding_exit_delay, vtxo_tree_expiry, round_min_participants_count, round_max_participants_count, vtxo_min_amount, vtxo_max_amount, utxo_min_amount, utxo_max_amount, settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, scheduled_session_round_max_participants_count, batch_onchain_input_fee, batch_offchain_input_fee, batch_onchain_output_fee, batch_offchain_output_fee, build_version_header, build_version_header_required, digest_header_required, updated_at, wallet_addr, wallet_fallback_addrs FROM settings WHERE id = 1 ` func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { @@ -1448,6 +1448,8 @@ func (q *Queries) SelectSettings(ctx context.Context) (Setting, error) { &i.BuildVersionHeaderRequired, &i.DigestHeaderRequired, &i.UpdatedAt, + &i.WalletAddr, + &i.WalletFallbackAddrs, ) return i, err } @@ -2029,6 +2031,23 @@ func (q *Queries) UpdateConvictionPardoned(ctx context.Context, id string) error return err } +const updateForfeitTx = `-- name: UpdateForfeitTx :execrows +UPDATE tx SET tx = ?1 WHERE txid = ?2 AND type = 'forfeit' +` + +type UpdateForfeitTxParams struct { + Tx string + Txid string +} + +func (q *Queries) UpdateForfeitTx(ctx context.Context, arg UpdateForfeitTxParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateRoundCollectedFees = `-- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = ?1 WHERE id = ?2 ` @@ -2370,6 +2389,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -2391,12 +2411,13 @@ INSERT INTO settings ( ?21, ?22, ?23, ?24, ?25, - ?26, - ?27, - ?28, ?29, + ?26, ?27, + ?28, + ?29, ?30, ?31, - ?32, ?33, ?34, - ?35 + ?32, ?33, + ?34, ?35, ?36, + ?37 ) ON CONFLICT(id) DO UPDATE SET session_duration = EXCLUDED.session_duration, @@ -2420,6 +2441,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, @@ -2460,6 +2483,8 @@ type UpsertSettingsParams struct { MaxOpReturnOutputs int64 AssetTxMaxWeightRatio float64 NoteUriPrefix string + WalletAddr string + WalletFallbackAddrs string ScheduledSessionStartTime int64 ScheduledSessionEndTime int64 ScheduledSessionPeriod int64 @@ -2499,6 +2524,8 @@ func (q *Queries) UpsertSettings(ctx context.Context, arg UpsertSettingsParams) arg.MaxOpReturnOutputs, arg.AssetTxMaxWeightRatio, arg.NoteUriPrefix, + arg.WalletAddr, + arg.WalletFallbackAddrs, arg.ScheduledSessionStartTime, arg.ScheduledSessionEndTime, arg.ScheduledSessionPeriod, diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index f567992dd..da55dd2f8 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -419,6 +419,9 @@ VALUES (@asset_id, @txid, @vout, @amount); -- name: UpdateRoundCollectedFees :exec UPDATE round SET fees = sqlc.arg('fees') WHERE id = sqlc.arg('id'); +-- name: UpdateForfeitTx :execrows +UPDATE tx SET tx = sqlc.arg('tx') WHERE txid = sqlc.arg('txid') AND type = 'forfeit'; + -- name: SelectAssetsByIds :many SELECT * FROM asset WHERE asset.id IN (sqlc.slice('ids')); @@ -460,6 +463,7 @@ INSERT INTO settings ( settlement_min_expiry_gap, vtxo_no_csv_validation_cutoff_date, max_tx_weight, max_op_return_outputs, asset_tx_max_weight_ratio, note_uri_prefix, + wallet_addr, wallet_fallback_addrs, scheduled_session_start_time, scheduled_session_end_time, scheduled_session_period, scheduled_session_duration, scheduled_session_round_min_participants_count, @@ -479,6 +483,7 @@ INSERT INTO settings ( @settlement_min_expiry_gap, @vtxo_no_csv_validation_cutoff_date, @max_tx_weight, @max_op_return_outputs, @asset_tx_max_weight_ratio, @note_uri_prefix, + @wallet_addr, @wallet_fallback_addrs, @scheduled_session_start_time, @scheduled_session_end_time, @scheduled_session_period, @scheduled_session_duration, @scheduled_session_round_min_participants_count, @@ -510,6 +515,8 @@ ON CONFLICT(id) DO UPDATE SET max_op_return_outputs = EXCLUDED.max_op_return_outputs, asset_tx_max_weight_ratio = EXCLUDED.asset_tx_max_weight_ratio, note_uri_prefix = EXCLUDED.note_uri_prefix, + wallet_addr = EXCLUDED.wallet_addr, + wallet_fallback_addrs = EXCLUDED.wallet_fallback_addrs, scheduled_session_start_time = EXCLUDED.scheduled_session_start_time, scheduled_session_end_time = EXCLUDED.scheduled_session_end_time, scheduled_session_period = EXCLUDED.scheduled_session_period, diff --git a/internal/infrastructure/live-store/live_store_test.go b/internal/infrastructure/live-store/live_store_test.go index c9e35b8e3..c0886ec43 100644 --- a/internal/infrastructure/live-store/live_store_test.go +++ b/internal/infrastructure/live-store/live_store_test.go @@ -834,13 +834,20 @@ func (m *mockedTxBuilder) BuildCommitmentTx( func (m *mockedTxBuilder) BuildSweepTx( inputs []ports.TxInput, -) (txid string, signedSweepTx string, err error) { +) (txid string, unsignedTx string, err error) { args := m.Called(inputs) res0 := args.Get(0).(string) res1 := args.Get(1).(string) return res0, res1, args.Error(2) } +func (m *mockedTxBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (signedTx string, err error) { + args := m.Called(wallet, unsignedTx) + return args.Get(0).(string), args.Error(1) +} + func (m *mockedTxBuilder) GetSweepableBatchOutputs( vtxoTree *tree.TxTree, ) (vtxoTreeExpiry *arklib.RelativeLocktime, sweepInput *ports.TxInput, err error) { diff --git a/internal/infrastructure/live-store/redis/settings.go b/internal/infrastructure/live-store/redis/settings.go index 3499cb73e..447f8d760 100644 --- a/internal/infrastructure/live-store/redis/settings.go +++ b/internal/infrastructure/live-store/redis/settings.go @@ -149,6 +149,8 @@ type settingsDTO struct { BuildVersionHeader string BuildVersionHeaderRequired bool DigestHeaderRequired bool + WalletAddr string + WalletFallbackAddrs []string ScheduledSession scheduledSessionDTO BatchFees batchFeesDTO Network string @@ -214,6 +216,8 @@ func newSettingsDTO(settings ports.Settings) settingsDTO { BuildVersionHeader: settings.BuildVersionHeader, BuildVersionHeaderRequired: settings.BuildVersionHeaderRequired, DigestHeaderRequired: settings.DigestHeaderRequired, + WalletAddr: settings.WalletAddr, + WalletFallbackAddrs: settings.WalletFallbackAddrs, BatchFees: settings.BatchFees, Network: settings.Network.Name, DustAmount: settings.DustAmount, @@ -296,6 +300,8 @@ func (s settingsDTO) parse() (*ports.Settings, error) { BuildVersionHeader: s.BuildVersionHeader, BuildVersionHeaderRequired: s.BuildVersionHeaderRequired, DigestHeaderRequired: s.DigestHeaderRequired, + WalletAddr: s.WalletAddr, + WalletFallbackAddrs: s.WalletFallbackAddrs, ScheduledSession: s.ScheduledSession.parse(), BatchFees: s.BatchFees, }, diff --git a/internal/infrastructure/tx-builder/covenantless/builder.go b/internal/infrastructure/tx-builder/covenantless/builder.go index 68ec3b334..59d25ea08 100644 --- a/internal/infrastructure/tx-builder/covenantless/builder.go +++ b/internal/infrastructure/tx-builder/covenantless/builder.go @@ -275,10 +275,17 @@ func (b *txBuilder) FinalizeAndExtract(tx string) (string, error) { } func (b *txBuilder) BuildSweepTx(inputs []ports.TxInput) ( - txid, signedSweepTx string, err error, + txid, unsignedTx string, err error, ) { ctx := context.Background() - return sweepTransaction(ctx, b.wallet, inputs) + return buildSweepTransaction(ctx, b.wallet, inputs) +} + +func (b *txBuilder) SignSweepTx( + wallet ports.WalletService, unsignedTx string, +) (signedTx string, err error) { + ctx := context.Background() + return signSweepTransaction(ctx, wallet, unsignedTx) } func (b *txBuilder) VerifyForfeitTxs( diff --git a/internal/infrastructure/tx-builder/covenantless/sweep.go b/internal/infrastructure/tx-builder/covenantless/sweep.go index dc34cd290..4ae0d4c7d 100644 --- a/internal/infrastructure/tx-builder/covenantless/sweep.go +++ b/internal/infrastructure/tx-builder/covenantless/sweep.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "strings" "github.com/arkade-os/arkd/internal/core/ports" arklib "github.com/arkade-os/arkd/pkg/ark-lib" @@ -17,9 +18,13 @@ import ( "github.com/btcsuite/btcd/wire" ) -func sweepTransaction( +// buildSweepTransaction builds the unsigned sweep transaction. The destination +// address, fee estimate and dust limit are all taken from the given wallet (the +// primary wallet) so every signing candidate sweeps to the same output and the +// returned txid is stable regardless of which wallet ends up signing it. +func buildSweepTransaction( ctx context.Context, wallet ports.WalletService, inputs []ports.TxInput, -) (txid string, txhex string, err error) { +) (txid string, unsignedTx string, err error) { ins := make([]*wire.OutPoint, 0) sequences := make([]uint32, 0) @@ -73,8 +78,6 @@ func sweepTransaction( amount := int64(0) - tapscriptInputIndexes := make([]int, 0) - for i, input := range inputs { if input.TapscriptLeaf != nil { tapscriptBytes, err := hex.DecodeString(input.TapscriptLeaf.Tapscript) @@ -107,8 +110,6 @@ func sweepTransaction( ptx.Inputs[i].TaprootInternalKey = schnorr.SerializePubKey( internalKey, ) - - tapscriptInputIndexes = append(tapscriptInputIndexes, i) } inputAmount := int64(input.Value) @@ -139,14 +140,14 @@ func sweepTransaction( return "", "", err } - script, err := txscript.PayToAddrScript(addr) + pkScript, err := txscript.PayToAddrScript(addr) if err != nil { return "", "", err } ptx.UnsignedTx.AddTxOut(&wire.TxOut{ Value: amount, - PkScript: script, + PkScript: pkScript, }) ptx.Outputs = append(ptx.Outputs, psbt.POutput{}) @@ -176,26 +177,39 @@ func sweepTransaction( ptx.UnsignedTx.TxOut[0].Value = amount - int64(fees) - sweepPsbtBase64, err := ptx.B64Encode() + unsignedTx, err = ptx.B64Encode() if err != nil { return "", "", err } - if len(tapscriptInputIndexes) > 0 { - sweepPsbtBase64, err = wallet.SignTransactionTapscript( - ctx, - sweepPsbtBase64, - tapscriptInputIndexes, - ) - if err != nil { - return "", "", err + return ptx.UnsignedTx.TxID(), unsignedTx, nil +} + +// signSweepTransaction signs the unsigned sweep transaction with the given wallet +// The tapscript inputs that need signing are re-derived from the psbt, so the caller +// only has to pass the unsigned tx. +func signSweepTransaction( + ctx context.Context, wallet ports.WalletService, unsignedTx string, +) (string, error) { + ptx, err := psbt.NewFromRawBytes(strings.NewReader(unsignedTx), true) + if err != nil { + return "", err + } + + tapscriptInputIndexes := make([]int, 0) + for i, in := range ptx.Inputs { + if len(in.TaprootLeafScript) > 0 { + tapscriptInputIndexes = append(tapscriptInputIndexes, i) } } - signedTxHex, err := wallet.SignTransaction(ctx, sweepPsbtBase64, true) - if err != nil { - return "", "", err + signedTx := unsignedTx + if len(tapscriptInputIndexes) > 0 { + signedTx, err = wallet.SignTransactionTapscript(ctx, signedTx, tapscriptInputIndexes) + if err != nil { + return "", err + } } - return ptx.UnsignedTx.TxID(), signedTxHex, nil + return wallet.SignTransaction(ctx, signedTx, true) } diff --git a/internal/infrastructure/tx-builder/covenantless/sweep_test.go b/internal/infrastructure/tx-builder/covenantless/sweep_test.go new file mode 100644 index 000000000..ca57ec1c9 --- /dev/null +++ b/internal/infrastructure/tx-builder/covenantless/sweep_test.go @@ -0,0 +1,106 @@ +package txbuilder + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/arkade-os/arkd/internal/core/ports" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// recordingWallet captures the arguments signSweepTransaction passes to the wallet. +type recordingWallet struct { + ports.WalletService + gotTapscriptIndexes []int + gotSignInput string + tapscriptSigned string + finalSigned string +} + +func (w *recordingWallet) SignTransactionTapscript( + _ context.Context, partialTx string, inputIndexes []int, +) (string, error) { + w.gotTapscriptIndexes = inputIndexes + return w.tapscriptSigned, nil +} + +func (w *recordingWallet) SignTransaction( + _ context.Context, partialTx string, _ bool, +) (string, error) { + w.gotSignInput = partialTx + return w.finalSigned, nil +} + +// TestSignSweepTransaction_DerivesTapscriptIndexes verifies that signing re-derives +// the tapscript input indexes from the psbt (the inputs carrying a TaprootLeafScript) +// rather than relying on indexes threaded through the builder API. +func TestSignSweepTransaction_DerivesTapscriptIndexes(t *testing.T) { + op0 := &wire.OutPoint{Hash: chainhash.Hash{0x01}, Index: 0} + op1 := &wire.OutPoint{Hash: chainhash.Hash{0x02}, Index: 0} + op2 := &wire.OutPoint{Hash: chainhash.Hash{0x03}, Index: 0} + + ptx, err := psbt.New( + []*wire.OutPoint{op0, op1, op2}, nil, 2, 0, + []uint32{wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum, wire.MaxTxInSequenceNum}, + ) + require.NoError(t, err) + + // a valid control block is a leaf-version/parity byte + a valid 32-byte x-only + // internal key (here secp256k1's generator G) + an optional merkle path; psbt + // deserialization parses and validates the internal key. + internalKey, err := hex.DecodeString( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + require.NoError(t, err) + controlBlock := append([]byte{byte(txscript.BaseLeafVersion)}, internalKey...) + + // only inputs 1 and 2 carry a taproot leaf script + for _, i := range []int{1, 2} { + ptx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{{ + ControlBlock: controlBlock, + Script: []byte{0x04, 0x05}, + LeafVersion: txscript.BaseLeafVersion, + }} + } + + unsignedTx, err := ptx.B64Encode() + require.NoError(t, err) + + w := &recordingWallet{tapscriptSigned: "tapscript-signed-psbt", finalSigned: "final-raw-tx"} + + out, err := signSweepTransaction(context.Background(), w, unsignedTx) + require.NoError(t, err) + + require.Equal(t, "final-raw-tx", out) + require.Equal(t, []int{1, 2}, w.gotTapscriptIndexes) + // the tapscript-signed psbt must be the one forwarded to SignTransaction + require.Equal(t, "tapscript-signed-psbt", w.gotSignInput) +} + +// TestSignSweepTransaction_NoTapscriptInputs verifies that with no tapscript inputs, +// SignTransactionTapscript is skipped and the unsigned psbt goes straight to signing. +func TestSignSweepTransaction_NoTapscriptInputs(t *testing.T) { + op0 := &wire.OutPoint{Hash: chainhash.Hash{0x01}, Index: 0} + ptx, err := psbt.New( + []*wire.OutPoint{op0}, nil, 2, 0, []uint32{wire.MaxTxInSequenceNum}, + ) + require.NoError(t, err) + + unsignedTx, err := ptx.B64Encode() + require.NoError(t, err) + + w := &recordingWallet{tapscriptSigned: "should-not-be-used", finalSigned: "final-raw-tx"} + + out, err := signSweepTransaction(context.Background(), w, unsignedTx) + require.NoError(t, err) + + require.Equal(t, "final-raw-tx", out) + require.Nil(t, w.gotTapscriptIndexes) + // with no tapscript inputs, the unsigned psbt is forwarded unchanged + require.Equal(t, unsignedTx, w.gotSignInput) +} diff --git a/internal/interface/grpc/handlers/adminservice.go b/internal/interface/grpc/handlers/adminservice.go index f23eb9186..7852d8edd 100644 --- a/internal/interface/grpc/handlers/adminservice.go +++ b/internal/interface/grpc/handlers/adminservice.go @@ -725,6 +725,8 @@ func (a *adminHandler) GetSettings( BuildVersionHeader: &settings.BuildVersionHeader, BuildVersionHeaderRequired: &settings.BuildVersionHeaderRequired, DigestHeaderRequired: &settings.DigestHeaderRequired, + WalletAddr: &settings.WalletAddr, + WalletFallbackAddrs: settings.WalletFallbackAddrs, UpdatedAt: formatTime(settings.UpdatedAt), } } @@ -864,6 +866,8 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { noteUriPrefix *string buildVersionHeader *string buildVersionHeaderRequired, digestHeaderRequired *bool + walletAddr *string + walletFallbackAddrs *[]string ) if settings.BanThreshold != nil { t := uint64(settings.GetBanThreshold()) @@ -921,6 +925,14 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { t := settings.GetDigestHeaderRequired() digestHeaderRequired = &t } + if settings.WalletAddr != nil { + t := settings.GetWalletAddr() + walletAddr = &t + } + if len(settings.WalletFallbackAddrs) > 0 { + t := settings.GetWalletFallbackAddrs() + walletFallbackAddrs = &t + } return &domain.SettingsUpdate{ SessionDuration: parseDuration(settings.SessionDuration), @@ -947,6 +959,8 @@ func parseSettings(settings *arkv1.Settings) (*domain.SettingsUpdate, error) { BuildVersionHeader: buildVersionHeader, BuildVersionHeaderRequired: buildVersionHeaderRequired, DigestHeaderRequired: digestHeaderRequired, + WalletAddr: walletAddr, + WalletFallbackAddrs: walletFallbackAddrs, }, nil } diff --git a/internal/interface/grpc/handlers/walletservice.go b/internal/interface/grpc/handlers/walletservice.go index 3efb0db40..29ad4756d 100644 --- a/internal/interface/grpc/handlers/walletservice.go +++ b/internal/interface/grpc/handlers/walletservice.go @@ -12,97 +12,62 @@ import ( type walletInitHandler struct { walletService ports.WalletService - onInit func(password string) - onUnlock func(password string) + onUnlock func(password string) error onReady func() } func NewWalletInitializerHandler( - walletService ports.WalletService, onInit, onUnlock func(string), onReady func(), + walletService ports.WalletService, onUnlock func(string) error, onReady func(), ) arkv1.WalletInitializerServiceServer { - svc := walletInitHandler{walletService, onInit, onUnlock, onReady} - if onInit != nil && onUnlock != nil && onReady != nil { + svc := walletInitHandler{walletService, onUnlock, onReady} + if onReady != nil { go svc.listenWhenReady() } return &svc } +// errWalletManagedExternally is returned by the wallet lifecycle RPCs that arkd +// no longer handles: each arkd-wallet must be initialized out of band. +const errWalletManagedExternally = "arkd no longer manages the wallet: " + + "initialize the arkd-wallet directly" + +// errWalletLockManagedExternally is returned by the Lock RPC: arkd no longer +// locks the (possibly shared) wallet; lock it out of band instead. +const errWalletLockManagedExternally = "arkd no longer manages the wallet: " + + "lock the arkd-wallet directly" + func (a *walletInitHandler) GenSeed( - ctx context.Context, _ *arkv1.GenSeedRequest, + _ context.Context, _ *arkv1.GenSeedRequest, ) (*arkv1.GenSeedResponse, error) { - seed, err := a.walletService.GenSeed(ctx) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - return &arkv1.GenSeedResponse{Seed: seed}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } func (a *walletInitHandler) Create( - ctx context.Context, req *arkv1.CreateRequest, + _ context.Context, _ *arkv1.CreateRequest, ) (*arkv1.CreateResponse, error) { - if len(req.GetSeed()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet seed") - } - if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - - if err := a.walletService.Create(ctx, req.GetSeed(), req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - if a.onInit != nil { - go a.onInit(req.GetPassword()) - } - - return &arkv1.CreateResponse{}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } func (a *walletInitHandler) Restore( - ctx context.Context, req *arkv1.RestoreRequest, + _ context.Context, _ *arkv1.RestoreRequest, ) (*arkv1.RestoreResponse, error) { - if len(req.GetSeed()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet seed") - } - if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - - if err := a.walletService.Restore(ctx, req.GetSeed(), req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - if a.onInit != nil { - go a.onInit(req.GetPassword()) - } - - return &arkv1.RestoreResponse{}, nil + return nil, status.Error(codes.Unimplemented, errWalletManagedExternally) } +// Unlock no longer unlocks the wallet (which must already be unlocked out of +// band); it only unlocks the macaroon (admin auth) service with the given +// password. func (a *walletInitHandler) Unlock( - ctx context.Context, req *arkv1.UnlockRequest, + _ context.Context, req *arkv1.UnlockRequest, ) (*arkv1.UnlockResponse, error) { if len(req.GetPassword()) <= 0 { - return nil, status.Error(codes.InvalidArgument, "missing wallet password") - } - walletStatus, err := a.walletService.Status(ctx) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - if !walletStatus.IsInitialized() { - return nil, status.Error(codes.InvalidArgument, "wallet not initialized, cannot unlock") - } - if walletStatus.IsUnlocked() { - return &arkv1.UnlockResponse{}, nil - } - - if err := a.walletService.Unlock(ctx, req.GetPassword()); err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return nil, status.Error(codes.InvalidArgument, "missing password") } if a.onUnlock != nil { - go a.onUnlock(req.GetPassword()) + if err := a.onUnlock(req.GetPassword()); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } } return &arkv1.UnlockResponse{}, nil @@ -147,13 +112,12 @@ func NewWalletHandler(walletService ports.WalletService) arkv1.WalletServiceServ } func (a *walletHandler) Lock( - ctx context.Context, _ *arkv1.LockRequest, + _ context.Context, _ *arkv1.LockRequest, ) (*arkv1.LockResponse, error) { - if err := a.walletService.Lock(ctx); err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - return &arkv1.LockResponse{}, nil + // arkd no longer manages the wallet lifecycle; locking the (possibly shared) + // wallet via arkd would break every other consumer, so the RPC is disabled + // — lock the wallet out of band through arkd-wallet directly. + return nil, status.Error(codes.Unimplemented, errWalletLockManagedExternally) } func (a *walletHandler) DeriveAddress( diff --git a/internal/interface/grpc/service.go b/internal/interface/grpc/service.go index 2accf3a9d..af22adc1d 100644 --- a/internal/interface/grpc/service.go +++ b/internal/interface/grpc/service.go @@ -53,6 +53,7 @@ type service struct { adminGrpcSrvr *grpc.Server readinessSvc *interceptors.ReadinessService appSvcStarted atomic.Bool + walletReady atomic.Bool macaroonSvc *macaroons.Service otelShutdown func(context.Context) error pyroscopeShutdown func() error @@ -122,8 +123,17 @@ func (s *service) Start() error { log.Infof("started admin listening at %s", s.config.adminAddress()) } + // arkd no longer manages the wallet lifecycle. The primary arkd-wallet must + // be initialized and unlocked out of band before arkd starts, otherwise we + // refuse to serve. This is what lets a single arkd front several arkd-wallets. + if err := s.ensureWalletReady(); err != nil { + return err + } + + // The unlock phase is retained only to unlock the macaroon (admin auth) + // service; it no longer touches the wallet. if s.appConfig.UnlockerService() != nil { - return s.autoUnlock() + return s.autoUnlockMacaroons() } return nil } @@ -222,6 +232,12 @@ func (s *service) stop() { log.Warn("failed to close admin transport connection") } } + + // Close the fallback wallet connections (the primary is closed by the app + // service). arkd owns these dialed connections, nothing else does. + for _, fb := range s.appConfig.FallbackWallets() { + fb.Service.Close() + } } func (s *service) startAppServices() error { @@ -339,7 +355,6 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { // Server grpc. grpcServer := grpc.NewServer(grpcConfig...) - onInit := s.onInit onUnlock := s.onUnlock onReady := s.onReady onLoadSigner := s.onLoadSigner @@ -369,7 +384,7 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { s.config.macaroonsDatadir(), s.appConfig.NoteUriPrefix, ) walletHandler := handlers.NewWalletHandler(walletSvc) - walletInitHandler := handlers.NewWalletInitializerHandler(walletSvc, onInit, onUnlock, onReady) + walletInitHandler := handlers.NewWalletInitializerHandler(walletSvc, onUnlock, onReady) signerManagerHandler := handlers.NewSignerManagerHandler(walletSvc, onLoadSigner) healthHandler := handlers.NewHealthHandler() @@ -577,16 +592,20 @@ func (s *service) newServer(tlsConfig *tls.Config, withPprof bool) error { return nil } -func (s *service) onUnlock(password string) { +// onUnlock unlocks the macaroon (admin auth) service with the given password +// and generates the macaroon files if needed. It never unlocks the wallet. Once +// the macaroon service is unlocked it tries to start the app services, which +// only come up once the wallet is also ready. +func (s *service) onUnlock(password string) error { if s.config.NoMacaroons { - return + return nil } pwd := []byte(password) datadir := s.config.macaroonsDatadir() if err := s.macaroonSvc.CreateUnlock(&pwd); err != nil { if err != macaroons.ErrAlreadyUnlocked { - log.WithError(err).Warn("failed to unlock macaroon store") + return fmt.Errorf("failed to unlock macaroon store: %s", err) } } @@ -594,43 +613,37 @@ func (s *service) onUnlock(password string) { context.Background(), s.macaroonSvc, datadir, ) if err != nil { - log.WithError(err).Warn("failed to create macaroons") + return fmt.Errorf("failed to create macaroons: %s", err) } if done { log.Debugf("created and stored macaroons at path %s", datadir) } -} - -func (s *service) onInit(password string) { - if s.config.NoMacaroons { - return - } - pwd := []byte(password) - datadir := s.config.macaroonsDatadir() - if err := s.macaroonSvc.CreateUnlock(&pwd); err != nil { - log.WithError(err).Warn("failed to initialize macaroon store") - } - if _, err := genMacaroons( - context.Background(), s.macaroonSvc, datadir, - ); err != nil { - log.WithError(err).Warn("failed to create macaroons") - } - log.Debugf("generated macaroons at path %s", datadir) + s.tryStartAppServices() + return nil } func (s *service) onReady() { - if !s.config.NoMacaroons { - ctx := context.Background() - if s.macaroonSvc.IsLocked(ctx) { - if err := s.appConfig.WalletService().Lock(ctx); err != nil { - log.WithError(err).Warn("failed to lock wallet and properly setup auth service") - } else { - return - } - } - } + // The wallet signalled it is ready (initialized, unlocked, synced). Record it + // and try to start: app services come up once the wallet is ready AND the + // macaroon service is unlocked, whichever happens last. We never lock the + // wallet here: arkd does not own its lifecycle anymore. + s.walletReady.Store(true) + s.tryStartAppServices() +} +// tryStartAppServices starts the app services exactly once, when both the wallet +// is ready and the macaroon service is unlocked (or macaroons are disabled). It +// is safe to call repeatedly and from multiple goroutines. +func (s *service) tryStartAppServices() { + if !s.walletReady.Load() { + log.Warn("app services not started yet: waiting for the wallet to become ready") + return + } + if !s.config.NoMacaroons && s.macaroonSvc.IsLocked(context.Background()) { + log.Warn("app services not started yet: waiting for the macaroon service to be unlocked") + return + } if err := s.startAppServices(); err != nil { log.WithError(err).Error("failed to activate app services") } @@ -642,7 +655,11 @@ func (s *service) onLoadSigner(addr string) error { return err } -func (s *service) autoUnlock() error { +// ensureWalletReady verifies the primary arkd-wallet has been initialized and +// unlocked out of band. arkd no longer creates or unlocks wallets, so if the +// wallet is not ready we refuse to start. Sync state is intentionally not +// enforced here: it is transient and already gated by the readiness layer. +func (s *service) ensureWalletReady() error { ctx := context.Background() wallet := s.appConfig.WalletService() @@ -651,28 +668,70 @@ func (s *service) autoUnlock() error { return fmt.Errorf("failed to get wallet status: %s", err) } if !status.IsInitialized() { - log.Debug("wallet not initialized, skipping auto unlock") - return nil + return fmt.Errorf( + "wallet is not initialized: initialize the arkd-wallet out of band before starting arkd", + ) + } + if !status.IsUnlocked() { + return fmt.Errorf( + "wallet is locked: unlock the arkd-wallet out of band before starting arkd", + ) } - // If the wallet is already unlocked, force the lock to make the very next call to Unlock - // to take effect and run the onUnlock callback - if status.IsUnlocked() { - // nolint - wallet.Lock(ctx) + confirmed, unconfirmed, err := wallet.MainAccountBalance(ctx) + if err != nil { + log.WithError(err). + Warnf("failed to read balance of wallet %s at startup", s.appConfig.WalletAddr) + } else if confirmed+unconfirmed == 0 { + log.Warnf( + "main account balance of wallet %s is zero: arkd may be unable to source liquidity", + s.appConfig.WalletAddr, + ) } - password, err := s.appConfig.UnlockerService().GetPassword(ctx) + // Fallback wallets must also be initialized and unlocked out of band: they + // co-sign sweeps of outputs the primary's key can't spend. arkd never sources + // liquidity from them (only the primary funds batches), so balance is not checked. + for _, fb := range s.appConfig.FallbackWallets() { + fbStatus, err := fb.Service.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get fallback wallet %q status: %s", fb.Addr, err) + } + if !fbStatus.IsInitialized() { + return fmt.Errorf( + "fallback wallet %q is not initialized: "+ + "initialize the arkd-wallet out of band before starting arkd", + fb.Addr, + ) + } + if !fbStatus.IsUnlocked() { + return fmt.Errorf( + "fallback wallet %q is locked: "+ + "unlock the arkd-wallet out of band before starting arkd", + fb.Addr, + ) + } + } + + return nil +} + +// autoUnlockMacaroons unlocks the macaroon (admin auth) service using the +// configured unlocker. It never touches the wallet. +func (s *service) autoUnlockMacaroons() error { + if s.config.NoMacaroons { + return nil + } + + password, err := s.appConfig.UnlockerService().GetPassword(context.Background()) if err != nil { return fmt.Errorf("failed to get password: %s", err) } - if err := wallet.Unlock(ctx, password); err != nil { - return fmt.Errorf("failed to auto unlock: %s", err) + if err := s.onUnlock(password); err != nil { + return err } - go s.onUnlock(password) - - log.Debug("service auto unlocked") + log.Debug("macaroon service auto unlocked") return nil } diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index 0f2e02d31..a6a3fcab7 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -3591,7 +3591,7 @@ func TestSweep(t *testing.T) { time.Sleep(2 * time.Second) - // lock/unlock the wallet to restart the sweeper + // restart arkd to restart the sweeper err = restartArkd() require.NoError(t, err) @@ -6756,3 +6756,131 @@ func TestDeprecatedSignerKey(t *testing.T) { require.ErrorContains(t, err, "is a deprecated key since") }) } + +// TestEagerForfeitSurvivesWalletRotation verifies that signing forfeit txs at +// collection time (this PR) closes the unpunishable-fraud window opened by a +// signer key rotation. +// +// On the pre-PR behavior the operator half of a forfeit was added lazily, at +// fraud-broadcast time; after a hard rotation (no deprecated key) the wallet +// signed the old-key forfeit leaf with the new key, the tx could not be +// finalized, and the fraud went unpunished. Here the forfeit is already +// operator-signed when it is collected (with the then-current/old key), so the +// stored tx stays broadcast-ready across the rotation: when the user +// fraudulently unrolls the forfeited vtxo the server still broadcasts the +// forfeit and claims it. +// +// The scenario is identical to the pre-PR repro (hard rotation, no deprecated +// key); only the expected outcome is inverted. +func TestEagerForfeitSurvivesWalletRotation(t *testing.T) { + const ( + oldSignerKey = "afcd3fa10f82a05fddc9574fdb13b3991b568e89cc39a72ba4401df8abef35f0" + newSignerKey = "2222222222222222222222222222222222222222222222222222222222222222" + ) + ctx := t.Context() + + // restore the old signer key (no deprecated keys) for other integration tests + t.Cleanup(func() { + require.NoError(t, recreateArkdWallet(oldSignerKey, "")) + }) + + client := setupClientWallet(t) + indexerClient := client.Indexer() + + _, arkAddr, boardingAddress, err := client.Receive(ctx) + require.NoError(t, err) + + faucetOnchain(t, boardingAddress.Address, 0.00021) + time.Sleep(5 * time.Second) + + // settle 1: board -> vtxo locked to the OLD signer key + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + vtxos, err := client.NotifyIncomingFunds(ctx, arkAddr.Address) + require.NoError(t, err) + require.NotNil(t, vtxos) + }() + res, err := client.Settle(ctx) + require.NoError(t, err) + require.NotNil(t, res) + require.NotEmpty(t, res.CommitmentTxid) + wg.Wait() + time.Sleep(5 * time.Second) + + // settle 2: the first vtxo is forfeited. Its forfeit tx is collected AND + // operator-signed here, while the OLD key is still current, so the stored + // forfeit is broadcast-ready. + wg.Add(1) + go func() { + defer wg.Done() + vtxos, err := client.NotifyIncomingFunds(ctx, arkAddr.Address) + require.NoError(t, err) + require.NotNil(t, vtxos) + }() + _, err = client.Settle(ctx) + require.NoError(t, err) + wg.Wait() + time.Sleep(time.Second) + + // the forfeited vtxo: spent, confirmed (non-preconfirmed), from commitment 1 + _, spentVtxos, err := client.ListVtxos(ctx) + require.NoError(t, err) + require.NotEmpty(t, spentVtxos) + + var vtxo types.Vtxo + for _, v := range spentVtxos { + if !v.Preconfirmed && v.CommitmentTxids[0] == res.CommitmentTxid { + vtxo = v + break + } + } + require.NotEmpty(t, vtxo.Txid) + + // hard rotation: new signer key, NO deprecated key. The forfeit was already + // signed with the old key at collection time, so it must remain broadcastable. + require.NoError(t, recreateArkdWallet(newSignerKey, "")) + + // confirm the rotation is a clean switch: no deprecated keys remain + info, err := client.Client().GetInfo(ctx) + require.NoError(t, err) + require.Empty(t, info.DeprecatedSignerPubKeys) + + explorer, err := mempoolexplorer.NewExplorer( + "http://localhost:3000", arklib.BitcoinRegTest, + mempoolexplorer.WithTracker(false), + ) + require.NoError(t, err) + + // fraud: unroll the already-forfeited vtxo onchain + branch, err := redemption.NewRedeemBranch(ctx, explorer, indexerClient, vtxo) + require.NoError(t, err) + leafTx, err := branch.NextRedeemTx() + require.NoError(t, err) + require.NotEmpty(t, leafTx) + + bumpAndBroadcastTx(t, leafTx, explorer) + time.Sleep(5 * time.Second) + + // the vtxo is now unrolled and unspent in the mempool + spentStatus, err := explorer.GetTxOutspends(vtxo.Txid) + require.NoError(t, err) + require.GreaterOrEqual(t, len(spentStatus), int(vtxo.VOut)) + require.False(t, spentStatus[vtxo.VOut].Spent) + + require.NoError(t, generateBlocks(1)) + + // give the server time to react to the fraud + time.Sleep(8 * time.Second) + + // the pre-signed forfeit survives the rotation: the server broadcast it and + // claimed the unrolled vtxo. Fraud is punished. + spentStatus, err = explorer.GetTxOutspends(vtxo.Txid) + require.NoError(t, err) + require.NotEmpty(t, spentStatus) + require.True(t, spentStatus[vtxo.VOut].Spent, + "forfeit signed at collection time must stay broadcastable across a hard "+ + "signer rotation, letting the server punish the fraud") + require.NotEmpty(t, spentStatus[vtxo.VOut].SpentBy) +} diff --git a/internal/test/e2e/utils_test.go b/internal/test/e2e/utils_test.go index e5156280a..bf26acc20 100644 --- a/internal/test/e2e/utils_test.go +++ b/internal/test/e2e/utils_test.go @@ -41,6 +41,8 @@ import ( const ( adminUrl = "http://127.0.0.1:7071" + walletUrl = "http://127.0.0.1:6060" + walletUrl2 = "http://127.0.0.1:6061" serverUrl = "127.0.0.1:7070" explorerUrl = "http://127.0.0.1:3000" ) @@ -561,12 +563,10 @@ func clearIntentFees() error { return nil } -// lock the wallet, wait 10s and unlock it +// restartArkd restarts the arkd container. The wallet stays unlocked across the +// restart (arkd no longer locks it), so arkd comes back up on its own; we just +// wait for it to be ready again. func restartArkd() error { - adminHttpClient := &http.Client{ - Timeout: 15 * time.Second, - } - // down arkd container if _, err := runCommand("docker", "container", "stop", "arkd"); err != nil { return err @@ -578,16 +578,9 @@ func restartArkd() error { return err } - time.Sleep(5 * time.Second) - - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err + adminHttpClient := &http.Client{ + Timeout: 15 * time.Second, } - - // wait until the wallet is synced again before returning, otherwise RPCs - // racing the restart get "server not ready". return waitUntilReady(adminHttpClient) } @@ -618,65 +611,99 @@ func recreateArkdWallet(signerKey, deprecated string) error { return restartArkd() } +// unlockArkdWallet unlocks the arkd-wallet directly through its own gateway. +// arkd no longer unlocks the wallet, so recreating the wallet container leaves +// it locked until we unlock it here. func unlockArkdWallet() error { - adminHttpClient := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) + httpClient := &http.Client{Timeout: 15 * time.Second} + url := fmt.Sprintf("%s/v1/wallet/unlock", walletUrl) body := fmt.Sprintf(`{"password": "%s"}`, password) - return post(adminHttpClient, url, body, "unlock") + return post(httpClient, url, body, "unlock wallet") } func setupArkd() error { - adminHttpClient := &http.Client{ + httpClient := &http.Client{ Timeout: 15 * time.Second, } - url := fmt.Sprintf("%s/v1/admin/wallet/status", adminUrl) - status, err := get[statusResp](adminHttpClient, url, "status") - if err != nil { + // arkd no longer initializes or unlocks the wallets: drive each arkd-wallet + // (the primary and every fallback LP wallet) directly so they are all + // initialized and unlocked. arkd hard-fails to start while any of them is + // locked, so it may have been crash-looping until now. + if err := setupArkdWalletAt(httpClient, walletUrl); err != nil { return err } - - if status.Initialized && !status.Unlocked { - url := fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body := fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err - } - - if err := waitUntilReady(adminHttpClient); err != nil { - return err - } - - return refill(adminHttpClient) + if err := setupArkdWalletAt(httpClient, walletUrl2); err != nil { + return err } - if status.Initialized && status.Unlocked && status.Synced { - return refill(adminHttpClient) + // Restart arkd for a prompt clean boot now that the wallet is ready, then + // wait for it to come up. + if _, err := runCommand("docker", "container", "restart", "arkd"); err != nil { + return err } - url = fmt.Sprintf("%s/v1/admin/wallet/seed", adminUrl) - seed, err := get[seedResp](adminHttpClient, url, "seed") - if err != nil { + if err := waitUntilReady(httpClient); err != nil { return err } - url = fmt.Sprintf("%s/v1/admin/wallet/create", adminUrl) - body := fmt.Sprintf(`{"seed": "%s", "password": "%s"}`, seed.Seed, password) - if err := post(adminHttpClient, url, body, "create"); err != nil { - return err + return refill(httpClient) +} + +// setupArkdWalletAt initializes and unlocks the arkd-wallet reachable at baseURL +// directly through its own gateway, which is what arkd now expects to be done +// out of band for the primary and every fallback wallet. +func setupArkdWalletAt(httpClient *http.Client, baseURL string) error { + // The arkd-wallet gateway may still be coming up; retry the first read until + // it is reachable. + statusURL := fmt.Sprintf("%s/v1/wallet/status", baseURL) + var status *statusResp + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.After(2 * time.Minute) + for status == nil { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for arkd-wallet at %s to become reachable", baseURL) + case <-ticker.C: + s, err := get[statusResp](httpClient, statusURL, "wallet status") + if err != nil { + // gateway not up yet; keep waiting. + continue + } + status = s + } } - url = fmt.Sprintf("%s/v1/admin/wallet/unlock", adminUrl) - body = fmt.Sprintf(`{"password": "%s"}`, password) - if err := post(adminHttpClient, url, body, "unlock"); err != nil { - return err + if !status.Initialized { + url := fmt.Sprintf("%s/v1/wallet/seed", baseURL) + seed, err := get[seedResp](httpClient, url, "wallet seed") + if err != nil { + return err + } + + url = fmt.Sprintf("%s/v1/wallet/create", baseURL) + body, err := json.Marshal(map[string]string{"seed": seed.Seed, "password": password}) + if err != nil { + return fmt.Errorf("failed to encode create wallet body: %s", err) + } + if err := post(httpClient, url, string(body), "create wallet"); err != nil { + return err + } } - if err := waitUntilReady(adminHttpClient); err != nil { - return err + if !status.Unlocked { + url := fmt.Sprintf("%s/v1/wallet/unlock", baseURL) + body, err := json.Marshal(map[string]string{"password": password}) + if err != nil { + return fmt.Errorf("failed to encode unlock wallet body: %s", err) + } + if err := post(httpClient, url, string(body), "unlock wallet"); err != nil { + return err + } } - return refill(adminHttpClient) + return nil } type statusResp struct { @@ -705,6 +732,13 @@ func get[T any](httpClient *http.Client, url, name string) (*T, error) { if err != nil { return nil, fmt.Errorf("failed to get %s: %s", name, err) } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf( + "failed to get %s: unexpected status %d: %s", name, resp.StatusCode, string(data), + ) + } var data T if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("failed to parse %s response: %s", name, err) @@ -718,27 +752,40 @@ func post(httpClient *http.Client, url, body, name string) error { return fmt.Errorf("failed to prepare %s request: %s", name, err) } req.Header.Set("Content-Type", "application/json") - if _, err := httpClient.Do(req); err != nil { - return fmt.Errorf("failed to %s wallet: %s", name, err) + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to %s: %s", name, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "failed to %s: unexpected status %d: %s", name, resp.StatusCode, string(data), + ) } return nil } func waitUntilReady(httpClient *http.Client) error { ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.After(2 * time.Minute) url := fmt.Sprintf("%s/v1/admin/wallet/status", adminUrl) - for range ticker.C { - status, err := get[statusResp](httpClient, url, "status") - if err != nil { - return err - } - - if status.Initialized && status.Unlocked && status.Synced { - ticker.Stop() - break + for { + select { + case <-timeout: + return fmt.Errorf("timed out waiting for arkd to become ready") + case <-ticker.C: + status, err := get[statusResp](httpClient, url, "status") + if err != nil { + // arkd may still be (re)starting; keep waiting. + continue + } + if status.Initialized && status.Unlocked && status.Synced { + return nil + } } } - return nil } func refill(httpClient *http.Client) error { diff --git a/pkg/arkd-wallet/core/application/wallet/service.go b/pkg/arkd-wallet/core/application/wallet/service.go index fdb5fd668..035a24a50 100644 --- a/pkg/arkd-wallet/core/application/wallet/service.go +++ b/pkg/arkd-wallet/core/application/wallet/service.go @@ -669,9 +669,17 @@ func (w *wallet) SignTransaction( } if len(input.TaprootLeafScript) > 0 { - signingKey := w.keyMgr.forfeitPrvkey + var signingKey *btcec.PrivateKey if signMode == application.SignModeSigner { - signingKey = w.signerKeyForLeaf(input.TaprootLeafScript[0].Script) + leafKey, err := w.signerKeyForLeaf( + input.TaprootLeafScript[0].Script, len(inputIndexes) > 0, + ) + if err != nil { + return "", err + } + signingKey = leafKey + } else { + signingKey = w.keyMgr.forfeitPrvkey } tapLeaf := txscript.NewBaseTapLeaf(input.TaprootLeafScript[0].Script) @@ -786,38 +794,77 @@ func (w *wallet) SignTransaction( return ptx.B64Encode() } -// signerKeyForLeaf returns the deprecated signer key referenced by the leaf, or the current SignerKey. -func (w *wallet) signerKeyForLeaf(leafScript []byte) *btcec.PrivateKey { - if len(w.DeprecatedSignerKeys) == 0 { - return w.SignerKey +// signerKeyForLeaf returns the wallet's signer key that must sign the given +// tapscript leaf: the current SignerKey or a deprecated one, whichever the leaf's +// multisig closure references. +// +// required is set when the caller explicitly asked for this input to be signed. In +// that case a leaf that references none of the wallet's keys is a hard error, +// usually because a signer key was rotated without being retained as a deprecated +// key, rather than silently signing with the wrong key and failing later at +// finalize. Best-effort callers (required false) fall back to the current key so a +// redundant signature on a leaf the wallet is not part of stays harmless. Leaves +// whose closure cannot be introspected also fall back to the current key. +func (w *wallet) signerKeyForLeaf(leafScript []byte, required bool) (*btcec.PrivateKey, error) { + leafKeys, ok := multisigClosureKeys(leafScript) + if !ok { + return w.SignerKey, nil + } + + if keyInLeaf(w.SignerKey, leafKeys) { + return w.SignerKey, nil + } + for _, k := range w.DeprecatedSignerKeys { + if keyInLeaf(k.Key, leafKeys) { + return k.Key, nil + } } + if required { + return nil, fmt.Errorf( + "no signer key for tapscript leaf: it references none of the wallet's keys " + + "(current or deprecated); a rotated signer key may not have been retained " + + "as a deprecated key", + ) + } + return w.SignerKey, nil +} + +// multisigClosureKeys returns the public keys of a multisig-bearing closure and +// whether the leaf could be decoded as one. +func multisigClosureKeys(leafScript []byte) ([]*btcec.PublicKey, bool) { closure, err := script.DecodeClosure(leafScript) if err != nil { - return w.SignerKey + return nil, false } - - leafKeys := make([]*btcec.PublicKey, 0) switch c := closure.(type) { case *script.MultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true + case *script.CSVMultisigClosure: + return c.PubKeys, true case *script.CLTVMultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true case *script.ConditionMultisigClosure: - leafKeys = c.PubKeys + return c.PubKeys, true + case *script.ConditionCSVMultisigClosure: + return c.PubKeys, true default: - return w.SignerKey + return nil, false } - - for _, k := range w.DeprecatedSignerKeys { - want := schnorr.SerializePubKey(k.Key.PubKey()) - for _, pubkey := range leafKeys { - if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { - return k.Key - } +} + +// keyInLeaf reports whether key is one of the leaf's multisig public keys. +func keyInLeaf(key *btcec.PrivateKey, leafKeys []*btcec.PublicKey) bool { + if key == nil { + return false + } + want := schnorr.SerializePubKey(key.PubKey()) + for _, pubkey := range leafKeys { + if bytes.Equal(schnorr.SerializePubKey(pubkey), want) { + return true } } - return w.SignerKey + return false } // WithdrawAll withdraws all available balance including connectors account funds diff --git a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go b/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go index 89fd99873..5af8524d1 100644 --- a/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go +++ b/pkg/arkd-wallet/core/application/wallet/signer_keys_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + arklib "github.com/arkade-os/arkd/pkg/ark-lib" "github.com/arkade-os/arkd/pkg/ark-lib/script" "github.com/arkade-os/arkd/pkg/arkd-wallet/core/application" "github.com/btcsuite/btcd/btcec/v2" @@ -132,3 +133,71 @@ func leafScript(t *testing.T, owner, signer *btcec.PublicKey) []byte { require.NoError(t, err) return s } + +// TestSignerKeyForLeaf covers the key selection used when signing tapscript leaves: +// the wallet signs with whichever of its keys (current or deprecated) the leaf +// references, including CSV (sweep) leaves; a required leaf that references none of +// the wallet's keys is a hard error instead of a silent wrong-key signature. +func TestSignerKeyForLeaf(t *testing.T) { + mustKey := func() *btcec.PrivateKey { + k, err := btcec.NewPrivateKey() + require.NoError(t, err) + return k + } + current, deprecated, user, stranger := mustKey(), mustKey(), mustKey(), mustKey() + + multisigLeaf := func(keys ...*btcec.PublicKey) []byte { + s, err := (&script.MultisigClosure{ + PubKeys: keys, Type: script.MultisigTypeChecksig, + }).Script() + require.NoError(t, err) + return s + } + csvLeaf := func(keys ...*btcec.PublicKey) []byte { + s, err := (&script.CSVMultisigClosure{ + MultisigClosure: script.MultisigClosure{ + PubKeys: keys, Type: script.MultisigTypeChecksig, + }, + Locktime: arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: 144}, + }).Script() + require.NoError(t, err) + return s + } + pub := func(k *btcec.PrivateKey) []byte { return schnorr.SerializePubKey(k.PubKey()) } + + w := &wallet{WalletOptions: WalletOptions{ + SignerKey: current, + DeprecatedSignerKeys: []DeprecatedSignerKey{{Key: deprecated}}, + }} + + t.Run("current key in multisig leaf", func(t *testing.T) { + key, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), current.PubKey()), true) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) + + // regression: CSV (sweep) leaves must be introspected so an old-key sweep is + // signed with the deprecated key rather than the current one. + t.Run("deprecated key in csv sweep leaf", func(t *testing.T) { + key, err := w.signerKeyForLeaf(csvLeaf(deprecated.PubKey()), true) + require.NoError(t, err) + require.Equal(t, pub(deprecated), pub(key)) + }) + + t.Run("required leaf with no held key errors", func(t *testing.T) { + _, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), stranger.PubKey()), true) + require.ErrorContains(t, err, "no signer key for tapscript leaf") + }) + + t.Run("best-effort leaf with no held key falls back to current", func(t *testing.T) { + key, err := w.signerKeyForLeaf(multisigLeaf(user.PubKey(), stranger.PubKey()), false) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) + + t.Run("non-multisig leaf falls back to current", func(t *testing.T) { + key, err := w.signerKeyForLeaf([]byte{0x01, 0x02, 0x03}, true) + require.NoError(t, err) + require.Equal(t, pub(current), pub(key)) + }) +} diff --git a/scripts/build-all b/scripts/build-all index 941c9de07..6680f5968 100755 --- a/scripts/build-all +++ b/scripts/build-all @@ -46,6 +46,13 @@ for os in "${OS[@]}"; do handle_error $os $arch $? fi + if VERSION=$VERSION GOOS=$os GOARCH=$arch ./scripts/build-arkd-forfeit-backfill; then + echo "Built arkd-forfeit-backfill successfully for $os $arch" + echo "" + else + handle_error $os $arch $? + fi + if VERSION=$VERSION GOOS=$os GOARCH=$arch ./pkg/ark-cli/scripts/build; then echo "Built ark-cli successfully for $os $arch" echo "" diff --git a/scripts/build-arkd-forfeit-backfill b/scripts/build-arkd-forfeit-backfill new file mode 100755 index 000000000..5b794f473 --- /dev/null +++ b/scripts/build-arkd-forfeit-backfill @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +# Get the parent directory path +PARENT_PATH=$(dirname $( + cd $(dirname $0) + pwd -P +)) + +# Set VERSION (you can modify this to get the version from a file or environment variable) +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "unknown") + +# Get OS and ARCH +OS=$(go env GOOS) +ARCH=$(go env GOARCH) + +echo "Building arkd-forfeit-backfill for $OS $ARCH" +echo "Version: $VERSION" + +# Change to the parent directory +pushd $PARENT_PATH + +# Create build directory if it doesn't exist +mkdir -p build + +# Build the binary +go build -ldflags="-s -w" -o build/arkd-forfeit-backfill-$OS-$ARCH ./cmd/arkd-forfeit-backfill + +echo "Build complete: build/arkd-forfeit-backfill-$OS-$ARCH" + +# Return to the original directory +popd