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

hicommonwealth / commonwealth / 16011211381

01 Jul 2025 10:09PM UTC coverage: 40.03% (+0.06%) from 39.972%
16011211381

push

github

web-flow
Merge pull request #12569 from hicommonwealth/rotorsoft/12553-trust-level-requirements

Refactors existing membership model to include new types

1856 of 5022 branches covered (36.96%)

Branch coverage included in aggregate %.

19 of 26 new or added lines in 2 files covered. (73.08%)

1 existing line in 1 file now uncovered.

3293 of 7841 relevant lines covered (42.0%)

36.98 hits per line

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

54.4
/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';
13

14
type Requirement = z.infer<typeof Requirement>;
15
type AllowlistData = z.infer<typeof AllowlistData>;
16
type ThresholdData = z.infer<typeof ThresholdData>;
17
type TrustLevelData = z.infer<typeof TrustLevelData>;
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!
NEW
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;
×
NEW
141
        chainId =
×
142
          'solana_network' in thresholdData.source
×
143
            ? thresholdData.source.solana_network.toString()
144
            : thresholdData.source.evm_chain_id.toString();
UNCOV
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 'erc20': {
166
        balanceSourceType = BalanceSourceType.ERC20;
6✔
167
        contractAddress = thresholdData.source.contract_address;
6✔
168
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
169
        break;
6✔
170
      }
171
      case 'erc721': {
172
        balanceSourceType = BalanceSourceType.ERC721;
6✔
173
        contractAddress = thresholdData.source.contract_address;
6✔
174
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
175
        break;
6✔
176
      }
177
      case 'erc1155': {
178
        balanceSourceType = BalanceSourceType.ERC1155;
×
179
        contractAddress = thresholdData.source.contract_address;
×
180
        chainId = thresholdData.source.evm_chain_id.toString();
×
181
        // @ts-expect-error StrictNullChecks
182
        tokenId = thresholdData.source.token_id.toString();
×
183
        break;
×
184
      }
185
      case 'eth_native': {
186
        balanceSourceType = BalanceSourceType.ETHNative;
6✔
187
        chainId = thresholdData.source.evm_chain_id.toString();
6✔
188
        break;
6✔
189
      }
190
      case 'cosmos_native': {
191
        //balanceSourceType not used downstream by tbc other than EVM contracts, Osmosis works for all cosmos chains
192
        balanceSourceType = BalanceSourceType.CosmosNative;
1✔
193
        chainId = thresholdData.source.cosmos_chain_id;
1✔
194
        break;
1✔
195
      }
196
      case 'cw20': {
197
        balanceSourceType = BalanceSourceType.CW20;
×
198
        contractAddress = thresholdData.source.contract_address;
×
199
        chainId = thresholdData.source.cosmos_chain_id;
×
200
        break;
×
201
      }
202
      case 'cw721': {
203
        balanceSourceType = BalanceSourceType.CW721;
×
204
        contractAddress = thresholdData.source.contract_address;
×
205
        chainId = thresholdData.source.cosmos_chain_id;
×
206
        break;
×
207
      }
208
      default:
209
        break;
×
210
    }
211

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

259
    if (typeof _balance !== 'string') {
19!
260
      throw new Error(`Failed to get balance for address`);
×
261
    }
262

263
    const balance = BigInt(_balance);
19✔
264
    const result = balance > toBigInt(thresholdData.threshold);
19✔
265

266
    return {
19✔
267
      result,
268
      message: !result
19✔
269
        ? `User Balance of ${_balance} below threshold ${thresholdData.threshold}`
270
        : 'pass',
271
      balance,
272
    };
273
  } catch (error) {
274
    return {
×
275
      result: false,
276
      message: `Error: ${error instanceof Error ? error.message : error}`,
×
277
    };
278
  }
279
}
280

281
function _allowlistCheck(
282
  userAddress: string,
283
  allowlistData: AllowlistData,
284
): { result: boolean; message: string } {
285
  try {
13✔
286
    const result = allowlistData.allow.includes(userAddress);
13✔
287
    return {
13✔
288
      result,
289
      message: !result ? 'User Address not in Allowlist' : 'pass',
13✔
290
    };
291
  } catch (error) {
292
    return {
×
293
      result: false,
294
      message: `Error: ${error instanceof Error ? error.message : error}`,
×
295
    };
296
  }
297
}
298

299
function _trustLevelCheck(
300
  user: UserInfo,
301
  trustLevelData: TrustLevelData,
302
): { result: boolean; message: string } {
303
  if (trustLevelData.sso_required) {
2✔
304
    if (
1!
305
      !user.wallet_sso ||
2✔
306
      !trustLevelData.sso_required.includes(user.wallet_sso)
307
    ) {
308
      return {
1✔
309
        result: false,
310
        message: 'User sso requirement not met',
311
      };
312
    }
313
  }
314

315
  if (user.user_tier < trustLevelData.minimum_trust_level)
1!
316
    return {
1✔
317
      result: false,
318
      message: 'User trust level requirement not met',
319
    };
320

NEW
321
  return {
×
322
    result: true,
323
    message: 'pass',
324
  };
325
}
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