diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 3fd3ce6172..ec5c29d40f 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1415,6 +1415,39 @@ func (tc *TbtcChain) PastNewWalletRegisteredEvents( return convertedEvents, err } +func (tc *TbtcChain) CalculateWalletID( + walletPublicKey *ecdsa.PublicKey, +) ([32]byte, error) { + return calculateWalletID(walletPublicKey) +} + +func calculateWalletID(walletPublicKey *ecdsa.PublicKey) ([32]byte, error) { + walletPublicKeyBytes, err := convertPubKeyToChainFormat(walletPublicKey) + if err != nil { + return [32]byte{}, fmt.Errorf( + "error while converting wallet public key to chain format: [%v]", + err, + ) + } + + return crypto.Keccak256Hash(walletPublicKeyBytes[:]), nil +} + +func (tc *TbtcChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { + isWalletRegistered, err := tc.walletRegistry.IsWalletRegistered( + EcdsaWalletID, + ) + if err != nil { + return false, fmt.Errorf( + "cannot check if wallet with ECDSA ID [0x%x] is registered: [%v]", + EcdsaWalletID, + err, + ) + } + + return isWalletRegistered, nil +} + func (tc *TbtcChain) GetWallet( walletPublicKeyHash [20]byte, ) (*tbtc.WalletChainData, error) { @@ -1453,6 +1486,21 @@ func (tc *TbtcChain) GetWallet( }, nil } +func (tc *TbtcChain) OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), +) subscription.EventSubscription { + onEvent := func( + walletID [32]byte, + blockNumber uint64, + ) { + handler(&tbtc.WalletClosedEvent{ + WalletID: walletID, + BlockNumber: blockNumber, + }) + } + return tc.walletRegistry.WalletClosedEvent(nil, nil).OnEvent(onEvent) +} + func (tc *TbtcChain) ComputeMainUtxoHash( mainUtxo *bitcoin.UnspentTransactionOutput, ) [32]byte { diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 996e06a199..1c9eef1be0 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -16,6 +16,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/chain/local_v1" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -279,6 +280,49 @@ func TestCalculateInactivityClaimHash(t *testing.T) { ) } +func TestCalculateWalletID(t *testing.T) { + hexToByte32 := func(hexStr string) [32]byte { + if len(hexStr) != 64 { + t.Fatal("hex string length incorrect") + } + + decoded, err := hex.DecodeString(hexStr) + if err != nil { + t.Fatal(err) + } + + var result [32]byte + copy(result[:], decoded) + + return result + } + + xBytes := hexToByte32( + "9a0544440cc47779235ccb76d669590c2cd20c7e431f97e17a1093faf03291c4", + ) + + yBytes := hexToByte32( + "73e661a208a8a565ca1e384059bd2ff7ff6886df081ff1229250099d388c83df", + ) + + walletPublicKey := &ecdsa.PublicKey{ + Curve: local_v1.DefaultCurve, + X: new(big.Int).SetBytes(xBytes[:]), + Y: new(big.Int).SetBytes(yBytes[:]), + } + + actualWalletID, err := calculateWalletID(walletPublicKey) + if err != nil { + t.Fatal(err) + } + + expectedWalletID := hexToByte32( + "a6602e554b8cf7c23538fd040e4ff3520ec680e5e5ce9a075259e613a3e5aa79", + ) + + testutils.AssertBytesEqual(t, expectedWalletID[:], actualWalletID[:]) +} + func TestParseDkgResultValidationOutcome(t *testing.T) { isValid, err := parseDkgResultValidationOutcome( &struct { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 1b579e326b..55206f86fb 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -235,13 +235,35 @@ type DKGParameters struct { ApprovePrecedencePeriodBlocks uint64 } +// WalletClosedEvent represents a wallet closed event. It is emitted when the +// wallet is closed in the wallet registry. +type WalletClosedEvent struct { + WalletID [32]byte + BlockNumber uint64 +} + // BridgeChain defines the subset of the TBTC chain interface that pertains // specifically to the tBTC Bridge operations. type BridgeChain interface { + // CalculateWalletID calculates the wallet's ECDSA ID based on the provided + // wallet public key. + CalculateWalletID(walletPublicKey *ecdsa.PublicKey) ([32]byte, error) + + // IsWalletRegistered checks whether the given wallet is registered in the + // ECDSA wallet registry. + IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) + // GetWallet gets the on-chain data for the given wallet. Returns an error // if the wallet was not found. GetWallet(walletPublicKeyHash [20]byte) (*WalletChainData, error) + // OnWalletClosed registers a callback that is invoked when an on-chain + // notification of the wallet closed is seen. The notification occurs when + // the wallet is closed or terminated. + OnWalletClosed( + func(event *WalletClosedEvent), + ) subscription.EventSubscription + // ComputeMainUtxoHash computes the hash of the provided main UTXO // according to the on-chain Bridge rules. ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 6e293f23dc..15bb4c94ca 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -14,17 +14,17 @@ import ( "sync" "time" - "golang.org/x/crypto/sha3" - "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" + "github.com/keep-network/keep-core/pkg/internal/byteutils" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/protocol/inactivity" "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tecdsa/dkg" + "golang.org/x/crypto/sha3" ) const ( @@ -843,6 +843,40 @@ func buildDepositRequestKey( return sha256.Sum256(append(fundingTxHash[:], buffer...)) } +func (lc *localChain) CalculateWalletID( + walletPublicKey *ecdsa.PublicKey, +) ([32]byte, error) { + walletPublicKeyBytes, err := convertPubKeyToChainFormat(walletPublicKey) + if err != nil { + return [32]byte{}, fmt.Errorf( + "error while converting wallet public key to chain format: [%v]", + err, + ) + } + + return crypto.Keccak256Hash(walletPublicKeyBytes[:]), nil +} + +func convertPubKeyToChainFormat(publicKey *ecdsa.PublicKey) ([64]byte, error) { + var serialized [64]byte + + x, err := byteutils.LeftPadTo32Bytes(publicKey.X.Bytes()) + if err != nil { + return serialized, err + } + + y, err := byteutils.LeftPadTo32Bytes(publicKey.Y.Bytes()) + if err != nil { + return serialized, err + } + + serializedBytes := append(x, y...) + + copy(serialized[:], serializedBytes) + + return serialized, nil +} + func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( *WalletChainData, error, @@ -858,6 +892,23 @@ func (lc *localChain) GetWallet(walletPublicKeyHash [20]byte) ( return walletChainData, nil } +func (lc *localChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + + for _, walletData := range lc.wallets { + if EcdsaWalletID == walletData.EcdsaWalletID { + if walletData.State == StateClosed || + walletData.State == StateTerminated { + return false, nil + } + return true, nil + } + } + + return false, fmt.Errorf("wallet not found") +} + func (lc *localChain) setWallet( walletPublicKeyHash [20]byte, walletChainData *WalletChainData, @@ -868,6 +919,12 @@ func (lc *localChain) setWallet( lc.wallets[walletPublicKeyHash] = walletChainData } +func (lc *localChain) OnWalletClosed( + handler func(event *WalletClosedEvent), +) subscription.EventSubscription { + panic("unsupported") +} + func (lc *localChain) ComputeMainUtxoHash( mainUtxo *bitcoin.UnspentTransactionOutput, ) [32]byte { diff --git a/pkg/tbtc/deduplicator.go b/pkg/tbtc/deduplicator.go index a674cea370..37d0b1704f 100644 --- a/pkg/tbtc/deduplicator.go +++ b/pkg/tbtc/deduplicator.go @@ -16,6 +16,9 @@ const ( // DKGResultHashCachePeriod is the time period the cache maintains // the given DKG result hash. DKGResultHashCachePeriod = 7 * 24 * time.Hour + // WalletClosedCachePeriod is the time period the cache maintains the ID of + // a closed wallet. + WalletClosedCachePeriod = 7 * 24 * time.Hour ) // deduplicator decides whether the given event should be handled by the @@ -31,15 +34,18 @@ const ( // Those events are supported: // - DKG started // - DKG result submitted +// - Wallet closed type deduplicator struct { dkgSeedCache *cache.TimeCache dkgResultHashCache *cache.TimeCache + walletClosedCache *cache.TimeCache } func newDeduplicator() *deduplicator { return &deduplicator{ dkgSeedCache: cache.NewTimeCache(DKGSeedCachePeriod), dkgResultHashCache: cache.NewTimeCache(DKGResultHashCachePeriod), + walletClosedCache: cache.NewTimeCache(WalletClosedCachePeriod), } } @@ -90,3 +96,23 @@ func (d *deduplicator) notifyDKGResultSubmitted( // proceed with the execution. return false } + +func (d *deduplicator) notifyWalletClosed( + WalletID [32]byte, +) bool { + d.walletClosedCache.Sweep() + + // Use wallet ID converted to string as the cache key. + cacheKey := hex.EncodeToString(WalletID[:]) + + // If the key is not in the cache, that means the wallet closure was not + // handled yet and the client should proceed with the execution. + if !d.walletClosedCache.Has(cacheKey) { + d.walletClosedCache.Add(cacheKey) + return true + } + + // Otherwise, the wallet closure is a duplicate and the client should not + // proceed with the execution. + return false +} diff --git a/pkg/tbtc/deduplicator_test.go b/pkg/tbtc/deduplicator_test.go index 4fdeb5ec7c..b75432a8c0 100644 --- a/pkg/tbtc/deduplicator_test.go +++ b/pkg/tbtc/deduplicator_test.go @@ -9,8 +9,11 @@ import ( "github.com/keep-network/keep-common/pkg/cache" ) -const testDKGSeedCachePeriod = 1 * time.Second -const testDKGResultHashCachePeriod = 1 * time.Second +const ( + testDKGSeedCachePeriod = 1 * time.Second + testDKGResultHashCachePeriod = 1 * time.Second + testWalletClosedCachePeriod = 1 * time.Second +) func TestNotifyDKGStarted(t *testing.T) { deduplicator := deduplicator{ @@ -112,3 +115,39 @@ func TestNotifyDKGResultSubmitted(t *testing.T) { t.Fatal("should be allowed to process") } } + +func TestNotifyWalletClosed(t *testing.T) { + deduplicator := deduplicator{ + walletClosedCache: cache.NewTimeCache(testWalletClosedCachePeriod), + } + + wallet1 := [32]byte{1} + wallet2 := [32]byte{2} + + // Add the first wallet ID. + canProcess := deduplicator.notifyWalletClosed(wallet1) + if !canProcess { + t.Fatal("should be allowed to process") + } + + // Add the second wallet ID. + canProcess = deduplicator.notifyWalletClosed(wallet2) + if !canProcess { + t.Fatal("should be allowed to process") + } + + // Add the first wallet ID before caching period elapses. + canProcess = deduplicator.notifyWalletClosed(wallet1) + if canProcess { + t.Fatal("should not be allowed to process") + } + + // Wait until caching period elapses. + time.Sleep(testWalletClosedCachePeriod) + + // Add the first wallet ID again. + canProcess = deduplicator.notifyWalletClosed(wallet1) + if !canProcess { + t.Fatal("should be allowed to process") + } +} diff --git a/pkg/tbtc/dkg_test.go b/pkg/tbtc/dkg_test.go index 552ad93f9f..547d1b5079 100644 --- a/pkg/tbtc/dkg_test.go +++ b/pkg/tbtc/dkg_test.go @@ -89,7 +89,14 @@ func TestDkgExecutor_RegisterSigner(t *testing.T) { for testName, test := range tests { t.Run(testName, func(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} - walletRegistry := newWalletRegistry(persistenceHandle) + chain := Connect() + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } dkgExecutor := &dkgExecutor{ // setting only the fields really needed for this test diff --git a/pkg/tbtc/inactivity_test.go b/pkg/tbtc/inactivity_test.go index b1065e4607..3c8370e0d5 100644 --- a/pkg/tbtc/inactivity_test.go +++ b/pkg/tbtc/inactivity_test.go @@ -157,12 +157,16 @@ func setupInactivityClaimExecutorScenario(t *testing.T) ( keyStorePersistence := createMockKeyStorePersistence(t, signers...) walletPublicKeyHash := bitcoin.PublicKeyHash(signers[0].wallet.publicKey) - ecdsaWalletID := [32]byte{1, 2, 3} + walletID, err := localChain.CalculateWalletID(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } localChain.setWallet( walletPublicKeyHash, &WalletChainData{ - EcdsaWalletID: ecdsaWalletID, + EcdsaWalletID: walletID, + State: StateLive, }, ) @@ -191,7 +195,7 @@ func setupInactivityClaimExecutorScenario(t *testing.T) ( t.Fatal("node is supposed to control wallet signers") } - return executor, ecdsaWalletID, localChain + return executor, walletID, localChain } func TestSignClaim_SigningSuccessful(t *testing.T) { diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index af0c37293f..fc9ba55b10 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -8,6 +8,7 @@ import ( "math/big" "sync" + "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" @@ -38,6 +39,12 @@ const ( // Moreover, the signature must be produced in the reasonable time. // That being said, the value `5` seems to be reasonable trade-off. signingAttemptsLimit = 5 + + // walletClosureConfirmationBlocks determines the period used when waiting + // for the wallet closure confirmation. This period ensures the wallet has + // been definitely closed and the closing transaction will not be removed by + // a chain reorganization. + walletClosureConfirmationBlocks = 32 ) // TODO: Unit tests for `node.go`. @@ -117,7 +124,13 @@ func newNode( proposalGenerator CoordinationProposalGenerator, config Config, ) (*node, error) { - walletRegistry := newWalletRegistry(keyStorePersistance) + walletRegistry, err := newWalletRegistry( + keyStorePersistance, + chain.CalculateWalletID, + ) + if err != nil { + return nil, fmt.Errorf("cannot create wallet registry: [%v]", err) + } latch := generator.NewProtocolLatch() scheduler.RegisterProtocol(latch) @@ -137,12 +150,19 @@ func newNode( proposalGenerator: proposalGenerator, } + // Archive any wallets that might have been closed or terminated while the + // client was turned off. + err = node.archiveClosedWallets() + if err != nil { + return nil, fmt.Errorf("cannot archive closed wallets: [%v]", err) + } + // Only the operator address is known at this point and can be pre-fetched. // The operator ID must be determined later as the operator may not be in // the sortition pool yet. operatorAddress, err := node.operatorAddress() if err != nil { - return nil, fmt.Errorf("cannot get node's operator adress: [%v]", err) + return nil, fmt.Errorf("cannot get node's operator address: [%v]", err) } // TODO: This chicken and egg problem should be solved when @@ -1075,6 +1095,130 @@ func processCoordinationResult(node *node, result *coordinationResult) { } } +// archiveClosedWallets archives closed or terminated wallets. +func (n *node) archiveClosedWallets() error { + // Get all the wallets controlled by the node. + walletPublicKeys := n.walletRegistry.getWalletsPublicKeys() + + for _, walletPublicKey := range walletPublicKeys { + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + + walletID, err := n.chain.CalculateWalletID(walletPublicKey) + if err != nil { + return fmt.Errorf( + "could not calculate wallet ID for wallet with public key "+ + "hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + isRegistered, err := n.chain.IsWalletRegistered(walletID) + if err != nil { + return fmt.Errorf( + "could not check if wallet is registered for wallet with ID "+ + "[0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + if !isRegistered { + // If the wallet is no longer registered it means the wallet has + // been closed or terminated. + err := n.walletRegistry.archiveWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf( + "could not archive wallet with public key hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + + logger.Infof( + "successfully archived wallet with ID [0x%x] and public key "+ + "hash [0x%x]", + walletID, + walletPublicKeyHash, + ) + } + } + + return nil +} + +// handleWalletClosure handles the wallet termination or closing process. +func (n *node) handleWalletClosure(walletID [32]byte) error { + blockCounter, err := n.chain.BlockCounter() + if err != nil { + return fmt.Errorf("error getting block counter [%w]", err) + } + + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return fmt.Errorf("error getting current block [%w]", err) + } + + // To verify there was no chain reorg and the wallet is really closed check + // if it is registered. Both terminated and closed wallets are removed + // from the ECDSA registry. + stateCheck := func() (bool, error) { + isRegistered, err := n.chain.IsWalletRegistered(walletID) + if err != nil { + return false, err + } + + return !isRegistered, nil + } + + // Wait a significant number of blocks to make sure the transaction has not + // been reverted for some reason, e.g. due to a chain reorganization. + result, err := ethereum.WaitForBlockConfirmations( + blockCounter, + currentBlock, + walletClosureConfirmationBlocks, + stateCheck, + ) + if err != nil { + return fmt.Errorf( + "error while waiting for wallet closure confirmation [%w]", + err, + ) + } + + if !result { + return fmt.Errorf("wallet closure not confirmed") + } + + wallet, ok := n.walletRegistry.getWalletByID(walletID) + if !ok { + // Wallet was not found in the registry. The wallet is not controlled by + // this node. + logger.Infof( + "node does not control wallet with ID [0x%x]; quitting wallet "+ + "archiving", + walletID, + ) + return nil + } + + walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + + err = n.walletRegistry.archiveWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("failed to archive the wallet: [%v]", err) + } + + logger.Infof( + "successfully archived wallet with wallet ID [0x%x] and public key "+ + "hash [0x%x]", + walletID, + walletPublicKeyHash, + ) + + return nil +} + // waitForBlockFn represents a function blocking the execution until the given // block height. type waitForBlockFn func(context.Context, uint64) error diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index b9dbb01992..bedfb30995 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -11,6 +11,7 @@ import ( "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/generator" "github.com/keep-network/keep-core/pkg/internal/tecdsatest" @@ -31,6 +32,20 @@ func TestNode_GetSigningExecutor(t *testing.T) { signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + // Populate the mock keystore with the mock signer's data. This is // required to make the node controlling the signer's wallet. keyStorePersistence := createMockKeyStorePersistence(t, signer) @@ -149,6 +164,20 @@ func TestNode_GetCoordinationExecutor(t *testing.T) { signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + // Populate the mock keystore with the mock signer's data. This is // required to make the node controlling the signer's wallet. keyStorePersistence := createMockKeyStorePersistence(t, signer) @@ -272,6 +301,20 @@ func TestNode_RunCoordinationLayer(t *testing.T) { signer := createMockSigner(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + // Populate the mock keystore with the mock signer's data. This is // required to make the node controlling the signer's wallet. keyStorePersistence := createMockKeyStorePersistence(t, signer) diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index 80334f7475..d39b56849d 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -12,6 +12,10 @@ import ( "github.com/keep-network/keep-common/pkg/persistence" ) +// CalculateWalletIDFunc calculates the ECDSA wallet ID based on the provided +// wallet public key. +type CalculateWalletIdFunc func(walletPublicKey *ecdsa.PublicKey) ([32]byte, error) + // walletRegistry is the component that holds the data of the wallets managed // by the given node. All functions of the registry are safe for concurrent use. type walletRegistry struct { @@ -26,18 +30,28 @@ type walletRegistry struct { // walletStorage is the handle to the wallet storage responsible for // wallet persistence. walletStorage *walletStorage + + // calculateWalletIdFunc calculates the ECDSA wallet ID based on the + // provided wallet public key. + calculateWalletIdFunc CalculateWalletIdFunc } type walletCacheValue struct { // SHA-256+RIPEMD-160 hash computed over the compressed ECDSA public key of // the wallet. walletPublicKeyHash [20]byte + // ECDSA wallet ID calculated as the keccak256 of the 64-byte-long + // concatenation of the X and Y coordinates of the wallet's public key. + walletID [32]byte // Array of wallet signers controlled by this node. signers []*signer } // newWalletRegistry creates a new instance of the walletRegistry. -func newWalletRegistry(persistence persistence.ProtectedHandle) *walletRegistry { +func newWalletRegistry( + persistence persistence.ProtectedHandle, + calculateWalletIdFunc CalculateWalletIdFunc, +) (*walletRegistry, error) { walletStorage := newWalletStorage(persistence) // Pre-populate the wallet cache using the wallet storage. @@ -53,9 +67,19 @@ func newWalletRegistry(persistence persistence.ProtectedHandle) *walletRegistry // them. wallet := signers[0].wallet walletPublicKeyHash := bitcoin.PublicKeyHash(wallet.publicKey) + walletID, err := calculateWalletIdFunc(wallet.publicKey) + if err != nil { + return nil, fmt.Errorf( + "error while calculating wallet ID for wallet with public "+ + "key hash [0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } walletCache[walletStorageKey] = &walletCacheValue{ walletPublicKeyHash: walletPublicKeyHash, + walletID: walletID, signers: signers, } @@ -72,9 +96,10 @@ func newWalletRegistry(persistence persistence.ProtectedHandle) *walletRegistry } return &walletRegistry{ - walletCache: walletCache, - walletStorage: walletStorage, - } + walletCache: walletCache, + walletStorage: walletStorage, + calculateWalletIdFunc: calculateWalletIdFunc, + }, nil } // getWalletsPublicKeys returns public keys of all registered wallets. @@ -105,12 +130,18 @@ func (wr *walletRegistry) registerSigner(signer *signer) error { walletStorageKey := getWalletStorageKey(signer.wallet.publicKey) // If the wallet cache does not have the given entry yet, initialize - // the value and compute the wallet public key hash. This way, the hash - // is computed only once. No need to initialize signers slice as + // the value and compute the wallet ID and wallet public key hash. This way, + // the hashes are computed only once. No need to initialize signers slice as // appending works with nil values. if _, ok := wr.walletCache[walletStorageKey]; !ok { + walletID, err := wr.calculateWalletIdFunc(signer.wallet.publicKey) + if err != nil { + return fmt.Errorf("cannot calculate wallet ID: [%v]", err) + } + wr.walletCache[walletStorageKey] = &walletCacheValue{ walletPublicKeyHash: bitcoin.PublicKeyHash(signer.wallet.publicKey), + walletID: walletID, } } @@ -156,6 +187,61 @@ func (wr *walletRegistry) getWalletByPublicKeyHash( return wallet{}, false } +// getWalletByID gets the given wallet by its 32-byte wallet ID. Second boolean +// return value denotes whether the wallet was found in the registry or not. +func (wr *walletRegistry) getWalletByID(walletID [32]byte) (wallet, bool) { + wr.mutex.Lock() + defer wr.mutex.Unlock() + + for _, value := range wr.walletCache { + if value.walletID == walletID { + // All signers belong to one wallet. Take that wallet from the + // first signer. + return value.signers[0].wallet, true + } + } + + return wallet{}, false +} + +// archiveWallet archives the wallet with the given public key hash. The wallet +// data is removed from the wallet cache and the entire wallet storage directory +// is moved to the archive directory. +func (wr *walletRegistry) archiveWallet( + walletPublicKeyHash [20]byte, +) error { + wr.mutex.Lock() + defer wr.mutex.Unlock() + + var walletPublicKey *ecdsa.PublicKey + + for _, value := range wr.walletCache { + if value.walletPublicKeyHash == walletPublicKeyHash { + // All signers belong to one wallet. Take the wallet public key from + // the first signer. + walletPublicKey = value.signers[0].wallet.publicKey + break + } + } + + if walletPublicKey == nil { + return fmt.Errorf("wallet not found in the wallet cache") + } + + walletStorageKey := getWalletStorageKey(walletPublicKey) + + // Archive the entire wallet storage. + err := wr.walletStorage.archiveWallet(walletStorageKey) + if err != nil { + return fmt.Errorf("could not archive wallet: [%v]", err) + } + + // Remove the wallet from the wallet cache. + delete(wr.walletCache, walletStorageKey) + + return nil +} + // walletStorage is the component that persists data of the wallets managed // by the given node using the underlying persistence layer. It should be // used directly only by the walletRegistry. @@ -194,6 +280,21 @@ func (ws *walletStorage) saveSigner(signer *signer) error { return nil } +// archiveWallet archives the given wallet data in the underlying persistence +// layer of the walletStorage. +func (ws *walletStorage) archiveWallet(walletStorageKey string) error { + err := ws.persistence.Archive(walletStorageKey) + if err != nil { + return fmt.Errorf( + "could not archive wallet storage using the "+ + "underlying persistence layer: [%w]", + err, + ) + } + + return nil +} + // loadSigners loads all signers stored using the underlying persistence layer. // This function should not be called from any other place than walletRegistry. func (ws *walletStorage) loadSigners() map[string][]*signer { diff --git a/pkg/tbtc/registry_test.go b/pkg/tbtc/registry_test.go index b0e81c14d8..f0d4964ce1 100644 --- a/pkg/tbtc/registry_test.go +++ b/pkg/tbtc/registry_test.go @@ -2,6 +2,7 @@ package tbtc import ( "crypto/ecdsa" + "fmt" "math/big" "reflect" "testing" @@ -15,14 +16,21 @@ import ( func TestWalletRegistry_RegisterSigner(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} + chain := Connect() - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } signer := createMockSigner(t) walletStorageKey := getWalletStorageKey(signer.wallet.publicKey) - err := walletRegistry.registerSigner(signer) + err = walletRegistry.registerSigner(signer) if err != nil { t.Fatal(err) } @@ -62,12 +70,19 @@ func TestWalletRegistry_RegisterSigner(t *testing.T) { func TestWalletRegistry_GetSigners(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} + chain := Connect() - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } signer := createMockSigner(t) - err := walletRegistry.registerSigner(signer) + err = walletRegistry.registerSigner(signer) if err != nil { t.Fatal(err) } @@ -88,12 +103,19 @@ func TestWalletRegistry_GetSigners(t *testing.T) { func TestWalletRegistry_getWalletByPublicKeyHash(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} + chain := Connect() - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } signer := createMockSigner(t) - err := walletRegistry.registerSigner(signer) + err = walletRegistry.registerSigner(signer) if err != nil { t.Fatal(err) } @@ -110,12 +132,19 @@ func TestWalletRegistry_getWalletByPublicKeyHash(t *testing.T) { func TestWalletRegistry_getWalletByPublicKeyHash_NotFound(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} + chain := Connect() - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } signer := createMockSigner(t) - err := walletRegistry.registerSigner(signer) + err = walletRegistry.registerSigner(signer) if err != nil { t.Fatal(err) } @@ -134,6 +163,75 @@ func TestWalletRegistry_getWalletByPublicKeyHash_NotFound(t *testing.T) { } } +func TestWalletRegistry_getWalletByID(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + chain := Connect() + + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + + signer := createMockSigner(t) + + err = walletRegistry.registerSigner(signer) + if err != nil { + t.Fatal(err) + } + + // walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + walletID, err := chain.CalculateWalletID(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + wallet, ok := walletRegistry.getWalletByID(walletID) + if !ok { + t.Error("should return a wallet") + } + + testutils.AssertStringsEqual(t, "wallet", signer.wallet.String(), wallet.String()) +} + +func TestWalletRegistry_getWalletByID_NotFound(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + chain := Connect() + + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + + signer := createMockSigner(t) + + err = walletRegistry.registerSigner(signer) + if err != nil { + t.Fatal(err) + } + + x, y := tecdsa.Curve.ScalarBaseMult(big.NewInt(100).Bytes()) + + walletID, err := chain.CalculateWalletID(&ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: x, + Y: y, + }) + if err != nil { + t.Fatal(err) + } + + _, ok := walletRegistry.getWalletByID(walletID) + if ok { + t.Error("should not return a wallet") + } +} + func TestWalletRegistry_PrePopulateWalletCache(t *testing.T) { signer := createMockSigner(t) signerBytes, err := signer.Marshal() @@ -153,8 +251,16 @@ func TestWalletRegistry_PrePopulateWalletCache(t *testing.T) { }, } + chain := Connect() + // Cache pre-population happens within newWalletRegistry. - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } testutils.AssertIntsEqual( t, @@ -184,12 +290,19 @@ func TestWalletRegistry_PrePopulateWalletCache(t *testing.T) { func TestWalletRegistry_GetWalletsPublicKeys(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} + chain := Connect() - walletRegistry := newWalletRegistry(persistenceHandle) + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } signer := createMockSigner(t) - err := walletRegistry.registerSigner(signer) + err = walletRegistry.registerSigner(signer) if err != nil { t.Fatal(err) } @@ -205,6 +318,90 @@ func TestWalletRegistry_GetWalletsPublicKeys(t *testing.T) { ) } +func TestWalletRegistry_ArchiveWallet(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + chain := Connect() + + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + + signer := createMockSigner(t) + + walletStorageKey := getWalletStorageKey(signer.wallet.publicKey) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + + err = walletRegistry.registerSigner(signer) + if err != nil { + t.Fatal(err) + } + + err = walletRegistry.archiveWallet(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "registered wallets count", + 0, + len(walletRegistry.walletCache), + ) + + testutils.AssertIntsEqual( + t, + "archived wallets count", + 1, + len(persistenceHandle.archived), + ) + + testutils.AssertStringsEqual( + t, + "archived wallet", + walletStorageKey, + persistenceHandle.archived[0], + ) +} + +func TestWalletRegistry_ArchiveWallet_NotFound(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + chain := Connect() + + walletRegistry, err := newWalletRegistry( + persistenceHandle, + chain.CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + + signer := createMockSigner(t) + + err = walletRegistry.registerSigner(signer) + if err != nil { + t.Fatal(err) + } + + // Public key hash of a wallet that does not exist in the registry. + anotherWalletPublicKeyHash := [20]byte{1, 1, 2, 2, 3, 3} + + err = walletRegistry.archiveWallet(anotherWalletPublicKeyHash) + + expectedErr := fmt.Errorf("wallet not found in the wallet cache") + + if !reflect.DeepEqual(err, expectedErr) { + t.Fatalf( + "unexpected error\nexpected: %v\nactual: %v", + expectedErr, + err, + ) + } +} + func TestWalletStorage_SaveSigner(t *testing.T) { persistenceHandle := &mockPersistenceHandle{} @@ -267,8 +464,43 @@ func TestWalletStorage_LoadSigners(t *testing.T) { } } +func TestWalletStorage_ArchiveWallet(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + + walletStorage := newWalletStorage(persistenceHandle) + + signer := createMockSigner(t) + + err := walletStorage.saveSigner(signer) + if err != nil { + t.Fatal(err) + } + + walletStorageKey := getWalletStorageKey(signer.wallet.publicKey) + + err = walletStorage.archiveWallet(walletStorageKey) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "archived wallets count", + 1, + len(persistenceHandle.archived), + ) + + testutils.AssertStringsEqual( + t, + "archived wallet", + walletStorageKey, + persistenceHandle.archived[0], + ) +} + type mockPersistenceHandle struct { - saved []persistence.DataDescriptor + saved []persistence.DataDescriptor + archived []string } func (mph *mockPersistenceHandle) Save( @@ -311,7 +543,8 @@ func (mph *mockPersistenceHandle) ReadAll() ( } func (mph *mockPersistenceHandle) Archive(directory string) error { - panic("not implemented") + mph.archived = append(mph.archived, directory) + return nil } func (mph *mockPersistenceHandle) Delete(directory string, name string) error { diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 9bcd42ef6c..9298ad7d7f 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/local_v1" "github.com/keep-network/keep-core/pkg/generator" @@ -159,6 +160,20 @@ func setupSigningExecutor(t *testing.T) *signingExecutor { } } + walletPublicKeyHash := bitcoin.PublicKeyHash(signers[0].wallet.publicKey) + walletID, err := localChain.CalculateWalletID(signers[0].wallet.publicKey) + if err != nil { + t.Fatal(err) + } + + localChain.setWallet( + walletPublicKeyHash, + &WalletChainData{ + EcdsaWalletID: walletID, + State: StateLive, + }, + ) + keyStorePersistence := createMockKeyStorePersistence(t, signers...) node, err := newNode( diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index e90356b2e9..6f6414ba35 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -264,6 +264,40 @@ func Initialize( }() }) + _ = chain.OnWalletClosed(func(event *WalletClosedEvent) { + go func() { + if ok := deduplicator.notifyWalletClosed( + event.WalletID, + ); !ok { + logger.Warnf( + "Wallet closure for wallet with ID [0x%x] at block [%v] "+ + "has been already processed", + event.WalletID, + event.BlockNumber, + ) + return + } + + logger.Infof( + "Wallet with ID [0x%x] has been closed at block [%v]; "+ + "proceeding with handling wallet closure", + event.WalletID, + event.BlockNumber, + ) + + err := node.handleWalletClosure( + event.WalletID, + ) + if err != nil { + logger.Errorf( + "Failure while handling wallet closure with ID [0x%x]: [%v]", + event.WalletID, + err, + ) + } + }() + }) + return nil } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index 5ddae422df..52f6ef4137 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -3,6 +3,7 @@ package tbtcpg import ( "bytes" "context" + "crypto/ecdsa" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -16,6 +17,7 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/subscription" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -1019,6 +1021,16 @@ func (lc *LocalChain) SetAverageBlockTime(averageBlockTime time.Duration) { lc.averageBlockTime = averageBlockTime } +func (lc *LocalChain) IsWalletRegistered(EcdsaWalletID [32]byte) (bool, error) { + panic("unsupported") +} + +func (lc *LocalChain) CalculateWalletID( + walletPublicKey *ecdsa.PublicKey, +) ([32]byte, error) { + panic("unsupported") +} + func (lc *LocalChain) GetWallet(walletPublicKeyHash [20]byte) ( *tbtc.WalletChainData, error, @@ -1045,6 +1057,12 @@ func (lc *LocalChain) SetWallet( lc.walletChainData[walletPublicKeyHash] = data } +func (lc *LocalChain) OnWalletClosed( + handler func(event *tbtc.WalletClosedEvent), +) subscription.EventSubscription { + panic("unsupported") +} + func (lc *LocalChain) GetWalletParameters() ( creationPeriod uint32, creationMinBtcBalance uint64,