• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 16654489606

31 Jul 2025 04:23PM UTC coverage: 38.356% (-0.8%) from 39.148%
16654489606

Pull #12697

github

web-flow
Merge bcbe9f130 into febfac2fb
Pull Request #12697: Topic Subscriptions

1861 of 5228 branches covered (35.6%)

Branch coverage included in aggregate %.

2 of 13 new or added lines in 7 files covered. (15.38%)

107 existing lines in 6 files now uncovered.

3296 of 8217 relevant lines covered (40.11%)

35.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.83
/libs/model/src/utils/validateGroupMembership.ts
1
import {
2
  AllowlistData,
3
  Membership,
4
  MembershipRejectReason,
5
  Requirement,
6
  ThresholdData,
7
  TrustLevelData,
8
} from '@hicommonwealth/schemas';
9
import { BalanceSourceType, WalletSsoSource } from '@hicommonwealth/shared';
10
import { toBigInt } from 'web3-utils';
11
import { z } from 'zod';
12
import type { OptionsWithBalances } from '../services/tokenBalanceCache/types';
13

14
type AllowlistData = z.infer<typeof AllowlistData>;
15
type ThresholdData = z.infer<typeof ThresholdData>;
16
type TrustLevelData = z.infer<typeof TrustLevelData>;
17
export type Requirement = z.infer<typeof Requirement>;
18
export type Membership = z.infer<typeof Membership> & { balance?: bigint };
19
export type UserInfo = {
20
  address_id: number;
21
  address: string;
22
  user_id: number;
23
  user_tier: number;
24
  wallet_sso?: WalletSsoSource;
25
  memberships?: Membership[];
26
};
27

28
export type ValidateGroupMembershipResponse = {
29
  isValid: boolean;
30
  messages?: z.infer<typeof MembershipRejectReason>;
31
  numRequirementsMet?: number;
32
  balance?: bigint;
33
};
34

35
/**
36
 * Validates if a given user address passes a set of requirements and grants access to group
37
 * @param userAddress address of user
38
 * @param requirements An array of requirement types to be validated against
39
 * @param balances address balances
40
 * @param numRequiredRequirements
41
 * @returns ValidateGroupMembershipResponse validity and messages on requirements that failed
42
 */
43
export function validateGroupMembership(
44
  user: UserInfo,
45
  requirements: Requirement[],
46
  balances: OptionsWithBalances[],
47
  numRequiredRequirements: number = 0,
3✔
48
): ValidateGroupMembershipResponse {
49
  const response: ValidateGroupMembershipResponse = {
24✔
50
    isValid: true,
51
    messages: [],
52
  };
53
  let allowListOverride = false;
24✔
54
  let trustLevelOverride = false;
24✔
55
  let numRequirementsMet = 0;
24✔
56

57
  requirements.forEach((requirement) => {
24✔
58
    let checkResult: { result: boolean; message: string; balance?: bigint };
59
    switch (requirement.rule) {
34!
60
      case 'threshold': {
61
        checkResult = _thresholdCheck(
19✔
62
          user.address,
63
          requirement.data as ThresholdData,
64
          balances,
65
        );
66
        response.balance = checkResult.balance;
19✔
67
        break;
19✔
68
      }
69
      case 'allow': {
70
        checkResult = _allowlistCheck(
13✔
71
          user.address,
72
          requirement.data as AllowlistData,
73
        );
74
        if (checkResult.result) {
13✔
75
          allowListOverride = true;
4✔
76
        }
77
        break;
13✔
78
      }
79
      case 'trust-level': {
80
        checkResult = _trustLevelCheck(
2✔
81
          user,
82
          requirement.data as TrustLevelData,
83
        );
84
        if (checkResult.result) {
2!
85
          trustLevelOverride = true;
×
86
        }
87
        break;
2✔
88
      }
89
      default:
90
        checkResult = {
×
91
          result: false,
92
          message: 'Invalid Requirement',
93
        };
94
        break;
×
95
    }
96

97
    if (checkResult.result) {
34✔
98
      numRequirementsMet++;
15✔
99
    } else {
100
      response.isValid = false;
19✔
101
      // @ts-expect-error StrictNullChecks
102
      response.messages.push({
19✔
103
        requirement,
104
        message: checkResult.message,
105
      });
106
    }
107
  });
108

109
  if (allowListOverride || trustLevelOverride) {
24✔
110
    // allow if address is whitelisted or trust level is met
111
    return { isValid: true, messages: undefined };
4✔
112
  }
113

114
  if (numRequiredRequirements) {
20✔
115
    if (numRequirementsMet >= numRequiredRequirements) {
17✔
116
      // allow if minimum number of requirements met
117
      return { ...response, isValid: true, numRequirementsMet };
6✔
118
    } else {
119
      return { ...response, isValid: false, numRequirementsMet };
11✔
120
    }
121
  }
122
  return response;
3✔
123
}
124

