Skip to content

feat: implement bulk member addition for DirectPayments and UBI pools#313

Open
EmekaManuel wants to merge 13 commits intoGoodDollar:masterfrom
EmekaManuel:bulk-member-integration
Open

feat: implement bulk member addition for DirectPayments and UBI pools#313
EmekaManuel wants to merge 13 commits intoGoodDollar:masterfrom
EmekaManuel:bulk-member-integration

Conversation

@EmekaManuel
Copy link
Contributor

@EmekaManuel EmekaManuel commented Feb 4, 2026

  • Added addMembers function to DirectPaymentsFactory and UBIPoolFactory for adding multiple members in a single transaction.
  • Introduced MemberAdded event to track successful additions of members.
  • Enhanced DirectPaymentsPool and UBIPool contracts with validation and handling for bulk member additions.
  • Updated GoodCollectiveSDK to include methods for adding members to both pool types and estimating gas for bulk operations.
  • Added comprehensive tests for bulk member addition functionality in both DirectPayments and UBI pools to ensure reliability and performance.

Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.

About # (link your issue here)
#307

How Has This Been Tested?

All unit tests passed -
image

Please describe the tests that you ran to verify your changes.

Checklist:

  • PR title matches follow: (Feature|Bug|Chore) Task Name
  • My code follows the style guidelines of this project
  • I have followed all the instructions described in the initial task (check Definitions of Done)
  • I have performed a self-review of my own code
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have added reference to a related issue in the repository
  • I have added a detailed description of the changes proposed in the pull request. I am as descriptive as possible, assisting reviewers as much as possible.
  • I have added screenshots related to my pull request (for frontend tasks)
  • I have pasted a gif showing the feature.
  • @mentions of the person or team responsible for reviewing proposed changes

Summary by Sourcery

Add bulk member addition capabilities to DirectPayments and UBI pools and expose them via the SDK, including gas estimation utilities.

New Features:

  • Support adding multiple members in a single transaction for DirectPaymentsPool and UBIPool contracts with validation, batching limits, and registry synchronization.
  • Expose SDK methods to add members to DirectPayments and UBI pools and to estimate gas usage and recommended batch sizes for bulk member operations.

Enhancements:

  • Emit MemberAdded events from pool and factory contracts to track successful member registrations and updates to member-pool registries.
  • Strengthen member validation and max-members enforcement in UBIPool while keeping behavior consistent for individual and bulk additions.
  • Relax Hardhat Solidity compiler strictness and allow larger contract sizes on the Hardhat network to support the expanded pool contracts.

Tests:

  • Add comprehensive unit tests for bulk member addition in both DirectPaymentsPool and UBIPool, covering success paths, validation logic, max-member limits, factory registry behavior, and gas usage across varying batch sizes.

EmekaManuel and others added 11 commits December 9, 2025 05:23
… in contracts and SDK, including gas estimation and tests.
… pools with bulk updates and error handling improvements
- Simplified factory addMembers() to inline logic instead of calling external functions
- Removed BATCH_TOO_LARGE guards - gas management is now caller's responsibility
- Added MANAGER_ROLE access control to pool addMembers() methods
- Removed _grantMemberRoleWithoutFactory() helper functions
- Consolidated SDK methods into single addPoolMembers() with poolType parameter
- Updated tests to remove obsolete double-counting and batch size checks
- Fixed SDK TypeScript error in gas estimation function
BREAKING CHANGE: SDK addDirectPaymentsPoolMembers() and addUBIPoolMembers()
methods removed in favor of consolidated addPoolMembers(poolType) method
…pools

- Consolidated member addition logic in addMembers() functions to improve readability and maintainability.
- Removed redundant event emissions and replaced them with role grants for better event handling.
- Updated tests to reflect changes in event verification and member addition processes.
- Adjusted SDK methods to align with the new member addition structure, enhancing usability.
…yments and UBI pools fixing contract build error

