Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/abis/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { invitationFarmAbi } from './invitationFarm.js';
export { referralsModuleAbi } from './referralsModule.js';
export { scoreGatedMintPolicyAbi } from './scoreGatedMintPolicy.js';
export { affiliateGroupRegistryAbi } from './affiliateGroupRegistry.js';
export { multiAffiliateGroupRegistryAbi } from './multiAffiliateGroupRegistry.js';
126 changes: 126 additions & 0 deletions packages/abis/src/multiAffiliateGroupRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* ABI for the MultiAffiliateGroupRegistry contract (GA 2.0 "communities").
*
* Lets each human avatar maintain its own list of affiliate groups it has signalled
* on-chain intent to join. Storage is a per-avatar singly-linked list keyed by avatar
* address (prepend-ordered, circular through the `0x01` sentinel). The registry stores
* **intent only** — it does not enforce the membership-fee cap or any group criteria.
*
* - `addAffiliateGroup(group)` — caller must be a Hub human and `group` a Hub group.
* Idempotent: re-adding a group already in the list is a no-op (no event, no revert).
* - `removeAffiliateGroup(group)` — reverts `AffiliateGroupNotExist` when absent.
* - `AffiliateGroupAdded` / `AffiliateGroupRemoved` carry both params **non-indexed**.
* - `initialize` / `lockInitialization` are deployer-only one-time seeding helpers.
*/
export const multiAffiliateGroupRegistryAbi = [
{
type: 'constructor',
inputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'hub',
inputs: [],
outputs: [{ name: '', type: 'address', internalType: 'contract IHub' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'deployer',
inputs: [],
outputs: [{ name: '', type: 'address', internalType: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'isInitialized',
inputs: [],
outputs: [{ name: '', type: 'bool', internalType: 'bool' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'affiliateGroupList',
inputs: [
{ name: '', type: 'address', internalType: 'address' },
{ name: '', type: 'address', internalType: 'address' },
],
outputs: [{ name: '', type: 'address', internalType: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'initialize',
inputs: [
{ name: 'avatars', type: 'address[]', internalType: 'address[]' },
{ name: 'affiliateGroup', type: 'address[]', internalType: 'address[]' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'lockInitialization',
inputs: [],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'addAffiliateGroup',
inputs: [{ name: 'affiliateGroupToAdd', type: 'address', internalType: 'address' }],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'removeAffiliateGroup',
inputs: [{ name: 'affiliateGroupToRemove', type: 'address', internalType: 'address' }],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'event',
name: 'AffiliateGroupAdded',
inputs: [
{ name: 'affiliateGroup', type: 'address', indexed: false, internalType: 'address' },
{ name: 'avatar', type: 'address', indexed: false, internalType: 'address' },
],
anonymous: false,
},
{
type: 'event',
name: 'AffiliateGroupRemoved',
inputs: [
{ name: 'affiliateGroup', type: 'address', indexed: false, internalType: 'address' },
{ name: 'avatar', type: 'address', indexed: false, internalType: 'address' },
],
anonymous: false,
},
{
type: 'error',
name: 'AffiliateGroupNotExist',
inputs: [{ name: 'affiliateGroup', type: 'address', internalType: 'address' }],
},
{
type: 'error',
name: 'OnlyHuman',
inputs: [],
},
{
type: 'error',
name: 'SenderNotDeployer',
inputs: [],
},
{
type: 'error',
name: 'ArrayLengthMismatch',
inputs: [],
},
{
type: 'error',
name: 'AlreadyInitialized',
inputs: [],
},
] as const;
1 change: 1 addition & 0 deletions packages/core/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export { InvitationFarmContract } from './invitationFarm.js';
export { ReferralsModuleContract } from './referralsModule.js';
export { ScoreGatedMintPolicyContract } from './scoreGatedMintPolicy.js';
export { AffiliateGroupRegistryContract } from './affiliateGroupRegistry.js';
export { MultiAffiliateGroupRegistryContract } from './multiAffiliateGroupRegistry.js';
export { Contract } from './contract.js';
133 changes: 133 additions & 0 deletions packages/core/src/contracts/multiAffiliateGroupRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Contract } from './contract.js';
import { multiAffiliateGroupRegistryAbi } from '@aboutcircles/sdk-abis';
import { AFFILIATE_GROUP_LIST_SENTINEL, isZeroAddress, hexEq } from '@aboutcircles/sdk-utils';
import type { Address, TransactionRequest } from '@aboutcircles/sdk-types';

/**
* Defensive bound on the affiliate-group linked-list walk in {@link
* MultiAffiliateGroupRegistryContract.affiliateGroups}. An avatar's list is tiny in
* practice (each entry is one signalled community), so this only guards against a
* malformed/cyclic on-chain state — it never truncates a real list.
*/
const MAX_LIST_WALK = 1000;

/**
* MultiAffiliateGroupRegistry Contract Wrapper (GA 2.0 "communities").
*
* Lets a human avatar maintain its own list of affiliate groups it has signalled
* on-chain intent to join. The registry stores **intent only** — it does not enforce
* the membership-fee cap or any group criteria (those are computed off-chain: fees from
* the group profile, served by `circles_getAvatarCommunityFeesPercentage`, and trust by the TMS).
*
* This wrapper produces the write calldata ({@link addAffiliateGroup} /
* {@link removeAffiliateGroup}) and offers thin on-chain reads ({@link isAffiliated},
* {@link affiliateGroups}) straight from the registry. For an enriched / paginated read
* (group names, fees, trusted-subset), prefer the RPC methods on `CirclesRpc.communities`,
* which hit the indexer instead of walking the list one `eth_call` at a time.
*
* The on-chain Hub is a hardcoded constant (the Gnosis production Hub), so the registry
* is Gnosis-mainnet only. Construct it with {@link MULTI_AFFILIATE_GROUP_REGISTRY}.
*/
export class MultiAffiliateGroupRegistryContract extends Contract<typeof multiAffiliateGroupRegistryAbi> {
constructor(config: { address: Address; rpcUrl: string }) {
super({
address: config.address,
abi: multiAffiliateGroupRegistryAbi,
rpcUrl: config.rpcUrl,
});
}

/**
* Signal the caller's on-chain intent to join `group` (`addAffiliateGroup`).
*
* The caller (tx sender) must be a Hub-registered human and `group` a Hub-registered
* group, or the tx reverts (`OnlyHuman` / `AffiliateGroupNotExist`). Idempotent on-chain:
* re-adding a group already in the caller's list is a no-op — the tx still succeeds, but
* emits no event and changes no state. The membership-fee 100% cap is **not** enforced
* here; gate it client-side via `CirclesRpc.communities.getAvatarCommunityFeesPercentage`.
*
* @param group The Circles group to affiliate the caller with
* @returns Transaction request (caller signs & sends from the avatar)
*/
addAffiliateGroup(group: Address): TransactionRequest {
return {
to: this.address,
data: this.encodeWrite('addAffiliateGroup', [group]),
value: BigInt(0),
};
}

/**
* Withdraw the caller's intent to join `group` (`removeAffiliateGroup`).
*
* Reverts `AffiliateGroupNotExist` when `group` is not currently in the caller's list,
* so preflight with {@link isAffiliated} when a no-op revert is undesirable.
*
* @param group The Circles group to remove from the caller's list
* @returns Transaction request (caller signs & sends from the avatar)
*/
removeAffiliateGroup(group: Address): TransactionRequest {
return {
to: this.address,
data: this.encodeWrite('removeAffiliateGroup', [group]),
value: BigInt(0),
};
}

/**
* Whether `avatar` currently lists `group` as an affiliate group (on-chain intent).
*
* One `eth_call`: a group present in the list has a non-zero linked-list successor,
* an absent group reads the mapping's zero default. This is the same test the contract
* uses to decide whether an add is redundant, so it doubles as a preflight for both
* "will my `addAffiliateGroup` be a no-op" and "will my `removeAffiliateGroup` revert".
*
* @param avatar The avatar that owns the list
* @param group The group to check for membership
*/
async isAffiliated(avatar: Address, group: Address): Promise<boolean> {
const next = (await this.read('affiliateGroupList', [avatar, group])) as Address;
return !isZeroAddress(next);
Comment on lines +88 to +90

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.

}

/**
* The avatar's affiliate groups read **directly from chain**, most-recently-added
* first (the list is prepend-ordered). Returns `[]` for an empty list.
*
* Walks the per-avatar linked list one `eth_call` per entry (head + one per group),
* so it's chattier than the indexer-backed `CirclesRpc.communities.getAvatarCommunitiesWishlist`
* — use this only when you need the unindexed on-chain truth (e.g. before the indexer
* has caught up, or with no RPC). The walk is bounded by {@link MAX_LIST_WALK}.
*
* @param avatar The avatar whose intent list to read
*/
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;
groups.push(node);
node = (await this.read('affiliateGroupList', [avatar, node])) as Address;
}

return groups;
Comment on lines +108 to +115

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.

Comment on lines +108 to +115
}

/**
* The account allowed to seed the registry via `initialize` (the deploying account).
* @returns The deployer address
*/
async deployer(): Promise<Address> {
return this.read('deployer') as Promise<Address>;
}

/**
* Whether one-time seeding has been permanently locked (`initialize` disabled).
* @returns `true` once `lockInitialization` has been called
*/
async isInitialized(): Promise<boolean> {
return this.read('isInitialized') as Promise<boolean>;
}
}
101 changes: 101 additions & 0 deletions packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, test, expect, afterEach } from 'bun:test';
import { decodeAbiParameters, decodeErrorResult } from '@aboutcircles/sdk-utils';
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;
Comment on lines +3 to +8
const AVATAR = '0x112b5cee910a077e4bd28ec158e35653b3ac2350' as Address;
const GROUP_A = '0xde6c6ecb280c6fa535000f2d5bbb8dfdf460d161' as Address;
const GROUP_B = '0x86533d1ada8ffbe7b6f7244f9a1b707f7f3e239b' as Address;
const ADD_SELECTOR = '0x812999c5'; // addAffiliateGroup(address)
const REMOVE_SELECTOR = '0x4b528ae5'; // removeAffiliateGroup(address)

/** Decode the single `address` argument from add/remove calldata. */
function decodeGroupArg(data: string): Address {
const [group] = decodeAbiParameters(['address'], ('0x' + data.slice(10)) as `0x${string}`) as [Address];
return group;
}

/** Left-pad an address to a 32-byte ABI word (no 0x prefix). */
function word(address: Address): string {
return address.toLowerCase().replace('0x', '').padStart(64, '0');
}

/** Mock the next N eth_calls to return the given ABI-encoded addresses, in order. */
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++;
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 +30 to +35
return () => {
globalThis.fetch = orig;
};
}

describe('MultiAffiliateGroupRegistryContract — calldata', () => {
const reg = new MultiAffiliateGroupRegistryContract({ address: REGISTRY, rpcUrl: 'http://localhost:9999/' });

test('addAffiliateGroup encodes the right selector + group arg, zero value, registry target', () => {
const tx = reg.addAffiliateGroup(GROUP_A);
expect(tx.to?.toLowerCase()).toBe(REGISTRY.toLowerCase());
expect(tx.value).toBe(0n);
expect((tx.data as string).slice(0, 10)).toBe(ADD_SELECTOR);
expect(decodeGroupArg(tx.data as string).toLowerCase()).toBe(GROUP_A.toLowerCase());
});

test('removeAffiliateGroup encodes the right selector + group arg', () => {
const tx = reg.removeAffiliateGroup(GROUP_B);
expect((tx.data as string).slice(0, 10)).toBe(REMOVE_SELECTOR);
expect(decodeGroupArg(tx.data as string).toLowerCase()).toBe(GROUP_B.toLowerCase());
});
});

describe('MultiAffiliateGroupRegistryContract — reads', () => {
let restore: (() => void) | undefined;
afterEach(() => restore?.());

const reg = new MultiAffiliateGroupRegistryContract({ address: REGISTRY, rpcUrl: 'http://localhost:9999/' });

test('isAffiliated is true when the list successor is non-zero', async () => {
restore = mockReadSequence([AFFILIATE_GROUP_LIST_SENTINEL]); // non-zero successor → present
expect(await reg.isAffiliated(AVATAR, GROUP_A)).toBe(true);
});

test('isAffiliated is false when the list successor is the zero default', async () => {
restore = mockReadSequence([ZERO_ADDRESS as Address]);
expect(await reg.isAffiliated(AVATAR, GROUP_A)).toBe(false);
});

test('affiliateGroups walks the linked list head→tail, most-recent first', async () => {
// head = GROUP_A → next(GROUP_A) = GROUP_B → next(GROUP_B) = SENTINEL (stop).
// read() decodes addresses to EIP-55 checksum, so compare case-insensitively.
restore = mockReadSequence([GROUP_A, GROUP_B, AFFILIATE_GROUP_LIST_SENTINEL]);
const walked = await reg.affiliateGroups(AVATAR);
expect(walked.map((g) => g.toLowerCase())).toEqual([GROUP_A.toLowerCase(), GROUP_B.toLowerCase()]);
});

test('affiliateGroups returns [] for an empty list (head == 0)', async () => {
restore = mockReadSequence([ZERO_ADDRESS as Address]);
expect(await reg.affiliateGroups(AVATAR)).toEqual([]);
});
});

describe('MultiAffiliateGroupRegistryContract — ABI carries the custom errors', () => {
test('decodes AffiliateGroupNotExist(group) revert data', () => {
const data = '0x6fdbf075' + word(GROUP_A);
const decoded = decodeErrorResult({ abi: multiAffiliateGroupRegistryAbi as unknown as never, data });
expect(decoded?.errorName).toBe('AffiliateGroupNotExist');
expect((decoded?.args?.[0] as string)?.toLowerCase()).toBe(GROUP_A.toLowerCase());
});

test('decodes the no-arg OnlyHuman revert data', () => {
const decoded = decodeErrorResult({ abi: multiAffiliateGroupRegistryAbi as unknown as never, data: '0x9aa42e72' });
expect(decoded?.errorName).toBe('OnlyHuman');
});
});
2 changes: 2 additions & 0 deletions packages/rpc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
InvitationMethods,
TransactionMethods,
GroupMethods,
CommunityMethods,
} from './methods/index.js';

// RPC-specific types
Expand All @@ -29,6 +30,7 @@ export type {

// Re-export shared types from @aboutcircles/sdk-types for convenience
export type { TrustRelationType, AggregatedTrustRelation, TransferDataRow } from '@aboutcircles/sdk-types';
export type { CommunityRow, CommunityListResponse, CommunityMemberRow } from '@aboutcircles/sdk-types';

// Error handling
export { RpcError } from './errors.js';
Expand Down
Loading