Skip to content

feat(affiliate): add MultiAffiliateGroupRegistry SDK support#73

Open
leinss wants to merge 1 commit into
mainfrom
feature/affiliate-rpc
Open

feat(affiliate): add MultiAffiliateGroupRegistry SDK support#73
leinss wants to merge 1 commit into
mainfrom
feature/affiliate-rpc

Conversation

@leinss

@leinss leinss commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Typed SDK surface for the multi-affiliate-group ("communities") registry — an avatar's
per-avatar on-chain intent to join Circles groups, plus the indexer read methods that
expose membership, fees, and the trusted (confirmed) subset.

What's added

Reads — CirclesRpc.affiliate namespace (rpc.affiliate.*), wrapping the five
circles_getAffiliateGroup* methods:

  • getAffiliateGroupWishlist(avatar) / getAffiliateGroups(avatar){ totalFeePercentage, groups } (intent, and the trusted subset)
  • getAffiliateGroupFeesPercentage(avatar)number (the 100%-cap pre-join check)
  • getAffiliateGroupMembersWishlist(group, limit?, cursor?) / getAffiliateGroupMembers(...) → cursor-paginated PagedResponse
  • Address params normalized to lowercase; addresses returned EIP-55 checksummed (consistent with the other RPC method classes).

Writes — MultiAffiliateGroupRegistryContract (@aboutcircles/sdk-core), mirroring the
existing single-group registry wrapper:

  • addAffiliateGroup / removeAffiliateGroupTransactionRequest calldata builders
  • on-chain reads: isAffiliated (single-call add/remove preflight), affiliateGroups (linked-list walk), deployer, isInitialized

Plumbing: multiAffiliateGroupRegistryAbi (abis); MULTI_AFFILIATE_GROUP_REGISTRY +
AFFILIATE_GROUP_LIST_SENTINEL (utils); AffiliateGroupRow / AffiliateGroupListResponse /
AffiliateGroupMemberRow (types). Reads depend only on types/utils; writes live in core.

Notes

  • The registry's Hub is a hardcoded constant (Gnosis production Hub), so the deployment is Gnosis-mainnet only.
  • addAffiliateGroup is idempotent on-chain (no-op if already present); removeAffiliateGroup reverts AffiliateGroupNotExist when absent — hence the isAffiliated preflight.
  • The read methods are intent-only off-chain enrichment; they always read chain head (no block-pinning).

Test plan

  • bun run build (all packages) + tsc --build clean
  • bun test packages/core (contract calldata selectors, custom-error decoding, linked-list walk)
  • bun test packages/rpc/src/tests/affiliate.test.ts (param normalization, checksum output, pagination)
  • bun test packages/utils (no regression from the new constants)

Typed SDK surface for the multi-affiliate-group ("communities") registry —
per-avatar on-chain intent to join groups, plus the indexer read methods.

- abis: multiAffiliateGroupRegistryAbi (functions, events, custom errors)
- core: MultiAffiliateGroupRegistryContract — addAffiliateGroup / removeAffiliateGroup
  calldata builders + on-chain reads (isAffiliated, affiliateGroups list walk,
  deployer, isInitialized)
- utils: MULTI_AFFILIATE_GROUP_REGISTRY + AFFILIATE_GROUP_LIST_SENTINEL constants
- rpc: CirclesRpc.affiliate namespace wrapping the five circles_getAffiliateGroup*
  read methods (wishlist, trusted subset, fee percentage, per-group members
  wishlist/trusted with cursor pagination); addresses returned EIP-55 checksummed
- types: AffiliateGroupRow, AffiliateGroupListResponse, AffiliateGroupMemberRow

Tests: contract calldata selectors + custom-error decoding + linked-list walk;
rpc param normalization, checksum output, and pagination.
@leinss
leinss force-pushed the feature/affiliate-rpc branch from a12b616 to d969677 Compare June 29, 2026 16:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts (1)

27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the mock fail on unexpected extra reads.

Clamping to the last mocked value hides over-reads. A traversal that forgets to stop at the sentinel can still pass these tests because every extra eth_call gets another valid address back.

Proposed fix
 function mockReadSequence(returns: Address[]) {
   const orig = globalThis.fetch;
   let i = 0;
   globalThis.fetch = (async (_input: unknown, init?: { body?: unknown }) => {
     const body = JSON.parse(String(init?.body ?? '{}'));
-    const next = returns[Math.min(i, returns.length - 1)];
-    i++;
+    if (i >= returns.length) {
+      throw new Error(`Unexpected extra eth_call after ${returns.length} mocked responses`);
+    }
+    const next = returns[i++];
     return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: body.id, result: '0x' + word(next) }) };
   }) as unknown as typeof fetch;
   return () => {
     globalThis.fetch = orig;
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts` around
lines 27 - 35, The mock in mockReadSequence currently clamps to the last
Address, which hides unexpected extra eth_call reads and lets traversal bugs
pass. Update the mock so it consumes the provided returns array strictly and
fails once the sequence is exhausted, using the existing mockReadSequence helper
in multiAffiliateGroupRegistry.test.ts to throw or reject on any over-read. Keep
the sentinel behavior explicit so tests fail when the code under test performs
more reads than expected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/contracts/multiAffiliateGroupRegistry.ts`:
- Around line 108-115: The list walk in multiAffiliateGroupRegistry’s read path
silently returns a partial result when it reaches MAX_LIST_WALK, so update the
traversal logic to detect that case and fail explicitly instead of returning
groups as if complete. Use the existing node-walk in the registry method that
pushes into groups and reads affiliateGroupList to either throw an error or
otherwise signal truncation when the guard is hit without reaching the
sentinel/zero address, so callers can distinguish malformed or oversized lists
from a valid on-chain result.
- Around line 88-90: The isAffiliated method in MultiAffiliateGroupRegistry
currently treats the sentinel head as a real affiliation because it only checks
for zero address; update the logic so
affiliateGroupList[avatar][AFFILIATE_GROUP_LIST_SENTINEL] (and any
zero/pseudo-node value) always returns false. Use the existing isAffiliated
helper and the AFFILIATE_GROUP_LIST_SENTINEL symbol to special-case the sentinel
before returning the read result.

---

Nitpick comments:
In `@packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts`:
- Around line 27-35: The mock in mockReadSequence currently clamps to the last
Address, which hides unexpected extra eth_call reads and lets traversal bugs
pass. Update the mock so it consumes the provided returns array strictly and
fails once the sequence is exhausted, using the existing mockReadSequence helper
in multiAffiliateGroupRegistry.test.ts to throw or reject on any over-read. Keep
the sentinel behavior explicit so tests fail when the code under test performs
more reads than expected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f4b27eba-a80c-46f9-85c6-1949a2bf87c4

📥 Commits

Reviewing files that changed from the base of the PR and between 8ba071e and a12b616.

📒 Files selected for processing (14)
  • packages/abis/src/index.ts
  • packages/abis/src/multiAffiliateGroupRegistry.ts
  • packages/core/src/contracts/index.ts
  • packages/core/src/contracts/multiAffiliateGroupRegistry.ts
  • packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts
  • packages/rpc/src/index.ts
  • packages/rpc/src/methods/affiliate.ts
  • packages/rpc/src/methods/index.ts
  • packages/rpc/src/rpc.ts
  • packages/rpc/src/tests/affiliate.test.ts
  • packages/types/src/affiliate.ts
  • packages/types/src/index.ts
  • packages/utils/src/constants.ts
  • packages/utils/src/index.ts

Comment on lines +88 to +90
async isAffiliated(avatar: Address, group: Address): Promise<boolean> {
const next = (await this.read('affiliateGroupList', [avatar, group])) as Address;
return !isZeroAddress(next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return false for sentinel/zero pseudo-nodes.

affiliateGroupList[avatar][AFFILIATE_GROUP_LIST_SENTINEL] is the list head, not a real group. As written, isAffiliated(avatar, AFFILIATE_GROUP_LIST_SENTINEL) returns true for any non-empty list, which breaks the method contract.

Proposed fix
  async isAffiliated(avatar: Address, group: Address): Promise<boolean> {
+   if (isZeroAddress(group) || hexEq(group, AFFILIATE_GROUP_LIST_SENTINEL)) return false;
    const next = (await this.read('affiliateGroupList', [avatar, group])) as Address;
    return !isZeroAddress(next);
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async isAffiliated(avatar: Address, group: Address): Promise<boolean> {
const next = (await this.read('affiliateGroupList', [avatar, group])) as Address;
return !isZeroAddress(next);
async isAffiliated(avatar: Address, group: Address): Promise<boolean> {
if (isZeroAddress(group) || hexEq(group, AFFILIATE_GROUP_LIST_SENTINEL)) return false;
const next = (await this.read('affiliateGroupList', [avatar, group])) as Address;
return !isZeroAddress(next);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/contracts/multiAffiliateGroupRegistry.ts` around lines 88 -
90, The isAffiliated method in MultiAffiliateGroupRegistry currently treats the
sentinel head as a real affiliation because it only checks for zero address;
update the logic so affiliateGroupList[avatar][AFFILIATE_GROUP_LIST_SENTINEL]
(and any zero/pseudo-node value) always returns false. Use the existing
isAffiliated helper and the AFFILIATE_GROUP_LIST_SENTINEL symbol to special-case
the sentinel before returning the read result.

Comment on lines +108 to +115
for (let i = 0; i < MAX_LIST_WALK; i++) {
// Empty list (head == 0) or wrapped back to the sentinel tail → done.
if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) break;
groups.push(node);
node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
}

return groups;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not silently return a truncated list when the walk hits MAX_LIST_WALK.

If the list is malformed or simply longer than the guard, this returns the first 1,000 entries as though the read completed successfully. That makes a partial result indistinguishable from the on-chain truth.

Proposed fix
  async affiliateGroups(avatar: Address): Promise<Address[]> {
    const groups: Address[] = [];
    let node = (await this.read('affiliateGroupList', [avatar, AFFILIATE_GROUP_LIST_SENTINEL])) as Address;

    for (let i = 0; i < MAX_LIST_WALK; i++) {
      // Empty list (head == 0) or wrapped back to the sentinel tail → done.
-     if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) break;
+     if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) return groups;
      groups.push(node);
      node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
    }

-   return groups;
+   throw new Error(`Affiliate group list walk exceeded ${MAX_LIST_WALK} nodes`);
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let i = 0; i < MAX_LIST_WALK; i++) {
// Empty list (head == 0) or wrapped back to the sentinel tail → done.
if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) break;
groups.push(node);
node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
}
return groups;
for (let i = 0; i < MAX_LIST_WALK; i++) {
// Empty list (head == 0) or wrapped back to the sentinel tail → done.
if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) return groups;
groups.push(node);
node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
}
throw new Error(`Affiliate group list walk exceeded ${MAX_LIST_WALK} nodes`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/contracts/multiAffiliateGroupRegistry.ts` around lines 108
- 115, The list walk in multiAffiliateGroupRegistry’s read path silently returns
a partial result when it reaches MAX_LIST_WALK, so update the traversal logic to
detect that case and fail explicitly instead of returning groups as if complete.
Use the existing node-walk in the registry method that pushes into groups and
reads affiliateGroupList to either throw an error or otherwise signal truncation
when the guard is hit without reaching the sentinel/zero address, so callers can
distinguish malformed or oversized lists from a valid on-chain result.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds typed SDK support for the GA 2.0 multi-affiliate-group (“communities”) registry across the SDK stack (types → RPC reads → core contract wrapper), plus ABI/constants plumbing.

Changes:

  • Added CirclesRpc.affiliate read methods wrapping circles_getAffiliateGroup*, including address normalization + EIP-55 checksum output and pagination support.
  • Added MultiAffiliateGroupRegistryContract (core) with calldata builders and on-chain reads (including a linked-list walk), plus bun tests.
  • Added shared ABI, constants, and shared sdk-types models for affiliate-group list/member rows.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/utils/src/index.ts Re-exports new multi-affiliate registry constants from utils.
packages/utils/src/constants.ts Adds MULTI_AFFILIATE_GROUP_REGISTRY and AFFILIATE_GROUP_LIST_SENTINEL.
packages/types/src/index.ts Re-exports new affiliate (“communities”) types.
packages/types/src/affiliate.ts Introduces AffiliateGroupRow, AffiliateGroupListResponse, AffiliateGroupMemberRow.
packages/rpc/src/tests/affiliate.test.ts Adds RPC method tests for normalization, checksum output, and pagination params.
packages/rpc/src/rpc.ts Adds CirclesRpc.affiliate getter and lazy initialization.
packages/rpc/src/methods/index.ts Exposes the new AffiliateMethods class.
packages/rpc/src/methods/affiliate.ts Implements the circles_getAffiliateGroup* RPC wrappers.
packages/rpc/src/index.ts Re-exports AffiliateMethods and affiliate types for consumers.
packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts Adds tests for calldata, reads, and custom-error decoding.
packages/core/src/contracts/multiAffiliateGroupRegistry.ts Adds MultiAffiliateGroupRegistry contract wrapper and linked-list walk.
packages/core/src/contracts/index.ts Exports MultiAffiliateGroupRegistryContract.
packages/abis/src/multiAffiliateGroupRegistry.ts Adds the MultiAffiliateGroupRegistry ABI.
packages/abis/src/index.ts Re-exports the new ABI from the abis package entrypoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +108 to +115
for (let i = 0; i < MAX_LIST_WALK; i++) {
// Empty list (head == 0) or wrapped back to the sentinel tail → done.
if (isZeroAddress(node) || hexEq(node, AFFILIATE_GROUP_LIST_SENTINEL)) break;
groups.push(node);
node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
}

return groups;
Comment on lines +30 to +35
globalThis.fetch = (async (_input: unknown, init?: { body?: unknown }) => {
const body = JSON.parse(String(init?.body ?? '{}'));
const next = returns[Math.min(i, returns.length - 1)];
i++;
return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: body.id, result: '0x' + word(next) }) };
}) as unknown as typeof fetch;
Comment on lines +3 to +8
import { AFFILIATE_GROUP_LIST_SENTINEL, ZERO_ADDRESS } from '@aboutcircles/sdk-utils';
import type { Address } from '@aboutcircles/sdk-types';
import { multiAffiliateGroupRegistryAbi } from '@aboutcircles/sdk-abis';
import { MultiAffiliateGroupRegistryContract } from '../multiAffiliateGroupRegistry.js';

const REGISTRY = '0x4a25a7cf216351963f1637ad965d77b3ae277ef3' as Address;
@aboutcircles aboutcircles deleted a comment from coderabbitai Bot Jul 2, 2026
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.

2 participants