- Updated addMember functions in DirectPaymentsFactory, UBIPool, and UBIPoolFactory contracts from external to public visibility to enhance accessibility.
- This change allows for more flexible member addition while maintaining existing access control mechanisms.
…ctPayments and UBI pools

- Updated for-loops in addMembers functions to remove unnecessary unchecked increment statements, enhancing code clarity and maintainability.
- This change streamlines the member addition process while preserving existing functionality.
…setup logic

- Introduced reusable setup functions for DirectPayments tests to streamline test initialization and improve maintainability.
- Updated test cases to utilize the new setup functions, reducing redundancy and enhancing clarity.
- Adjusted member validation mocks to ensure accurate testing of member addition scenarios.
…pools

- Consolidated member addition functionality by introducing internal helper functions for adding members, improving code clarity and maintainability.
- Updated addMembers functions to utilize the new helper methods, enhancing the overall structure of member addition processes.
- Introduced an internal helper function for adding members with validation, improving clarity and maintainability.
- Updated the addMembers function to utilize the new helper, allowing for better error handling and skipping invalid members.
- Adjusted member validation checks to ensure robust member addition processes while preserving existing functionality.
- Added `addMembers` function to `DirectPaymentsFactory` and `UBIPoolFactory` for adding multiple members in a single transaction.
- Introduced `MemberAdded` event to track successful additions of members.
- Enhanced `DirectPaymentsPool` and `UBIPool` contracts with validation and handling for bulk member additions.
- Updated `GoodCollectiveSDK` to include methods for adding members to both pool types and estimating gas for bulk operations.
- Added comprehensive tests for bulk member addition functionality in both DirectPayments and UBI pools to ensure reliability and performance.
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues, and left some high level feedback:

  • In estimateBulkAddMembersGas you type the first parameter as Signer | Provider but then unconditionally cast to Signer before calling estimateGas, which will fail at runtime when a plain provider is passed; either narrow the type to Signer or branch on the instance type and use a provider-connected contract for estimation.
  • The new MemberAdded events in DirectPaymentsFactory and UBIPoolFactory are never emitted in the new addMembers functions; if the event is intended to track registry updates, consider emitting it inside addMembers (and optionally guarding against duplicate memberPools entries for the same pool/member pair to keep the registry compact).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `estimateBulkAddMembersGas` you type the first parameter as `Signer | Provider` but then unconditionally cast to `Signer` before calling `estimateGas`, which will fail at runtime when a plain provider is passed; either narrow the type to `Signer` or branch on the instance type and use a provider-connected contract for estimation.
- The new `MemberAdded` events in `DirectPaymentsFactory` and `UBIPoolFactory` are never emitted in the new `addMembers` functions; if the event is intended to track registry updates, consider emitting it inside `addMembers` (and optionally guarding against duplicate `memberPools` entries for the same pool/member pair to keep the registry compact).

## Individual Comments

### Comment 1
<location> `packages/sdk-js/src/goodcollective/goodcollective.ts:750-751` </location>
<code_context>
+    const connected = pool.attach(poolAddress);
+
+    // Estimate gas for sample batch
+    const estimatedGas = await connected
+      .connect(signerOrProvider as ethers.Signer)
+      .estimateGas.addMembers(sampleMembers, sampleExtraData);
+
</code_context>

<issue_to_address>
**issue:** Casting a Provider to `Signer` will fail when a provider is passed to `estimateBulkAddMembersGas`.

This function takes `ethers.Signer | ethers.providers.Provider` but then casts `signerOrProvider` to `ethers.Signer` before calling `connect`. If a provider is passed, that cast is incorrect. Since `Contract.connect` already accepts both `Signer` and `Provider`, you can drop the cast and call `connected.connect(signerOrProvider)` directly, or better, construct `pool` with the correct signer/provider and avoid reconnecting here.
</issue_to_address>

