diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index e8db1004ff..a871df7e59 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -1236,6 +1236,25 @@ func computeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOutput) [32]byte { return mainUtxoHash } +func (tc *TbtcChain) ComputeMovingFundsCommitmentHash( + targetWallets [][20]byte, +) [32]byte { + return computeMovingFundsCommitmentHash(targetWallets) +} + +func computeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { + packedWallets := []byte{} + + for _, wallet := range targetWallets { + packedWallets = append(packedWallets, wallet[:]...) + // Each wallet hash must be padded with 12 zero bytes following the + // actual hash. + packedWallets = append(packedWallets, make([]byte, 12)...) + } + + return crypto.Keccak256Hash(packedWallets) +} + func (tc *TbtcChain) BuildDepositKey( fundingTxHash bitcoin.Hash, fundingOutputIndex uint32, @@ -1463,6 +1482,81 @@ func (tc *TbtcChain) GetRedemptionParameters() ( return } +func (tc *TbtcChain) GetWalletParameters() ( + creationPeriod uint32, + creationMinBtcBalance uint64, + creationMaxBtcBalance uint64, + closureMinBtcBalance uint64, + maxAge uint32, + maxBtcTransfer uint64, + closingPeriod uint32, + err error, +) { + parameters, callErr := tc.bridge.WalletParameters() + if callErr != nil { + err = callErr + return + } + + creationPeriod = parameters.WalletCreationPeriod + creationMinBtcBalance = parameters.WalletCreationMinBtcBalance + creationMaxBtcBalance = parameters.WalletCreationMaxBtcBalance + closureMinBtcBalance = parameters.WalletClosureMinBtcBalance + maxAge = parameters.WalletMaxAge + maxBtcTransfer = parameters.WalletMaxBtcTransfer + closingPeriod = parameters.WalletClosingPeriod + + return +} + +func (tc *TbtcChain) GetLiveWalletsCount() (uint32, error) { + return tc.bridge.LiveWalletsCount() +} + +func (tc *TbtcChain) PastMovingFundsCommitmentSubmittedEvents( + filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, +) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) { + var startBlock uint64 + var endBlock *uint64 + var walletPublicKeyHash [][20]byte + + if filter != nil { + startBlock = filter.StartBlock + endBlock = filter.EndBlock + walletPublicKeyHash = filter.WalletPublicKeyHash + } + + events, err := tc.bridge.PastMovingFundsCommitmentSubmittedEvents( + startBlock, + endBlock, + walletPublicKeyHash, + ) + if err != nil { + return nil, err + } + + convertedEvents := make([]*tbtc.MovingFundsCommitmentSubmittedEvent, 0) + for _, event := range events { + convertedEvent := &tbtc.MovingFundsCommitmentSubmittedEvent{ + WalletPublicKeyHash: event.WalletPubKeyHash, + TargetWallets: event.TargetWallets, + Submitter: chain.Address(event.Submitter.Hex()), + BlockNumber: event.Raw.BlockNumber, + } + + convertedEvents = append(convertedEvents, convertedEvent) + } + + sort.SliceStable( + convertedEvents, + func(i, j int) bool { + return convertedEvents[i].BlockNumber < convertedEvents[j].BlockNumber + }, + ) + + return convertedEvents, err +} + func buildDepositKey( fundingTxHash bitcoin.Hash, fundingOutputIndex uint32, @@ -1571,6 +1665,28 @@ func (tc *TbtcChain) GetDepositSweepMaxSize() (uint16, error) { return tc.walletProposalValidator.DEPOSITSWEEPMAXSIZE() } +func (tc *TbtcChain) SubmitMovingFundsCommitment( + walletPublicKeyHash [20]byte, + walletMainUTXO bitcoin.UnspentTransactionOutput, + walletMembersIDs []uint32, + walletMemberIndex uint32, + targetWallets [][20]byte, +) error { + mainUtxo := tbtcabi.BitcoinTxUTXO{ + TxHash: walletMainUTXO.Outpoint.TransactionHash, + TxOutputIndex: walletMainUTXO.Outpoint.OutputIndex, + TxOutputValue: uint64(walletMainUTXO.Value), + } + _, err := tc.bridge.SubmitMovingFundsCommitment( + walletPublicKeyHash, + mainUtxo, + walletMembersIDs, + big.NewInt(int64(walletMemberIndex)), + targetWallets, + ) + return err +} + func (tc *TbtcChain) ValidateRedemptionProposal( walletPublicKeyHash [20]byte, proposal *tbtc.RedemptionProposal, @@ -1660,3 +1776,71 @@ func (tc *TbtcChain) ValidateHeartbeatProposal( return nil } + +func (tc *TbtcChain) GetMovingFundsParameters() ( + txMaxTotalFee uint64, + dustThreshold uint64, + timeoutResetDelay uint32, + timeout uint32, + timeoutSlashingAmount *big.Int, + timeoutNotifierRewardMultiplier uint32, + commitmentGasOffset uint16, + sweepTxMaxTotalFee uint64, + sweepTimeout uint32, + sweepTimeoutSlashingAmount *big.Int, + sweepTimeoutNotifierRewardMultiplier uint32, + err error, +) { + parameters, callErr := tc.bridge.MovingFundsParameters() + if callErr != nil { + err = callErr + return + } + + txMaxTotalFee = parameters.MovingFundsTxMaxTotalFee + dustThreshold = parameters.MovingFundsDustThreshold + timeoutResetDelay = parameters.MovingFundsTimeoutResetDelay + timeout = parameters.MovingFundsTimeout + timeoutSlashingAmount = parameters.MovingFundsTimeoutSlashingAmount + timeoutNotifierRewardMultiplier = parameters.MovingFundsTimeoutNotifierRewardMultiplier + commitmentGasOffset = parameters.MovingFundsCommitmentGasOffset + sweepTxMaxTotalFee = parameters.MovedFundsSweepTxMaxTotalFee + sweepTimeout = parameters.MovedFundsSweepTimeout + sweepTimeoutSlashingAmount = parameters.MovedFundsSweepTimeoutSlashingAmount + sweepTimeoutNotifierRewardMultiplier = parameters.MovedFundsSweepTimeoutNotifierRewardMultiplier + + return +} + +func (tc *TbtcChain) ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *tbtc.MovingFundsProposal, +) error { + abiProposal := tbtcabi.WalletProposalValidatorMovingFundsProposal{ + WalletPubKeyHash: walletPublicKeyHash, + TargetWallets: proposal.TargetWallets, + MovingFundsTxFee: proposal.MovingFundsTxFee, + } + abiMainUTXO := tbtcabi.BitcoinTxUTXO3{ + TxHash: mainUTXO.Outpoint.TransactionHash, + TxOutputIndex: mainUTXO.Outpoint.OutputIndex, + TxOutputValue: uint64(mainUTXO.Value), + } + + valid, err := tc.walletProposalValidator.ValidateMovingFundsProposal( + abiProposal, + abiMainUTXO, + ) + if err != nil { + return fmt.Errorf("validation failed: [%v]", err) + } + + // Should never happen because `validateMovingFundsProposal` returns true + // or reverts (returns an error) but do the check just in case. + if !valid { + return fmt.Errorf("unexpected validation result") + } + + return nil +} diff --git a/pkg/chain/ethereum/tbtc_test.go b/pkg/chain/ethereum/tbtc_test.go index 99299bd0a5..294f5a34db 100644 --- a/pkg/chain/ethereum/tbtc_test.go +++ b/pkg/chain/ethereum/tbtc_test.go @@ -338,6 +338,45 @@ func TestComputeMainUtxoHash(t *testing.T) { testutils.AssertBytesEqual(t, expectedMainUtxoHash, mainUtxoHash[:]) } +func TestComputeMovingFundsCommitmentHash(t *testing.T) { + toByte20 := func(s string) [20]byte { + bytes, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + + if len(bytes) != 20 { + t.Fatal("incorrect hexstring length") + } + + var result [20]byte + copy(result[:], bytes[:]) + return result + } + + targetWallets := [][20]byte{ + toByte20("4b440cb29c80c3f256212d8fdd4f2125366f3c91"), + toByte20("888f01315e0268bfa05d5e522f8d63f6824d9a96"), + toByte20("b2a89e53a4227dbe530a52a1c419040735fa636c"), + } + + movingFundsCommitmentHash := computeMovingFundsCommitmentHash( + targetWallets, + ) + + expectedMovingFundsCommitmentHash, err := hex.DecodeString( + "8ba62d1d754a3429e2ff1fb4f523b5fad2b605c873a2968bb5985a625eb96202", + ) + if err != nil { + t.Fatal(err) + } + testutils.AssertBytesEqual( + t, + expectedMovingFundsCommitmentHash, + movingFundsCommitmentHash[:], + ) +} + // Test data based on: https://etherscan.io/tx/0x97c7a293127a604da77f7ef8daf4b19da2bf04327dd891b6d717eaef89bd8bca func TestBuildDepositKey(t *testing.T) { fundingTxHash, err := bitcoin.NewHashFromString( diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 85d7675236..dfb73efcca 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -351,6 +351,15 @@ type WalletProposalValidatorChain interface { walletPublicKeyHash [20]byte, proposal *HeartbeatProposal, ) error + + // ValidateMovingFundsProposal validates the given moving funds proposal + // against the chain. Returns an error if the proposal is not valid or + // nil otherwise. + ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *MovingFundsProposal, + ) error } // RedemptionRequestedEvent represents a redemption requested event. @@ -376,6 +385,21 @@ type RedemptionRequestedEventFilter struct { Redeemer []chain.Address } +// MovingFundsCommitmentSubmittedEvent represents a moving funds commitment submitted event. +type MovingFundsCommitmentSubmittedEvent struct { + WalletPublicKeyHash [20]byte + TargetWallets [][20]byte + Submitter chain.Address + BlockNumber uint64 +} + +// MovingFundsCommitmentSubmittedEventFilter is a component allowing to filter MovingFundsCommitmentSubmittedEvent. +type MovingFundsCommitmentSubmittedEventFilter struct { + StartBlock uint64 + EndBlock *uint64 + WalletPublicKeyHash [][20]byte +} + // Chain represents the interface that the TBTC module expects to interact // with the anchoring blockchain on. type Chain interface { diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 69b9fdc450..32857b5d23 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -702,6 +702,19 @@ func (lc *localChain) operatorAddress() (chain.Address, error) { return lc.Signing().PublicKeyToAddress(operatorPublicKey) } +func (lc *localChain) GetWalletParameters() ( + creationPeriod uint32, + creationMinBtcBalance uint64, + creationMaxBtcBalance uint64, + closureMinBtcBalance uint64, + maxAge uint32, + maxBtcTransfer uint64, + closingPeriod uint32, + err error, +) { + panic("unsupported") +} + func (lc *localChain) ValidateDepositSweepProposal( walletPublicKeyHash [20]byte, proposal *DepositSweepProposal, @@ -891,6 +904,14 @@ func (lc *localChain) setHeartbeatProposalValidationResult( lc.heartbeatProposalValidations[proposal.Message] = result } +func (lc *localChain) ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *MovingFundsProposal, +) error { + panic("unsupported") +} + // Connect sets up the local chain. func Connect(blockTime ...time.Duration) *localChain { operatorPrivateKey, _, err := operator.GenerateKeyPair(local_v1.DefaultCurve) diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 9f0fa4ea0f..2dc909b14a 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -200,6 +200,7 @@ func (cf *coordinationFault) String() string { type CoordinationProposalRequest struct { WalletPublicKeyHash [20]byte WalletOperators []chain.Address + ExecutingOperator chain.Address ActionsChecklist []WalletActionType } @@ -581,6 +582,7 @@ func (ce *coordinationExecutor) executeLeaderRoutine( &CoordinationProposalRequest{ WalletPublicKeyHash: walletPublicKeyHash, WalletOperators: ce.coordinatedWallet.signingGroupOperators, + ExecutingOperator: ce.operatorAddress, ActionsChecklist: actionsChecklist, }, ) diff --git a/pkg/tbtc/gen/pb/message.pb.go b/pkg/tbtc/gen/pb/message.pb.go index 424df0eacd..03a02f70ee 100644 --- a/pkg/tbtc/gen/pb/message.pb.go +++ b/pkg/tbtc/gen/pb/message.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.28.1 -// protoc v3.7.1 +// protoc-gen-go v1.28.0 +// protoc v3.19.4 // source: pkg/tbtc/gen/pb/message.proto package pb @@ -390,6 +390,61 @@ func (x *RedemptionProposal) GetRedemptionTxFee() []byte { return nil } +type MovingFundsProposal struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + TargetWallets [][]byte `protobuf:"bytes,1,rep,name=targetWallets,proto3" json:"targetWallets,omitempty"` + MovingFundsTxFee []byte `protobuf:"bytes,2,opt,name=movingFundsTxFee,proto3" json:"movingFundsTxFee,omitempty"` +} + +func (x *MovingFundsProposal) Reset() { + *x = MovingFundsProposal{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MovingFundsProposal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MovingFundsProposal) ProtoMessage() {} + +func (x *MovingFundsProposal) ProtoReflect() protoreflect.Message { + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MovingFundsProposal.ProtoReflect.Descriptor instead. +func (*MovingFundsProposal) Descriptor() ([]byte, []int) { + return file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP(), []int{6} +} + +func (x *MovingFundsProposal) GetTargetWallets() [][]byte { + if x != nil { + return x.TargetWallets + } + return nil +} + +func (x *MovingFundsProposal) GetMovingFundsTxFee() []byte { + if x != nil { + return x.MovingFundsTxFee + } + return nil +} + type DepositSweepProposal_DepositKey struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -402,7 +457,7 @@ type DepositSweepProposal_DepositKey struct { func (x *DepositSweepProposal_DepositKey) Reset() { *x = DepositSweepProposal_DepositKey{} if protoimpl.UnsafeEnabled { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[6] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -415,7 +470,7 @@ func (x *DepositSweepProposal_DepositKey) String() string { func (*DepositSweepProposal_DepositKey) ProtoMessage() {} func (x *DepositSweepProposal_DepositKey) ProtoReflect() protoreflect.Message { - mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[6] + mi := &file_pkg_tbtc_gen_pb_message_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -507,8 +562,14 @@ var file_pkg_tbtc_gen_pb_message_proto_rawDesc = []byte{ 0x75, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x78, 0x46, 0x65, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x65, 0x64, 0x65, 0x6d, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x78, - 0x46, 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x46, 0x65, 0x65, 0x22, 0x67, 0x0a, 0x13, 0x4d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, + 0x64, 0x73, 0x50, 0x72, 0x6f, 0x70, 0x6f, 0x73, 0x61, 0x6c, 0x12, 0x24, 0x0a, 0x0d, 0x74, 0x61, + 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0c, 0x52, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x73, + 0x12, 0x2a, 0x0a, 0x10, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, + 0x78, 0x46, 0x65, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x6d, 0x6f, 0x76, 0x69, + 0x6e, 0x67, 0x46, 0x75, 0x6e, 0x64, 0x73, 0x54, 0x78, 0x46, 0x65, 0x65, 0x42, 0x06, 0x5a, 0x04, + 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -523,7 +584,7 @@ func file_pkg_tbtc_gen_pb_message_proto_rawDescGZIP() []byte { return file_pkg_tbtc_gen_pb_message_proto_rawDescData } -var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_pkg_tbtc_gen_pb_message_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*SigningDoneMessage)(nil), // 0: tbtc.SigningDoneMessage (*CoordinationProposal)(nil), // 1: tbtc.CoordinationProposal @@ -531,11 +592,12 @@ var file_pkg_tbtc_gen_pb_message_proto_goTypes = []interface{}{ (*HeartbeatProposal)(nil), // 3: tbtc.HeartbeatProposal (*DepositSweepProposal)(nil), // 4: tbtc.DepositSweepProposal (*RedemptionProposal)(nil), // 5: tbtc.RedemptionProposal - (*DepositSweepProposal_DepositKey)(nil), // 6: tbtc.DepositSweepProposal.DepositKey + (*MovingFundsProposal)(nil), // 6: tbtc.MovingFundsProposal + (*DepositSweepProposal_DepositKey)(nil), // 7: tbtc.DepositSweepProposal.DepositKey } var file_pkg_tbtc_gen_pb_message_proto_depIdxs = []int32{ 1, // 0: tbtc.CoordinationMessage.proposal:type_name -> tbtc.CoordinationProposal - 6, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey + 7, // 1: tbtc.DepositSweepProposal.depositsKeys:type_name -> tbtc.DepositSweepProposal.DepositKey 2, // [2:2] is the sub-list for method output_type 2, // [2:2] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name @@ -622,6 +684,18 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { } } file_pkg_tbtc_gen_pb_message_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MovingFundsProposal); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_tbtc_gen_pb_message_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DepositSweepProposal_DepositKey); i { case 0: return &v.state @@ -640,7 +714,7 @@ func file_pkg_tbtc_gen_pb_message_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_tbtc_gen_pb_message_proto_rawDesc, NumEnums: 0, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/tbtc/gen/pb/message.proto b/pkg/tbtc/gen/pb/message.proto index 1da799a18f..d8aa30357e 100644 --- a/pkg/tbtc/gen/pb/message.proto +++ b/pkg/tbtc/gen/pb/message.proto @@ -41,4 +41,9 @@ message DepositSweepProposal { message RedemptionProposal { repeated bytes redeemersOutputScripts = 1; bytes redemptionTxFee = 2; -} \ No newline at end of file +} + +message MovingFundsProposal { + repeated bytes targetWallets = 1; + bytes movingFundsTxFee = 2; +} diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index eb6899dc45..ec9aa32277 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -4,10 +4,11 @@ import ( "crypto/ecdsa" "crypto/elliptic" "fmt" - "github.com/keep-network/keep-core/pkg/bitcoin" "math" "math/big" + "github.com/keep-network/keep-core/pkg/bitcoin" + "google.golang.org/protobuf/proto" "github.com/keep-network/keep-core/pkg/chain" @@ -229,8 +230,8 @@ func unmarshalCoordinationProposal(actionType uint32, payload []byte) ( ActionHeartbeat: &HeartbeatProposal{}, ActionDepositSweep: &DepositSweepProposal{}, ActionRedemption: &RedemptionProposal{}, + ActionMovingFunds: &MovingFundsProposal{}, // TODO: Uncomment when moving funds support is implemented. - // ActionMovingFunds: &MovingFundsProposal{}, // ActionMovedFundsSweep: &MovedFundsSweepProposal{}, }[parsedActionType] if !ok { @@ -395,6 +396,43 @@ func (rp *RedemptionProposal) Unmarshal(bytes []byte) error { return nil } +// Marshal converts the movingFundsProposal to a byte array. +func (mfp *MovingFundsProposal) Marshal() ([]byte, error) { + targetWallets := make([][]byte, len(mfp.TargetWallets)) + + for i, wallet := range mfp.TargetWallets { + targetWallet := make([]byte, len(wallet)) + copy(targetWallet, wallet[:]) + + targetWallets[i] = targetWallet + } + + return proto.Marshal( + &pb.MovingFundsProposal{ + TargetWallets: targetWallets, + MovingFundsTxFee: mfp.MovingFundsTxFee.Bytes(), + }) +} + +// Unmarshal converts a byte array back to the movingFundsProposal. +func (mfp *MovingFundsProposal) Unmarshal(data []byte) error { + pbMsg := pb.MovingFundsProposal{} + if err := proto.Unmarshal(data, &pbMsg); err != nil { + return fmt.Errorf("failed to unmarshal MovingFundsProposal: [%v]", err) + } + + mfp.TargetWallets = make([][20]byte, len(pbMsg.TargetWallets)) + for i, wallet := range pbMsg.TargetWallets { + if len(wallet) != 20 { + return fmt.Errorf("invalid target wallet length: [%v]", len(wallet)) + } + copy(mfp.TargetWallets[i][:], wallet) + } + + mfp.MovingFundsTxFee = new(big.Int).SetBytes(pbMsg.MovingFundsTxFee) + return nil +} + // marshalPublicKey converts an ECDSA public key to a byte // array (uncompressed). func marshalPublicKey(publicKey *ecdsa.PublicKey) ([]byte, error) { diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index 9092eb2ef4..da3ddfe5d7 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -4,11 +4,12 @@ import ( "crypto/ecdsa" "crypto/elliptic" "encoding/hex" - "github.com/keep-network/keep-core/pkg/bitcoin" "math/big" "reflect" "testing" + "github.com/keep-network/keep-core/pkg/bitcoin" + fuzz "github.com/google/gofuzz" "github.com/keep-network/keep-core/internal/testutils" @@ -126,6 +127,21 @@ func TestCoordinationMessage_MarshalingRoundtrip(t *testing.T) { return parsed } + toByte20 := func(s string) [20]byte { + bytes, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + + if len(bytes) != 20 { + t.Fatal("incorrect hexstring length") + } + + var result [20]byte + copy(result[:], bytes[:]) + return result + } + tests := map[string]struct { proposal CoordinationProposal }{ @@ -168,23 +184,22 @@ func TestCoordinationMessage_MarshalingRoundtrip(t *testing.T) { RedemptionTxFee: big.NewInt(10000), }, }, - // TODO: Uncomment when moving funds support is implemented. - // "with moving funds proposal": { - // proposal: &MovingFundsProposal{}, - // }, + "with moving funds proposal": { + proposal: &MovingFundsProposal{ + TargetWallets: [][20]byte{ + toByte20("cb7d88a87c37aff0c1535fa4efe6f0a2406ea5e9"), + toByte20("f87eb7ec3b15a3fdd7b57754d765694b3e0b4bf4"), + }, + MovingFundsTxFee: big.NewInt(10000), + }, + }, + // TODO: Uncomment when moved funds sweep support is implemented. // "with moved funds sweep proposal": { // proposal: &MovedFundsSweepProposal{}, // }, } - walletPublicKeyHashBytes, err := hex.DecodeString( - "aa768412ceed10bd423c025542ca90071f9fb62d", - ) - if err != nil { - t.Fatal(err) - } - var walletPublicKeyHash [20]byte - copy(walletPublicKeyHash[:], walletPublicKeyHashBytes) + walletPublicKeyHash := toByte20("aa768412ceed10bd423c025542ca90071f9fb62d") for testName, test := range tests { t.Run(testName, func(t *testing.T) { @@ -226,14 +241,14 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithHeartbeatProposal(t *te f.Fuzz(&walletPublicKeyHash) f.Fuzz(&proposal) - doneMessage := &coordinationMessage{ + coordinationMsg := &coordinationMessage{ senderID: senderID, coordinationBlock: coordinationBlock, walletPublicKeyHash: walletPublicKeyHash, proposal: &proposal, } - _ = pbutils.RoundTrip(doneMessage, &coordinationMessage{}) + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) } } @@ -255,14 +270,14 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithDepositSweepProposal(t f.Fuzz(&walletPublicKeyHash) f.Fuzz(&proposal) - doneMessage := &coordinationMessage{ + coordinationMsg := &coordinationMessage{ senderID: senderID, coordinationBlock: coordinationBlock, walletPublicKeyHash: walletPublicKeyHash, proposal: &proposal, } - _ = pbutils.RoundTrip(doneMessage, &coordinationMessage{}) + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) } } @@ -284,14 +299,43 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithRedemptionProposal(t *t f.Fuzz(&walletPublicKeyHash) f.Fuzz(&proposal) - doneMessage := &coordinationMessage{ + coordinationMsg := &coordinationMessage{ + senderID: senderID, + coordinationBlock: coordinationBlock, + walletPublicKeyHash: walletPublicKeyHash, + proposal: &proposal, + } + + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) + } +} + +func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithMovingFundsProposal(t *testing.T) { + for i := 0; i < 10; i++ { + var ( + senderID group.MemberIndex + coordinationBlock uint64 + walletPublicKeyHash [20]byte + proposal MovingFundsProposal + ) + + f := fuzz.New().NilChance(0.1). + NumElements(0, 512). + Funcs(pbutils.FuzzFuncs()...) + + f.Fuzz(&senderID) + f.Fuzz(&coordinationBlock) + f.Fuzz(&walletPublicKeyHash) + f.Fuzz(&proposal) + + coordinationMsg := &coordinationMessage{ senderID: senderID, coordinationBlock: coordinationBlock, walletPublicKeyHash: walletPublicKeyHash, proposal: &proposal, } - _ = pbutils.RoundTrip(doneMessage, &coordinationMessage{}) + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) } } @@ -313,14 +357,14 @@ func TestFuzzCoordinationMessage_MarshalingRoundtrip_WithNoopProposal(t *testing f.Fuzz(&walletPublicKeyHash) f.Fuzz(&proposal) - doneMessage := &coordinationMessage{ + coordinationMsg := &coordinationMessage{ senderID: senderID, coordinationBlock: coordinationBlock, walletPublicKeyHash: walletPublicKeyHash, proposal: &proposal, } - _ = pbutils.RoundTrip(doneMessage, &coordinationMessage{}) + _ = pbutils.RoundTrip(coordinationMsg, &coordinationMessage{}) } } diff --git a/pkg/tbtc/moving_funds.go b/pkg/tbtc/moving_funds.go new file mode 100644 index 0000000000..9982d150c7 --- /dev/null +++ b/pkg/tbtc/moving_funds.go @@ -0,0 +1,138 @@ +package tbtc + +import ( + "fmt" + "math/big" + + "github.com/ipfs/go-log/v2" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "go.uber.org/zap" +) + +const ( + // movingFundsProposalValidityBlocks determines the moving funds proposal + // validity time expressed in blocks. In other words, this is the worst-case + // time for moving funds during which the wallet is busy and cannot take + // another actions. The value of 650 blocks is roughly 2 hours and 10 + // minutes, assuming 12 seconds per block. It is a slightly longer validity + // time than in case of redemptions as moving funds involves waiting for + // target wallets commitment transaction to be confirmed. + movingFundsProposalValidityBlocks = 650 +) + +// MovingFundsProposal represents a moving funds proposal issued by a wallet's +// coordination leader. +type MovingFundsProposal struct { + TargetWallets [][20]byte + MovingFundsTxFee *big.Int +} + +func (mfp *MovingFundsProposal) ActionType() WalletActionType { + return ActionMovingFunds +} + +func (mfp *MovingFundsProposal) ValidityBlocks() uint64 { + return movingFundsProposalValidityBlocks +} + +// movingFundsAction is a walletAction implementation handling moving funds +// requests from the wallet coordinator. +type movingFundsAction struct { + logger *zap.SugaredLogger + chain Chain + btcChain bitcoin.Chain + + movingFundsWallet wallet + transactionExecutor *walletTransactionExecutor + + proposal *MovingFundsProposal + proposalProcessingStartBlock uint64 + proposalExpiryBlock uint64 +} + +func newMovingFundsAction( + logger *zap.SugaredLogger, + chain Chain, + btcChain bitcoin.Chain, + movingFundsWallet wallet, + signingExecutor walletSigningExecutor, + proposal *MovingFundsProposal, + proposalProcessingStartBlock uint64, + proposalExpiryBlock uint64, + waitForBlockFn waitForBlockFn, +) *movingFundsAction { + transactionExecutor := newWalletTransactionExecutor( + btcChain, + movingFundsWallet, + signingExecutor, + waitForBlockFn, + ) + + return &movingFundsAction{ + logger: logger, + chain: chain, + btcChain: btcChain, + movingFundsWallet: movingFundsWallet, + transactionExecutor: transactionExecutor, + proposal: proposal, + proposalProcessingStartBlock: proposalProcessingStartBlock, + proposalExpiryBlock: proposalExpiryBlock, + } +} + +func (mfa *movingFundsAction) execute() error { + // TODO: Before proceeding with creation of the Bitcoin transaction, wait + // 32 blocks to ensure the commitment transaction has accumulated + // enough confirmations in the Ethereum chain and will not be reverted + // even if a reorg occurs. + // Remember to call `validateMovingFundsProposal` twice: before and + // after waiting for confirmations. The second validation is needed + // to ensure the commitment has not been changed during the waiting. + return nil +} + +// ValidateMovingFundsProposal checks the moving funds proposal with on-chain +// validation rules. +func ValidateMovingFundsProposal( + validateProposalLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *MovingFundsProposal, + chain interface { + // ValidateMovingFundsProposal validates the given moving funds proposal + // against the chain. Returns an error if the proposal is not valid or + // nil otherwise. + ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *MovingFundsProposal, + ) error + }, +) ([][20]byte, error) { + validateProposalLogger.Infof("calling chain for proposal validation") + + err := chain.ValidateMovingFundsProposal( + walletPublicKeyHash, + mainUTXO, + proposal, + ) + if err != nil { + return nil, fmt.Errorf( + "moving funds proposal is invalid: [%v]", + err, + ) + } + + validateProposalLogger.Infof("moving funds proposal is valid") + + return proposal.TargetWallets, nil +} + +func (mfa *movingFundsAction) wallet() wallet { + return mfa.movingFundsWallet +} + +func (mfa *movingFundsAction) actionType() WalletActionType { + return ActionMovingFunds +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 638b1a48ed..4c097b590d 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -5,10 +5,10 @@ import ( "crypto/ecdsa" "encoding/hex" "fmt" - "github.com/keep-network/keep-core/pkg/bitcoin" "math/big" "sync" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "go.uber.org/zap" @@ -608,6 +608,74 @@ func (n *node) handleRedemptionProposal( walletActionLogger.Infof("wallet action dispatched successfully") } +// handleMovingFundsProposal handles an incoming moving funds proposal by +// orchestrating and dispatching an appropriate wallet action. +func (n *node) handleMovingFundsProposal( + wallet wallet, + proposal *MovingFundsProposal, + startBlock uint64, + expiryBlock uint64, +) { + walletPublicKeyBytes, err := marshalPublicKey(wallet.publicKey) + if err != nil { + logger.Errorf("cannot marshal wallet public key: [%v]", err) + return + } + + signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) + if err != nil { + logger.Errorf("cannot get signing executor: [%v]", err) + return + } + // This check is actually redundant. We know the node controls some + // wallet signers as we just got the wallet from the registry using their + // public key hash. However, we are doing it just in case. The API + // contract of getSigningExecutor may change one day. + if !ok { + logger.Infof( + "node does not control signers of wallet PKH [0x%x]; "+ + "ignoring the received moving funds proposal", + walletPublicKeyBytes, + ) + return + } + + logger.Infof( + "starting orchestration of the moving funds action for wallet [0x%x]; "+ + "20-byte public key hash of that wallet is [0x%x]", + walletPublicKeyBytes, + bitcoin.PublicKeyHash(wallet.publicKey), + ) + + walletActionLogger := logger.With( + zap.String("wallet", fmt.Sprintf("0x%x", walletPublicKeyBytes)), + zap.String("action", ActionMovingFunds.String()), + zap.Uint64("startBlock", startBlock), + zap.Uint64("expiryBlock", expiryBlock), + ) + walletActionLogger.Infof("dispatching wallet action") + + action := newMovingFundsAction( + walletActionLogger, + n.chain, + n.btcChain, + wallet, + signingExecutor, + proposal, + startBlock, + expiryBlock, + n.waitForBlockHeight, + ) + + err = n.walletDispatcher.dispatch(action) + if err != nil { + walletActionLogger.Errorf("cannot dispatch wallet action: [%v]", err) + return + } + + walletActionLogger.Infof("wallet action dispatched successfully") +} + // coordinationLayerSettings represents settings for the coordination layer. type coordinationLayerSettings struct { // executeCoordinationProcedureFn is a function executing the coordination @@ -794,16 +862,16 @@ func processCoordinationResult(node *node, result *coordinationResult) { expiryBlock, ) } + case ActionMovingFunds: + if proposal, ok := result.proposal.(*MovingFundsProposal); ok { + node.handleMovingFundsProposal( + result.wallet, + proposal, + startBlock, + expiryBlock, + ) + } // TODO: Uncomment when moving funds support is implemented. - // case ActionMovingFunds: - // if proposal, ok := result.proposal.(*MovingFundsProposal); ok { - // node.handleMovingFundsProposal( - // result.wallet, - // proposal, - // startBlock, - // expiryBlock, - // ) - // } // case ActionMovedFundsSweep: // if proposal, ok := result.proposal.(*MovedFundsSweepProposal); ok { // node.handleMovedFundsSweepProposal( diff --git a/pkg/tbtcpg/chain.go b/pkg/tbtcpg/chain.go index 976648b079..f9887973be 100644 --- a/pkg/tbtcpg/chain.go +++ b/pkg/tbtcpg/chain.go @@ -23,6 +23,22 @@ type Chain interface { filter *tbtc.NewWalletRegisteredEventFilter, ) ([]*tbtc.NewWalletRegisteredEvent, error) + // GetWalletParameters gets the current value of parameters relevant to + // wallet. + GetWalletParameters() ( + creationPeriod uint32, + creationMinBtcBalance uint64, + creationMaxBtcBalance uint64, + closureMinBtcBalance uint64, + maxAge uint32, + maxBtcTransfer uint64, + closingPeriod uint32, + err error, + ) + + // GetLiveWalletsCount gets the current count of live wallets. + GetLiveWalletsCount() (uint32, error) + // BuildDepositKey calculates a deposit key for the given funding transaction // which is a unique identifier for a deposit on-chain. BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int @@ -104,6 +120,9 @@ type Chain interface { AverageBlockTime() time.Duration + // GetOperatorID returns the operator ID for the given operator address. + GetOperatorID(operatorAddress chain.Address) (chain.OperatorID, error) + // ValidateHeartbeatProposal validates the given heartbeat proposal // against the chain. Returns an error if the proposal is not valid or // nil otherwise. @@ -111,4 +130,52 @@ type Chain interface { walletPublicKeyHash [20]byte, proposal *tbtc.HeartbeatProposal, ) error + + // GetMovingFundsParameters gets the current value of parameters relevant + // for the moving funds process. + GetMovingFundsParameters() ( + txMaxTotalFee uint64, + dustThreshold uint64, + timeoutResetDelay uint32, + timeout uint32, + timeoutSlashingAmount *big.Int, + timeoutNotifierRewardMultiplier uint32, + commitmentGasOffset uint16, + sweepTxMaxTotalFee uint64, + sweepTimeout uint32, + sweepTimeoutSlashingAmount *big.Int, + sweepTimeoutNotifierRewardMultiplier uint32, + err error, + ) + + // PastMovingFundsCommitmentSubmittedEvents fetches past moving funds + // commitment submitted events according to the provided filter or + // unfiltered if the filter is nil. Returned events are sorted by the block + // number in the ascending order, i.e. the latest event is at the end of the + // slice. + PastMovingFundsCommitmentSubmittedEvents( + filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, + ) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) + + // ValidateMovingFundsProposal validates the given moving funds proposal + // against the chain. Returns an error if the proposal is not valid or + // nil otherwise. + ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *tbtc.MovingFundsProposal, + ) error + + // Submits the moving funds target wallets commitment. + SubmitMovingFundsCommitment( + walletPublicKeyHash [20]byte, + walletMainUTXO bitcoin.UnspentTransactionOutput, + walletMembersIDs []uint32, + walletMemberIndex uint32, + targetWallets [][20]byte, + ) error + + // Computes the moving funds commitment hash from the provided public key + // hashes of target wallets. + ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte } diff --git a/pkg/tbtcpg/chain_test.go b/pkg/tbtcpg/chain_test.go index c158dab9a4..54cb8b5ff9 100644 --- a/pkg/tbtcpg/chain_test.go +++ b/pkg/tbtcpg/chain_test.go @@ -11,6 +11,8 @@ import ( "sync" "time" + "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/tbtc" @@ -33,34 +35,78 @@ type redemptionParameters = struct { timeoutNotifierRewardMultiplier uint32 } +type walletParameters = struct { + creationPeriod uint32 + creationMinBtcBalance uint64 + creationMaxBtcBalance uint64 + closureMinBtcBalance uint64 + maxAge uint32 + maxBtcTransfer uint64 + closingPeriod uint32 +} + +type movingFundsParameters = struct { + txMaxTotalFee uint64 + dustThreshold uint64 + timeoutResetDelay uint32 + timeout uint32 + timeoutSlashingAmount *big.Int + timeoutNotifierRewardMultiplier uint32 + commitmentGasOffset uint16 + sweepTxMaxTotalFee uint64 + sweepTimeout uint32 + sweepTimeoutSlashingAmount *big.Int + sweepTimeoutNotifierRewardMultiplier uint32 +} + +type movingFundsCommitmentSubmission struct { + WalletPublicKeyHash [20]byte + WalletMainUtxo *bitcoin.UnspentTransactionOutput + WalletMembersIDs []uint32 + WalletMemberIndex uint32 + TargetWallets [][20]byte +} + type LocalChain struct { mutex sync.Mutex - depositRequests map[[32]byte]*tbtc.DepositChainRequest - pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent - pastNewWalletRegisteredEvents map[[32]byte][]*tbtc.NewWalletRegisteredEvent - depositParameters depositParameters - depositSweepProposalValidations map[[32]byte]bool - redemptionParameters redemptionParameters - redemptionRequestMinAge uint32 - blockCounter chain.BlockCounter - pastRedemptionRequestedEvents map[[32]byte][]*tbtc.RedemptionRequestedEvent - averageBlockTime time.Duration - pendingRedemptionRequests map[[32]byte]*tbtc.RedemptionRequest - redemptionProposalValidations map[[32]byte]bool - heartbeatProposalValidations map[[16]byte]bool + depositRequests map[[32]byte]*tbtc.DepositChainRequest + pastDepositRevealedEvents map[[32]byte][]*tbtc.DepositRevealedEvent + pastNewWalletRegisteredEvents map[[32]byte][]*tbtc.NewWalletRegisteredEvent + depositParameters depositParameters + depositSweepProposalValidations map[[32]byte]bool + redemptionParameters redemptionParameters + redemptionRequestMinAge uint32 + walletParameters walletParameters + walletChainData map[[20]byte]*tbtc.WalletChainData + blockCounter chain.BlockCounter + pastRedemptionRequestedEvents map[[32]byte][]*tbtc.RedemptionRequestedEvent + averageBlockTime time.Duration + pendingRedemptionRequests map[[32]byte]*tbtc.RedemptionRequest + redemptionProposalValidations map[[32]byte]bool + heartbeatProposalValidations map[[16]byte]bool + movingFundsParameters movingFundsParameters + pastMovingFundsCommitmentSubmittedEvents map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent + movingFundsProposalValidations map[[32]byte]bool + movingFundsCommitmentSubmissions []*movingFundsCommitmentSubmission + operatorIDs map[chain.Address]uint32 } func NewLocalChain() *LocalChain { return &LocalChain{ - depositRequests: make(map[[32]byte]*tbtc.DepositChainRequest), - pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), - pastNewWalletRegisteredEvents: make(map[[32]byte][]*tbtc.NewWalletRegisteredEvent), - depositSweepProposalValidations: make(map[[32]byte]bool), - pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), - pendingRedemptionRequests: make(map[[32]byte]*tbtc.RedemptionRequest), - redemptionProposalValidations: make(map[[32]byte]bool), - heartbeatProposalValidations: make(map[[16]byte]bool), + depositRequests: make(map[[32]byte]*tbtc.DepositChainRequest), + pastDepositRevealedEvents: make(map[[32]byte][]*tbtc.DepositRevealedEvent), + pastNewWalletRegisteredEvents: make(map[[32]byte][]*tbtc.NewWalletRegisteredEvent), + depositSweepProposalValidations: make(map[[32]byte]bool), + pastRedemptionRequestedEvents: make(map[[32]byte][]*tbtc.RedemptionRequestedEvent), + walletChainData: make(map[[20]byte]*tbtc.WalletChainData), + pendingRedemptionRequests: make(map[[32]byte]*tbtc.RedemptionRequest), + redemptionProposalValidations: make(map[[32]byte]bool), + heartbeatProposalValidations: make(map[[16]byte]bool), + pastMovingFundsCommitmentSubmittedEvents: make(map[[32]byte][]*tbtc.MovingFundsCommitmentSubmittedEvent), + movingFundsProposalValidations: make(map[[32]byte]bool), + movingFundsCommitmentSubmissions: make([]*movingFundsCommitmentSubmission, 0), + operatorIDs: make(map[chain.Address]uint32), } } @@ -315,6 +361,32 @@ func buildPastRedemptionRequestedEventsKey( return sha256.Sum256(buffer.Bytes()), nil } +func buildPastMovingFundsCommitmentSubmittedEventsKey( + filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, +) ([32]byte, error) { + if filter == nil { + return [32]byte{}, nil + } + + var buffer bytes.Buffer + + startBlock := make([]byte, 8) + binary.BigEndian.PutUint64(startBlock, filter.StartBlock) + buffer.Write(startBlock) + + if filter.EndBlock != nil { + endBlock := make([]byte, 8) + binary.BigEndian.PutUint64(startBlock, *filter.EndBlock) + buffer.Write(endBlock) + } + + for _, walletPublicKeyHash := range filter.WalletPublicKeyHash { + buffer.Write(walletPublicKeyHash[:]) + } + + return sha256.Sum256(buffer.Bytes()), nil +} + func (lc *LocalChain) BuildDepositKey(fundingTxHash bitcoin.Hash, fundingOutputIndex uint32) *big.Int { depositKeyBytes := buildDepositRequestKey(fundingTxHash, fundingOutputIndex) @@ -616,6 +688,142 @@ func (lc *LocalChain) SetHeartbeatProposalValidationResult( lc.heartbeatProposalValidations[proposal.Message] = result } +func (lc *LocalChain) GetMovingFundsParameters() ( + txMaxTotalFee uint64, + dustThreshold uint64, + timeoutResetDelay uint32, + timeout uint32, + timeoutSlashingAmount *big.Int, + timeoutNotifierRewardMultiplier uint32, + commitmentGasOffset uint16, + sweepTxMaxTotalFee uint64, + sweepTimeout uint32, + sweepTimeoutSlashingAmount *big.Int, + sweepTimeoutNotifierRewardMultiplier uint32, + err error, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.movingFundsParameters.txMaxTotalFee, + lc.movingFundsParameters.dustThreshold, + lc.movingFundsParameters.timeoutResetDelay, + lc.movingFundsParameters.timeout, + lc.movingFundsParameters.timeoutSlashingAmount, + lc.movingFundsParameters.timeoutNotifierRewardMultiplier, + lc.movingFundsParameters.commitmentGasOffset, + lc.movingFundsParameters.sweepTxMaxTotalFee, + lc.movingFundsParameters.sweepTimeout, + lc.movingFundsParameters.sweepTimeoutSlashingAmount, + lc.movingFundsParameters.sweepTimeoutNotifierRewardMultiplier, + nil +} + +func (lc *LocalChain) SetMovingFundsParameters( + txMaxTotalFee uint64, + dustThreshold uint64, + timeoutResetDelay uint32, + timeout uint32, + timeoutSlashingAmount *big.Int, + timeoutNotifierRewardMultiplier uint32, + commitmentGasOffset uint16, + sweepTxMaxTotalFee uint64, + sweepTimeout uint32, + sweepTimeoutSlashingAmount *big.Int, + sweepTimeoutNotifierRewardMultiplier uint32, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.movingFundsParameters = movingFundsParameters{ + txMaxTotalFee: txMaxTotalFee, + dustThreshold: dustThreshold, + timeoutResetDelay: timeoutResetDelay, + timeout: timeout, + timeoutSlashingAmount: timeoutSlashingAmount, + timeoutNotifierRewardMultiplier: timeoutNotifierRewardMultiplier, + commitmentGasOffset: commitmentGasOffset, + sweepTxMaxTotalFee: sweepTxMaxTotalFee, + sweepTimeout: sweepTimeout, + sweepTimeoutSlashingAmount: sweepTimeoutSlashingAmount, + sweepTimeoutNotifierRewardMultiplier: sweepTimeoutNotifierRewardMultiplier, + } +} + +func buildMovingFundsProposalValidationKey( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *tbtc.MovingFundsProposal, +) ([32]byte, error) { + var buffer bytes.Buffer + + buffer.Write(walletPublicKeyHash[:]) + + buffer.Write(mainUTXO.Outpoint.TransactionHash[:]) + binary.Write(&buffer, binary.BigEndian, mainUTXO.Outpoint.OutputIndex) + binary.Write(&buffer, binary.BigEndian, mainUTXO.Value) + + for _, wallet := range proposal.TargetWallets { + buffer.Write(wallet[:]) + } + + buffer.Write(proposal.MovingFundsTxFee.Bytes()) + + return sha256.Sum256(buffer.Bytes()), nil +} + +func (lc *LocalChain) ValidateMovingFundsProposal( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *tbtc.MovingFundsProposal, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key, err := buildMovingFundsProposalValidationKey( + walletPublicKeyHash, + mainUTXO, + proposal, + ) + if err != nil { + return err + } + + result, ok := lc.movingFundsProposalValidations[key] + if !ok { + return fmt.Errorf("validation result unknown") + } + + if !result { + return fmt.Errorf("validation failed") + } + + return nil +} + +func (lc *LocalChain) SetMovingFundsProposalValidationResult( + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + proposal *tbtc.MovingFundsProposal, + result bool, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + key, err := buildMovingFundsProposalValidationKey( + walletPublicKeyHash, + mainUTXO, + proposal, + ) + if err != nil { + return err + } + + lc.movingFundsProposalValidations[key] = result + + return nil +} + func buildRedemptionProposalValidationKey( walletPublicKeyHash [20]byte, proposal *tbtc.RedemptionProposal, @@ -676,6 +884,35 @@ func (lc *LocalChain) AverageBlockTime() time.Duration { return lc.averageBlockTime } +func (lc *LocalChain) SetOperatorID( + operatorAddress chain.Address, + operatorID chain.OperatorID, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + _, ok := lc.operatorIDs[operatorAddress] + if !ok { + lc.operatorIDs[operatorAddress] = operatorID + } + + return nil +} + +func (lc *LocalChain) GetOperatorID( + operatorAddress chain.Address, +) (chain.OperatorID, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + operatorID, ok := lc.operatorIDs[operatorAddress] + if !ok { + return 0, fmt.Errorf("operator not found") + } + + return operatorID, nil +} + func (lc *LocalChain) SetAverageBlockTime(averageBlockTime time.Duration) { lc.mutex.Lock() defer lc.mutex.Unlock() @@ -687,6 +924,75 @@ func (lc *LocalChain) GetWallet(walletPublicKeyHash [20]byte) ( *tbtc.WalletChainData, error, ) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + data, ok := lc.walletChainData[walletPublicKeyHash] + if !ok { + fmt.Println("Not found") + return nil, fmt.Errorf("wallet chain data not found") + } + + return data, nil +} + +func (lc *LocalChain) SetWallet( + walletPublicKeyHash [20]byte, + data *tbtc.WalletChainData, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.walletChainData[walletPublicKeyHash] = data +} + +func (lc *LocalChain) GetWalletParameters() ( + creationPeriod uint32, + creationMinBtcBalance uint64, + creationMaxBtcBalance uint64, + closureMinBtcBalance uint64, + maxAge uint32, + maxBtcTransfer uint64, + closingPeriod uint32, + err error, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.walletParameters.creationPeriod, + lc.walletParameters.creationMinBtcBalance, + lc.walletParameters.creationMaxBtcBalance, + lc.walletParameters.closureMinBtcBalance, + lc.walletParameters.maxAge, + lc.walletParameters.maxBtcTransfer, + lc.walletParameters.closingPeriod, + nil +} + +func (lc *LocalChain) SetWalletParameters( + creationPeriod uint32, + creationMinBtcBalance uint64, + creationMaxBtcBalance uint64, + closureMinBtcBalance uint64, + maxAge uint32, + maxBtcTransfer uint64, + closingPeriod uint32, +) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.walletParameters = walletParameters{ + creationPeriod: creationPeriod, + creationMinBtcBalance: creationMinBtcBalance, + creationMaxBtcBalance: creationMaxBtcBalance, + closureMinBtcBalance: closureMinBtcBalance, + maxAge: maxAge, + maxBtcTransfer: maxBtcTransfer, + closingPeriod: closingPeriod, + } +} + +func (lc *LocalChain) GetLiveWalletsCount() (uint32, error) { panic("unsupported") } @@ -694,6 +1000,93 @@ func (lc *LocalChain) ComputeMainUtxoHash(mainUtxo *bitcoin.UnspentTransactionOu panic("unsupported") } +func (lc *LocalChain) ComputeMovingFundsCommitmentHash(targetWallets [][20]byte) [32]byte { + packedWallets := []byte{} + + for _, wallet := range targetWallets { + packedWallets = append(packedWallets, wallet[:]...) + // Each wallet hash must be padded with 12 zero bytes following the + // actual hash. + packedWallets = append(packedWallets, make([]byte, 12)...) + } + + return crypto.Keccak256Hash(packedWallets) +} + +func (lc *LocalChain) AddPastMovingFundsCommitmentSubmittedEvent( + filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, + event *tbtc.MovingFundsCommitmentSubmittedEvent, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastMovingFundsCommitmentSubmittedEventsKey(filter) + if err != nil { + return err + } + + if _, ok := lc.pastMovingFundsCommitmentSubmittedEvents[eventsKey]; !ok { + lc.pastMovingFundsCommitmentSubmittedEvents[eventsKey] = []*tbtc.MovingFundsCommitmentSubmittedEvent{} + } + + lc.pastMovingFundsCommitmentSubmittedEvents[eventsKey] = append( + lc.pastMovingFundsCommitmentSubmittedEvents[eventsKey], + event, + ) + + return nil +} + +func (lc *LocalChain) PastMovingFundsCommitmentSubmittedEvents( + filter *tbtc.MovingFundsCommitmentSubmittedEventFilter, +) ([]*tbtc.MovingFundsCommitmentSubmittedEvent, error) { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + eventsKey, err := buildPastMovingFundsCommitmentSubmittedEventsKey(filter) + if err != nil { + return nil, err + } + + events, ok := lc.pastMovingFundsCommitmentSubmittedEvents[eventsKey] + if !ok { + return nil, fmt.Errorf("no events for given filter") + } + + return events, nil +} + +func (lc *LocalChain) SubmitMovingFundsCommitment( + walletPublicKeyHash [20]byte, + walletMainUtxo bitcoin.UnspentTransactionOutput, + walletMembersIDs []uint32, + walletMemberIndex uint32, + targetWallets [][20]byte, +) error { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + lc.movingFundsCommitmentSubmissions = append( + lc.movingFundsCommitmentSubmissions, + &movingFundsCommitmentSubmission{ + WalletPublicKeyHash: walletPublicKeyHash, + WalletMainUtxo: &walletMainUtxo, + WalletMembersIDs: walletMembersIDs, + WalletMemberIndex: walletMemberIndex, + TargetWallets: targetWallets, + }, + ) + + return nil +} + +func (lc *LocalChain) GetMovingFundsSubmissions() []*movingFundsCommitmentSubmission { + lc.mutex.Lock() + defer lc.mutex.Unlock() + + return lc.movingFundsCommitmentSubmissions +} + type MockBlockCounter struct { mutex sync.Mutex currentBlock uint64 @@ -704,7 +1097,7 @@ func NewMockBlockCounter() *MockBlockCounter { } func (mbc *MockBlockCounter) WaitForBlockHeight(blockNumber uint64) error { - panic("unsupported") + return nil } func (mbc *MockBlockCounter) BlockHeightWaiter(blockNumber uint64) ( diff --git a/pkg/tbtcpg/deposit_sweep_test.go b/pkg/tbtcpg/deposit_sweep_test.go index 7cbbe6d9a7..add13aed70 100644 --- a/pkg/tbtcpg/deposit_sweep_test.go +++ b/pkg/tbtcpg/deposit_sweep_test.go @@ -176,7 +176,7 @@ func TestDepositSweepTask_ProposeDepositsSweep(t *testing.T) { actualDepositSweepProposals, expectedDepositSweepProposals, ); diff != nil { - t.Errorf("invalid deposits: %v", diff) + t.Errorf("invalid deposit sweep proposal: %v", diff) } }) } diff --git a/pkg/tbtcpg/moving_funds.go b/pkg/tbtcpg/moving_funds.go new file mode 100644 index 0000000000..d38942b590 --- /dev/null +++ b/pkg/tbtcpg/moving_funds.go @@ -0,0 +1,629 @@ +package tbtcpg + +import ( + "fmt" + "math/big" + "sort" + + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/tbtc" + "go.uber.org/zap" +) + +var ( + // ErrMaxBtcTransferZero is the error returned when wallet max BTC transfer + // parameter is zero. + ErrMaxBtcTransferZero = fmt.Errorf( + "wallet max BTC transfer must be positive", + ) + + // ErrNotEnoughTargetWallets is the error returned when the number of + // gathered target wallets does not match the required target wallets count. + ErrNotEnoughTargetWallets = fmt.Errorf("not enough target wallets") + + // ErrWrongCommitmentHash is the error returned when the hash calculated + // from retrieved target wallets does not match the committed hash. + ErrWrongCommitmentHash = fmt.Errorf( + "target wallets hash must match commitment hash", + ) + + // ErrNoExecutingOperator is the error returned when the task executing + // operator is not found among the wallet operator IDs. + ErrNoExecutingOperator = fmt.Errorf( + "task executing operator not found among wallet operators", + ) + + // ErrTransactionNotIncluded is the error returned when the commitment + // submission transaction was not included in the Ethereum blockchain. + ErrTransactionNotIncluded = fmt.Errorf( + "transaction not included in blockchain", + ) + + // ErrFeeTooHigh is the error returned when the estimated fee exceeds the + // maximum fee allowed for the moving funds transaction. + ErrFeeTooHigh = fmt.Errorf("estimated fee exceeds the maximum fee") +) + +// MovingFundsCommitmentLookBackBlocks is the look-back period in blocks used +// when searching for submitted moving funds commitment events. It's equal to +// 30 days assuming 12 seconds per block. +const MovingFundsCommitmentLookBackBlocks = uint64(216000) + +// MovingFundsTask is a task that may produce a moving funds proposal. +type MovingFundsTask struct { + chain Chain + btcChain bitcoin.Chain +} + +func NewMovingFundsTask( + chain Chain, + btcChain bitcoin.Chain, +) *MovingFundsTask { + return &MovingFundsTask{ + chain: chain, + btcChain: btcChain, + } +} + +func (mft *MovingFundsTask) Run(request *tbtc.CoordinationProposalRequest) ( + tbtc.CoordinationProposal, + bool, + error, +) { + walletPublicKeyHash := request.WalletPublicKeyHash + + taskLogger := logger.With( + zap.String("task", mft.ActionType().String()), + zap.String("walletPKH", fmt.Sprintf("0x%x", walletPublicKeyHash)), + ) + + // Check if the wallet is eligible for moving funds. + walletChainData, err := mft.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get source wallet's chain data: [%w]", + err, + ) + } + + if walletChainData.State != tbtc.StateMovingFunds { + taskLogger.Infof("source wallet not in MoveFunds state") + return nil, false, nil + } + + if walletChainData.PendingRedemptionsValue > 0 { + taskLogger.Infof("source wallet has pending redemptions") + return nil, false, nil + } + + if walletChainData.PendingMovedFundsSweepRequestsCount > 0 { + taskLogger.Infof("source wallet has pending moved funds sweep requests") + return nil, false, nil + } + + walletMainUtxo, err := tbtc.DetermineWalletMainUtxo( + walletPublicKeyHash, + mft.chain, + mft.btcChain, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get wallet's main UTXO: [%w]", + err, + ) + } + + walletBalance := int64(0) + if walletMainUtxo != nil { + walletBalance = walletMainUtxo.Value + } + + if walletBalance <= 0 { + // The wallet's balance cannot be `0`. Since we are dealing with + // a signed integer we also check it's not negative just in case. + taskLogger.Infof("source wallet does not have a positive balance") + return nil, false, nil + } + + liveWalletsCount, err := mft.chain.GetLiveWalletsCount() + if err != nil { + return nil, false, fmt.Errorf( + "cannot get Live wallets count: [%w]", + err, + ) + } + + if liveWalletsCount == 0 { + taskLogger.Infof("there are no Live wallets available") + return nil, false, nil + } + + targetWalletsCommitmentHash := + walletChainData.MovingFundsTargetWalletsCommitmentHash + + targetWallets, commitmentExists, err := mft.FindTargetWallets( + taskLogger, + walletPublicKeyHash, + targetWalletsCommitmentHash, + uint64(walletBalance), + liveWalletsCount, + ) + if err != nil { + return nil, false, fmt.Errorf("cannot find target wallets: [%w]", err) + } + + if !commitmentExists { + walletMemberIDs, walletMemberIndex, err := mft.GetWalletMembersInfo( + request.WalletOperators, + request.ExecutingOperator, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot get wallet members IDs: [%w]", + err, + ) + } + + err = mft.SubmitMovingFundsCommitment( + taskLogger, + walletPublicKeyHash, + walletMainUtxo, + walletMemberIDs, + walletMemberIndex, + targetWallets, + ) + if err != nil { + return nil, false, fmt.Errorf( + "error while submitting moving funds commitment: [%w]", + err, + ) + } + } + + proposal, err := mft.ProposeMovingFunds( + taskLogger, + walletPublicKeyHash, + walletMainUtxo, + targetWallets, + 0, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot prepare moving funds proposal: [%w]", + err, + ) + } + + return proposal, true, nil +} + +// FindTargetWallets returns a list of target wallets for the moving funds +// procedure. If the source wallet has not submitted moving funds commitment yet +// a new list of target wallets is prepared. If the source wallet has already +// submitted the commitment, the returned target wallet list is prepared based +// on the submitted commitment event. +func (mft *MovingFundsTask) FindTargetWallets( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + targetWalletsCommitmentHash [32]byte, + walletBalance uint64, + liveWalletsCount uint32, +) ([][20]byte, bool, error) { + if targetWalletsCommitmentHash == [32]byte{} { + targetWallets, err := mft.findNewTargetWallets( + taskLogger, + sourceWalletPublicKeyHash, + walletBalance, + liveWalletsCount, + ) + + return targetWallets, false, err + } else { + targetWallets, err := mft.retrieveCommittedTargetWallets( + taskLogger, + sourceWalletPublicKeyHash, + targetWalletsCommitmentHash, + ) + + return targetWallets, true, err + } +} + +func (mft *MovingFundsTask) findNewTargetWallets( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + walletBalance uint64, + liveWalletsCount uint32, +) ([][20]byte, error) { + taskLogger.Infof( + "commitment not submitted yet; looking for new target wallets", + ) + + _, _, _, _, _, walletMaxBtcTransfer, _, err := mft.chain.GetWalletParameters() + if err != nil { + return nil, fmt.Errorf("cannot get wallet parameters: [%w]", err) + } + + if walletMaxBtcTransfer == 0 { + return nil, ErrMaxBtcTransferZero + } + + ceilingDivide := func(x, y uint64) uint64 { + // The divisor must be positive, but we do not need to check it as + // this function will be executed with wallet max BTC transfer as + // the divisor and we already ensured it is positive. + if x == 0 { + return 0 + } + return 1 + (x-1)/y + } + min := func(x, y uint64) uint64 { + if x < y { + return x + } + return y + } + + targetWalletsCount := min( + uint64(liveWalletsCount), + ceilingDivide(walletBalance, walletMaxBtcTransfer), + ) + + // Prepare a list of target wallets using the new wallets registration + // events. Retrieve only the necessary number of live wallets. + // The iteration is started from the end of the list as the newest wallets + // are located there and have the highest chance of being Live. + events, err := mft.chain.PastNewWalletRegisteredEvents(nil) + if err != nil { + return nil, fmt.Errorf( + "failed to get past new wallet registered events: [%v]", + err, + ) + } + + targetWallets := make([][20]byte, 0) + + for i := len(events) - 1; i >= 0; i-- { + walletPubKeyHash := events[i].WalletPublicKeyHash + if walletPubKeyHash == sourceWalletPublicKeyHash { + // Just in case make sure not to include the source wallet + // itself. + continue + } + + wallet, err := mft.chain.GetWallet(walletPubKeyHash) + if err != nil { + taskLogger.Errorf( + "failed to get wallet data for wallet with PKH [0x%x]: [%v]", + walletPubKeyHash, + err, + ) + continue + } + + if wallet.State == tbtc.StateLive { + targetWallets = append(targetWallets, walletPubKeyHash) + } + if len(targetWallets) == int(targetWalletsCount) { + // Stop the iteration if enough live wallets have been gathered. + break + } + } + + if len(targetWallets) != int(targetWalletsCount) { + return nil, fmt.Errorf( + "%w: required [%v] target wallets; gathered [%v]", + ErrNotEnoughTargetWallets, + targetWalletsCount, + len(targetWallets), + ) + } + + // Sort the target wallets according to their numerical representation + // as the on-chain contract expects. + sort.Slice(targetWallets, func(i, j int) bool { + bigIntI := new(big.Int).SetBytes(targetWallets[i][:]) + bigIntJ := new(big.Int).SetBytes(targetWallets[j][:]) + return bigIntI.Cmp(bigIntJ) < 0 + }) + + logger.Infof("gathered [%v] target wallets", len(targetWallets)) + + return targetWallets, nil +} + +func (mft *MovingFundsTask) retrieveCommittedTargetWallets( + taskLogger log.StandardLogger, + sourceWalletPublicKeyHash [20]byte, + targetWalletsCommitmentHash [32]byte, +) ([][20]byte, error) { + taskLogger.Infof( + "commitment already submitted; retrieving committed target wallets", + ) + + blockCounter, err := mft.chain.BlockCounter() + if err != nil { + return nil, fmt.Errorf( + "failed to get block counter: [%w]", + err, + ) + } + + currentBlockNumber, err := blockCounter.CurrentBlock() + if err != nil { + return nil, fmt.Errorf( + "failed to get current block number: [%w]", + err, + ) + } + + // When calculating the filter start block make sure the current block is + // greater than the commitment look back blocks. This condition could be + // unmet for example when running local tests. In that case keep the filter + // start block at `0`. + filterStartBlock := uint64(0) + if currentBlockNumber > MovingFundsCommitmentLookBackBlocks { + filterStartBlock = currentBlockNumber - MovingFundsCommitmentLookBackBlocks + } + + filter := &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: filterStartBlock, + WalletPublicKeyHash: [][20]byte{sourceWalletPublicKeyHash}, + } + + events, err := mft.chain.PastMovingFundsCommitmentSubmittedEvents(filter) + if err != nil { + return nil, fmt.Errorf( + "failed to get past moving funds commitment submitted events: [%w]", + err, + ) + } + + // Moving funds commitment can be submitted only once for a given wallet. + // Check just in case. + if len(events) != 1 { + return nil, fmt.Errorf( + "unexpected number of moving funds commitment submitted events: [%v]", + len(events), + ) + } + + targetWallets := events[0].TargetWallets + + // Just in case check if the hash of the target wallets matches the moving + // funds target wallets commitment hash. + calculatedHash := mft.chain.ComputeMovingFundsCommitmentHash(targetWallets) + if calculatedHash != targetWalletsCommitmentHash { + return nil, ErrWrongCommitmentHash + } + + return targetWallets, nil +} + +// GetWalletMembersInfo returns the wallet member IDs based on the provided +// wallet operator addresses. Additionally, it returns the position of the +// moving funds task execution operator on the list. +func (mft *MovingFundsTask) GetWalletMembersInfo( + walletOperators []chain.Address, + executingOperator chain.Address, +) ([]uint32, uint32, error) { + // Cache mapping operator addresses to their wallet member IDs. It helps to + // limit the number of calls to the ETH client if some operator addresses + // occur on the list multiple times. + operatorIDCache := make(map[chain.Address]uint32) + // TODO: Consider adding a global cache at the `ProposalGenerator` level. + + walletMemberIndex := 0 + walletMemberIDs := make([]uint32, 0) + + for index, operatorAddress := range walletOperators { + // If the operator address is the address of the executing operator save + // its position. Note that since the executing operator can control + // multiple wallet members its address can occur on the list multiple + // times. For clarity, we should save the first occurrence on the list, + // i.e. when `walletMemberIndex` still holds the value of `0`. + if operatorAddress == executingOperator && walletMemberIndex == 0 { + // Increment the index by 1 as operator indexing starts at 1, not 0. + // This ensures the operator's position is correctly identified in + // the range [1, walletOperators.length]. + walletMemberIndex = index + 1 + } + + // Search for the operator address in the cache. Store the operator + // address in the cache if it's not there. + if operatorID, found := operatorIDCache[operatorAddress]; !found { + operatorID, err := mft.chain.GetOperatorID(operatorAddress) + if err != nil { + return nil, 0, fmt.Errorf("failed to get operator ID: [%w]", err) + } + operatorIDCache[operatorAddress] = operatorID + walletMemberIDs = append(walletMemberIDs, operatorID) + } else { + walletMemberIDs = append(walletMemberIDs, operatorID) + } + } + + // The task executing operator must always be on the wallet operators list. + if walletMemberIndex == 0 { + return nil, 0, ErrNoExecutingOperator + } + + return walletMemberIDs, uint32(walletMemberIndex), nil +} + +// SubmitMovingFundsCommitment submits the moving funds commitment and waits +// until the transaction has entered the Ethereum blockchain. +func (mft *MovingFundsTask) SubmitMovingFundsCommitment( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + walletMainUTXO *bitcoin.UnspentTransactionOutput, + walletMembersIDs []uint32, + walletMemberIndex uint32, + targetWallets [][20]byte, +) error { + err := mft.chain.SubmitMovingFundsCommitment( + walletPublicKeyHash, + *walletMainUTXO, + walletMembersIDs, + walletMemberIndex, + targetWallets, + ) + if err != nil { + return fmt.Errorf( + "error while submitting moving funds commitment to chain: [%w]", + err, + ) + } + + blockCounter, err := mft.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) + } + + // Make sure the moving funds commitment transaction has been confirmed. + // Give the transaction at most `6` blocks to enter the blockchain. + for blockHeight := currentBlock + 1; blockHeight <= currentBlock+6; blockHeight++ { + err := blockCounter.WaitForBlockHeight(blockHeight) + if err != nil { + return fmt.Errorf("error while waiting for block height [%w]", err) + } + + walletData, err := mft.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("error wile getting wallet chain data [%w]", err) + } + + // To verify the commitment transaction has entered the Ethereum + // blockchain check that the commitment hash is not zero. + if walletData.MovingFundsTargetWalletsCommitmentHash != [32]byte{} { + taskLogger.Infof( + "the moving funds commitment transaction successfully "+ + "confirmed at block: [%d]", + blockHeight, + ) + return nil + } + + taskLogger.Infof( + "the moving funds commitment transaction still not confirmed at "+ + "block: [%d]", + blockHeight, + ) + } + + taskLogger.Info( + "failed to verify the moving funds commitment transaction submission", + ) + + return ErrTransactionNotIncluded +} + +// ProposeMovingFunds returns a moving funds proposal. +func (mft *MovingFundsTask) ProposeMovingFunds( + taskLogger log.StandardLogger, + walletPublicKeyHash [20]byte, + mainUTXO *bitcoin.UnspentTransactionOutput, + targetWallets [][20]byte, + fee int64, +) (*tbtc.MovingFundsProposal, error) { + if len(targetWallets) == 0 { + return nil, fmt.Errorf("target wallets list is empty") + } + + taskLogger.Infof("preparing a moving funds proposal") + + // Estimate fee if it's missing. + if fee <= 0 { + taskLogger.Infof("estimating moving funds transaction fee") + + txMaxTotalFee, _, _, _, _, _, _, _, _, _, _, err := mft.chain.GetMovingFundsParameters() + if err != nil { + return nil, fmt.Errorf( + "cannot get moving funds tx max total fee: [%w]", + err, + ) + } + + estimatedFee, err := EstimateMovingFundsFee( + mft.btcChain, + len(targetWallets), + txMaxTotalFee, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot estimate moving funds transaction fee: [%w]", + err, + ) + } + + fee = estimatedFee + } + + taskLogger.Infof("moving funds transaction fee: [%d]", fee) + + proposal := &tbtc.MovingFundsProposal{ + TargetWallets: targetWallets, + MovingFundsTxFee: big.NewInt(fee), + } + + taskLogger.Infof("validating the moving funds proposal") + + if _, err := tbtc.ValidateMovingFundsProposal( + taskLogger, + walletPublicKeyHash, + mainUTXO, + proposal, + mft.chain, + ); err != nil { + return nil, fmt.Errorf( + "failed to verify moving funds proposal: [%w]", + err, + ) + } + + return proposal, nil +} + +func (mft *MovingFundsTask) ActionType() tbtc.WalletActionType { + return tbtc.ActionMovingFunds +} + +// EstimateMovingFundsFee estimates fee for the moving funds transaction that +// moves funds from the source wallet to target wallets. +func EstimateMovingFundsFee( + btcChain bitcoin.Chain, + targetWalletsCount int, + txMaxTotalFee uint64, +) (int64, error) { + sizeEstimator := bitcoin.NewTransactionSizeEstimator(). + AddPublicKeyHashInputs(1, true). + AddPublicKeyHashOutputs(targetWalletsCount, true) + + transactionSize, err := sizeEstimator.VirtualSize() + if err != nil { + return 0, fmt.Errorf( + "cannot estimate transaction virtual size: [%v]", + err, + ) + } + + feeEstimator := bitcoin.NewTransactionFeeEstimator(btcChain) + + totalFee, err := feeEstimator.EstimateFee(transactionSize) + if err != nil { + return 0, fmt.Errorf("cannot estimate transaction fee: [%v]", err) + } + + if uint64(totalFee) > txMaxTotalFee { + return 0, ErrFeeTooHigh + } + + return totalFee, nil +} diff --git a/pkg/tbtcpg/moving_funds_test.go b/pkg/tbtcpg/moving_funds_test.go new file mode 100644 index 0000000000..ba44594dbf --- /dev/null +++ b/pkg/tbtcpg/moving_funds_test.go @@ -0,0 +1,709 @@ +package tbtcpg_test + +import ( + "encoding/hex" + "math/big" + "testing" + "time" + + "github.com/go-test/deep" + + "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/tbtc" + "github.com/keep-network/keep-core/pkg/tbtcpg" +) + +func TestMovingFundsAction_FindTargetWallets_CommitmentNotSubmittedYet(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "ffb3f7538bfa98a511495dd96027cfbd57baf2fa", + ) + + var tests = map[string]struct { + walletBalance uint64 + walletMaxBtcTransfer uint64 + registeredWallets []walletInfo + liveWalletsCount uint32 + expectedTargetWallets [][20]byte + expectedError error + }{ + "success scenario": { + walletBalance: 2000000, + walletMaxBtcTransfer: 300000, + registeredWallets: []walletInfo{ + { + publicKeyHash: hexToByte20( + "92a6ec889a8fa34f731e639edede4c75e184307c", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "fdfa28e238734271f5e0d4f53d3843ae6cc09b24", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "840dac51a6346e9372efbdc5d3503ed9fd32abdf", + ), + state: tbtc.StateMovingFunds, + }, + { + publicKeyHash: hexToByte20( + "3091d288521caec06ea912eacfd733edc5a36d6e", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ), + state: tbtc.StateLive, + }, + }, + liveWalletsCount: 4, + expectedTargetWallets: [][20]byte{ + // The target wallets list should include all the Live wallets + // and the wallets should be sorted according to their numerical + // representation. + hexToByte20( + "3091d288521caec06ea912eacfd733edc5a36d6e", + ), + hexToByte20( + "92a6ec889a8fa34f731e639edede4c75e184307c", + ), + hexToByte20( + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ), + hexToByte20( + "fdfa28e238734271f5e0d4f53d3843ae6cc09b24", + ), + }, + expectedError: nil, + }, + "wallet max BTC transfer is zero": { + walletBalance: 10000, + walletMaxBtcTransfer: 0, // Set to zero. + registeredWallets: []walletInfo{}, + liveWalletsCount: 4, + expectedTargetWallets: nil, + expectedError: tbtcpg.ErrMaxBtcTransferZero, + }, + "not enough live wallets": { + walletBalance: 2000000, + walletMaxBtcTransfer: 300000, + // Simulate there should be two Live wallets, but set only one Live + // wallet. This could only happen if one of the target wallets + // changed its state between getting the live wallets count and + // getting wallets chain data. + registeredWallets: []walletInfo{ + { + publicKeyHash: hexToByte20( + "92a6ec889a8fa34f731e639edede4c75e184307c", + ), + state: tbtc.StateClosing, + }, + { + publicKeyHash: hexToByte20( + "fdfa28e238734271f5e0d4f53d3843ae6cc09b24", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "840dac51a6346e9372efbdc5d3503ed9fd32abdf", + ), + state: tbtc.StateMovingFunds, + }, + }, + liveWalletsCount: 2, + expectedTargetWallets: nil, + expectedError: tbtcpg.ErrNotEnoughTargetWallets, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + tbtcChain.SetWalletParameters( + 0, + 0, + 0, + 0, + 0, + test.walletMaxBtcTransfer, + 0, + ) + + for _, walletInfo := range test.registeredWallets { + tbtcChain.AddPastNewWalletRegisteredEvent( + nil, + &tbtc.NewWalletRegisteredEvent{ + WalletPublicKeyHash: walletInfo.publicKeyHash, + }, + ) + tbtcChain.SetWallet( + walletInfo.publicKeyHash, + &tbtc.WalletChainData{State: walletInfo.state}, + ) + } + + task := tbtcpg.NewMovingFundsTask(tbtcChain, nil) + + // Always simulate the moving funds commitment has not been + // submitted yet. + targetWalletsCommitmentHash := [32]byte{} + + targetWallets, alreadySubmitted, err := task.FindTargetWallets( + &testutils.MockLogger{}, + walletPublicKeyHash, + targetWalletsCommitmentHash, + test.walletBalance, + test.liveWalletsCount, + ) + + if diff := deep.Equal( + test.expectedTargetWallets, + targetWallets, + ); diff != nil { + t.Errorf("unexpected target wallets: %v", diff) + } + + // Returned value for the already submitted commitment should + // always be false. + expectedAlreadySubmitted := false + testutils.AssertBoolsEqual( + t, + "already submitted flag", + expectedAlreadySubmitted, + alreadySubmitted, + ) + + testutils.AssertAnyErrorInChainMatchesTarget( + t, + test.expectedError, + err, + ) + }) + } +} + +func TestMovingFundsAction_FindTargetWallets_CommitmentAlreadySubmitted(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "ffb3f7538bfa98a511495dd96027cfbd57baf2fa", + ) + + currentBlock := uint64(1000000) + + averageBlockTime := 10 * time.Second + + var tests = map[string]struct { + targetWalletsCommitmentHash [32]byte + targetWallets []walletInfo + expectedTargetWallets [][20]byte + expectedError error + }{ + "success scenario": { + targetWalletsCommitmentHash: hexToByte32( + "7820cd666bf13bda0850e52cfacf64140716e578f7f6a0567cae9b002fc83775", + ), + targetWallets: []walletInfo{ + { + publicKeyHash: hexToByte20( + "92a6ec889a8fa34f731e639edede4c75e184307c", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ), + state: tbtc.StateLive, + }, + { + publicKeyHash: hexToByte20( + "fdfa28e238734271f5e0d4f53d3843ae6cc09b24", + ), + state: tbtc.StateLive, + }, + }, + expectedTargetWallets: [][20]byte{ + hexToByte20("92a6ec889a8fa34f731e639edede4c75e184307c"), + hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e"), + hexToByte20("fdfa28e238734271f5e0d4f53d3843ae6cc09b24"), + }, + expectedError: nil, + }, + "target wallet commitment hash mismatch": { + targetWalletsCommitmentHash: hexToByte32( + "7820cd666bf13bda0850e52cfacf64140716e578f7f6a0567cae9b002fc83775", + ), + targetWallets: []walletInfo{ + { // Use only one target wallet. + publicKeyHash: hexToByte20( + "92a6ec889a8fa34f731e639edede4c75e184307c", + ), + state: tbtc.StateLive, + }, + }, + expectedTargetWallets: nil, + expectedError: tbtcpg.ErrWrongCommitmentHash, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + + tbtcChain.SetAverageBlockTime(averageBlockTime) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + + startBlock := currentBlock - tbtcpg.MovingFundsCommitmentLookBackBlocks + + targetWallets := [][20]byte{} + for _, walletInfo := range test.targetWallets { + targetWallets = append(targetWallets, walletInfo.publicKeyHash) + tbtcChain.SetWallet( + walletInfo.publicKeyHash, + &tbtc.WalletChainData{State: walletInfo.state}, + ) + } + + err := tbtcChain.AddPastMovingFundsCommitmentSubmittedEvent( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: startBlock, + WalletPublicKeyHash: [][20]byte{walletPublicKeyHash}, + }, + &tbtc.MovingFundsCommitmentSubmittedEvent{ + TargetWallets: targetWallets, + }, + ) + if err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewMovingFundsTask(tbtcChain, nil) + + // Live wallets count and wallet's balance don't matter, as we are + // retrieving target wallets from an already submitted commitment. + walletBalance := uint64(2000000) + liveWalletsCount := uint32(5) + + targetWallets, alreadySubmitted, err := task.FindTargetWallets( + &testutils.MockLogger{}, + walletPublicKeyHash, + test.targetWalletsCommitmentHash, + walletBalance, + liveWalletsCount, + ) + + if diff := deep.Equal( + test.expectedTargetWallets, + targetWallets, + ); diff != nil { + t.Errorf("unexpected target wallets: %v", diff) + } + + // Returned value for the already submitted commitment should + // always be true. + expectedAlreadySubmitted := true + testutils.AssertBoolsEqual( + t, + "already submitted flag", + expectedAlreadySubmitted, + alreadySubmitted, + ) + + testutils.AssertAnyErrorInChainMatchesTarget( + t, + test.expectedError, + err, + ) + }) + } +} + +func TestMovingFundsAction_GetWalletMembersInfo(t *testing.T) { + var tests = map[string]struct { + walletOperators []operatorInfo + executingOperator chain.Address + expectedMemberIDs []uint32 + expectedOperatorPosition uint32 + expectedError error + }{ + "success case": { + walletOperators: []operatorInfo{ + // The executing operator controls two wallet members. + {"5df232b0348928793658dd05dfc6b05a59d11ae8", 3}, + {"dcc895d32b74b34cef2baa6546884fcda65da1e9", 1}, + {"28759deda2ea33bd72f68ea2e8f60cd670c2549f", 2}, + {"f7891d42f3c61a49e0aed1e31b151877c0905cf7", 4}, + {"28759deda2ea33bd72f68ea2e8f60cd670c2549f", 2}, + }, + executingOperator: "28759deda2ea33bd72f68ea2e8f60cd670c2549f", + expectedMemberIDs: []uint32{3, 1, 2, 4, 2}, + expectedOperatorPosition: 3, + expectedError: nil, + }, + "executing operator not among operators": { + walletOperators: []operatorInfo{ + {"5df232b0348928793658dd05dfc6b05a59d11ae8", 2}, + {"dcc895d32b74b34cef2baa6546884fcda65da1e9", 1}, + }, + executingOperator: "28759deda2ea33bd72f68ea2e8f60cd670c2549f", + expectedMemberIDs: nil, + expectedOperatorPosition: 0, + expectedError: tbtcpg.ErrNoExecutingOperator, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + + task := tbtcpg.NewMovingFundsTask(tbtcChain, nil) + + walletOperators := []chain.Address{} + for _, operatorInfo := range test.walletOperators { + err := tbtcChain.SetOperatorID( + operatorInfo.Address, + operatorInfo.OperatorID, + ) + if err != nil { + t.Fatal(err) + } + walletOperators = append(walletOperators, operatorInfo.Address) + } + + memberIDs, operatorPosition, err := task.GetWalletMembersInfo( + walletOperators, + test.executingOperator, + ) + + if diff := deep.Equal(test.expectedMemberIDs, memberIDs); diff != nil { + t.Errorf("unexpected memberIDs: %v", diff) + } + + testutils.AssertUintsEqual( + t, + "operator position", + uint64(test.expectedOperatorPosition), + uint64(operatorPosition), + ) + + testutils.AssertAnyErrorInChainMatchesTarget( + t, + test.expectedError, + err, + ) + }) + } +} + +func TestMovingFundsAction_SubmitMovingFundsCommitment(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "ffb3f7538bfa98a511495dd96027cfbd57baf2fa", + ) + + walletMainUtxo := bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: hexToByte32( + "102414558e061ea6e73d5a7bdbf1159b1518c071c22005475d0215ec78a0b911", + ), + OutputIndex: 11, + }, + Value: 111, + } + + currentBlock := uint64(200000) + + var tests = map[string]struct { + targetWalletsCommitmentHash [32]byte + walletMemberIDs []uint32 + walletMemberIndex uint32 + targetWallets [][20]byte + expectedError error + }{ + "submission successful": { + // Simulate the commitment has updated. + targetWalletsCommitmentHash: hexToByte32( + "9d9368117956680760fa27bb9542ceba2d4fcc398d640a5a0769f5a9593afb0e", + ), + walletMemberIDs: []uint32{11, 22, 33, 44}, + walletMemberIndex: 1, + targetWallets: [][20]byte{ + hexToByte20("92a6ec889a8fa34f731e639edede4c75e184307c"), + hexToByte20("fdfa28e238734271f5e0d4f53d3843ae6cc09b24"), + }, + expectedError: nil, + }, + "submission unsuccessful": { + // Simulate the commitment has not been updated by setting target + // wallets commitment has to zero. The rest of the parameters is + // not important. + targetWalletsCommitmentHash: [32]byte{}, + walletMemberIDs: []uint32{}, + walletMemberIndex: 0, + targetWallets: [][20]byte{}, + expectedError: tbtcpg.ErrTransactionNotIncluded, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + + tbtcChain.SetWallet( + walletPublicKeyHash, + &tbtc.WalletChainData{ + MovingFundsTargetWalletsCommitmentHash: test.targetWalletsCommitmentHash, + }, + ) + + blockCounter := tbtcpg.NewMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + tbtcChain.SetBlockCounter(blockCounter) + + task := tbtcpg.NewMovingFundsTask(tbtcChain, nil) + + err := task.SubmitMovingFundsCommitment( + &testutils.MockLogger{}, + walletPublicKeyHash, + &walletMainUtxo, + test.walletMemberIDs, + test.walletMemberIndex, + test.targetWallets, + ) + + testutils.AssertAnyErrorInChainMatchesTarget( + t, + test.expectedError, + err, + ) + + submittedMovingFundsCommitments := tbtcChain.GetMovingFundsSubmissions() + testutils.AssertIntsEqual( + t, + "commitment submission count", + 1, + len(submittedMovingFundsCommitments), + ) + + submittedMovingFundsCommitment := submittedMovingFundsCommitments[0] + + expectedWalletPublicKeyHash := walletPublicKeyHash + actualWalletPublicKeyHash := submittedMovingFundsCommitment.WalletPublicKeyHash + testutils.AssertBytesEqual( + t, + expectedWalletPublicKeyHash[:], + actualWalletPublicKeyHash[:], + ) + + expectedWalletMainUtxo := &walletMainUtxo + actualWalletMainUtxo := submittedMovingFundsCommitment.WalletMainUtxo + if diff := deep.Equal(expectedWalletMainUtxo, actualWalletMainUtxo); diff != nil { + t.Errorf("invalid wallet main utxo: %v", diff) + } + + expectedWalletMemberIDs := test.walletMemberIDs + actualWalletMemberIDs := submittedMovingFundsCommitment.WalletMembersIDs + if diff := deep.Equal(expectedWalletMemberIDs, actualWalletMemberIDs); diff != nil { + t.Errorf("invalid wallet member IDs: %v", diff) + } + + expectedWalletMemberIndex := test.walletMemberIndex + actualWalletMemberIndex := submittedMovingFundsCommitment.WalletMemberIndex + if diff := deep.Equal(expectedWalletMemberIndex, actualWalletMemberIndex); diff != nil { + t.Errorf("invalid wallet member index: %v", diff) + } + + expectedTargetWallets := test.targetWallets + actualTargetWallets := submittedMovingFundsCommitment.TargetWallets + if diff := deep.Equal(expectedTargetWallets, actualTargetWallets); diff != nil { + t.Errorf("invalid target wallets: %v", diff) + } + }) + } +} + +func TestMovingFundsAction_ProposeMovingFunds(t *testing.T) { + walletPublicKeyHash := hexToByte20( + "ffb3f7538bfa98a511495dd96027cfbd57baf2fa", + ) + + targetWallets := [][20]byte{ + hexToByte20("92a6ec889a8fa34f731e639edede4c75e184307c"), + hexToByte20("fdfa28e238734271f5e0d4f53d3843ae6cc09b24"), + hexToByte20("c7302d75072d78be94eb8d36c4b77583c7abb06e"), + } + + walletMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: hexToByte32( + "102414558e061ea6e73d5a7bdbf1159b1518c071c22005475d0215ec78a0b911", + ), + OutputIndex: 11, + }, + Value: 111, + } + + txMaxTotalFee := uint64(6000) + + var tests = map[string]struct { + fee int64 + expectedProposal *tbtc.MovingFundsProposal + }{ + "fee provided": { + fee: 10000, + expectedProposal: &tbtc.MovingFundsProposal{ + TargetWallets: targetWallets, + MovingFundsTxFee: big.NewInt(10000), + }, + }, + "fee estimated": { + fee: 0, // trigger fee estimation + expectedProposal: &tbtc.MovingFundsProposal{ + TargetWallets: targetWallets, + MovingFundsTxFee: big.NewInt(4300), + }, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + tbtcChain := tbtcpg.NewLocalChain() + btcChain := tbtcpg.NewLocalBitcoinChain() + + btcChain.SetEstimateSatPerVByteFee(1, 25) + + tbtcChain.SetMovingFundsParameters( + txMaxTotalFee, + 0, + 0, + 0, + nil, + 0, + 0, + 0, + 0, + nil, + 0, + ) + + err := tbtcChain.SetMovingFundsProposalValidationResult( + walletPublicKeyHash, + walletMainUtxo, + test.expectedProposal, + true, + ) + if err != nil { + t.Fatal(err) + } + + task := tbtcpg.NewMovingFundsTask(tbtcChain, btcChain) + + proposal, err := task.ProposeMovingFunds( + &testutils.MockLogger{}, + walletPublicKeyHash, + walletMainUtxo, + targetWallets, + test.fee, + ) + if err != nil { + t.Fatal(err) + } + + if diff := deep.Equal(proposal, test.expectedProposal); diff != nil { + t.Errorf("invalid moving funds proposal: %v", diff) + } + }) + } +} + +func TestEstimateMovingFundsFee(t *testing.T) { + var tests = map[string]struct { + txMaxTotalFee uint64 + expectedFee uint64 + expectedError error + }{ + "estimated fee correct": { + txMaxTotalFee: 6000, + expectedFee: 3248, + expectedError: nil, + }, + "estimated fee too high": { + txMaxTotalFee: 3000, + expectedFee: 0, + expectedError: tbtcpg.ErrFeeTooHigh, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + btcChain := tbtcpg.NewLocalBitcoinChain() + btcChain.SetEstimateSatPerVByteFee(1, 16) + + targetWalletsCount := 4 + + actualFee, err := tbtcpg.EstimateMovingFundsFee( + btcChain, + targetWalletsCount, + test.txMaxTotalFee, + ) + + testutils.AssertUintsEqual( + t, + "fee", + test.expectedFee, + uint64(actualFee), + ) + + testutils.AssertAnyErrorInChainMatchesTarget( + t, + test.expectedError, + err, + ) + }) + } +} + +type walletInfo struct { + publicKeyHash [20]byte + state tbtc.WalletState +} + +type operatorInfo struct { + chain.Address + chain.OperatorID +} + +func hexToByte20(hexStr string) [20]byte { + if len(hexStr) != 40 { + panic("hex string length incorrect") + } + decoded, err := hex.DecodeString(hexStr) + if err != nil { + panic(err) + } + var result [20]byte + copy(result[:], decoded) + return result +} + +func hexToByte32(hexStr string) [32]byte { + if len(hexStr) != 64 { + panic("hex string length incorrect") + } + decoded, err := hex.DecodeString(hexStr) + if err != nil { + panic(err) + } + var result [32]byte + copy(result[:], decoded) + return result +} diff --git a/pkg/tbtcpg/redemptions_test.go b/pkg/tbtcpg/redemptions_test.go index 205f5f7c75..c9dff57417 100644 --- a/pkg/tbtcpg/redemptions_test.go +++ b/pkg/tbtcpg/redemptions_test.go @@ -2,14 +2,15 @@ package tbtcpg_test import ( "encoding/hex" + "math/big" + "testing" + "github.com/go-test/deep" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/tbtc" "github.com/keep-network/keep-core/pkg/tbtcpg" "github.com/keep-network/keep-core/pkg/tbtcpg/internal/test" - "math/big" - "testing" ) // Test based on example testnet redemption transaction: @@ -203,7 +204,7 @@ func TestRedemptionAction_ProposeRedemption(t *testing.T) { } if diff := deep.Equal(proposal, test.expectedProposal); diff != nil { - t.Errorf("invalid deposits: %v", diff) + t.Errorf("invalid redemption proposal: %v", diff) } }) } diff --git a/pkg/tbtcpg/tbtcpg.go b/pkg/tbtcpg/tbtcpg.go index 24d5434b3e..f14cd1f7f4 100644 --- a/pkg/tbtcpg/tbtcpg.go +++ b/pkg/tbtcpg/tbtcpg.go @@ -40,9 +40,9 @@ func NewProposalGenerator( NewDepositSweepTask(chain, btcChain), NewRedemptionTask(chain, btcChain), NewHeartbeatTask(chain), + NewMovingFundsTask(chain, btcChain), // TODO: Uncomment when moving funds support is implemented. // newMovedFundsSweepTask(), - // newMovingFundsTask(), } return &ProposalGenerator{