diff --git a/packages/abis/src/index.ts b/packages/abis/src/index.ts index 4794341..e77479c 100644 --- a/packages/abis/src/index.ts +++ b/packages/abis/src/index.ts @@ -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'; diff --git a/packages/abis/src/multiAffiliateGroupRegistry.ts b/packages/abis/src/multiAffiliateGroupRegistry.ts new file mode 100644 index 0000000..c599de4 --- /dev/null +++ b/packages/abis/src/multiAffiliateGroupRegistry.ts @@ -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; diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts index 0a66295..76ac39f 100644 --- a/packages/core/src/contracts/index.ts +++ b/packages/core/src/contracts/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/core/src/contracts/multiAffiliateGroupRegistry.ts b/packages/core/src/contracts/multiAffiliateGroupRegistry.ts new file mode 100644 index 0000000..f79cc5e --- /dev/null +++ b/packages/core/src/contracts/multiAffiliateGroupRegistry.ts @@ -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 { + 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 { + const next = (await this.read('affiliateGroupList', [avatar, group])) as Address; + return !isZeroAddress(next); + } + + /** + * 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 { + 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; + } + + /** + * The account allowed to seed the registry via `initialize` (the deploying account). + * @returns The deployer address + */ + async deployer(): Promise
{ + return this.read('deployer') as Promise
; + } + + /** + * Whether one-time seeding has been permanently locked (`initialize` disabled). + * @returns `true` once `lockInitialization` has been called + */ + async isInitialized(): Promise { + return this.read('isInitialized') as Promise; + } +} diff --git a/packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts b/packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts new file mode 100644 index 0000000..33bfead --- /dev/null +++ b/packages/core/src/contracts/tests/multiAffiliateGroupRegistry.test.ts @@ -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; +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; + 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'); + }); +}); diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 653619a..9fb6f97 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -16,6 +16,7 @@ export { InvitationMethods, TransactionMethods, GroupMethods, + CommunityMethods, } from './methods/index.js'; // RPC-specific types @@ -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'; diff --git a/packages/rpc/src/methods/communities.ts b/packages/rpc/src/methods/communities.ts new file mode 100644 index 0000000..2cb15ec --- /dev/null +++ b/packages/rpc/src/methods/communities.ts @@ -0,0 +1,138 @@ +import type { RpcClient } from '../client.js'; +import type { + Address, + CommunityRow, + CommunityListResponse, + CommunityMemberRow, + PagedResponse, +} from '@aboutcircles/sdk-types'; +import { normalizeAddress, checksumAddresses } from '../utils.js'; + +/** + * Community (GA 2.0) RPC methods. + * + * Backed by the MultiAffiliateGroupRegistry: an avatar signals on-chain *intent* to join + * a community (the "wishlist"); a community becomes a confirmed membership once it also + * trusts the avatar (the bilateral handshake) — so the **trusted** subset always lags, + * and is a subset of, the wishlist. Each community's `membershipFee` is read off its + * profile; an avatar's fees across all communities are capped at 100% by the consumer + * (the registry does not enforce it). Use {@link getAvatarCommunityFeesPercentage} for + * the pre-join cap check. + * + * Write side (signalling intent) is `MultiAffiliateGroupRegistryContract` in `@aboutcircles/sdk-core`. + * + * @remarks These methods are served by the indexer RPC and roll out staging-first; an RPC + * endpoint that predates the feature rejects them with JSON-RPC `-32601` (method not found), + * so callers should degrade gracefully (treat `-32601` as "unavailable on this endpoint"). + * They always read chain head — `X-Max-Block-Number` block-pinning is a no-op here. + */ +export class CommunityMethods { + constructor(private client: RpcClient) {} + + /** + * The communities an avatar has signalled intent to join (the wishlist), each with its + * membership fee, plus the summed total committed fee percentage. + * + * @param avatar - The avatar whose wishlist to read + * + * @example + * ```typescript + * const { totalFeePercentage, communities } = await rpc.communities.getAvatarCommunitiesWishlist( + * '0x112b5cee910a077e4bd28ec158e35653b3ac2350' + * ); + * ``` + */ + async getAvatarCommunitiesWishlist(avatar: Address): Promise { + const response = await this.client.call<[Address], CommunityListResponse>( + 'circles_getAvatarCommunitiesWishlist', + [normalizeAddress(avatar)] + ); + return normalizeListResponse(response); + } + + /** + * The confirmed-membership subset of the wishlist: communities that currently trust the + * avatar on-chain. `totalFeePercentage` is summed over this confirmed subset, so it + * reflects the TMS trust delay (a wished community only appears here once it trusts the avatar). + * + * @param avatar - The avatar whose confirmed communities to read + */ + async getAvatarCommunities(avatar: Address): Promise { + const response = await this.client.call<[Address], CommunityListResponse>( + 'circles_getAvatarCommunities', + [normalizeAddress(avatar)] + ); + return normalizeListResponse(response); + } + + /** + * The avatar's total committed fee percentage across its **wishlist** (intent set) — + * the number to check against the 100% cap before signalling a new join. + * + * @param avatar - The avatar whose committed fee total to read + * @returns The summed fee percentage (a `null` per-community fee counts as 0) + */ + async getAvatarCommunityFeesPercentage(avatar: Address): Promise { + const response = await this.client.call<[Address], { totalFeePercentage?: number } | null>( + 'circles_getAvatarCommunityFeesPercentage', + [normalizeAddress(avatar)] + ); + return Number(response?.totalFeePercentage ?? 0); + } + + /** + * The avatars that have signalled intent to join the community (the community's members + * wishlist). This is the set the TMS reconciles against. Paginated. + * + * @param communityAddress - The community whose intended members to list + * @param limit - Max members per page (1–1000, clamped server-side; default 100) + * @param cursor - Opaque page token from a prior `nextCursor` (omit for the first page) + */ + async getCommunityMembersWishlist( + communityAddress: Address, + limit: number = 100, + cursor?: string | null + ): Promise> { + const response = await this.client.call<[Address, number, string | null], PagedResponse>( + 'circles_getCommunityMembersWishlist', + [normalizeAddress(communityAddress), limit, cursor ?? null] + ); + return { + hasMore: response.hasMore, + nextCursor: response.nextCursor, + results: checksumAddresses(response.results), + }; + } + + /** + * The confirmed-membership subset of a community's wishlist: avatars the community actually + * trusts on-chain. Reflects the TMS trust delay. Paginated. + * + * @param communityAddress - The community whose confirmed members to list + * @param limit - Max members per page (1–1000, clamped server-side; default 100) + * @param cursor - Opaque page token from a prior `nextCursor` (omit for the first page) + */ + async getCommunityMembers( + communityAddress: Address, + limit: number = 100, + cursor?: string | null + ): Promise> { + const response = await this.client.call<[Address, number, string | null], PagedResponse>( + 'circles_getCommunityMembers', + [normalizeAddress(communityAddress), limit, cursor ?? null] + ); + return { + hasMore: response.hasMore, + nextCursor: response.nextCursor, + results: checksumAddresses(response.results), + }; + } +} + +/** Checksum community addresses and coerce the summed fee to a number (a null total → 0). */ +function normalizeListResponse(response: CommunityListResponse): CommunityListResponse { + return { + totalFeePercentage: Number(response?.totalFeePercentage ?? 0), + communities: checksumAddresses(response?.communities ?? ([] as CommunityRow[])), + }; +} diff --git a/packages/rpc/src/methods/index.ts b/packages/rpc/src/methods/index.ts index be8da6c..39a3c94 100644 --- a/packages/rpc/src/methods/index.ts +++ b/packages/rpc/src/methods/index.ts @@ -8,4 +8,5 @@ export { TokenMethods } from './token.js'; export { InvitationMethods } from './invitation.js'; export { TransactionMethods } from './transaction.js'; export { GroupMethods } from './group.js'; +export { CommunityMethods } from './communities.js'; export { SdkMethods } from './sdk.js'; diff --git a/packages/rpc/src/rpc.ts b/packages/rpc/src/rpc.ts index d3c4a42..c205ce9 100644 --- a/packages/rpc/src/rpc.ts +++ b/packages/rpc/src/rpc.ts @@ -10,6 +10,7 @@ import { InvitationMethods, TransactionMethods, GroupMethods, + CommunityMethods, SdkMethods, } from './methods/index.js'; @@ -63,6 +64,7 @@ export class CirclesRpc { private _invitation?: InvitationMethods; private _transaction?: TransactionMethods; private _group?: GroupMethods; + private _communities?: CommunityMethods; private _sdk?: SdkMethods; /** @@ -144,6 +146,13 @@ export class CirclesRpc { return this._group; } + get communities(): CommunityMethods { + if (!this._communities) { + this._communities = new CommunityMethods(this.client); + } + return this._communities; + } + get sdk(): SdkMethods { if (!this._sdk) { this._sdk = new SdkMethods(this.client); diff --git a/packages/rpc/src/tests/communities.test.ts b/packages/rpc/src/tests/communities.test.ts new file mode 100644 index 0000000..87315ab --- /dev/null +++ b/packages/rpc/src/tests/communities.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import type { Address } from '@aboutcircles/sdk-types'; +import { CirclesRpc } from '../rpc.js'; +import { checksumAddress } from '../utils.js'; + +const AVATAR_LOWER = '0x112b5cee910a077e4bd28ec158e35653b3ac2350'; +const AVATAR_MIXED = checksumAddress(AVATAR_LOWER as Address); // valid EIP-55, exercises param normalization +const AVATAR_CHECKSUMMED = checksumAddress(AVATAR_LOWER as Address); +const COMMUNITY_LOWER = '0xde6c6ecb280c6fa535000f2d5bbb8dfdf460d161'; +const COMMUNITY_CHECKSUMMED = checksumAddress(COMMUNITY_LOWER as Address); + +interface CapturedRequest { + method: string; + params: unknown[]; +} + +/** Stub fetch to capture the JSON-RPC request and return a canned `result`. */ +function stubRpc(result: unknown): { captured: CapturedRequest[]; restore: () => void } { + const orig = globalThis.fetch; + const captured: CapturedRequest[] = []; + globalThis.fetch = (async (_input: unknown, init?: { body?: unknown }) => { + const body = JSON.parse(String(init?.body ?? '{}')); + captured.push({ method: body.method, params: body.params }); + return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: body.id, result }) }; + }) as unknown as typeof fetch; + return { captured, restore: () => { globalThis.fetch = orig; } }; +} + +describe('CommunityMethods', () => { + let restore: (() => void) | undefined; + afterEach(() => restore?.()); + + const rpc = new CirclesRpc('http://localhost:9999/'); + + test('getAvatarCommunitiesWishlist lowercases the avatar param and checksums community addresses', async () => { + const stub = stubRpc({ + totalFeePercentage: 99, + communities: [{ communityName: 'New Dummy Group', communityAddress: COMMUNITY_LOWER, membershipFee: 99, timestamp: 1782464475 }], + }); + restore = stub.restore; + + const res = await rpc.communities.getAvatarCommunitiesWishlist(AVATAR_MIXED); + + expect(stub.captured[0].method).toBe('circles_getAvatarCommunitiesWishlist'); + expect(stub.captured[0].params).toEqual([AVATAR_LOWER]); + expect(res.totalFeePercentage).toBe(99); + expect(res.communities[0].communityAddress).toBe(COMMUNITY_CHECKSUMMED); + expect(res.communities[0].membershipFee).toBe(99); + expect(res.communities[0].communityName).toBe('New Dummy Group'); + }); + + test('getAvatarCommunities routes to the trusted-subset method', async () => { + const stub = stubRpc({ totalFeePercentage: 0, communities: [] }); + restore = stub.restore; + + const res = await rpc.communities.getAvatarCommunities(AVATAR_MIXED); + + expect(stub.captured[0].method).toBe('circles_getAvatarCommunities'); + expect(res.communities).toEqual([]); + expect(res.totalFeePercentage).toBe(0); + }); + + test('getAvatarCommunityFeesPercentage unwraps the totalFeePercentage number', async () => { + const stub = stubRpc({ totalFeePercentage: 42 }); + restore = stub.restore; + + expect(await rpc.communities.getAvatarCommunityFeesPercentage(AVATAR_MIXED)).toBe(42); + expect(stub.captured[0].method).toBe('circles_getAvatarCommunityFeesPercentage'); + }); + + test('getAvatarCommunityFeesPercentage treats a null/absent total as 0', async () => { + const stub = stubRpc(null); + restore = stub.restore; + expect(await rpc.communities.getAvatarCommunityFeesPercentage(AVATAR_MIXED)).toBe(0); + }); + + test('getCommunityMembersWishlist passes [community, limit, cursor] and checksums avatars', async () => { + const stub = stubRpc({ + results: [{ avatarName: 'Charles', avatarAddress: AVATAR_LOWER, timestamp: 1782468390 }], + hasMore: true, + nextCursor: 'eyJiIjo0Njg5', + }); + restore = stub.restore; + + const res = await rpc.communities.getCommunityMembersWishlist(COMMUNITY_CHECKSUMMED as Address, 50, null); + + expect(stub.captured[0].method).toBe('circles_getCommunityMembersWishlist'); + expect(stub.captured[0].params).toEqual([COMMUNITY_LOWER, 50, null]); + expect(res.hasMore).toBe(true); + expect(res.nextCursor).toBe('eyJiIjo0Njg5'); + expect(res.results[0].avatarAddress).toBe(AVATAR_CHECKSUMMED); + }); + + test('getCommunityMembers defaults limit to 100 and cursor to null', async () => { + const stub = stubRpc({ results: [], hasMore: false, nextCursor: null }); + restore = stub.restore; + + await rpc.communities.getCommunityMembers(COMMUNITY_CHECKSUMMED as Address); + + expect(stub.captured[0].method).toBe('circles_getCommunityMembers'); + expect(stub.captured[0].params).toEqual([COMMUNITY_LOWER, 100, null]); + }); +}); diff --git a/packages/types/src/communities.ts b/packages/types/src/communities.ts new file mode 100644 index 0000000..c73b10a --- /dev/null +++ b/packages/types/src/communities.ts @@ -0,0 +1,49 @@ +import type { Address } from './base.js'; + +/** + * Community (GA 2.0) types. + * + * A community = an underlying Circles group + a bilateral handshake. The + * MultiAffiliateGroupRegistry stores the avatar's on-chain *intent* (the "wishlist"); + * a group becomes a confirmed membership once it also trusts the avatar. These rows + * are returned by the `circles_get*Communit*` RPC methods. + */ + +/** + * One community in a per-avatar wishlist / confirmed-membership list. + */ +export interface CommunityRow { + /** Community profile name, or `null` when the community has no profile/name. */ + communityName: string | null; + /** The community's address. */ + communityAddress: Address; + /** + * The community's membership fee — a percent in `[0,100]` of the avatar's daily gCRC + * mint (`membershipCriteria.membershipFee` in the group profile), or `null` when + * the community sets no fee. A `null` fee contributes `0` to `totalFeePercentage`. + */ + membershipFee: number | null; + /** Unix seconds of the winning `AffiliateGroupAdded` event. */ + timestamp: number; +} + +/** + * Per-avatar wishlist / confirmed-membership response: the communities plus the summed fee. + */ +export interface CommunityListResponse { + /** Sum of `membershipFee` across `communities` (null fees count as 0). */ + totalFeePercentage: number; + communities: CommunityRow[]; +} + +/** + * One member in a per-community members wishlist / confirmed-members list. + */ +export interface CommunityMemberRow { + /** Member avatar profile name, or `null` when none. */ + avatarName: string | null; + /** The member avatar's address. */ + avatarAddress: Address; + /** Unix seconds of the winning `AffiliateGroupAdded` event. */ + timestamp: number; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3747e48..83760a5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -73,6 +73,9 @@ export type { TrustRelation, TrustRelationType, AggregatedTrustRelation } from ' // Group types export type { GroupRow, GroupMembershipRow, GroupQueryParams } from './group.js'; +// Community (GA 2.0) types +export type { CommunityRow, CommunityListResponse, CommunityMemberRow } from './communities.js'; + // Pathfinding types export type { SimulatedBalance, diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index 89e914b..cae6390 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -61,3 +61,21 @@ export const AFFILIATE_GROUP_REGISTRY: Address = '0xca8222e780d046707083f51377B5 * back to the zero address when reading. */ export const AFFILIATE_GROUP_NONE_SENTINEL: Address = '0x6CF165a39263984827d8C13829C60bd047B089E6'; + +/** + * MultiAffiliateGroupRegistry contract address (GA 2.0 "communities"), Gnosis Chain. + * + * Per-avatar registry of affiliate-group *intent* — distinct from the legacy + * single-group {@link AFFILIATE_GROUP_REGISTRY}. Its Hub is a hardcoded constant + * (the Gnosis production Hub), so this deployment is Gnosis-mainnet only. The + * registry holds no public getter for its own address, so the SDK carries it directly. + */ +export const MULTI_AFFILIATE_GROUP_REGISTRY: Address = '0x4a25a7cf216351963f1637ad965d77b3ae277ef3'; + +/** + * Sentinel node (`address(0x01)`) bounding each avatar's affiliate-group linked + * list in the MultiAffiliateGroupRegistry. `affiliateGroupList[avatar][SENTINEL]` + * is the list head; the tail points back to the sentinel; an empty list reads as + * the zero address. Used when walking the list on-chain (never a real group). + */ +export const AFFILIATE_GROUP_LIST_SENTINEL: Address = '0x0000000000000000000000000000000000000001'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ea5c7d3..75b1b5c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,7 +3,7 @@ export { bytesToHex, hexToBytes } from './bytes.js'; export { encodeFunctionData, decodeFunctionResult, decodeErrorResult, checksumAddress, encodeAbiParameters, decodeAbiParameters } from './abi.js'; export { cidV0ToHex, cidV0ToUint8Array } from './cid.js'; export { uint256ToAddress, isZeroAddress, hexEq } from './address.js'; -export { ZERO_ADDRESS, INVITATION_FEE, MAX_FLOW, SAFE_PROXY_FACTORY, ACCOUNT_INITIALIZER_HASH, ACCOUNT_CREATION_CODE_HASH, GNOSIS_GROUP_ADDRESS, FARM_DESTINATION, AFFILIATE_GROUP_REGISTRY, AFFILIATE_GROUP_NONE_SENTINEL } from './constants.js'; +export { ZERO_ADDRESS, INVITATION_FEE, MAX_FLOW, SAFE_PROXY_FACTORY, ACCOUNT_INITIALIZER_HASH, ACCOUNT_CREATION_CODE_HASH, GNOSIS_GROUP_ADDRESS, FARM_DESTINATION, AFFILIATE_GROUP_REGISTRY, AFFILIATE_GROUP_NONE_SENTINEL, MULTI_AFFILIATE_GROUP_REGISTRY, AFFILIATE_GROUP_LIST_SENTINEL } from './constants.js'; export { circlesConfig } from './config.js'; export { PERMISSIONLESS_GROUPS_STAGING,