### Comment 2
<location> `packages/sdk-js/src/goodcollective/goodcollective.ts:759-761` </location>
<code_context>
+    // Conservative batch size calculation
+    // Target ~10M gas per batch to stay well under block gas limit
+    const targetGasPerBatch = 10_000_000;
+    const recommendedBatchSize = Math.min(
+      Math.floor(targetGasPerBatch / estimatedGasPerMember.toNumber()),
+      200 // MAX_BATCH_SIZE
+    );
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Using `BigNumber.toNumber()` for gas math risks overflow and unnecessary JS number assumptions.

Here `estimatedGasPerMember.toNumber()` is used in `targetGasPerBatch / estimatedGasPerMember.toNumber()`. This is brittle and can overflow if gas estimates are larger than expected. Prefer keeping the math in `BigNumber` by doing `BigNumber.from(targetGasPerBatch).div(estimatedGasPerMember)` and only converting to a JS number for the final clamped result (e.g. `Math.min(bnValue.toNumber(), 200)`).

Suggested implementation:

```typescript
    // Conservative batch size calculation
    // Target ~10M gas per batch to stay well under block gas limit
    const targetGasPerBatch = ethers.BigNumber.from(10_000_000);
    const recommendedBatchSizeBn = targetGasPerBatch.div(estimatedGasPerMember);

    const recommendedBatchSize = Math.min(
      recommendedBatchSizeBn.toNumber(),
      200 // MAX_BATCH_SIZE
    );

```

1. Ensure `ethers` is already imported in this file (it appears to be, since `ethers.Signer` is used). If not, add `import { ethers } from "ethers";` at the top.
2. If there is a centralized constant for `MAX_BATCH_SIZE` elsewhere in the file, replace the inline `200` with that constant to keep config consistent.
</issue_to_address>

### Comment 3
<location> `packages/contracts/contracts/DirectPayments/DirectPaymentsPool.sol:231-222` </location>
<code_context>
         super._revokeRole(role, account);
     }

+    function addMembers(address[] calldata members, bytes[] calldata extraData) external {
+        if (members.length != extraData.length || members.length > 200) revert INVALID_INPUT();
+        
+        address[] memory addedMembers = new address[](members.length);
+        uint256 addedCount;
+
+        for (uint256 i; i < members.length; ++i) {
+            if (_addMemberWithoutRegistry(members[i], extraData[i])) {
+                addedMembers[addedCount++] = members[i];
+            }
+        }
+
</code_context>

<issue_to_address>
**🚨 issue (security):** Public `addMembers` on DirectPaymentsPool has no access control and allows arbitrary callers to add members.

Unlike the UBI pool’s bulk entrypoint, this one relies only on validators/uniqueness checks, so any address can repeatedly attempt to add arbitrary members (griefing, spam) and effectively shifts admission control to validator configuration instead of pool governance. Please align this function’s access control with the single-member path (e.g., `onlyRole(MANAGER_ROLE)` or equivalent) so bulk and single additions are governed by the same permissions model.
</issue_to_address>

### Comment 4
<location> `packages/contracts/test/UBIPool/UBIPool.bulkMembers.test.ts:512-516` </location>
<code_context>
+  });
+
+  describe('addMembers - Error Cases', () => {
+    it('should revert if arrays have mismatched lengths', async () => {
+      const members = [signers[1].address, signers[2].address];
+      const extraData = ['0x']; // Only 1 element
+
+      await expect(pool.addMembers(members, extraData)).to.be.reverted;
+    });
+
</code_context>

<issue_to_address>
**suggestion (testing):** Assert on the specific revert reason for better regression protection

This test only asserts that `addMembers` reverts, not *why* it reverts. If the contract uses a specific revert message or custom error for length mismatch, please assert it via `.to.be.revertedWith(...)` or `.to.be.revertedWithCustomError(...)` so changes in revert semantics are caught by the test.

Suggested implementation:

```typescript
  describe('addMembers - Error Cases', () => {
    it('should revert with length mismatch error when arrays have mismatched lengths', async () => {
      const members = [signers[1].address, signers[2].address];
      const extraData = ['0x']; // Only 1 element

      await expect(pool.addMembers(members, extraData)).to.be.revertedWith(
        'UBIPool: members and extraData length mismatch',
      );
    });
  });

type SignerWithAddress = Awaited<ReturnType<typeof ethers.getSigner>>;

```

If the actual revert semantics differ, you should adjust the expectation accordingly:
1. If the contract uses a different revert string, replace `'UBIPool: members and extraData length mismatch'` with the actual message.
2. If the contract uses a custom error instead of a revert string, replace `.to.be.revertedWith(...)` with `.to.be.revertedWithCustomError(pool, 'YourCustomErrorName')`.
</issue_to_address>

### Comment 5
<location> `packages/contracts/test/UBIPool/UBIPool.bulkMembers.test.ts:519-516` </location>
<code_context>
+      await expect(pool.addMembers(members, extraData)).to.be.reverted;
+    });
+
+    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
+      const members: string[] = [];
+      const extraData: string[] = [];
+
+      // Try to add 201 members
+      for (let i = 0; i < 201; i++) {
+        const wallet = ethers.Wallet.createRandom();
+        members.push(wallet.address);
+        extraData.push('0x');
+      }
+
+      await expect(pool.addMembers(members, extraData)).to.be.reverted;
+    });
+  });
</code_context>