125
function _thresholdCheck(
126
  userAddress: string,
127
  thresholdData: ThresholdData,
128
  balances: OptionsWithBalances[],
129
): { result: boolean; message: string; balance?: bigint } {
130
  try {
19✔
131
    let balanceSourceType: BalanceSourceType;
132
    let contractAddress: string;
133
    let chainId: string;
134
    let tokenId: string;
135
    let objectId: string;
136

137
    switch (thresholdData.source.source_type) {
19!
138
      case 'spl': {
139
        balanceSourceType = BalanceSourceType.SPL;
×
140
        contractAddress = thresholdData.source.contract_address;
×
141
        chainId =
×
142
          'solana_network' in thresholdData.source
×
143
            ? thresholdData.source.solana_network.toString()
144
            : thresholdData.source.evm_chain_id.toString();
145
        break;
×
146
      }
147
      case 'metaplex': {
148
        balanceSourceType = BalanceSourceType.SOLNFT;
×
149
        contractAddress = thresholdData.source.contract_address;
×
150
        chainId = thresholdData.source.solana_network.toString();
×
151
        break;
×
152
      }
153
      case 'sui_native': {
154
        balanceSourceType = BalanceSourceType.SuiNative;
×
155
        chainId = thresholdData.source.sui_network.toString();
×
156
        objectId = thresholdData.source.object_id!;
×
157
        break;
×
158
      }
159
      case 'sui_token': {
160
        balanceSourceType = BalanceSourceType.SuiToken;
×
161
        chainId = thresholdData.source.sui_network.toString();
×
162
        contractAddress = thresholdData.source.coin_type;
×
163
        break;
×
164
      }
165
      case 'sui_nft': {
UNCOV
166
        balanceSourceType = BalanceSourceType.SuiNFT;
×
UNCOV
167
        chainId = thresholdData.source.sui_network.toString();
×
UNCOV
168
        contractAddress = thresholdData.source.collection_id;
×
UNCOV
169
        break;
×
170
      }
171
      case 'erc20': {
172
        balanceSourceType = BalanceSourceType.ERC20;
6✔
173
        contractAddress = thresholdData.source.contract_address;
6✔
174
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
175
        break;
6✔
176
      }
177
      case 'erc721': {
178
        balanceSourceType = BalanceSourceType.ERC721;
6✔
179
        contractAddress = thresholdData.source.contract_address;
6✔
180
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
181
        break;
6✔
182
      }
183
      case 'erc1155': {
UNCOV
184
        balanceSourceType = BalanceSourceType.ERC1155;
×
UNCOV
185
        contractAddress = thresholdData.source.contract_address;
×
UNCOV
186
        chainId = thresholdData.source.evm_chain_id.toString();
×
187
        // @ts-expect-error StrictNullChecks
UNCOV
188
        tokenId = thresholdData.source.token_id.toString();
×
UNCOV
189
        break;
×
190
      }
191
      case 'eth_native': {
192
        balanceSourceType = BalanceSourceType.ETHNative;
6✔
193
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
194
        break;
6✔
195
      }
196
      case 'cosmos_native': {
197
        //balanceSourceType not used downstream by tbc other than EVM contracts, Osmosis works for all cosmos chains
198
        balanceSourceType = BalanceSourceType.CosmosNative;
1✔
199
        chainId = thresholdData.source.cosmos_chain_id;
1✔
200
        break;
1✔
201
      }
202
      case 'cw20': {
203
        balanceSourceType = BalanceSourceType.CW20;
×
204
        contractAddress = thresholdData.source.contract_address;
×
205
        chainId = thresholdData.source.cosmos_chain_id;
×
206
        break;
×
207
      }
208
      case 'cw721': {
209
        balanceSourceType = BalanceSourceType.CW721;
×
UNCOV
210
        contractAddress = thresholdData.source.contract_address;
×
UNCOV
211
        chainId = thresholdData.source.cosmos_chain_id;
×
UNCOV
212
        break;
×
213
      }
214
      default:
UNCOV
215
        break;
×
216
    }
217

218
    const _balance = balances
19✔
219
      .filter((b) => b.options.balanceSourceType === balanceSourceType)
49✔
220
      .find((b) => {
221
        switch (b.options.balanceSourceType) {
19!
222
          case BalanceSourceType.ERC20:
223
          case BalanceSourceType.ERC721:
224
            return (
12✔
225
              b.options.sourceOptions.contractAddress == contractAddress &&
24✔
226
              b.options.sourceOptions.evmChainId.toString() === chainId
227
            );
228
          case BalanceSourceType.ERC1155:
UNCOV
229
            return (
×
230
              b.options.sourceOptions.contractAddress == contractAddress &&
×
231
              b.options.sourceOptions.evmChainId.toString() === chainId &&
232
              b.options.sourceOptions.tokenId.toString() === tokenId
233
            );
234
          case BalanceSourceType.ETHNative:
235
            return b.options.sourceOptions.evmChainId.toString() === chainId;
6✔
236
          case BalanceSourceType.CosmosNative:
237
            return b.options.sourceOptions.cosmosChainId.toString() === chainId;
1✔
238
          case BalanceSourceType.CW20:
239
          case BalanceSourceType.CW721:
240
            return (
×
241
              b.options.sourceOptions.contractAddress == contractAddress &&
×
242
              b.options.sourceOptions.cosmosChainId.toString() === chainId
243
            );
244
          case BalanceSourceType.SOLNFT:
245
          case BalanceSourceType.SPL:
UNCOV
246
            return b.options.mintAddress == contractAddress;
×
247
          case BalanceSourceType.SuiNative:
248
            if (objectId) {
×
UNCOV
249
              return (
×
250
                b.options.sourceOptions.suiNetwork === chainId &&
×
251
                b.options.sourceOptions.objectId === objectId
252
              );
253
            }
UNCOV
254
            return b.options.sourceOptions.suiNetwork === chainId;
×
255
          case BalanceSourceType.SuiToken:
UNCOV
256
            return (
×
257
              b.options.sourceOptions.suiNetwork === chainId &&
×
258
              b.options.sourceOptions.coinType === contractAddress
259
            );
260
          case BalanceSourceType.SuiNFT:
UNCOV
261
            return (
×
262
              b.options.sourceOptions.suiNetwork === chainId &&
×
263
              b.options.sourceOptions.collectionId === contractAddress
264
            );
265
          default:
UNCOV
266
            return null;
×
267
        }
268
      })?.balances[userAddress];
269

270
    if (typeof _balance !== 'string') {
19!
UNCOV
271
      throw new Error(`Failed to get balance for address`);
×
272
    }
273

274
    const balance = BigInt(_balance);
19✔
275
    const result = balance > toBigInt(thresholdData.threshold);
19✔
276

277
    return {
19✔
278
      result,
279
      message: !result
19✔
280
        ? `User Balance of ${_balance} below threshold ${thresholdData.threshold}`
281
        : 'pass',
282
      balance,
283
    };
284
  } catch (error) {
UNCOV
285
    return {
×
286
      result: false,
287
      message: `Error: ${error instanceof Error ? error.message : error}`,
×
288
    };
289
  }
290
}
291

