-
Notifications
You must be signed in to change notification settings - Fork 4
feat(affiliate): add MultiAffiliateGroupRegistry SDK support #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents
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>; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| 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'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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
falsefor 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)returnstruefor 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
🤖 Prompt for AI Agents