diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 2bbe3d2bae..e17151b89d 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -994,6 +994,78 @@ func (tc *TbtcChain) DKGParameters() (*tbtc.DKGParameters, error) { }, nil } +func (tc *TbtcChain) AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, +) ( + *tbtc.InactivityChainClaim, + error, +) { + signingMemberIndices, signatureBytes, err := convertSignaturesToChainFormat( + signatures, + ) + if err != nil { + return nil, fmt.Errorf( + "could not convert signatures to chain format: [%v]", + err, + ) + } + + // Sort inactiveMembersIndices slice in ascending order as expected by the + // on-chain contract. + sort.Slice(inactiveMembersIndices[:], func(i, j int) bool { + return inactiveMembersIndices[i] < inactiveMembersIndices[j] + }) + + return &tbtc.InactivityChainClaim{ + WalletID: walletID, + InactiveMembersIndices: inactiveMembersIndices, + HeartbeatFailed: heartbeatFailed, + Signatures: signatureBytes, + SigningMembersIndices: signingMemberIndices, + }, nil +} + +// convertInactivityClaimToAbiType converts the TBTC-specific inactivity claim +// to the format applicable for the WalletRegistry ABI. +func convertInactivityClaimToAbiType( + claim *tbtc.InactivityChainClaim, +) ecdsaabi.EcdsaInactivityClaim { + inactiveMembersIndices := make([]*big.Int, len(claim.InactiveMembersIndices)) + for i, memberIndex := range claim.InactiveMembersIndices { + inactiveMembersIndices[i] = big.NewInt(int64(memberIndex)) + } + + signingMembersIndices := make([]*big.Int, len(claim.SigningMembersIndices)) + for i, memberIndex := range claim.SigningMembersIndices { + signingMembersIndices[i] = big.NewInt(int64(memberIndex)) + } + + return ecdsaabi.EcdsaInactivityClaim{ + WalletID: claim.WalletID, + InactiveMembersIndices: inactiveMembersIndices, + HeartbeatFailed: claim.HeartbeatFailed, + Signatures: claim.Signatures, + SigningMembersIndices: signingMembersIndices, + } +} + +func (tc *TbtcChain) SubmitInactivityClaim( + claim *tbtc.InactivityChainClaim, + nonce *big.Int, + groupMembers []uint32, +) error { + _, err := tc.walletRegistry.NotifyOperatorInactivity( + convertInactivityClaimToAbiType(claim), + nonce, + groupMembers, + ) + + return err +} + func (tc *TbtcChain) CalculateInactivityClaimSignatureHash( claim *inactivity.Claim, ) (inactivity.ClaimSignatureHash, error) { @@ -1006,21 +1078,17 @@ func (tc *TbtcChain) CalculateInactivityClaimSignatureHash( // expects an unprefixed 64-byte public key, unprefixedGroupPublicKeyBytes := walletPublicKeyBytes[1:] - // Inactive members indexes should be sorted in the ascending order. As the - // claim object may possibly be shared between concurrent code, it is - // safer to copy the indexes into a new slice. Additionally, the type - // representing inactive member index should be `big.Int` as the smart - // contract reading the calculated hash uses `uint256` for inactive member - // indexes. - inactiveMembersIndexes := make([]*big.Int, len(claim.InactiveMembersIndexes)) - for i, index := range claim.InactiveMembersIndexes { + // The indexes are already sorted. + sortedIndexes := claim.GetInactiveMembersIndexes() + + // The type representing inactive member index should be `big.Int` as the + // smart contract reading the calculated hash uses `uint256` for inactive + // member indexes. + inactiveMembersIndexes := make([]*big.Int, len(sortedIndexes)) + for i, index := range sortedIndexes { inactiveMembersIndexes[i] = big.NewInt(int64(index)) } - sort.Slice(inactiveMembersIndexes, func(i, j int) bool { - return inactiveMembersIndexes[i].Cmp(inactiveMembersIndexes[j]) < 0 - }) - return calculateInactivityClaimSignatureHash( tc.chainID, claim.Nonce, diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 49fdc59ed2..7f2686fe10 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -121,7 +121,32 @@ type DistributedKeyGenerationChain interface { DKGParameters() (*DKGParameters, error) } +// InactivityChainClaim represents an inactivity claim submitted to the chain. +type InactivityChainClaim struct { + WalletID [32]byte + InactiveMembersIndices []group.MemberIndex + HeartbeatFailed bool + Signatures []byte + SigningMembersIndices []group.MemberIndex +} + type InactivityClaimChain interface { + // AssembleDKGResult assembles the inactivity chain claim according to the + // rules expected by the given chain. + AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, + ) (*InactivityChainClaim, error) + + // SubmitInactivityClaim submits the inactivity claim to the chain. + SubmitInactivityClaim( + claim *InactivityChainClaim, + nonce *big.Int, + groupMembers []uint32, + ) error + // CalculateInactivityClaimSignatureHash calculates hash for the given // inactivity claim. CalculateInactivityClaimSignatureHash( diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 707a647e4f..bf622e5f1c 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -552,6 +552,26 @@ func (lc *localChain) DKGParameters() (*DKGParameters, error) { }, nil } +func (lc *localChain) AssembleInactivityClaim( + walletID [32]byte, + inactiveMembersIndices []group.MemberIndex, + signatures map[group.MemberIndex][]byte, + heartbeatFailed bool, +) ( + *InactivityChainClaim, + error, +) { + panic("unsupported") +} + +func (lc *localChain) SubmitInactivityClaim( + claim *InactivityChainClaim, + nonce *big.Int, + groupMembers []uint32, +) error { + panic("unsupported") +} + func (lc *localChain) CalculateInactivityClaimSignatureHash( claim *inactivity.Claim, ) (inactivity.ClaimSignatureHash, error) { diff --git a/pkg/tbtc/inactivity.go b/pkg/tbtc/inactivity.go index 99827853e3..c0a798b6d9 100644 --- a/pkg/tbtc/inactivity.go +++ b/pkg/tbtc/inactivity.go @@ -11,12 +11,21 @@ import ( "golang.org/x/sync/semaphore" "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/net" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa/inactivity" ) +const ( + // inactivityClaimSubmissionDelayStepBlocks determines the delay step in blocks + // that is used to calculate the submission delay period that should be respected + // by the given member to avoid all members submitting the same inactivity claim + // at the same time. + inactivityClaimSubmissionDelayStepBlocks = 3 +) + // errInactivityClaimExecutorBusy is an error returned when the inactivity claim // executor cannot execute the inactivity claim due to another inactivity claim // execution in progress. @@ -30,7 +39,7 @@ type inactivityClaimExecutor struct { broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator groupParameters *GroupParameters - protocolLatch *generator.ProtocolLatch + protocolLatch *generator.ProtocolLatch waitForBlockFn waitForBlockFn } @@ -98,6 +107,11 @@ func (ice *inactivityClaimExecutor) publishClaim( HeartbeatFailed: heartbeatFailed, } + groupMembers, err := ice.getWalletMembersInfo() + if err != nil { + return fmt.Errorf("could not get wallet members info: [%v]", err) + } + wg := sync.WaitGroup{} wg.Add(len(ice.signers)) @@ -126,6 +140,7 @@ func (ice *inactivityClaimExecutor) publishClaim( wallet.groupDishonestThreshold( ice.groupParameters.HonestThreshold, ), + groupMembers, ice.membershipValidator, claim, ) @@ -139,6 +154,32 @@ func (ice *inactivityClaimExecutor) publishClaim( return nil } +func (ice *inactivityClaimExecutor) getWalletMembersInfo() ([]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) + + walletMemberIDs := make([]uint32, 0) + + for _, operatorAddress := range ice.wallet().signingGroupOperators { + // 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 { + fetchedOperatorID, err := ice.chain.GetOperatorID(operatorAddress) + if err != nil { + return nil, fmt.Errorf("could not get operator ID: [%w]", err) + } + operatorIDCache[operatorAddress] = fetchedOperatorID + walletMemberIDs = append(walletMemberIDs, fetchedOperatorID) + } else { + walletMemberIDs = append(walletMemberIDs, operatorID) + } + } + + return walletMemberIDs, nil +} + func (ice *inactivityClaimExecutor) publish( ctx context.Context, inactivityLogger log.StandardLogger, @@ -146,6 +187,7 @@ func (ice *inactivityClaimExecutor) publish( memberIndex group.MemberIndex, groupSize int, dishonestThreshold int, + groupMembers []uint32, membershipValidator *group.MembershipValidator, inactivityClaim *inactivity.Claim, ) error { @@ -159,7 +201,13 @@ func (ice *inactivityClaimExecutor) publish( dishonestThreshold, membershipValidator, newInactivityClaimSigner(ice.chain), - newInactivityClaimSubmitter(), + newInactivityClaimSubmitter( + inactivityLogger, + ice.chain, + ice.groupParameters, + groupMembers, + ice.waitForBlockFn, + ), inactivityClaim, ) } diff --git a/pkg/tbtc/inactivity_submit.go b/pkg/tbtc/inactivity_submit.go index 8e7ad856a4..1dec255ee1 100644 --- a/pkg/tbtc/inactivity_submit.go +++ b/pkg/tbtc/inactivity_submit.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/ipfs/go-log/v2" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tecdsa/inactivity" ) @@ -71,12 +73,29 @@ func (ics *inactivityClaimSigner) VerifySignature( } type inactivityClaimSubmitter struct { - // TODO: Implement + inactivityLogger log.StandardLogger + + chain Chain + groupParameters *GroupParameters + groupMembers []uint32 + + waitForBlockFn waitForBlockFn } -func newInactivityClaimSubmitter() *inactivityClaimSubmitter { - // TODO: Implement - return &inactivityClaimSubmitter{} +func newInactivityClaimSubmitter( + inactivityLogger log.StandardLogger, + chain Chain, + groupParameters *GroupParameters, + groupMembers []uint32, + waitForBlockFn waitForBlockFn, +) *inactivityClaimSubmitter { + return &inactivityClaimSubmitter{ + inactivityLogger: inactivityLogger, + chain: chain, + groupParameters: groupParameters, + groupMembers: groupMembers, + waitForBlockFn: waitForBlockFn, + } } func (ics *inactivityClaimSubmitter) SubmitClaim( @@ -85,6 +104,106 @@ func (ics *inactivityClaimSubmitter) SubmitClaim( claim *inactivity.Claim, signatures map[group.MemberIndex][]byte, ) error { - // TODO: Implement - return nil + if len(signatures) < ics.groupParameters.HonestThreshold { + return fmt.Errorf( + "could not submit inactivity claim with [%v] signatures for "+ + "group honest threshold [%v]", + len(signatures), + ics.groupParameters.HonestThreshold, + ) + } + + // The inactivity nonce at the beginning of the execution process. + inactivityNonce := claim.Nonce + + walletPublicKeyHash := bitcoin.PublicKeyHash(claim.WalletPublicKey) + + walletRegistryData, err := ics.chain.GetWallet(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("could not get registry data on wallet: [%v]", err) + } + + ecdsaWalletID := walletRegistryData.EcdsaWalletID + + currentNonce, err := ics.chain.GetInactivityClaimNonce( + ecdsaWalletID, + ) + if err != nil { + return fmt.Errorf("could not get nonce for wallet: [%v]", err) + } + + if currentNonce.Cmp(inactivityNonce) > 0 { + // Someone who was ahead of us in the queue submitted the claim. Giving up. + ics.inactivityLogger.Infof( + "[member:%v] inactivity claim already submitted; "+ + "aborting inactivity claim on-chain submission", + memberIndex, + ) + return nil + } + + inactivityClaim, err := ics.chain.AssembleInactivityClaim( + ecdsaWalletID, + claim.GetInactiveMembersIndexes(), + signatures, + claim.HeartbeatFailed, + ) + if err != nil { + return fmt.Errorf("could not assemble inactivity chain claim [%w]", err) + } + + blockCounter, err := ics.chain.BlockCounter() + if err != nil { + return err + } + + // We can't determine a common block at which the publication starts. + // However, all we want here is to ensure the members does not submit + // in the same time. This can be achieved by simply using the index-based + // delay starting from the current block. + currentBlock, err := blockCounter.CurrentBlock() + if err != nil { + return fmt.Errorf("cannot get current block: [%v]", err) + } + delayBlocks := uint64(memberIndex-1) * inactivityClaimSubmissionDelayStepBlocks + submissionBlock := currentBlock + delayBlocks + + ics.inactivityLogger.Infof( + "[member:%v] waiting for block [%v] to submit inactivity claim", + memberIndex, + submissionBlock, + ) + + err = ics.waitForBlockFn(ctx, submissionBlock) + if err != nil { + return fmt.Errorf( + "error while waiting for DKG result submission block: [%v]", + err, + ) + } + + if ctx.Err() != nil { + // The context was cancelled by the upstream. Regardless of the cause, + // that means the inactivity execution is no longer awaiting the result, + // and we can safely return. + ics.inactivityLogger.Infof( + "[member:%v] inactivity execution is no longer awaiting the "+ + "result; aborting inactivity claim on-chain submission", + memberIndex, + ) + return nil + } + + ics.inactivityLogger.Infof( + "[member:%v] submitting inactivity claim with [%v] supporting "+ + "member signatures", + memberIndex, + len(signatures), + ) + + return ics.chain.SubmitInactivityClaim( + inactivityClaim, + inactivityNonce, + ics.groupMembers, + ) } diff --git a/pkg/tecdsa/inactivity/claim.go b/pkg/tecdsa/inactivity/claim.go index b434fa09c2..a582abe28b 100644 --- a/pkg/tecdsa/inactivity/claim.go +++ b/pkg/tecdsa/inactivity/claim.go @@ -4,6 +4,7 @@ import ( "crypto/ecdsa" "fmt" "math/big" + "sort" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -16,6 +17,21 @@ type Claim struct { HeartbeatFailed bool } +// GetInactiveMembersIndexes returns the indexes of inactive members. +// The original slice is copied to avoid concurrency issues if the claim object +// is shared between many goroutines. The returned indexes are sorted. +func (c *Claim) GetInactiveMembersIndexes() []group.MemberIndex { + sortedIndexes := make([]group.MemberIndex, len(c.InactiveMembersIndexes)) + + copy(sortedIndexes, c.InactiveMembersIndexes) + + sort.Slice(sortedIndexes, func(i, j int) bool { + return sortedIndexes[i] < sortedIndexes[j] + }) + + return sortedIndexes +} + const ClaimSignatureHashByteSize = 32 // ClaimSignatureHash is a signature hash of the inactivity claim. The hashing