<issue_to_address>
**suggestion (testing):** Strengthen MAX_BATCH_SIZE test by asserting the exact revert reason

To better validate the batch-size guard, assert the specific revert reason here (e.g., `.to.be.revertedWith('...')` or `.to.be.revertedWithCustomError(...)`) rather than just `.to.be.reverted`. Otherwise a different revert path (like gas issues or a new pre-check) could still make this test pass without confirming the intended condition.

Suggested implementation:

```typescript
    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
      const members: string[] = [];
      const extraData: string[] = [];

      // Try to add 201 members
      for (let i = 0; i < 201; i++) {
        const wallet = ethers.Wallet.createRandom();
        members.push(wallet.address);
        extraData.push('0x');
      }

      // Assert the specific revert reason to ensure we're hitting the MAX_BATCH_SIZE guard
      await expect(pool.addMembers(members, extraData))
        .to.be.revertedWith('MAX_BATCH_SIZE_EXCEEDED');
      // If the contract instead uses a custom error, replace the line above with:
      // await expect(pool.addMembers(members, extraData))
      //   .to.be.revertedWithCustomError(pool, 'MaxBatchSizeExceeded');
    });

```

` that you should replace it with the actual revert reason or custom error.

Here are the edits:

```xml
<file_operations>
<file_operation operation="edit" file_path="packages/contracts/test/UBIPool/UBIPool.bulkMembers.test.ts">
<<<<<<< SEARCH
    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
      const members: string[] = [];
      const extraData: string[] = [];

      // Try to add 201 members
      for (let i = 0; i < 201; i++) {
        const wallet = ethers.Wallet.createRandom();
        members.push(wallet.address);
        extraData.push('0x');
      }

      await expect(pool.addMembers(members, extraData)).to.be.reverted;
    });
=======
    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
      const members: string[] = [];
      const extraData: string[] = [];

      // Try to add 201 members
      for (let i = 0; i < 201; i++) {
        const wallet = ethers.Wallet.createRandom();
        members.push(wallet.address);
        extraData.push('0x');
      }

      // Assert the specific revert reason to ensure we're hitting the MAX_BATCH_SIZE guard
      await expect(pool.addMembers(members, extraData))
        .to.be.revertedWith('MAX_BATCH_SIZE_EXCEEDED');
      // If the contract instead uses a custom error, replace the line above with:
      // await expect(pool.addMembers(members, extraData))
      //   .to.be.revertedWithCustomError(pool, 'MaxBatchSizeExceeded');
    });