292
function _allowlistCheck(
293
  userAddress: string,
294
  allowlistData: AllowlistData,
295
): { result: boolean; message: string } {
296
  try {
13✔
297
    const result = allowlistData.allow.includes(userAddress);
13✔
298
    return {
13✔
299
      result,
300
      message: !result ? 'User Address not in Allowlist' : 'pass',
13✔
301
    };
302
  } catch (error) {
UNCOV
303
    return {
×
304
      result: false,
305
      message: `Error: ${error instanceof Error ? error.message : error}`,
×
306
    };
307
  }
308
}
309

310
function _trustLevelCheck(
311
  user: UserInfo,
312
  trustLevelData: TrustLevelData,
313
): { result: boolean; message: string } {
314
  if (trustLevelData.sso_required) {
2✔
315
    if (
1!
316
      !user.wallet_sso ||
2✔
317
      !trustLevelData.sso_required.includes(user.wallet_sso)
318
    ) {
319
      return {
1✔
320
        result: false,
321
        message: 'User sso requirement not met',
322
      };
323
    }
324
  }
325

326
  if (user.user_tier < trustLevelData.minimum_trust_level)
1!
327
    return {
1✔
328
      result: false,
329
      message: 'User trust level requirement not met',
330
    };
331

UNCOV
332
  return {
×
333
    result: true,
334
    message: 'pass',
335
  };
336
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc