package communities import ( "context" "errors" "fmt" "math/big" "strings" "go.uber.org/zap" maps "golang.org/x/exp/maps" slices "golang.org/x/exp/slices" gethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/status-im/status-go/protocol/ens" "github.com/status-im/status-go/protocol/protobuf" walletcommon "github.com/status-im/status-go/services/wallet/common" "github.com/status-im/status-go/services/wallet/thirdparty" ) type PermissionChecker interface { CheckPermissionToJoin(*Community, []gethcommon.Address) (*CheckPermissionToJoinResponse, error) CheckPermissions(permissionsParsedData *PreParsedCommunityPermissionsData, accountsAndChainIDs []*AccountChainIDsCombination, shortcircuit bool) (*CheckPermissionsResponse, error) } type DefaultPermissionChecker struct { tokenManager TokenManager collectiblesManager CollectiblesManager ensVerifier *ens.Verifier logger *zap.Logger } type PreParsedPermissionsData struct { Erc721TokenRequirements map[uint64]map[string]*protobuf.TokenCriteria Erc20TokenAddresses []gethcommon.Address Erc20ChainIDsMap map[uint64]bool Erc721ChainIDsMap map[uint64]bool } type PreParsedCommunityPermissionsData struct { *PreParsedPermissionsData Permissions []*CommunityTokenPermission } func (p *DefaultPermissionChecker) getOwnedENS(addresses []gethcommon.Address) ([]string, error) { ownedENS := make([]string, 0) if p.ensVerifier == nil { p.logger.Warn("no ensVerifier configured for communities manager") return ownedENS, nil } for _, address := range addresses { name, err := p.ensVerifier.ReverseResolve(address) if err != nil && err.Error() != "not a resolver" { return ownedENS, err } if name != "" { ownedENS = append(ownedENS, name) } } return ownedENS, nil } func (p *DefaultPermissionChecker) GetOwnedERC721Tokens(walletAddresses []gethcommon.Address, tokenRequirements map[uint64]map[string]*protobuf.TokenCriteria, chainIDs []uint64) (CollectiblesByChain, error) { if p.collectiblesManager == nil { return nil, errors.New("no collectibles manager") } ctx := context.Background() ownedERC721Tokens := make(CollectiblesByChain) for chainID, erc721Tokens := range tokenRequirements { skipChain := true for _, cID := range chainIDs { if chainID == cID { skipChain = false } } if skipChain { continue } contractAddresses := make([]gethcommon.Address, 0) for contractAddress := range erc721Tokens { contractAddresses = append(contractAddresses, gethcommon.HexToAddress(contractAddress)) } if _, exists := ownedERC721Tokens[chainID]; !exists { ownedERC721Tokens[chainID] = make(map[gethcommon.Address]thirdparty.TokenBalancesPerContractAddress) } for _, owner := range walletAddresses { balances, err := p.collectiblesManager.FetchBalancesByOwnerAndContractAddress(ctx, walletcommon.ChainID(chainID), owner, contractAddresses) if err != nil { p.logger.Info("couldn't fetch owner assets", zap.Error(err)) return nil, err } ownedERC721Tokens[chainID][owner] = balances } } return ownedERC721Tokens, nil } func (p *DefaultPermissionChecker) accountChainsCombinationToMap(combinations []*AccountChainIDsCombination) map[gethcommon.Address][]uint64 { result := make(map[gethcommon.Address][]uint64) for _, combination := range combinations { result[combination.Address] = combination.ChainIDs } return result } // merge valid combinations w/o duplicates func (p *DefaultPermissionChecker) MergeValidCombinations(left, right []*AccountChainIDsCombination) []*AccountChainIDsCombination { leftMap := p.accountChainsCombinationToMap(left) rightMap := p.accountChainsCombinationToMap(right) // merge maps, result in left map for k, v := range rightMap { if _, exists := leftMap[k]; !exists { leftMap[k] = v continue } else { // append chains which are new chains := leftMap[k] for _, chainID := range v { if !slices.Contains(chains, chainID) { chains = append(chains, chainID) } } leftMap[k] = chains } } result := []*AccountChainIDsCombination{} for k, v := range leftMap { result = append(result, &AccountChainIDsCombination{ Address: k, ChainIDs: v, }) } return result } func (p *DefaultPermissionChecker) CheckPermissionToJoin(community *Community, addresses []gethcommon.Address) (*CheckPermissionToJoinResponse, error) { becomeAdminPermissions := community.TokenPermissionsByType(protobuf.CommunityTokenPermission_BECOME_ADMIN) becomeMemberPermissions := community.TokenPermissionsByType(protobuf.CommunityTokenPermission_BECOME_MEMBER) becomeTokenMasterPermissions := community.TokenPermissionsByType(protobuf.CommunityTokenPermission_BECOME_TOKEN_MASTER) adminOrTokenMasterPermissionsToJoin := append(becomeAdminPermissions, becomeTokenMasterPermissions...) allChainIDs, err := p.tokenManager.GetAllChainIDs() if err != nil { return nil, err } accountsAndChainIDs := combineAddressesAndChainIDs(addresses, allChainIDs) // Check becomeMember and (admin & token master) permissions separately. becomeMemberPermissionsResponse, err := p.checkPermissionsOrDefault(becomeMemberPermissions, accountsAndChainIDs) if err != nil { return nil, err } if len(adminOrTokenMasterPermissionsToJoin) <= 0 { return becomeMemberPermissionsResponse, nil } // If there are any admin or token master permissions, combine result. preParsedPermissions := preParsedCommunityPermissionsData(adminOrTokenMasterPermissionsToJoin) adminOrTokenPermissionsResponse, err := p.CheckPermissions(preParsedPermissions, accountsAndChainIDs, false) if err != nil { return nil, err } mergedPermissions := make(map[string]*PermissionTokenCriteriaResult) maps.Copy(mergedPermissions, becomeMemberPermissionsResponse.Permissions) maps.Copy(mergedPermissions, adminOrTokenPermissionsResponse.Permissions) mergedCombinations := p.MergeValidCombinations(becomeMemberPermissionsResponse.ValidCombinations, adminOrTokenPermissionsResponse.ValidCombinations) combinedResponse := &CheckPermissionsResponse{ Satisfied: becomeMemberPermissionsResponse.Satisfied || adminOrTokenPermissionsResponse.Satisfied, Permissions: mergedPermissions, ValidCombinations: mergedCombinations, } return combinedResponse, nil } func (p *DefaultPermissionChecker) checkPermissionsOrDefault(permissions []*CommunityTokenPermission, accountsAndChainIDs []*AccountChainIDsCombination) (*CheckPermissionsResponse, error) { if len(permissions) == 0 { // There are no permissions to join on this community at the moment, // so we reveal all accounts + all chain IDs response := &CheckPermissionsResponse{ Satisfied: true, Permissions: make(map[string]*PermissionTokenCriteriaResult), ValidCombinations: accountsAndChainIDs, } return response, nil } preParsedPermissions := preParsedCommunityPermissionsData(permissions) return p.CheckPermissions(preParsedPermissions, accountsAndChainIDs, false) } // CheckPermissions will retrieve balances and check whether the user has // permission to join the community, if shortcircuit is true, it will stop as soon // as we know the answer func (p *DefaultPermissionChecker) CheckPermissions(permissionsParsedData *PreParsedCommunityPermissionsData, accountsAndChainIDs []*AccountChainIDsCombination, shortcircuit bool) (*CheckPermissionsResponse, error) { response := &CheckPermissionsResponse{ Satisfied: false, Permissions: make(map[string]*PermissionTokenCriteriaResult), ValidCombinations: make([]*AccountChainIDsCombination, 0), } if permissionsParsedData == nil { response.Satisfied = true return response, nil } erc721TokenRequirements := permissionsParsedData.Erc721TokenRequirements erc20ChainIDsMap := permissionsParsedData.Erc20ChainIDsMap erc721ChainIDsMap := permissionsParsedData.Erc721ChainIDsMap erc20TokenAddresses := permissionsParsedData.Erc20TokenAddresses accounts := make([]gethcommon.Address, 0) // TODO: move outside in order not to convert it for _, accountAndChainIDs := range accountsAndChainIDs { accounts = append(accounts, accountAndChainIDs.Address) } chainIDsForERC20 := calculateChainIDsSet(accountsAndChainIDs, erc20ChainIDsMap) chainIDsForERC721 := calculateChainIDsSet(accountsAndChainIDs, erc721ChainIDsMap) // if there are no chain IDs that match token criteria chain IDs // we aren't able to check balances on selected networks if len(erc20ChainIDsMap) > 0 && len(chainIDsForERC20) == 0 { response.NetworksNotSupported = true return response, nil } ownedERC20TokenBalances := make(map[uint64]map[gethcommon.Address]map[gethcommon.Address]*hexutil.Big, 0) if len(chainIDsForERC20) > 0 { // this only returns balances for the networks we're actually interested in balances, err := p.tokenManager.GetBalancesByChain(context.Background(), accounts, erc20TokenAddresses, chainIDsForERC20) if err != nil { return nil, err } ownedERC20TokenBalances = balances } ownedERC721Tokens := make(CollectiblesByChain) if len(chainIDsForERC721) > 0 { collectibles, err := p.GetOwnedERC721Tokens(accounts, erc721TokenRequirements, chainIDsForERC721) if err != nil { return nil, err } ownedERC721Tokens = collectibles } accountsChainIDsCombinations := make(map[gethcommon.Address]map[uint64]bool) for _, tokenPermission := range permissionsParsedData.Permissions { permissionRequirementsMet := true response.Permissions[tokenPermission.Id] = &PermissionTokenCriteriaResult{Role: tokenPermission.Type} // There can be multiple token requirements per permission. // If only one is not met, the entire permission is marked // as not fulfilled for _, tokenRequirement := range tokenPermission.TokenCriteria { tokenRequirementMet := false tokenRequirementResponse := TokenRequirementResponse{TokenCriteria: tokenRequirement} if tokenRequirement.Type == protobuf.CommunityTokenType_ERC721 { if len(ownedERC721Tokens) == 0 { response.Permissions[tokenPermission.Id].TokenRequirements = append(response.Permissions[tokenPermission.Id].TokenRequirements, tokenRequirementResponse) response.Permissions[tokenPermission.Id].Criteria = append(response.Permissions[tokenPermission.Id].Criteria, false) continue } chainIDLoopERC721: for chainID, addressStr := range tokenRequirement.ContractAddresses { contractAddress := gethcommon.HexToAddress(addressStr) if _, exists := ownedERC721Tokens[chainID]; !exists || len(ownedERC721Tokens[chainID]) == 0 { continue chainIDLoopERC721 } for account := range ownedERC721Tokens[chainID] { if _, exists := ownedERC721Tokens[chainID][account]; !exists { continue } tokenBalances := ownedERC721Tokens[chainID][account][contractAddress] if len(tokenBalances) > 0 { // 'account' owns some TokenID owned from contract 'address' if _, exists := accountsChainIDsCombinations[account]; !exists { accountsChainIDsCombinations[account] = make(map[uint64]bool) } if len(tokenRequirement.TokenIds) == 0 { // no specific tokenId of this collection is needed tokenRequirementMet = true accountsChainIDsCombinations[account][chainID] = true break chainIDLoopERC721 } tokenIDsLoop: for _, tokenID := range tokenRequirement.TokenIds { tokenIDBigInt := new(big.Int).SetUint64(tokenID) for _, asset := range tokenBalances { if asset.TokenID.Cmp(tokenIDBigInt) == 0 && asset.Balance.Sign() > 0 { tokenRequirementMet = true accountsChainIDsCombinations[account][chainID] = true break tokenIDsLoop } } } } } } } else if tokenRequirement.Type == protobuf.CommunityTokenType_ERC20 { if len(ownedERC20TokenBalances) == 0 { response.Permissions[tokenPermission.Id].TokenRequirements = append(response.Permissions[tokenPermission.Id].TokenRequirements, tokenRequirementResponse) response.Permissions[tokenPermission.Id].Criteria = append(response.Permissions[tokenPermission.Id].Criteria, false) continue } accumulatedBalance := new(big.Int) chainIDLoopERC20: for chainID, address := range tokenRequirement.ContractAddresses { if _, exists := ownedERC20TokenBalances[chainID]; !exists || len(ownedERC20TokenBalances[chainID]) == 0 { continue chainIDLoopERC20 } contractAddress := gethcommon.HexToAddress(address) for account := range ownedERC20TokenBalances[chainID] { if _, exists := ownedERC20TokenBalances[chainID][account][contractAddress]; !exists { continue } value := ownedERC20TokenBalances[chainID][account][contractAddress] if _, exists := accountsChainIDsCombinations[account]; !exists { accountsChainIDsCombinations[account] = make(map[uint64]bool) } if value.ToInt().Cmp(big.NewInt(0)) > 0 { // account has balance > 0 on this chain for this token, so let's add it the chain IDs accountsChainIDsCombinations[account][chainID] = true } // check if adding current chain account balance to accumulated balance // satisfies required amount prevBalance := accumulatedBalance accumulatedBalance.Add(prevBalance, value.ToInt()) requiredAmount, success := new(big.Int).SetString(tokenRequirement.AmountInWei, 10) if !success { return nil, fmt.Errorf("amountInWeis value is incorrect: %s", tokenRequirement.AmountInWei) } if accumulatedBalance.Cmp(requiredAmount) != -1 { tokenRequirementMet = true if shortcircuit { break chainIDLoopERC20 } } } } } else if tokenRequirement.Type == protobuf.CommunityTokenType_ENS { for _, account := range accounts { ownedENSNames, err := p.getOwnedENS([]gethcommon.Address{account}) if err != nil { return nil, err } if _, exists := accountsChainIDsCombinations[account]; !exists { accountsChainIDsCombinations[account] = make(map[uint64]bool) } if !strings.HasPrefix(tokenRequirement.EnsPattern, "*.") { for _, ownedENS := range ownedENSNames { if ownedENS == tokenRequirement.EnsPattern { tokenRequirementMet = true accountsChainIDsCombinations[account][walletcommon.EthereumMainnet] = true } } } else { parentName := tokenRequirement.EnsPattern[2:] for _, ownedENS := range ownedENSNames { if strings.HasSuffix(ownedENS, parentName) { tokenRequirementMet = true accountsChainIDsCombinations[account][walletcommon.EthereumMainnet] = true } } } } } if !tokenRequirementMet { permissionRequirementsMet = false } tokenRequirementResponse.Satisfied = tokenRequirementMet response.Permissions[tokenPermission.Id].TokenRequirements = append(response.Permissions[tokenPermission.Id].TokenRequirements, tokenRequirementResponse) response.Permissions[tokenPermission.Id].Criteria = append(response.Permissions[tokenPermission.Id].Criteria, tokenRequirementMet) } response.Permissions[tokenPermission.Id].ID = tokenPermission.Id // multiple permissions are treated as logical OR, meaning // if only one of them is fulfilled, the user gets permission // to join and we can stop early if shortcircuit && permissionRequirementsMet { break } } // attach valid account and chainID combinations to response for account, chainIDs := range accountsChainIDsCombinations { combination := &AccountChainIDsCombination{ Address: account, } for chainID := range chainIDs { combination.ChainIDs = append(combination.ChainIDs, chainID) } response.ValidCombinations = append(response.ValidCombinations, combination) } response.calculateSatisfied() return response, nil } func preParsedPermissionsData(permissions []*CommunityTokenPermission) *PreParsedPermissionsData { erc20TokenRequirements, erc721TokenRequirements, _ := ExtractTokenCriteria(permissions) erc20ChainIDsMap := make(map[uint64]bool) erc721ChainIDsMap := make(map[uint64]bool) erc20TokenAddresses := make([]gethcommon.Address, 0) // figure out chain IDs we're interested in for chainID, tokens := range erc20TokenRequirements { erc20ChainIDsMap[chainID] = true for contractAddress := range tokens { erc20TokenAddresses = append(erc20TokenAddresses, gethcommon.HexToAddress(contractAddress)) } } for chainID := range erc721TokenRequirements { erc721ChainIDsMap[chainID] = true } return &PreParsedPermissionsData{ Erc721TokenRequirements: erc721TokenRequirements, Erc20TokenAddresses: erc20TokenAddresses, Erc20ChainIDsMap: erc20ChainIDsMap, Erc721ChainIDsMap: erc721ChainIDsMap, } } func preParsedCommunityPermissionsData(permissions []*CommunityTokenPermission) *PreParsedCommunityPermissionsData { if len(permissions) == 0 { return nil } return &PreParsedCommunityPermissionsData{ Permissions: permissions, PreParsedPermissionsData: preParsedPermissionsData(permissions), } } func PreParsePermissionsData(permissions map[string]*CommunityTokenPermission) (map[protobuf.CommunityTokenPermission_Type]*PreParsedCommunityPermissionsData, map[string]*PreParsedCommunityPermissionsData) { becomeMemberPermissions := TokenPermissionsByType(permissions, protobuf.CommunityTokenPermission_BECOME_MEMBER) becomeAdminPermissions := TokenPermissionsByType(permissions, protobuf.CommunityTokenPermission_BECOME_ADMIN) becomeTokenMasterPermissions := TokenPermissionsByType(permissions, protobuf.CommunityTokenPermission_BECOME_TOKEN_MASTER) viewOnlyPermissions := TokenPermissionsByType(permissions, protobuf.CommunityTokenPermission_CAN_VIEW_CHANNEL) viewAndPostPermissions := TokenPermissionsByType(permissions, protobuf.CommunityTokenPermission_CAN_VIEW_AND_POST_CHANNEL) channelPermissions := append(viewAndPostPermissions, viewOnlyPermissions...) communityPermissionsPreParsedData := make(map[protobuf.CommunityTokenPermission_Type]*PreParsedCommunityPermissionsData) communityPermissionsPreParsedData[protobuf.CommunityTokenPermission_BECOME_MEMBER] = preParsedCommunityPermissionsData(becomeMemberPermissions) communityPermissionsPreParsedData[protobuf.CommunityTokenPermission_BECOME_ADMIN] = preParsedCommunityPermissionsData(becomeAdminPermissions) communityPermissionsPreParsedData[protobuf.CommunityTokenPermission_BECOME_TOKEN_MASTER] = preParsedCommunityPermissionsData(becomeTokenMasterPermissions) channelPermissionsPreParsedData := make(map[string]*PreParsedCommunityPermissionsData) for _, channelPermission := range channelPermissions { channelPermissionsPreParsedData[channelPermission.Id] = preParsedCommunityPermissionsData([]*CommunityTokenPermission{channelPermission}) } return communityPermissionsPreParsedData, channelPermissionsPreParsedData }