diff --git a/server/internal/core/ports/wallet.go b/server/internal/core/ports/wallet.go index b78c9c24..5d2de751 100644 --- a/server/internal/core/ports/wallet.go +++ b/server/internal/core/ports/wallet.go @@ -13,6 +13,7 @@ var ErrNonFinalBIP68 = errors.New("non-final BIP68 sequence") type WalletService interface { BlockchainScanner + GetSyncedUpdate(ctx context.Context) <-chan struct{} GenSeed(ctx context.Context) (string, error) Create(ctx context.Context, seed, password string) error Restore(ctx context.Context, seed, password string) error diff --git a/server/internal/infrastructure/tx-builder/covenant/mocks_test.go b/server/internal/infrastructure/tx-builder/covenant/mocks_test.go index 61b8104a..5a5e422e 100644 --- a/server/internal/infrastructure/tx-builder/covenant/mocks_test.go +++ b/server/internal/infrastructure/tx-builder/covenant/mocks_test.go @@ -13,6 +13,16 @@ type mockedWallet struct { mock.Mock } +func (m *mockedWallet) GetSyncedUpdate(ctx context.Context) <-chan struct{} { + args := m.Called(ctx) + + var res chan struct{} + if a := args.Get(0); a != nil { + res = a.(chan struct{}) + } + return res +} + func (m *mockedWallet) GenSeed(ctx context.Context) (string, error) { args := m.Called(ctx) diff --git a/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go index 224c3563..2bf397ab 100644 --- a/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go +++ b/server/internal/infrastructure/tx-builder/covenantless/mocks_test.go @@ -13,6 +13,16 @@ type mockedWallet struct { mock.Mock } +func (m *mockedWallet) GetSyncedUpdate(ctx context.Context) <-chan struct{} { + args := m.Called(ctx) + + var res chan struct{} + if a := args.Get(0); a != nil { + res = a.(chan struct{}) + } + return res +} + func (m *mockedWallet) GenSeed(ctx context.Context) (string, error) { args := m.Called(ctx) diff --git a/server/internal/infrastructure/wallet/btc-embedded/wallet.go b/server/internal/infrastructure/wallet/btc-embedded/wallet.go index cea1923b..78f5c7ae 100644 --- a/server/internal/infrastructure/wallet/btc-embedded/wallet.go +++ b/server/internal/infrastructure/wallet/btc-embedded/wallet.go @@ -83,10 +83,14 @@ const ( ) var ( - ErrWalletNotLoaded = fmt.Errorf("wallet not loaded, create or unlock it first") - p2wpkhKeyScope = waddrmgr.KeyScopeBIP0084 - p2trKeyScope = waddrmgr.KeyScopeBIP0086 - outputLockDuration = time.Minute + ErrNotLoaded = fmt.Errorf("wallet not loaded, create or unlock it first") + ErrNotSynced = fmt.Errorf("wallet still syncing, please retry later") + ErrNotReady = fmt.Errorf("wallet not ready, please init and wait for it to complete syncing") + ErrNotUnlocked = fmt.Errorf("wallet is locked, please unlock it to perform this operation") + ErrAlreadyInitialized = fmt.Errorf("wallet already initialized") + p2wpkhKeyScope = waddrmgr.KeyScopeBIP0084 + p2trKeyScope = waddrmgr.KeyScopeBIP0086 + outputLockDuration = time.Minute ) // add additional chain API not supported by the chain.Interface type @@ -110,6 +114,9 @@ type service struct { // holds the data related to the ASP key used in Vtxo scripts aspKeyAddr waddrmgr.ManagedPubKeyAddress + + isSynced bool + syncedCh chan struct{} } // WithNeutrino creates a start a neutrino node using the provided service datadir @@ -144,15 +151,6 @@ func WithNeutrino(initialPeer string, esploraURL string) WalletOption { return err } - if err := neutrinoSvc.Start(); err != nil { - return err - } - - // wait for neutrino to sync - for !neutrinoSvc.IsCurrent() { - time.Sleep(1 * time.Second) - } - chainSrc := chain.NewNeutrinoClient(netParams, neutrinoSvc) scanner := chain.NewNeutrinoClient(netParams, neutrinoSvc) @@ -268,6 +266,7 @@ func NewService(cfg WalletConfig, options ...WalletOption) (ports.WalletService, cfg: cfg, watchedScriptsLock: sync.RWMutex{}, watchedScripts: make(map[string]struct{}), + syncedCh: make(chan struct{}), } for _, option := range options { @@ -280,11 +279,14 @@ func NewService(cfg WalletConfig, options ...WalletOption) (ports.WalletService, } func (s *service) Close() { - if s.walletLoaded() { - if err := s.wallet.Stop(); err != nil { - log.WithError(err).Warn("failed to gracefully stop the wallet, forcing shutdown") - } + if s.isLoaded() { + s.wallet.InternalWallet().Stop() } + s.chainSource.Stop() +} + +func (s *service) GetSyncedUpdate(_ context.Context) <-chan struct{} { + return s.syncedCh } func (s *service) GenSeed(_ context.Context) (string, error) { @@ -304,11 +306,11 @@ func (s *service) Restore(_ context.Context, seed, password string) error { } func (s *service) Unlock(_ context.Context, password string) error { - if !s.walletInitialized() { + if !s.isInitialized() { return fmt.Errorf("wallet not initialized") } - if !s.walletLoaded() { + if !s.isLoaded() { pwd := []byte(password) opt := btcwallet.LoaderWithLocalWalletDB(s.cfg.Datadir, false, time.Minute) config := btcwallet.Config{ @@ -332,16 +334,6 @@ func (s *service) Unlock(_ context.Context, password string) error { return fmt.Errorf("failed to start wallet: %s", err) } - for { - if !wallet.InternalWallet().ChainSynced() { - log.Debugf("waiting sync: current height %d", wallet.InternalWallet().Manager.SyncedTo().Height) - time.Sleep(3 * time.Second) - continue - } - break - } - log.Debugf("chain synced") - addrs, err := wallet.ListAddresses(string(aspKeyAccount), false) if err != nil { return err @@ -381,14 +373,18 @@ func (s *service) Unlock(_ context.Context, password string) error { } s.wallet = wallet + + go s.listenToSynced() + return nil } + return s.wallet.InternalWallet().Unlock([]byte(password), nil) } func (s *service) Lock(_ context.Context, _ string) error { - if !s.walletLoaded() { - return ErrWalletNotLoaded + if !s.isLoaded() { + return ErrNotLoaded } s.wallet.InternalWallet().Lock() @@ -404,14 +400,15 @@ func (s *service) BroadcastTransaction(ctx context.Context, txHex string) (strin if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txHex))); err != nil { return "", err } - if err := s.wallet.PublishTransaction(&tx, ""); err != nil { - return "", err - } return tx.TxHash().String(), nil } func (s *service) ConnectorsAccountBalance(ctx context.Context) (uint64, uint64, error) { + if err := s.safeCheck(); err != nil { + return 0, 0, err + } + utxos, err := s.listUtxos(p2trKeyScope) if err != nil { return 0, 0, err @@ -426,6 +423,10 @@ func (s *service) ConnectorsAccountBalance(ctx context.Context) (uint64, uint64, } func (s *service) MainAccountBalance(ctx context.Context) (uint64, uint64, error) { + if err := s.safeCheck(); err != nil { + return 0, 0, err + } + utxos, err := s.listUtxos(p2wpkhKeyScope) if err != nil { return 0, 0, err @@ -440,8 +441,11 @@ func (s *service) MainAccountBalance(ctx context.Context) (uint64, uint64, error } func (s *service) DeriveAddresses(ctx context.Context, num int) ([]string, error) { - addresses := make([]string, 0, num) + if err := s.safeCheck(); err != nil { + return nil, err + } + addresses := make([]string, 0, num) for i := 0; i < num; i++ { addr, err := s.deriveNextAddress() if err != nil { @@ -459,6 +463,10 @@ func (s *service) DeriveAddresses(ctx context.Context, num int) ([]string, error } func (s *service) DeriveConnectorAddress(ctx context.Context) (string, error) { + if err := s.safeCheck(); err != nil { + return "", err + } + addr, err := s.wallet.NewAddress(lnwallet.TaprootPubkey, false, string(connectorAccount)) if err != nil { return "", err @@ -468,10 +476,17 @@ func (s *service) DeriveConnectorAddress(ctx context.Context) (string, error) { } func (s *service) GetPubkey(ctx context.Context) (*secp256k1.PublicKey, error) { + if !s.isLoaded() { + return nil, ErrNotLoaded + } return s.aspKeyAddr.PubKey(), nil } func (s *service) GetForfeitAddress(ctx context.Context) (string, error) { + if err := s.safeCheck(); err != nil { + return "", err + } + addrs, err := s.wallet.ListAddresses(string(mainAccount), false) if err != nil { return "", err @@ -512,6 +527,10 @@ func (s *service) GetForfeitAddress(ctx context.Context) (string, error) { } func (s *service) ListConnectorUtxos(ctx context.Context, connectorAddress string) ([]ports.TxInput, error) { + if err := s.safeCheck(); err != nil { + return nil, err + } + w := s.wallet.InternalWallet() addr, err := btcutil.DecodeAddress(connectorAddress, w.ChainParams()) @@ -542,6 +561,10 @@ func (s *service) ListConnectorUtxos(ctx context.Context, connectorAddress strin } func (s *service) LockConnectorUtxos(ctx context.Context, utxos []ports.TxOutpoint) error { + if err := s.safeCheck(); err != nil { + return err + } + w := s.wallet.InternalWallet() for _, utxo := range utxos { @@ -562,6 +585,10 @@ func (s *service) LockConnectorUtxos(ctx context.Context, utxos []ports.TxOutpoi } func (s *service) SelectUtxos(ctx context.Context, _ string, amount uint64) ([]ports.TxInput, uint64, error) { + if err := s.safeCheck(); err != nil { + return nil, 0, err + } + w := s.wallet.InternalWallet() utxos, err := s.listUtxos(p2wpkhKeyScope) @@ -616,6 +643,10 @@ func (s *service) SelectUtxos(ctx context.Context, _ string, amount uint64) ([]p } func (s *service) SignTransaction(ctx context.Context, partialTx string, extractRawTx bool) (string, error) { + if err := s.safeCheck(); err != nil { + return "", err + } + ptx, err := psbt.NewFromRawBytes( strings.NewReader(partialTx), true, @@ -701,6 +732,10 @@ func (s *service) SignTransaction(ctx context.Context, partialTx string, extract } func (s *service) SignTransactionTapscript(ctx context.Context, partialTx string, inputIndexes []int) (string, error) { + if err := s.safeCheck(); err != nil { + return "", err + } + partial, err := psbt.NewFromRawBytes( strings.NewReader(partialTx), true, @@ -739,9 +774,10 @@ func (s *service) SignTransactionTapscript(ctx context.Context, partialTx string } func (s *service) Status(ctx context.Context) (ports.WalletStatus, error) { - if !s.walletLoaded() { + if !s.isLoaded() { return status{ - initialized: s.walletInitialized(), + initialized: s.isInitialized(), + synced: s.chainSource.IsCurrent(), }, nil } @@ -754,6 +790,10 @@ func (s *service) Status(ctx context.Context) (ports.WalletStatus, error) { } func (s *service) WaitForSync(ctx context.Context, txid string) error { + if err := s.safeCheck(); err != nil { + return err + } + w := s.wallet.InternalWallet() txhash, err := chainhash.NewHashFromStr(txid) @@ -869,6 +909,10 @@ func (s *service) EstimateFees(ctx context.Context, partialTx string) (uint64, e } func (s *service) WatchScripts(ctx context.Context, scripts []string) error { + if !s.isSynced { + return ErrNotSynced + } + addresses := make([]btcutil.Address, 0, len(scripts)) for _, script := range scripts { @@ -904,6 +948,10 @@ func (s *service) WatchScripts(ctx context.Context, scripts []string) error { } func (s *service) UnwatchScripts(ctx context.Context, scripts []string) error { + if !s.isSynced { + return ErrNotSynced + } + s.watchedScriptsLock.Lock() defer s.watchedScriptsLock.Unlock() for _, script := range scripts { @@ -1014,6 +1062,10 @@ func (s *service) castNotification(tx *wtxmgr.TxRecord) map[string][]ports.VtxoW } func (s *service) create(mnemonic, password string, addrGap uint32) error { + if s.isInitialized() { + return ErrAlreadyInitialized + } + if len(mnemonic) <= 0 { return fmt.Errorf("missing hd seed") } @@ -1057,24 +1109,62 @@ func (s *service) create(mnemonic, password string, addrGap uint32) error { return fmt.Errorf("failed to start wallet: %s", err) } - for { - if !wallet.InternalWallet().ChainSynced() { - log.Debugf("waiting sync: current height %d", wallet.InternalWallet().Manager.SyncedTo().Height) - time.Sleep(3 * time.Second) - continue - } - break - } - log.Debugf("chain synced") - if err := s.initAspKeyAddress(wallet); err != nil { return err } s.wallet = wallet + + go s.listenToSynced() + return nil } +func (s *service) listenToSynced() { + counter := 0 + for { + if s.wallet.InternalWallet().ChainSynced() { + log.Debug("wallet: syncing completed") + s.isSynced = true + s.syncedCh <- struct{}{} + return + } + + isRestore, progress, err := s.wallet.GetRecoveryInfo() + if err != nil { + log.Warnf("wallet: failed to check if wallet is synced: %s", err) + } else { + if !isRestore { + if counter%6 == 0 { + log.Debug("wallet: syncing in progress...") + } + counter++ + } else { + switch progress { + case 0: + // nolint: all + if counter%6 == 0 { + _, bestBlock, _ := s.wallet.IsSynced() + if bestBlock > 0 { + log.Debugf("wallet: waiting for chain source to be synced, last block fetched: %s", time.Unix(bestBlock, 0)) + } + } + counter++ + case 1: + log.Debug("wallet: syncing completed") + s.isSynced = true + s.syncedCh <- struct{}{} + return + default: + log.Debugf("wallet: syncing progress %.0f%%", progress*100) + } + } + } + + time.Sleep(10 * time.Second) + } +} + // initAspKeyAccount creates the asp key account if it doesn't exist func (s *service) initAspKeyAccount(wallet *btcwallet.BtcWallet) error { w := wallet.InternalWallet() @@ -1173,18 +1263,31 @@ func (s *service) initAspKeyAddress(wallet *btcwallet.BtcWallet) error { } func (s *service) deriveNextAddress() (btcutil.Address, error) { - if !s.walletLoaded() { - return nil, ErrWalletNotLoaded + if !s.isLoaded() { + return nil, ErrNotLoaded } return s.wallet.NewAddress(lnwallet.WitnessPubKey, false, string(mainAccount)) } -func (s *service) walletLoaded() bool { +func (s *service) safeCheck() error { + if !s.isLoaded() { + if s.isInitialized() { + return ErrNotUnlocked + } + return ErrNotReady + } + if !s.isSynced { + return ErrNotSynced + } + return nil +} + +func (s *service) isLoaded() bool { return s.wallet != nil } -func (s *service) walletInitialized() bool { +func (s *service) isInitialized() bool { opts := []btcwallet.LoaderOption{btcwallet.LoaderWithLocalWalletDB(s.cfg.Datadir, false, time.Minute)} loader, err := btcwallet.NewWalletLoader( s.cfg.chainParams(), 0, opts..., @@ -1241,10 +1344,6 @@ func withChainSource(chainSource chain.Interface) WalletOption { return fmt.Errorf("chain source already set") } - if err := chainSource.Start(); err != nil { - return fmt.Errorf("failed to start chain source: %s", err) - } - s.chainSource = chainSource return nil } diff --git a/server/internal/infrastructure/wallet/liquid-standalone/service.go b/server/internal/infrastructure/wallet/liquid-standalone/service.go index 3ea133f7..0c4837c4 100644 --- a/server/internal/infrastructure/wallet/liquid-standalone/service.go +++ b/server/internal/infrastructure/wallet/liquid-standalone/service.go @@ -24,6 +24,7 @@ type service struct { notifyClient pb.NotificationServiceClient chVtxos chan map[string][]ports.VtxoWithValue isListening bool + syncedCh chan struct{} } func NewService(addr string) (ports.WalletService, error) { @@ -44,6 +45,7 @@ func NewService(addr string) (ports.WalletService, error) { txClient: txClient, notifyClient: notifyClient, chVtxos: chVtxos, + syncedCh: make(chan struct{}), } ctx := context.Background() @@ -65,6 +67,10 @@ func (s *service) Close() { s.conn.Close() } +func (s *service) GetSyncedUpdate(_ context.Context) <-chan struct{} { + return s.syncedCh +} + func (s *service) GenSeed(ctx context.Context) (string, error) { res, err := s.walletClient.GenSeed(ctx, &pb.GenSeedRequest{}) if err != nil { diff --git a/server/internal/interface/grpc/handlers/walletservice.go b/server/internal/interface/grpc/handlers/walletservice.go index 041b6d4f..708bc1c8 100644 --- a/server/internal/interface/grpc/handlers/walletservice.go +++ b/server/internal/interface/grpc/handlers/walletservice.go @@ -6,18 +6,22 @@ import ( arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1" "github.com/ark-network/ark/server/internal/core/ports" + log "github.com/sirupsen/logrus" ) type walletInitHandler struct { walletService ports.WalletService onInit func(password string) onUnlock func(password string) + onReady func() } func NewWalletInitializerHandler( - walletService ports.WalletService, onInit, onUnlock func(string), + walletService ports.WalletService, onInit, onUnlock func(string), onReady func(), ) arkv1.WalletInitializerServiceServer { - return &walletInitHandler{walletService, onInit, onUnlock} + svc := walletInitHandler{walletService, onInit, onUnlock, onReady} + go svc.listenWhenReady() + return &svc } func (a *walletInitHandler) GenSeed(ctx context.Context, _ *arkv1.GenSeedRequest) (*arkv1.GenSeedResponse, error) { @@ -77,6 +81,17 @@ func (a *walletInitHandler) Unlock(ctx context.Context, req *arkv1.UnlockRequest go a.onUnlock(req.GetPassword()) + go func() { + status, err := a.walletService.Status(context.Background()) + if err != nil { + log.Warnf("failed to get wallet status: %s", err) + return + } + if status.IsUnlocked() && status.IsSynced() { + a.onReady() + } + }() + return &arkv1.UnlockResponse{}, nil } @@ -93,6 +108,19 @@ func (a *walletInitHandler) GetStatus(ctx context.Context, _ *arkv1.GetStatusReq }, nil } +func (a *walletInitHandler) listenWhenReady() { + ctx := context.Background() + <-a.walletService.GetSyncedUpdate(ctx) + + status, err := a.walletService.Status(ctx) + if err != nil { + log.Warnf("failed to get wallet status: %s", err) + } + if status.IsUnlocked() && status.IsSynced() { + a.onReady() + } +} + type walletHandler struct { walletService ports.WalletService } diff --git a/server/internal/interface/grpc/service.go b/server/internal/interface/grpc/service.go index 2d15c6f0..0d48897e 100644 --- a/server/internal/interface/grpc/service.go +++ b/server/internal/interface/grpc/service.go @@ -183,7 +183,7 @@ func (s *service) newServer(tlsConfig *tls.Config, withAppSvc bool) error { arkv1.RegisterWalletServiceServer(grpcServer, walletHandler) walletInitHandler := handlers.NewWalletInitializerHandler( - s.appConfig.WalletService(), s.onInit, s.onUnlock, + s.appConfig.WalletService(), s.onInit, s.onUnlock, s.onReady, ) arkv1.RegisterWalletInitializerServiceServer(grpcServer, walletInitHandler) @@ -271,14 +271,6 @@ func (s *service) newServer(tlsConfig *tls.Config, withAppSvc bool) error { } func (s *service) onUnlock(password string) { - withoutAppSvc := false - s.stop(withoutAppSvc) - - withAppSvc := true - if err := s.start(withAppSvc); err != nil { - panic(err) - } - if s.config.NoMacaroons { return } @@ -320,6 +312,16 @@ func (s *service) onInit(password string) { log.Debugf("generated macaroons at path %s", datadir) } +func (s *service) onReady() { + withoutAppSvc := false + s.stop(withoutAppSvc) + + withAppSvc := true + if err := s.start(withAppSvc); err != nil { + panic(err) + } +} + func (s *service) autoUnlock() error { ctx := context.Background() wallet := s.appConfig.WalletService() diff --git a/server/test/e2e/covenantless/e2e_test.go b/server/test/e2e/covenantless/e2e_test.go index 18bb1161..b7e45b68 100644 --- a/server/test/e2e/covenantless/e2e_test.go +++ b/server/test/e2e/covenantless/e2e_test.go @@ -415,7 +415,7 @@ func setupAspWallet() error { return fmt.Errorf("failed to unlock wallet: %s", err) } - time.Sleep(time.Second) + time.Sleep(5 * time.Second) req, err = http.NewRequest("GET", "http://localhost:7070/v1/admin/wallet/address", nil) if err != nil {