>>>>>>> REPLACE
</file_operation>
</file_operations>
```

<additional_changes>
1. Replace `'MAX_BATCH_SIZE_EXCEEDED'` with the actual revert string used in the UBIPool contract for the batch-size check, if it uses `require(..., "message")`.
2. If the contract uses a custom error instead (for example `error MaxBatchSizeExceeded();`), delete the `.revertedWith(...)` call and switch to the `.revertedWithCustomError(pool, 'MaxBatchSizeExceeded')` version shown in the comment.
3. After adjusting the revert reason/custom error name, remove the inline commented alternative to keep the test file clean and consistent with the rest of the codebase.
</issue_to_address>

### Comment 6
<location> `packages/contracts/test/DirectPayments/DirectPayments.bulkMembers.test.ts:306-303` </location>
<code_context>
+      await expect(pool.addMembers(members, extraData)).to.be.reverted;
+    });
+
+    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
+      const members: string[] = [];
+      const extraData: string[] = [];
+
+      // Try to add 201 members
+      for (let i = 0; i < 201; i++) {
+        const wallet = ethers.Wallet.createRandom();
+        members.push(wallet.address);
+        extraData.push('0x');
+      }
+
+      await expect(pool.addMembers(members, extraData)).to.be.reverted;
+    });
+  });
</code_context>

<issue_to_address>
**suggestion (testing):** Tighten MAX_BATCH_SIZE test with explicit revert message/custom error

For the DirectPayments pool tests, please also assert the specific revert reason/custom error when `MAX_BATCH_SIZE` is exceeded. Using only `to.be.reverted` allows unrelated failures to pass the test and can hide regressions in the batch-size check.

Suggested implementation:

```typescript
    it('should revert if batch size exceeds MAX_BATCH_SIZE (200)', async () => {
      const members: string[] = [];
      const extraData: string[] = [];

      // Try to add 201 members
      for (let i = 0; i < 201; i++) {
        const wallet = ethers.Wallet.createRandom();
        members.push(wallet.address);
        extraData.push('0x');
      }

      await expect(pool.addMembers(members, extraData))
        .to.be.revertedWithCustomError(pool, 'MaxBatchSizeExceeded');
    });

```

1. Replace `'MaxBatchSizeExceeded'` with the actual custom error name emitted by the DirectPayments pool when the batch size exceeds `MAX_BATCH_SIZE`. If the contract uses a `require` with a revert string instead of a custom error, change the assertion to:
   ```ts
   await expect(pool.addMembers(members, extraData))
     .to.be.revertedWith('YourExactRevertMessageHere');
   ```
2. Ensure the DirectPayments contract is compiled with the custom error and that the test suite already uses `.to.be.revertedWithCustomError` elsewhere (for consistency). If not, you may want to update similar tests to also assert specific revert reasons.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@openzeppelin-code
Copy link

openzeppelin-code bot commented Feb 4, 2026

feat: implement bulk member addition for DirectPayments and UBI pools

Generated at commit: a8898e93030c6c16de13d31d1642df906ece6149

🚨 Report Summary

Severity Level Results
Contracts Critical
High
Medium
Low
Note
Total
1
1
0
4
28
34
Dependencies Critical
High
Medium
Low
Note
Total
0
0
0
0
0
0

For more details view the full report in OpenZeppelin Code Inspector

@EmekaManuel
Copy link
Contributor Author

kindly review @sirpy

- Updated `addMembers` function to include role validation for managers, allowing them to bypass certain checks.
- Modified `_addMemberWithoutRegistry` to accept a new parameter for manager status, refining member validation logic.
- Adjusted tests to ensure proper handling of member addition based on role, including scenarios for non-manager callers.
- Improved error handling in tests to provide clearer feedback on invalid input cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant