feat(affiliate): add MultiAffiliateGroupRegistry SDK support#73
Conversation
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.
a12b616 to
d969677
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts (1)
27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMake 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_callgets 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
📒 Files selected for processing (14)
packages/abis/src/index.tspackages/abis/src/multiAffiliateGroupRegistry.tspackages/core/src/contracts/index.tspackages/core/src/contracts/multiAffiliateGroupRegistry.tspackages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.tspackages/rpc/src/index.tspackages/rpc/src/methods/affiliate.tspackages/rpc/src/methods/index.tspackages/rpc/src/rpc.tspackages/rpc/src/tests/affiliate.test.tspackages/types/src/affiliate.tspackages/types/src/index.tspackages/utils/src/constants.tspackages/utils/src/index.ts
| async isAffiliated(avatar: Address, group: Address): Promise<boolean> { | ||
| const next = (await this.read('affiliateGroupList', [avatar, group])) as Address; | ||
| return !isZeroAddress(next); |
There was a problem hiding this comment.
🎯 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.
| 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.
| 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; |
There was a problem hiding this comment.
🎯 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.
| 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.
There was a problem hiding this comment.
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.affiliateread methods wrappingcircles_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-typesmodels 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.
| 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; |
| 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; |
| 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; |
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.affiliatenamespace (rpc.affiliate.*), wrapping the fivecircles_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-paginatedPagedResponseWrites —
MultiAffiliateGroupRegistryContract(@aboutcircles/sdk-core), mirroring theexisting single-group registry wrapper:
addAffiliateGroup/removeAffiliateGroup→TransactionRequestcalldata buildersisAffiliated(single-call add/remove preflight),affiliateGroups(linked-list walk),deployer,isInitializedPlumbing:
multiAffiliateGroupRegistryAbi(abis);MULTI_AFFILIATE_GROUP_REGISTRY+AFFILIATE_GROUP_LIST_SENTINEL(utils);AffiliateGroupRow/AffiliateGroupListResponse/AffiliateGroupMemberRow(types). Reads depend only ontypes/utils; writes live incore.Notes
addAffiliateGroupis idempotent on-chain (no-op if already present);removeAffiliateGrouprevertsAffiliateGroupNotExistwhen absent — hence theisAffiliatedpreflight.Test plan
bun run build(all packages) +tsc --buildcleanbun 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)