diff --git a/.changeset/funky-streets-deny.md b/.changeset/funky-streets-deny.md new file mode 100644 index 0000000..e2bbc79 --- /dev/null +++ b/.changeset/funky-streets-deny.md @@ -0,0 +1,5 @@ +--- +"@aragon/aragon-domain": minor +--- + +Implement ERC20 token voting membership use case diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 917011f..e13e55f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -73,6 +73,10 @@ jobs: - name: Get base version id: base-version run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + - name: Disable changelog + run: | + tmp=$(mktemp) + jq '.changelog = false' .changeset/config.json > "$tmp" && mv "$tmp" .changeset/config.json - name: Update version run: pnpm changeset version --snapshot env: diff --git a/CHANGELOG.md b/CHANGELOG.md index a946057..d99366c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Minor Changes -- [#15](https://github.com/aragon/aragon-domain/pull/15) [`0f63598`](https://github.com/aragon/aragon-domain/commit/0f63598d834be0e45a4008ad25af703f283cb4f0) Thanks [@tyhonchik](https://github.com/tyhonchik)! - Rename package from `@aragon/aragon-subdomain` to `@aragon/aragon-domain`. Public controller export is now `AragonDomain`. +- [#15](https://github.com/aragon/aragon-domain/pull/15) [`0f63598`](https://github.com/aragon/aragon-domain/commit/0f63598d834be0e45a4008ad25af703f283cb4f0) Thanks [@tyhonchik](https://github.com/tyhonchik)! - Rename package from `@aragon/aragon-domain` to `@aragon/aragon-domain`. Public controller export is now `AragonDomain`. ### Patch Changes @@ -22,8 +22,8 @@ ### Minor Changes -- [#3](https://github.com/aragon/aragon-subdomain/pull/3) [`bd9fff8`](https://github.com/aragon/aragon-subdomain/commit/bd9fff8beb08911fe4b796ef237211f7fb041f7d) Thanks [@asciiman](https://github.com/asciiman)! - Look up member profile ENS records +- [#3](https://github.com/aragon/aragon-domain/pull/3) [`bd9fff8`](https://github.com/aragon/aragon-domain/commit/bd9fff8beb08911fe4b796ef237211f7fb041f7d) Thanks [@asciiman](https://github.com/asciiman)! - Look up member profile ENS records ### Patch Changes -- [#1](https://github.com/aragon/aragon-subdomain/pull/1) [`11e3275`](https://github.com/aragon/aragon-subdomain/commit/11e3275bcdd0815509523f9410cbc5f004fa86e0) Thanks [@jjavieralv](https://github.com/jjavieralv)! - Adding CI flow for initial version +- [#1](https://github.com/aragon/aragon-domain/pull/1) [`11e3275`](https://github.com/aragon/aragon-domain/commit/11e3275bcdd0815509523f9410cbc5f004fa86e0) Thanks [@jjavieralv](https://github.com/jjavieralv)! - Adding CI flow for initial version diff --git a/src/domain/ens/ENSName.test.ts b/src/domain/ens/ENSName.test.ts new file mode 100644 index 0000000..965f399 --- /dev/null +++ b/src/domain/ens/ENSName.test.ts @@ -0,0 +1,31 @@ +import { ENSName } from './ENSName'; + +describe('ENSName', () => { + it('stores a valid name', () => { + expect(ENSName.fromString('alice.eth').toString()).toBe('alice.eth'); + }); + + it('normalizes to the ENSIP-15 canonical form (case folding)', () => { + expect(ENSName.fromString('Alice.ETH').toString()).toBe('alice.eth'); + }); + + it('preserves a non-.eth primary name (e.g. an L2 basename)', () => { + expect(ENSName.fromString('alice.base.eth').toString()).toBe( + 'alice.base.eth', + ); + }); + + it('throws on an empty name', () => { + expect(() => ENSName.fromString('')).toThrow(); + }); + + it('throws on a name with disallowed characters', () => { + expect(() => ENSName.fromString('has space.eth')).toThrow(); + }); + + it('compares equal for equal names', () => { + expect( + ENSName.fromString('alice.eth').equals(ENSName.fromString('Alice.eth')), + ).toBe(true); + }); +}); diff --git a/src/domain/ens/ENSName.ts b/src/domain/ens/ENSName.ts new file mode 100644 index 0000000..8a5ae68 --- /dev/null +++ b/src/domain/ens/ENSName.ts @@ -0,0 +1,41 @@ +import { ValueObject } from 'ddd-core-ts'; +import { normalize as ensNormalize } from 'viem/ens'; +import { z } from 'zod'; + +/** + * Normalizes a raw ENS name to its ENSIP-15 canonical form. + */ +function canonicalize(input: string): string { + return ensNormalize(input.trim()); +} + +const ENSNamePropsSchema = z + .object({ + value: z.string().min(1, 'name must not be empty'), + }) + .transform(({ value }) => ({ value: canonicalize(value) })); + +type ENSNameProps = z.output; +type ENSNameInput = z.input; + +/** + * A normalized ENS name — e.g. a primary name obtained via reverse + * resolution. Stored in its ENSIP-15 canonical form so that equal names + * compare equal and any downstream hashing/display stays consistent. + * + * `create` throws if the input is not a valid ENS name. + */ +export class ENSName extends ValueObject { + toString(): string { + return this.props.value; + } + + static create(props: ENSNameInput): ENSName { + const validated = ENSNamePropsSchema.parse(props); + return new ENSName(validated); + } + + static fromString(input: string): ENSName { + return ENSName.create({ value: input }); + } +} diff --git a/src/domain/ens/ENSStore.ts b/src/domain/ens/ENSStore.ts new file mode 100644 index 0000000..b868c56 --- /dev/null +++ b/src/domain/ens/ENSStore.ts @@ -0,0 +1,13 @@ +import type { Address, HexString } from '@/domain/primitives'; +import type { ENSName } from './ENSName'; + +export interface ENSStore { + /** + * Resolves the primary ENS name for each address. + * + * Returns a map keyed by checksummed hex address. Addresses with no + * primary name, or whose name fails resolution or normalization, are + * absent from the map. A missing key means "no name", never an error. + */ + lookUpPrimaryNames(addresses: Address[]): Promise>; +} diff --git a/src/domain/member/MemberGovernanceMetrics.test.ts b/src/domain/member/MemberGovernanceMetrics.test.ts new file mode 100644 index 0000000..91a8325 --- /dev/null +++ b/src/domain/member/MemberGovernanceMetrics.test.ts @@ -0,0 +1,30 @@ +import { Address } from '@/domain/primitives'; +import { MemberGovernanceMetrics } from './MemberGovernanceMetrics'; + +describe('MemberGovernanceMetrics', () => { + const memberAddress = Address.fromHexString( + '0x0123456789abcdef0123456789abcdef01234567', + ); + + it('exposes its properties', () => { + const metrics = MemberGovernanceMetrics.create({ + memberAddress, + firstActivityTimestamp: 1650000000, + lastActivityTimestamp: 1750000000, + }); + + expect(metrics.memberAddress.equals(memberAddress)).toBe(true); + expect(metrics.firstActivityTimestamp).toBe(1650000000); + expect(metrics.lastActivityTimestamp).toBe(1750000000); + }); + + it('rejects a negative timestamp', () => { + expect(() => + MemberGovernanceMetrics.create({ + memberAddress, + firstActivityTimestamp: -1, + lastActivityTimestamp: 1750000000, + }), + ).toThrow(); + }); +}); diff --git a/src/domain/member/MemberGovernanceMetrics.ts b/src/domain/member/MemberGovernanceMetrics.ts new file mode 100644 index 0000000..68d0ff1 --- /dev/null +++ b/src/domain/member/MemberGovernanceMetrics.ts @@ -0,0 +1,45 @@ +import { ValueObject } from 'ddd-core-ts'; +import { z } from 'zod'; +import { Address } from '@/domain/primitives'; + +const MemberGovernanceMetricsPropsSchema = z.object({ + memberAddress: z.instanceof(Address), + firstActivityTimestamp: z.number().int().nonnegative(), + lastActivityTimestamp: z.number().int().nonnegative(), +}); + +type MemberGovernanceMetricsProps = z.infer< + typeof MemberGovernanceMetricsPropsSchema +>; + +/** + * A member's governance activity (votes cast or proposals created) within a + * plugin. + */ +export class MemberGovernanceMetrics extends ValueObject { + /** + * The member's account address. + */ + get memberAddress(): Address { + return this.props.memberAddress; + } + + /** + * Unix-seconds timestamp of the member's first governance action. + */ + get firstActivityTimestamp(): number { + return this.props.firstActivityTimestamp; + } + + /** + * Unix-seconds timestamp of the member's most recent governance action. + */ + get lastActivityTimestamp(): number { + return this.props.lastActivityTimestamp; + } + + static create(props: MemberGovernanceMetricsProps): MemberGovernanceMetrics { + const validated = MemberGovernanceMetricsPropsSchema.parse(props); + return new MemberGovernanceMetrics(validated); + } +} diff --git a/src/domain/member/MemberStore.ts b/src/domain/member/MemberStore.ts new file mode 100644 index 0000000..9fec408 --- /dev/null +++ b/src/domain/member/MemberStore.ts @@ -0,0 +1,23 @@ +import type { Address } from '@/domain/primitives'; +import type { Page } from '@/domain/primitives/pagination/Page'; +import type { PageRequest } from '@/domain/primitives/pagination/PageRequest'; +import type { MemberGovernanceMetrics } from './MemberGovernanceMetrics'; +import type { TokenVotingMemberRecord } from './TokenVotingMemberRecord'; + +export interface TokenVotingMemberData { + record: TokenVotingMemberRecord; + metrics: MemberGovernanceMetrics | null; +} + +export interface MemberStore { + /** + * Finds members of a TokenVoting plugin scoped to its token contract. + * Returns a page of on-chain records paired with their governance + * metrics, sorted by voting power descending. + */ + findTokenVotingMembers( + pluginAddress: Address, + tokenContractAddress: Address, + page: PageRequest, + ): Promise>; +} diff --git a/src/domain/member/TokenVotingMember.test.ts b/src/domain/member/TokenVotingMember.test.ts new file mode 100644 index 0000000..c69893d --- /dev/null +++ b/src/domain/member/TokenVotingMember.test.ts @@ -0,0 +1,126 @@ +import { ENSName } from '@/domain/ens/ENSName'; +import { Address } from '@/domain/primitives'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; +import { MemberGovernanceMetrics } from './MemberGovernanceMetrics'; +import { TokenVotingMember } from './TokenVotingMember'; +import { TokenVotingMemberRecord } from './TokenVotingMemberRecord'; + +describe('TokenVotingMember', () => { + const address = Address.fromHexString( + '0x0123456789abcdef0123456789abcdef01234567', + ); + + const buildRecord = ( + overrides: Partial< + Parameters[0] + > = {}, + ) => + TokenVotingMemberRecord.create({ + address, + votingPower: VotingPower.fromBigInt(1000000000000000000n), + delegationCount: 3, + firstVotingPowerChangeTimestamp: 1700000000, + lastVotingPowerChangeTimestamp: 1700000100, + ...overrides, + }); + + const buildMetrics = ( + overrides: Partial< + Parameters[0] + > = {}, + ) => + MemberGovernanceMetrics.create({ + memberAddress: address, + firstActivityTimestamp: 1650000000, + lastActivityTimestamp: 1750000000, + ...overrides, + }); + + describe('getters delegate to the source objects', () => { + it('pulls identity, voting power, and delegation count from the record', () => { + const member = TokenVotingMember.create(buildRecord(), null, null); + expect(member.address.equals(address)).toBe(true); + expect(member.votingPower.isZero).toBe(false); + expect(member.delegationCount).toBe(3); + }); + + it('pulls the ENS name from the composed name', () => { + const named = TokenVotingMember.create( + buildRecord(), + null, + ENSName.fromString('alice.eth'), + ); + expect(named.ens?.toString()).toBe('alice.eth'); + + const anonymous = TokenVotingMember.create(buildRecord(), null, null); + expect(anonymous.ens).toBeNull(); + }); + }); + + describe('activity window is derived in the getters', () => { + it('spans the earliest/latest across governance metrics and VP changes', () => { + const member = TokenVotingMember.create( + buildRecord(), + buildMetrics(), + null, + ); + // earliest first: metrics 1650000000 < record 1700000000 + expect(member.firstActivityTimestamp).toBe(1650000000); + // latest last: metrics 1750000000 > record 1700000100 + expect(member.lastActivityTimestamp).toBe(1750000000); + }); + + it('uses the VP-change window when there are no governance metrics', () => { + const member = TokenVotingMember.create(buildRecord(), null, null); + expect(member.firstActivityTimestamp).toBe(1700000000); + expect(member.lastActivityTimestamp).toBe(1700000100); + }); + + it('uses the governance window when the record has no VP changes', () => { + const member = TokenVotingMember.create( + buildRecord({ + firstVotingPowerChangeTimestamp: null, + lastVotingPowerChangeTimestamp: null, + }), + buildMetrics(), + null, + ); + expect(member.firstActivityTimestamp).toBe(1650000000); + expect(member.lastActivityTimestamp).toBe(1750000000); + }); + + it('reports 0 timestamps when no activity signal is present', () => { + const member = TokenVotingMember.create( + buildRecord({ + firstVotingPowerChangeTimestamp: null, + lastVotingPowerChangeTimestamp: null, + }), + null, + null, + ); + expect(member.firstActivityTimestamp).toBe(0); + expect(member.lastActivityTimestamp).toBe(0); + }); + }); + + describe('equals', () => { + it('is structural over the composed objects', () => { + const record = buildRecord(); + const metrics = buildMetrics(); + const a = TokenVotingMember.create(record, metrics, null); + const b = TokenVotingMember.create(record, metrics, null); + expect(a.equals(b)).toBe(true); + }); + + it('differs when the composed record differs', () => { + const metrics = buildMetrics(); + const a = TokenVotingMember.create(buildRecord(), metrics, null); + const b = TokenVotingMember.create( + buildRecord({ votingPower: VotingPower.zero() }), + metrics, + null, + ); + expect(a.equals(b)).toBe(false); + }); + }); +}); diff --git a/src/domain/member/TokenVotingMember.ts b/src/domain/member/TokenVotingMember.ts new file mode 100644 index 0000000..ef179d1 --- /dev/null +++ b/src/domain/member/TokenVotingMember.ts @@ -0,0 +1,103 @@ +import { ValueObject } from 'ddd-core-ts'; +import { z } from 'zod'; +import { ENSName } from '@/domain/ens/ENSName'; +import type { Address } from '@/domain/primitives'; +import type { VotingPower } from '@/domain/voting-power/VotingPower'; +import { MemberGovernanceMetrics } from './MemberGovernanceMetrics'; +import { TokenVotingMemberRecord } from './TokenVotingMemberRecord'; + +const TokenVotingMemberPropsSchema = z.object({ + record: z.instanceof(TokenVotingMemberRecord), + metrics: z.instanceof(MemberGovernanceMetrics).nullable(), + ens: z.instanceof(ENSName).nullable(), +}); + +type TokenVotingMemberProps = z.infer; + +/** + * A member of a TokenVoting plugin. + */ +export class TokenVotingMember extends ValueObject { + /** + * The member's account address. + */ + get address(): Address { + return this.props.record.address; + } + + /** + * The member's primary ENS name, or null when the address has no + * primary name (or it could not be resolved). + */ + get ens(): ENSName | null { + return this.props.ens; + } + + /** + * The member's voting power. + */ + get votingPower(): VotingPower { + return this.props.record.votingPower; + } + + /** + * Number of distinct accounts currently delegating their voting power + * to this member (counts self-delegation). + */ + get delegationCount(): number { + return this.props.record.delegationCount; + } + + /** + * Unix-seconds timestamp of the member's first observed activity. + * "Activity" includes voting power changes, votes cast, and proposals + * created. `0` means "no activity recorded". + */ + get firstActivityTimestamp(): number { + return earliest([ + this.props.metrics?.firstActivityTimestamp, + this.props.record.firstVotingPowerChangeTimestamp, + ]); + } + + /** + * Unix-seconds timestamp of the member's most recent observed activity. + * "Activity" includes voting power changes, votes cast, and proposals + * created. `0` means "no activity recorded". + */ + get lastActivityTimestamp(): number { + return latest([ + this.props.metrics?.lastActivityTimestamp, + this.props.record.lastVotingPowerChangeTimestamp, + ]); + } + + static create( + record: TokenVotingMemberRecord, + metrics: MemberGovernanceMetrics | null, + ens: ENSName | null, + ): TokenVotingMember { + const validated = TokenVotingMemberPropsSchema.parse({ + record, + metrics, + ens, + }); + return new TokenVotingMember(validated); + } +} + +/** + * Earliest of the defined unix-seconds signals; `0` when none is present. + */ +function earliest(signals: Array): number { + const defined = signals.filter((signal): signal is number => signal != null); + return defined.length > 0 ? Math.min(...defined) : 0; +} + +/** + * Latest of the defined unix-seconds signals; `0` when none is present. + */ +function latest(signals: Array): number { + const defined = signals.filter((signal): signal is number => signal != null); + return defined.length > 0 ? Math.max(...defined) : 0; +} diff --git a/src/domain/member/TokenVotingMemberRecord.test.ts b/src/domain/member/TokenVotingMemberRecord.test.ts new file mode 100644 index 0000000..54c50e8 --- /dev/null +++ b/src/domain/member/TokenVotingMemberRecord.test.ts @@ -0,0 +1,45 @@ +import { Address } from '@/domain/primitives'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; +import { TokenVotingMemberRecord } from './TokenVotingMemberRecord'; + +describe('TokenVotingMemberRecord', () => { + const address = Address.fromHexString( + '0x0123456789abcdef0123456789abcdef01234567', + ); + + const build = ( + overrides: Partial< + Parameters[0] + > = {}, + ) => + TokenVotingMemberRecord.create({ + address, + votingPower: VotingPower.fromBigInt(1000000000000000000n), + delegationCount: 2, + firstVotingPowerChangeTimestamp: 1700000000, + lastVotingPowerChangeTimestamp: 1700000100, + ...overrides, + }); + + it('exposes its properties', () => { + const record = build(); + expect(record.address.equals(address)).toBe(true); + expect(record.votingPower.isZero).toBe(false); + expect(record.delegationCount).toBe(2); + expect(record.firstVotingPowerChangeTimestamp).toBe(1700000000); + expect(record.lastVotingPowerChangeTimestamp).toBe(1700000100); + }); + + it('allows null voting-power-change timestamps', () => { + const record = build({ + firstVotingPowerChangeTimestamp: null, + lastVotingPowerChangeTimestamp: null, + }); + expect(record.firstVotingPowerChangeTimestamp).toBeNull(); + expect(record.lastVotingPowerChangeTimestamp).toBeNull(); + }); + + it('rejects a negative delegation count', () => { + expect(() => build({ delegationCount: -1 })).toThrow(); + }); +}); diff --git a/src/domain/member/TokenVotingMemberRecord.ts b/src/domain/member/TokenVotingMemberRecord.ts new file mode 100644 index 0000000..5eb07d1 --- /dev/null +++ b/src/domain/member/TokenVotingMemberRecord.ts @@ -0,0 +1,66 @@ +import { ValueObject } from 'ddd-core-ts'; +import { z } from 'zod'; +import { Address } from '@/domain/primitives'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; + +const TokenVotingMemberRecordPropsSchema = z.object({ + address: z.instanceof(Address), + votingPower: z.instanceof(VotingPower), + delegationCount: z.number().int().nonnegative(), + firstVotingPowerChangeTimestamp: z.number().int().nonnegative().nullable(), + lastVotingPowerChangeTimestamp: z.number().int().nonnegative().nullable(), +}); + +type TokenVotingMemberRecordProps = z.infer< + typeof TokenVotingMemberRecordPropsSchema +>; + +/** + * A TokenVoting member as derived purely from on-chain + * delegate state: their voting power, delegation count, and the window + * of their voting-power changes. + */ +export class TokenVotingMemberRecord extends ValueObject { + /** + * The member's account address. + */ + get address(): Address { + return this.props.address; + } + + /** + * The member's current voting power. + */ + get votingPower(): VotingPower { + return this.props.votingPower; + } + + /** + * Number of distinct accounts delegating their voting power to this + * member (counts self-delegation). + */ + get delegationCount(): number { + return this.props.delegationCount; + } + + /** + * Unix-seconds timestamp of the member's first voting-power change, + * or null when no such change has been observed. + */ + get firstVotingPowerChangeTimestamp(): number | null { + return this.props.firstVotingPowerChangeTimestamp; + } + + /** + * Unix-seconds timestamp of the member's most recent voting-power + * change, or null when no such change has been observed. + */ + get lastVotingPowerChangeTimestamp(): number | null { + return this.props.lastVotingPowerChangeTimestamp; + } + + static create(props: TokenVotingMemberRecordProps): TokenVotingMemberRecord { + const validated = TokenVotingMemberRecordPropsSchema.parse(props); + return new TokenVotingMemberRecord(validated); + } +} diff --git a/src/domain/primitives/units/Ether.test.ts b/src/domain/primitives/units/Ether.test.ts index d909778..b44e1b5 100644 --- a/src/domain/primitives/units/Ether.test.ts +++ b/src/domain/primitives/units/Ether.test.ts @@ -48,4 +48,17 @@ describe('Ether', () => { const token = Ether.create(new BigNumber(1.23)); expect(token.toEther()).toBe(token); }); + + it('equals compares by numeric value across instances', () => { + const a = Ether.create(new BigNumber(1.23)); + const b = Ether.create(new BigNumber(1.23)); + const c = Ether.create(new BigNumber(4.56)); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); + + it('equals(undefined) returns false', () => { + const token = Ether.create(new BigNumber(1.23)); + expect(token.equals(undefined)).toBe(false); + }); }); diff --git a/src/domain/primitives/units/Ether.ts b/src/domain/primitives/units/Ether.ts index 8fced2a..a6da027 100644 --- a/src/domain/primitives/units/Ether.ts +++ b/src/domain/primitives/units/Ether.ts @@ -31,6 +31,17 @@ export class Ether extends ValueObject implements EVMUnit { return this; } + /** + * Value-based equality: the inherited shallow props comparison would + * compare the inner BigNumber by reference, so compare numerically. + */ + public equals(other?: Ether): boolean { + if (!other) { + return false; + } + return this.props.etherValue.isEqualTo(other.toBigNumber()); + } + /** * Adds this value to another value. * @param other The other value to add to. diff --git a/src/domain/primitives/units/Gwei.test.ts b/src/domain/primitives/units/Gwei.test.ts index c4be801..9fc5add 100644 --- a/src/domain/primitives/units/Gwei.test.ts +++ b/src/domain/primitives/units/Gwei.test.ts @@ -48,4 +48,17 @@ describe('Gwei', () => { const token = Gwei.create(new BigNumber(1_230_000_000)); expect(token.toGwei()).toBe(token); }); + + it('equals compares by numeric value across instances', () => { + const a = Gwei.create(new BigNumber(100)); + const b = Gwei.create(new BigNumber(100)); + const c = Gwei.create(new BigNumber(200)); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); + + it('equals(undefined) returns false', () => { + const token = Gwei.create(new BigNumber(100)); + expect(token.equals(undefined)).toBe(false); + }); }); diff --git a/src/domain/primitives/units/Gwei.ts b/src/domain/primitives/units/Gwei.ts index 77dcfef..7542a4f 100644 --- a/src/domain/primitives/units/Gwei.ts +++ b/src/domain/primitives/units/Gwei.ts @@ -31,6 +31,17 @@ export class Gwei extends ValueObject implements EVMUnit { return Ether.create(etherValue); } + /** + * Value-based equality: the inherited shallow props comparison would + * compare the inner BigNumber by reference, so compare numerically. + */ + public equals(other?: Gwei): boolean { + if (!other) { + return false; + } + return this.props.gweiValue.isEqualTo(other.toBigNumber()); + } + /** * Adds this value to another value. * @param other The other value to add to. diff --git a/src/domain/primitives/units/Wei.test.ts b/src/domain/primitives/units/Wei.test.ts index a6b2c8f..c8fd4fb 100644 --- a/src/domain/primitives/units/Wei.test.ts +++ b/src/domain/primitives/units/Wei.test.ts @@ -48,4 +48,17 @@ describe('Wei', () => { const token = Wei.create(new BigNumber('1230000000000000000')); expect(token.toWei()).toBe(token); }); + + it('equals compares by numeric value across instances', () => { + const a = Wei.create(new BigNumber(100)); + const b = Wei.create(new BigNumber(100)); + const c = Wei.create(new BigNumber(200)); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); + + it('equals(undefined) returns false', () => { + const token = Wei.create(new BigNumber(100)); + expect(token.equals(undefined)).toBe(false); + }); }); diff --git a/src/domain/primitives/units/Wei.ts b/src/domain/primitives/units/Wei.ts index 0b8deeb..3222116 100644 --- a/src/domain/primitives/units/Wei.ts +++ b/src/domain/primitives/units/Wei.ts @@ -34,6 +34,17 @@ export class Wei extends ValueObject implements EVMUnit { return Ether.create(etherValue); } + /** + * Value-based equality: the inherited shallow props comparison would + * compare the inner BigNumber by reference, so compare numerically. + */ + public equals(other?: Wei): boolean { + if (!other) { + return false; + } + return this.props.weiValue.isEqualTo(other.toBigNumber()); + } + /** * Adds this value to another value. * @param other The other value to add to. diff --git a/src/domain/voting-power/VotingPower.test.ts b/src/domain/voting-power/VotingPower.test.ts new file mode 100644 index 0000000..9716714 --- /dev/null +++ b/src/domain/voting-power/VotingPower.test.ts @@ -0,0 +1,40 @@ +import { VotingPower } from './VotingPower'; + +describe('VotingPower', () => { + it('creates from bigint', () => { + const vp = VotingPower.fromBigInt(1000000000000000000n); + expect(vp.toWei().toBigNumber().toString()).toBe('1000000000000000000'); + }); + + it('creates zero', () => { + const vp = VotingPower.zero(); + expect(vp.isZero).toBe(true); + }); + + it('detects non-zero', () => { + const vp = VotingPower.fromBigInt(1n); + expect(vp.isZero).toBe(false); + }); + + it('structural equality', () => { + const a = VotingPower.fromBigInt(100n); + const b = VotingPower.fromBigInt(100n); + expect(a.equals(b)).toBe(true); + }); + + it('structural inequality', () => { + const a = VotingPower.fromBigInt(100n); + const b = VotingPower.fromBigInt(200n); + expect(a.equals(b)).toBe(false); + }); + + it('equals(undefined) returns false', () => { + const vp = VotingPower.fromBigInt(100n); + expect(vp.equals(undefined)).toBe(false); + }); + + it('converts to ether', () => { + const vp = VotingPower.fromBigInt(1230000000000000000n); + expect(vp.toWei().toEther().toBigNumber().toNumber()).toBeCloseTo(1.23); + }); +}); diff --git a/src/domain/voting-power/VotingPower.ts b/src/domain/voting-power/VotingPower.ts new file mode 100644 index 0000000..a95d5a6 --- /dev/null +++ b/src/domain/voting-power/VotingPower.ts @@ -0,0 +1,55 @@ +import BigNumber from 'bignumber.js'; +import { ValueObject } from 'ddd-core-ts'; +import { z } from 'zod'; +import { Wei } from '@/domain/primitives'; + +const VotingPowerPropsSchema = z.object({ + weiValue: z.instanceof(Wei), +}); + +type VotingPowerProps = z.infer; + +export class VotingPower extends ValueObject { + /** + * The voting power in wei. + */ + toWei(): Wei { + return this.props.weiValue; + } + + /** + * Whether this voting power is zero. + */ + get isZero(): boolean { + return this.props.weiValue.toBigNumber().isZero(); + } + + /** + * True if the internal wei value is equal to the other's. + */ + equals(other?: VotingPower): boolean { + if (!other) { + return false; + } + return this.props.weiValue.equals(other.toWei()); + } + + static create(weiValue: Wei): VotingPower { + const validated = VotingPowerPropsSchema.parse({ weiValue }); + return new VotingPower(validated); + } + + /** + * Creates a VotingPower from a bigint value in wei. + */ + static fromBigInt(wei: bigint): VotingPower { + return VotingPower.create(Wei.create(new BigNumber(wei.toString()))); + } + + /** + * Creates a zero voting power. + */ + static zero(): VotingPower { + return VotingPower.create(Wei.create(new BigNumber(0))); + } +} diff --git a/src/index.ts b/src/index.ts index 6f183b2..ac2eaf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,12 @@ export { AragonController as AragonDomain } from './infrastructure/controllers/AragonController/AragonController'; // Infrastructure +export type { RpcUrls } from './infrastructure/config/RpcUrls'; export { EnvioClient } from './infrastructure/stores/EnvioClient'; // DTOs export type { MemberProfileTextRecordDTO } from './infrastructure/controllers/AragonController/maps/domain/MemberProfileTextRecordMap'; +export type { PageDTO } from './infrastructure/controllers/AragonController/maps/domain/PageMap'; +export type { TokenVotingMemberDTO } from './infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap'; export type { GetMemberProfileTextRecordsRequestDTO } from './infrastructure/controllers/AragonController/maps/use-cases/GetMemberProfileTextRecordsMap'; +export type { GetTokenVotingMembershipRequestDTO } from './infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap'; diff --git a/src/infrastructure/config/RpcUrls.ts b/src/infrastructure/config/RpcUrls.ts new file mode 100644 index 0000000..c77aa4c --- /dev/null +++ b/src/infrastructure/config/RpcUrls.ts @@ -0,0 +1,4 @@ +/** + * RPC endpoint URLs keyed by EVM chain id. + */ +export type RpcUrls = Partial>; diff --git a/src/infrastructure/controllers/AragonController/AragonController.ts b/src/infrastructure/controllers/AragonController/AragonController.ts index 6eb0ca9..1fda5ce 100644 --- a/src/infrastructure/controllers/AragonController/AragonController.ts +++ b/src/infrastructure/controllers/AragonController/AragonController.ts @@ -1,16 +1,33 @@ import type { HandlerDefinition } from 'ddd-core-ts'; import { handleRequest } from 'ddd-core-ts'; +import type { TokenVotingMember } from '@/domain/member/TokenVotingMember'; import type { MemberProfileTextRecord } from '@/domain/member-profile/MemberProfileTextRecord'; +import type { Page } from '@/domain/primitives/pagination/Page'; +import type { RpcUrls } from '@/infrastructure/config/RpcUrls'; import type { EnvioClient } from '@/infrastructure/stores/EnvioClient'; import { EnvioMemberProfileStore } from '@/infrastructure/stores/EnvioMemberProfileStore/EnvioMemberProfileStore'; +import { EnvioMemberStore } from '@/infrastructure/stores/EnvioMemberStore/EnvioMemberStore'; +import { ViemENSStore } from '@/infrastructure/stores/ViemENSStore/ViemENSStore'; import type { GetMemberProfileTextRecordsUseCaseProps } from '@/use-cases/GetMemberProfileTextRecordsUseCase'; import { GetMemberProfileTextRecordsUseCase } from '@/use-cases/GetMemberProfileTextRecordsUseCase'; +import type { GetTokenVotingMembershipUseCaseProps } from '@/use-cases/GetTokenVotingMembershipUseCase'; +import { GetTokenVotingMembershipUseCase } from '@/use-cases/GetTokenVotingMembershipUseCase'; import type { MemberProfileTextRecordDTO } from './maps/domain/MemberProfileTextRecordMap'; import * as MemberProfileTextRecordMap from './maps/domain/MemberProfileTextRecordMap'; +import type { PageDTO } from './maps/domain/PageMap'; +import type { TokenVotingMemberDTO } from './maps/domain/TokenVotingMemberMap'; import type { GetMemberProfileTextRecordsRequestDTO } from './maps/use-cases/GetMemberProfileTextRecordsMap'; import * as GetMemberProfileTextRecordsMap from './maps/use-cases/GetMemberProfileTextRecordsMap'; +import type { GetTokenVotingMembershipRequestDTO } from './maps/use-cases/GetTokenVotingMembershipMap'; +import * as GetTokenVotingMembershipMap from './maps/use-cases/GetTokenVotingMembershipMap'; interface HandlersRecord { + getTokenVotingMembership: HandlerDefinition< + GetTokenVotingMembershipRequestDTO, + GetTokenVotingMembershipUseCaseProps, + Page, + PageDTO + >; getMemberProfileTextRecords: HandlerDefinition< GetMemberProfileTextRecordsRequestDTO, GetMemberProfileTextRecordsUseCaseProps, @@ -27,13 +44,28 @@ export class AragonController { /** * Initializes the `AragonDomain`. + * + * @param envioClient Indexer client backing the on-chain member data. + * @param rpcUrls RPC endpoint URLs keyed by chain id. */ - static load(envioClient: EnvioClient): AragonController { + static load(envioClient: EnvioClient, rpcUrls: RpcUrls): AragonController { + const memberStore = new EnvioMemberStore(envioClient); + const ensStore = ViemENSStore.fromRpcUrls(rpcUrls); + const getTokenVotingMembershipUseCase = new GetTokenVotingMembershipUseCase( + memberStore, + ensStore, + ); + const memberProfileStore = new EnvioMemberProfileStore(envioClient); const getMemberProfileTextRecordsUseCase = new GetMemberProfileTextRecordsUseCase(memberProfileStore); const handlers: HandlersRecord = { + getTokenVotingMembership: { + requestMapper: GetTokenVotingMembershipMap, + responseMapper: GetTokenVotingMembershipMap, + useCaseExecutor: getTokenVotingMembershipUseCase, + }, getMemberProfileTextRecords: { requestMapper: GetMemberProfileTextRecordsMap, responseMapper: MemberProfileTextRecordMap, @@ -44,6 +76,15 @@ export class AragonController { return new AragonController(handlers); } + /** + * Returns a page of members of an Aragon TokenVoting plugin, scoped + * to a specific token contract and ordered by current voting power + * descending. + */ + public getTokenVotingMembership(dto: GetTokenVotingMembershipRequestDTO) { + return handleRequest(this.handlers.getTokenVotingMembership, dto); + } + /** * Looks up the live forward ENS text records attached to a member's `.aragon.eth` * subdomain. Returns an empty list when the subdomain is unknown, has diff --git a/src/infrastructure/controllers/AragonController/maps/domain/PageMap.ts b/src/infrastructure/controllers/AragonController/maps/domain/PageMap.ts new file mode 100644 index 0000000..c47d498 --- /dev/null +++ b/src/infrastructure/controllers/AragonController/maps/domain/PageMap.ts @@ -0,0 +1,13 @@ +/** + * Generic paginated response DTO — the serialized counterpart of the + * domain `Page` primitive. Reused by any paginated use-case result. + */ +export interface PageDTO { + metadata: { + page: number; + pageSize: number; + totalPages: number; + totalRecords: number; + }; + data: T[]; +} diff --git a/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.test.ts b/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.test.ts new file mode 100644 index 0000000..ddc4b1a --- /dev/null +++ b/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.test.ts @@ -0,0 +1,62 @@ +import { ENSName } from '@/domain/ens/ENSName'; +import { TokenVotingMember } from '@/domain/member/TokenVotingMember'; +import { TokenVotingMemberRecord } from '@/domain/member/TokenVotingMemberRecord'; +import { Address } from '@/domain/primitives'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; +import { mapDomainToDTO } from './TokenVotingMemberMap'; + +describe('TokenVotingMemberMap.mapDomainToDTO', () => { + const address = Address.fromHexString( + '0x0123456789abcdef0123456789abcdef01234567', + ); + + const buildMember = ( + opts: { + ens?: ENSName | null; + firstVotingPowerChangeTimestamp?: number | null; + lastVotingPowerChangeTimestamp?: number | null; + } = {}, + ) => { + const { + ens = ENSName.fromString('alice.eth'), + firstVotingPowerChangeTimestamp = 1705320000, + lastVotingPowerChangeTimestamp = 1718872200, + } = opts; + return TokenVotingMember.create( + TokenVotingMemberRecord.create({ + address, + votingPower: VotingPower.fromBigInt(5000000000000000000n), + delegationCount: 3, + firstVotingPowerChangeTimestamp, + lastVotingPowerChangeTimestamp, + }), + null, + ens, + ); + }; + + it('maps a Member with full activity to a DTO', () => { + const dto = mapDomainToDTO(buildMember()); + + expect(dto.ens).toBe('alice.eth'); + expect(dto.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(dto.votingPower).toBe('5000000000000000000'); + expect(dto.metrics).toEqual({ + firstActivityTimestamp: 1705320000, + lastActivityTimestamp: 1718872200, + delegationCount: 3, + }); + }); + + it('serializes zero activity timestamps as null (no recorded activity)', () => { + const dto = mapDomainToDTO( + buildMember({ + firstVotingPowerChangeTimestamp: null, + lastVotingPowerChangeTimestamp: null, + }), + ); + + expect(dto.metrics.firstActivityTimestamp).toBeNull(); + expect(dto.metrics.lastActivityTimestamp).toBeNull(); + }); +}); diff --git a/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.ts b/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.ts new file mode 100644 index 0000000..a011bb3 --- /dev/null +++ b/src/infrastructure/controllers/AragonController/maps/domain/TokenVotingMemberMap.ts @@ -0,0 +1,64 @@ +import type { TokenVotingMember } from '@/domain/member/TokenVotingMember'; + +/** + * A member of a TokenVoting plugin. + */ +export interface TokenVotingMemberDTO { + /** + * A member of a TokenVoting plugin. + */ + address: string; + + /** + * The member's primary ENS name, or null when the address has no + * primary name (or it could not be resolved). + */ + ens: string | null; + + /** + * The member's voting power. + */ + votingPower: string | null; + + /** + * The member's governance metrics. + */ + metrics: { + /** + * Unix-seconds timestamp of the member's first observed activity. + * "Activity" includes voting power changes, votes cast, and proposals + * created. `0` means "no activity recorded". + */ + firstActivityTimestamp: number | null; + + /** + * Unix-seconds timestamp of the member's most recent observed activity. + * "Activity" includes voting power changes, votes cast, and proposals + * created. `0` means "no activity recorded". + */ + lastActivityTimestamp: number | null; + + /** + * Number of distinct accounts currently delegating their voting power + * to this member (counts self-delegation). + */ + delegationCount: number; + }; +} + +export function mapDomainToDTO( + member: TokenVotingMember, +): TokenVotingMemberDTO { + const first = member.firstActivityTimestamp; + const last = member.lastActivityTimestamp; + return { + address: member.address.toHexString(), + ens: member.ens ? member.ens.toString() : null, + votingPower: member.votingPower.toWei().toBigNumber().toFixed(0), + metrics: { + firstActivityTimestamp: first === 0 ? null : first, + lastActivityTimestamp: last === 0 ? null : last, + delegationCount: member.delegationCount, + }, + }; +} diff --git a/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.test.ts b/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.test.ts new file mode 100644 index 0000000..4d9ba38 --- /dev/null +++ b/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.test.ts @@ -0,0 +1,78 @@ +import { ENSName } from '@/domain/ens/ENSName'; +import { TokenVotingMember } from '@/domain/member/TokenVotingMember'; +import { TokenVotingMemberRecord } from '@/domain/member/TokenVotingMemberRecord'; +import { Address } from '@/domain/primitives'; +import { createPage } from '@/domain/primitives/pagination/Page'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; +import { mapDomainToDTO, mapDTOToDomain } from './GetTokenVotingMembershipMap'; + +describe('GetTokenVotingMembershipMap', () => { + const pluginAddress = '0x1111111111111111111111111111111111111111'; + const tokenContractAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + + describe('mapDTOToDomain', () => { + it('passes explicit page and pageSize through to the PageRequest', () => { + const result = mapDTOToDomain({ + pluginAddress, + tokenContractAddress, + page: 3, + pageSize: 50, + }); + + expect(result.page.page).toBe(3); + expect(result.page.pageSize).toBe(50); + }); + + it('defaults page to 1 and pageSize to 20 when omitted', () => { + const result = mapDTOToDomain({ pluginAddress, tokenContractAddress }); + + expect(result.page.page).toBe(1); + expect(result.page.pageSize).toBe(20); + }); + + it('converts plugin and token addresses to Address value objects', () => { + const result = mapDTOToDomain({ pluginAddress, tokenContractAddress }); + + expect( + result.pluginAddress.equals(Address.fromHexString(pluginAddress)), + ).toBe(true); + expect( + result.tokenContractAddress.equals( + Address.fromHexString(tokenContractAddress), + ), + ).toBe(true); + }); + }); + + describe('mapDomainToDTO', () => { + it('maps a domain page to a PageDTO of TokenVotingMemberDTO', () => { + const member = TokenVotingMember.create( + TokenVotingMemberRecord.create({ + address: Address.fromHexString( + '0x0123456789abcdef0123456789abcdef01234567', + ), + votingPower: VotingPower.fromBigInt(5000000000000000000n), + delegationCount: 3, + firstVotingPowerChangeTimestamp: 1705320000, + lastVotingPowerChangeTimestamp: 1718872200, + }), + null, + ENSName.fromString('alice.eth'), + ); + const page = createPage([member], 1, 20, 1); + + const dto = mapDomainToDTO(page); + + expect(dto.metadata).toEqual({ + page: 1, + pageSize: 20, + totalPages: 1, + totalRecords: 1, + }); + expect(dto.data).toHaveLength(1); + expect(dto.data[0].ens).toBe('alice.eth'); + expect(dto.data[0].votingPower).toBe('5000000000000000000'); + expect(dto.data[0].metrics.delegationCount).toBe(3); + }); + }); +}); diff --git a/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.ts b/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.ts new file mode 100644 index 0000000..d7c46c1 --- /dev/null +++ b/src/infrastructure/controllers/AragonController/maps/use-cases/GetTokenVotingMembershipMap.ts @@ -0,0 +1,50 @@ +import type { TokenVotingMember } from '@/domain/member/TokenVotingMember'; +import { Address, assertHexString } from '@/domain/primitives'; +import type { Page } from '@/domain/primitives/pagination/Page'; +import { PageRequest } from '@/domain/primitives/pagination/PageRequest'; +import type { GetTokenVotingMembershipUseCaseProps } from '@/use-cases/GetTokenVotingMembershipUseCase'; +import type { PageDTO } from '../domain/PageMap'; +import type { TokenVotingMemberDTO } from '../domain/TokenVotingMemberMap'; +import * as TokenVotingMemberMap from '../domain/TokenVotingMemberMap'; + +export interface GetTokenVotingMembershipRequestDTO { + pluginAddress: string; + tokenContractAddress: string; + page?: number; + pageSize?: number; +} + +export function mapDTOToDomain( + dto: GetTokenVotingMembershipRequestDTO, +): GetTokenVotingMembershipUseCaseProps { + assertHexString( + dto.pluginAddress, + 'Expected plugin address to be a hex string', + ); + assertHexString( + dto.tokenContractAddress, + 'Expected token contract address to be a hex string', + ); + return { + pluginAddress: Address.fromHexString(dto.pluginAddress), + tokenContractAddress: Address.fromHexString(dto.tokenContractAddress), + page: PageRequest.create({ + page: dto.page ?? 1, + pageSize: dto.pageSize ?? 20, + }), + }; +} + +export function mapDomainToDTO( + result: Page, +): PageDTO { + return { + metadata: { + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages, + totalRecords: result.totalRecords, + }, + data: result.items.map(TokenVotingMemberMap.mapDomainToDTO), + }; +} diff --git a/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.test.ts b/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.test.ts new file mode 100644 index 0000000..8fb9de9 --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.test.ts @@ -0,0 +1,145 @@ +import { Address } from '@/domain/primitives'; +import { PageRequest } from '@/domain/primitives/pagination/PageRequest'; +import type { EnvioClient } from '@/infrastructure/stores/EnvioClient'; +import { EnvioMemberStore } from './EnvioMemberStore'; + +const PLUGIN = '0x1111111111111111111111111111111111111111'; +const TOKEN = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + +const PLUGIN_ADDRESS = Address.fromHexString(PLUGIN); +const TOKEN_ADDRESS = Address.fromHexString(TOKEN); + +const ALICE = '0x0123456789abcdef0123456789abcdef01234567'; +const BOB = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +interface QueryCall { + document: string; + variables: Record; +} + +interface MockClient { + envio: EnvioClient; + calls: QueryCall[]; +} + +/** + * Builds an EnvioClient stub that returns each `response` in order for + * the corresponding call. + */ +function buildMockEnvioClient(responses: unknown[]): MockClient { + const calls: QueryCall[] = []; + const queue = [...responses]; + const envio = { + query: vi.fn( + async (document: string, variables: Record) => { + calls.push({ document, variables }); + return queue.shift(); + }, + ), + } as unknown as EnvioClient; + return { envio, calls }; +} + +const buildDelegate = ( + address: string, + votingPower = '5000000000000000000', +) => ({ + id: `1-${TOKEN}-${address}`, + chainId: 1, + tokenContractAddress: TOKEN, + delegateAddress: address, + votingPower, + delegationCount: 1, + firstVotingPowerChangeTimestamp: '1700000000', + lastVotingPowerChangeTimestamp: '1700000100', +}); + +describe('EnvioMemberStore', () => { + const page = PageRequest.create({ page: 1, pageSize: 20 }); + + it('returns a page of record/metrics pairs in a single query (no ENS round-trip)', async () => { + const { envio, calls } = buildMockEnvioClient([ + { + ERC20VotesDelegate: [buildDelegate(ALICE), buildDelegate(BOB)], + AllERC20VotesDelegate: [{ id: 'a' }, { id: 'b' }], + MemberGovernanceMetrics: [], + }, + ]); + const store = new EnvioMemberStore(envio); + + const result = await store.findTokenVotingMembers( + PLUGIN_ADDRESS, + TOKEN_ADDRESS, + page, + ); + + expect(result.items).toHaveLength(2); + expect(result.totalRecords).toBe(2); + expect(calls).toHaveLength(1); + // No governance metrics for these members → null on each pair. + expect(result.items[0].metrics).toBeNull(); + }); + + it('pairs MemberGovernanceMetrics with the matching record by address', async () => { + const { envio } = buildMockEnvioClient([ + { + ERC20VotesDelegate: [buildDelegate(ALICE)], + AllERC20VotesDelegate: [{ id: 'a' }], + MemberGovernanceMetrics: [ + { + id: `1-${PLUGIN}-${ALICE}`, + chainId: 1, + pluginAddress: PLUGIN, + memberAddress: ALICE, + firstActivityTimestamp: '1650000000', + lastActivityTimestamp: '1750000000', + }, + ], + }, + ]); + const store = new EnvioMemberStore(envio); + + const result = await store.findTokenVotingMembers( + PLUGIN_ADDRESS, + TOKEN_ADDRESS, + page, + ); + + const { record, metrics } = result.items[0]; + expect(record.address.toHexString().toLowerCase()).toBe(ALICE); + expect(record.firstVotingPowerChangeTimestamp).toBe(1700000000); + expect(metrics?.firstActivityTimestamp).toBe(1650000000); + expect(metrics?.lastActivityTimestamp).toBe(1750000000); + }); + + it('returns an empty page when there are no delegates', async () => { + const { envio, calls } = buildMockEnvioClient([ + { + ERC20VotesDelegate: [], + AllERC20VotesDelegate: [], + MemberGovernanceMetrics: [], + }, + ]); + const store = new EnvioMemberStore(envio); + + const result = await store.findTokenVotingMembers( + PLUGIN_ADDRESS, + TOKEN_ADDRESS, + page, + ); + + expect(result.items).toHaveLength(0); + expect(calls).toHaveLength(1); + }); + + it('wraps query failures with a member-level error', async () => { + const envio = { + query: vi.fn().mockRejectedValue(new Error('graphql exploded')), + } as unknown as EnvioClient; + const store = new EnvioMemberStore(envio); + + await expect( + store.findTokenVotingMembers(PLUGIN_ADDRESS, TOKEN_ADDRESS, page), + ).rejects.toThrow('Error querying members from Envio'); + }); +}); diff --git a/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.ts b/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.ts new file mode 100644 index 0000000..cf5fdaa --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/EnvioMemberStore.ts @@ -0,0 +1,112 @@ +import type { + MemberStore, + TokenVotingMemberData, +} from '@/domain/member/MemberStore'; +import type { Address } from '@/domain/primitives'; +import type { Page } from '@/domain/primitives/pagination/Page'; +import { createPage } from '@/domain/primitives/pagination/Page'; +import type { PageRequest } from '@/domain/primitives/pagination/PageRequest'; +import type { EnvioClient } from '@/infrastructure/stores/EnvioClient'; +import * as MemberGovernanceMetricsMap from './maps/MemberGovernanceMetricsMap'; +import * as TokenVotingMemberRecordMap from './maps/TokenVotingMemberRecordMap'; + +/** + * Fetches a page of members by token (ordered by VP desc). + */ +const FIND_MEMBERS_QUERY = ` + query FindMembers( + $tokenContractAddress: String! + $pluginAddress: String! + $limit: Int! + $offset: Int! + ) { + ERC20VotesDelegate( + where: { + tokenContractAddress: { _eq: $tokenContractAddress } + votingPower: { _gt: "0" } + } + order_by: [{ votingPower: desc }] + limit: $limit + offset: $offset + ) { + id + chainId + tokenContractAddress + delegateAddress + votingPower + delegationCount + firstVotingPowerChangeTimestamp + lastVotingPowerChangeTimestamp + } + AllERC20VotesDelegate: ERC20VotesDelegate( + where: { + tokenContractAddress: { _eq: $tokenContractAddress } + votingPower: { _gt: "0" } + } + ) { + id + } + MemberGovernanceMetrics( + where: { + pluginAddress: { _eq: $pluginAddress } + } + ) { + id + chainId + pluginAddress + memberAddress + firstActivityTimestamp + lastActivityTimestamp + } + } +`; + +export class EnvioMemberStore implements MemberStore { + constructor(private readonly envio: EnvioClient) {} + + public async findTokenVotingMembers( + pluginAddress: Address, + tokenContractAddress: Address, + request: PageRequest, + ): Promise> { + try { + // The indexer stores addresses lowercased; serialize the primitives + // to lowercase hex for the query variables. + const pluginAddressLower = pluginAddress.toHexString().toLowerCase(); + const tokenAddressLower = tokenContractAddress + .toHexString() + .toLowerCase(); + + const raw = await this.envio.query(FIND_MEMBERS_QUERY, { + tokenContractAddress: tokenAddressLower, + pluginAddress: pluginAddressLower, + limit: request.pageSize, + offset: request.offset, + }); + + const { records, totalRecords } = + TokenVotingMemberRecordMap.mapDTOToDomain(raw); + const metrics = MemberGovernanceMetricsMap.mapDTOToDomain(raw); + + const metricsByMember = new Map( + metrics.map((entry) => [ + entry.memberAddress.toHexString().toLowerCase(), + entry, + ]), + ); + + // Pair each on-chain record with its governance metrics (one query, + // so the join happens here); ENS is resolved in the use case. + const data = records.map((record) => ({ + record, + metrics: + metricsByMember.get(record.address.toHexString().toLowerCase()) ?? + null, + })); + + return createPage(data, request.page, request.pageSize, totalRecords); + } catch (cause) { + throw new Error('Error querying members from Envio', { cause }); + } + } +} diff --git a/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.test.ts b/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.test.ts new file mode 100644 index 0000000..c6b970d --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.test.ts @@ -0,0 +1,32 @@ +import { mapDTOToDomain } from './MemberGovernanceMetricsMap'; + +const PLUGIN = '0x1111111111111111111111111111111111111111'; +const MEMBER = '0x0123456789abcdef0123456789abcdef01234567'; + +const buildMetrics = (overrides: Record = {}) => ({ + id: `1-${PLUGIN}-${MEMBER}`, + chainId: 1, + pluginAddress: PLUGIN, + memberAddress: MEMBER, + firstActivityTimestamp: '1650000000', + lastActivityTimestamp: '1750000000', + ...overrides, +}); + +describe('MemberGovernanceMetricsMap.mapDTOToDomain', () => { + it('throws when the response shape does not match', () => { + expect(() => mapDTOToDomain({ MemberGovernanceMetrics: 'oops' })).toThrow(); + expect(() => mapDTOToDomain(null)).toThrow(); + }); + + it('maps governance-metrics rows to domain objects', () => { + const metrics = mapDTOToDomain({ + MemberGovernanceMetrics: [buildMetrics()], + }); + + expect(metrics).toHaveLength(1); + expect(metrics[0].memberAddress.toHexString().toLowerCase()).toBe(MEMBER); + expect(metrics[0].firstActivityTimestamp).toBe(1650000000); + expect(metrics[0].lastActivityTimestamp).toBe(1750000000); + }); +}); diff --git a/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.ts b/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.ts new file mode 100644 index 0000000..9babf03 --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/maps/MemberGovernanceMetricsMap.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { MemberGovernanceMetrics } from '@/domain/member/MemberGovernanceMetrics'; +import { Address, zExtended } from '@/domain/primitives'; + +const MemberGovernanceMetricsSchema = z.object({ + id: z.string(), + chainId: z.number(), + pluginAddress: zExtended.hexString(), + memberAddress: zExtended.hexString(), + firstActivityTimestamp: z.string(), + lastActivityTimestamp: z.string(), +}); + +/** + * Slice of the `FindMembers` response owned by this mapper: the + * governance-metrics rows for the plugin. Other top-level lists in the + * response are ignored. + */ +const ResponseSchema = z.object({ + MemberGovernanceMetrics: z.array(MemberGovernanceMetricsSchema), +}); + +export type MemberGovernanceMetricsDTO = z.infer< + typeof MemberGovernanceMetricsSchema +>; + +export function mapDTOToDomain(raw: unknown): MemberGovernanceMetrics[] { + const data = ResponseSchema.parse(raw); + + return data.MemberGovernanceMetrics.map((metrics) => + MemberGovernanceMetrics.create({ + memberAddress: Address.fromHexString(metrics.memberAddress), + firstActivityTimestamp: Number(metrics.firstActivityTimestamp), + lastActivityTimestamp: Number(metrics.lastActivityTimestamp), + }), + ); +} diff --git a/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.test.ts b/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.test.ts new file mode 100644 index 0000000..9abb028 --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.test.ts @@ -0,0 +1,54 @@ +import { mapDTOToDomain } from './TokenVotingMemberRecordMap'; + +const TOKEN = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const MEMBER = '0x0123456789abcdef0123456789abcdef01234567'; + +const buildDelegate = (overrides: Record = {}) => ({ + id: `1-${TOKEN}-${MEMBER}`, + chainId: 1, + tokenContractAddress: TOKEN, + delegateAddress: MEMBER, + votingPower: '5000000000000000000', + delegationCount: 1, + firstVotingPowerChangeTimestamp: '1700000000', + lastVotingPowerChangeTimestamp: '1700000100', + ...overrides, +}); + +describe('TokenVotingMemberRecordMap.mapDTOToDomain', () => { + it('throws when the response shape does not match', () => { + expect(() => mapDTOToDomain({ ERC20VotesDelegate: 'oops' })).toThrow(); + expect(() => mapDTOToDomain(null)).toThrow(); + }); + + it('maps records and reports the chain-wide total', () => { + const { records, totalRecords } = mapDTOToDomain({ + ERC20VotesDelegate: [buildDelegate()], + AllERC20VotesDelegate: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }); + + expect(records).toHaveLength(1); + expect(totalRecords).toBe(3); + expect(records[0].votingPower.toWei().toBigNumber().toFixed(0)).toBe( + '5000000000000000000', + ); + expect(records[0].delegationCount).toBe(1); + expect(records[0].firstVotingPowerChangeTimestamp).toBe(1700000000); + expect(records[0].lastVotingPowerChangeTimestamp).toBe(1700000100); + }); + + it('preserves null voting-power-change timestamps', () => { + const { records } = mapDTOToDomain({ + ERC20VotesDelegate: [ + buildDelegate({ + firstVotingPowerChangeTimestamp: null, + lastVotingPowerChangeTimestamp: null, + }), + ], + AllERC20VotesDelegate: [{ id: 'a' }], + }); + + expect(records[0].firstVotingPowerChangeTimestamp).toBeNull(); + expect(records[0].lastVotingPowerChangeTimestamp).toBeNull(); + }); +}); diff --git a/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.ts b/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.ts new file mode 100644 index 0000000..4cc1ead --- /dev/null +++ b/src/infrastructure/stores/EnvioMemberStore/maps/TokenVotingMemberRecordMap.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { TokenVotingMemberRecord } from '@/domain/member/TokenVotingMemberRecord'; +import { Address, zExtended } from '@/domain/primitives'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; + +const ERC20VotesDelegateSchema = z.object({ + id: z.string(), + chainId: z.number(), + tokenContractAddress: zExtended.hexString(), + delegateAddress: zExtended.hexString(), + votingPower: z.string(), + delegationCount: z.number(), + firstVotingPowerChangeTimestamp: z.string().nullable(), + lastVotingPowerChangeTimestamp: z.string().nullable(), +}); + +/** + * Slice of the `FindMembers` response owned by this mapper: the requested + * page of delegates plus the id-only list used for the chain-wide total + * count. Other top-level lists in the response are ignored. + */ +const ResponseSchema = z.object({ + ERC20VotesDelegate: z.array(ERC20VotesDelegateSchema), + AllERC20VotesDelegate: z.array(z.object({ id: z.string() })), +}); + +export interface TokenVotingMemberRecordsResult { + records: TokenVotingMemberRecord[]; + totalRecords: number; +} + +export function mapDTOToDomain(raw: unknown): TokenVotingMemberRecordsResult { + const data = ResponseSchema.parse(raw); + + const records = data.ERC20VotesDelegate.map((delegate) => + TokenVotingMemberRecord.create({ + address: Address.fromHexString(delegate.delegateAddress), + votingPower: VotingPower.fromBigInt(BigInt(delegate.votingPower)), + delegationCount: delegate.delegationCount, + firstVotingPowerChangeTimestamp: toTimestamp( + delegate.firstVotingPowerChangeTimestamp, + ), + lastVotingPowerChangeTimestamp: toTimestamp( + delegate.lastVotingPowerChangeTimestamp, + ), + }), + ); + + return { records, totalRecords: data.AllERC20VotesDelegate.length }; +} + +/** Parses a nullable unix-seconds string into a number, preserving null. */ +function toTimestamp(value: string | null): number | null { + return value != null ? Number(value) : null; +} diff --git a/src/infrastructure/stores/ViemENSStore/ViemENSStore.test.ts b/src/infrastructure/stores/ViemENSStore/ViemENSStore.test.ts new file mode 100644 index 0000000..0a86b5b --- /dev/null +++ b/src/infrastructure/stores/ViemENSStore/ViemENSStore.test.ts @@ -0,0 +1,165 @@ +import { createPublicClient, http, type PublicClient } from 'viem'; +import { mainnet } from 'viem/chains'; +import { Address } from '@/domain/primitives'; +import { ViemENSStore } from './ViemENSStore'; + +// `createPublicClient` / `http` are stubbed so `fromRpcUrls` can be asserted +// without a real RPC; everything else (e.g. `getAddress` used by `Address`) +// is the real implementation. +vi.mock('viem', async (importOriginal) => ({ + ...(await importOriginal()), + createPublicClient: vi.fn(), + http: vi.fn((url?: string) => ({ url })), +})); + +const MAINNET_RPC = 'https://eth.example/rpc'; +const ALICE = '0x0123456789abcdef0123456789abcdef01234567'; +const BOB = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +const address = (hex: string) => Address.fromHexString(hex as `0x${string}`); + +/** + * A stub viem client whose `getEnsName` resolves from a + * `name-by-lowercase-address` table. Absent addresses resolve to `null`; + * addresses mapped to an `Error` reject (RPC failure). + */ +function buildClient(table: Record) { + const getEnsName = vi.fn( + async ({ address: addr }: { address: string; coinType?: bigint }) => { + const entry = table[addr.toLowerCase()]; + if (entry instanceof Error) { + throw entry; + } + return entry ?? null; + }, + ); + return { client: { getEnsName } as unknown as PublicClient, getEnsName }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ViemENSStore.fromRpcUrls', () => { + it('builds a mainnet client with multicall batching from the chain-id URL map', () => { + vi.mocked(createPublicClient).mockReturnValue(buildClient({}).client); + + ViemENSStore.fromRpcUrls({ [mainnet.id]: MAINNET_RPC }); + + expect(http).toHaveBeenCalledWith(MAINNET_RPC); + expect(createPublicClient).toHaveBeenCalledWith( + expect.objectContaining({ chain: mainnet, batch: { multicall: true } }), + ); + }); + + it('falls back to the default endpoint when no mainnet URL is provided', () => { + vi.mocked(createPublicClient).mockReturnValue(buildClient({}).client); + + ViemENSStore.fromRpcUrls({}); + + expect(http).toHaveBeenCalledWith(undefined); + }); + + it('produces a store that resolves names through the built client', async () => { + const { client } = buildClient({ [ALICE]: 'alice.eth' }); + vi.mocked(createPublicClient).mockReturnValue(client); + + const store = ViemENSStore.fromRpcUrls({ [mainnet.id]: MAINNET_RPC }); + const names = await store.lookUpPrimaryNames([address(ALICE)]); + + expect(names.get(address(ALICE).toHexString())?.toString()).toBe( + 'alice.eth', + ); + }); +}); + +describe('ViemENSStore.lookUpPrimaryNames', () => { + it('returns a map keyed by checksummed address for resolved names', async () => { + const store = new ViemENSStore( + buildClient({ [ALICE]: 'alice.eth' }).client, + ); + + const names = await store.lookUpPrimaryNames([address(ALICE)]); + + expect(names.get(address(ALICE).toHexString())?.toString()).toBe( + 'alice.eth', + ); + }); + + it('omits addresses with no primary name', async () => { + const store = new ViemENSStore( + buildClient({ [ALICE]: 'alice.eth', [BOB]: null }).client, + ); + + const names = await store.lookUpPrimaryNames([ + address(ALICE), + address(BOB), + ]); + + expect(names.has(address(ALICE).toHexString())).toBe(true); + expect(names.has(address(BOB).toHexString())).toBe(false); + }); + + it('degrades a per-address RPC failure to "no name" without failing the page', async () => { + const store = new ViemENSStore( + buildClient({ + [ALICE]: 'alice.eth', + [BOB]: new Error('rpc exploded'), + }).client, + ); + + const names = await store.lookUpPrimaryNames([ + address(ALICE), + address(BOB), + ]); + + expect(names.get(address(ALICE).toHexString())?.toString()).toBe( + 'alice.eth', + ); + expect(names.has(address(BOB).toHexString())).toBe(false); + }); + + it('drops a name that fails ENSIP-15 normalization', async () => { + const store = new ViemENSStore( + buildClient({ [ALICE]: 'has space.eth' }).client, + ); + + const names = await store.lookUpPrimaryNames([address(ALICE)]); + + expect(names.has(address(ALICE).toHexString())).toBe(false); + }); + + it('normalizes resolved names to their canonical form', async () => { + const store = new ViemENSStore( + buildClient({ [ALICE]: 'Alice.ETH' }).client, + ); + + const names = await store.lookUpPrimaryNames([address(ALICE)]); + + expect(names.get(address(ALICE).toHexString())?.toString()).toBe( + 'alice.eth', + ); + }); + + it('queries with the checksummed address and the legacy mainnet coin type', async () => { + const { client, getEnsName } = buildClient({ [ALICE]: 'alice.eth' }); + const store = new ViemENSStore(client); + + await store.lookUpPrimaryNames([address(ALICE)]); + + expect(getEnsName).toHaveBeenCalledWith({ + address: address(ALICE).toHexString(), + coinType: 60n, + }); + }); + + it('returns an empty map and issues no calls for no addresses', async () => { + const { client, getEnsName } = buildClient({}); + const store = new ViemENSStore(client); + + const names = await store.lookUpPrimaryNames([]); + + expect(names.size).toBe(0); + expect(getEnsName).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infrastructure/stores/ViemENSStore/ViemENSStore.ts b/src/infrastructure/stores/ViemENSStore/ViemENSStore.ts new file mode 100644 index 0000000..94bc643 --- /dev/null +++ b/src/infrastructure/stores/ViemENSStore/ViemENSStore.ts @@ -0,0 +1,101 @@ +import { createPublicClient, http, type PublicClient } from 'viem'; +import { mainnet } from 'viem/chains'; +import { ENSName } from '@/domain/ens/ENSName'; +import type { ENSStore } from '@/domain/ens/ENSStore'; +import type { Address, HexString } from '@/domain/primitives'; +import type { RpcUrls } from '@/infrastructure/config/RpcUrls'; + +/** + * Legacy Ethereum mainnet coin type (ENSIP-11). The `addr.reverse` + * namespace, where the overwhelming majority of primary names live today. + */ +const LEGACY_MAINNET_COIN_TYPE = 60n; + +/** + * Resolves wallet addresses to their primary ENS names via viem's + * `getEnsName`. ENS reverse resolution executes on Ethereum L1, even for L2 + * names (which arrive via CCIP-Read), so this store always resolves against + * mainnet — `fromRpcUrls` reads the mainnet URL from the injected map. + * + * This class accounts for the safety considerations in the [SEAL security + * framework](https://frameworks.securityalliance.org/ens/overview): + * + * 1. Forward-resolution verification — `getEnsName` calls the + * UniversalResolver's `reverseWithGateways`, whose `reverse()` + * forward-resolves the candidate name back to the address on-chain and + * reverts (`ReverseAddressMismatch`) on a mismatch. This guarantee + * holds ONLY while the client's `universalResolverAddress` is the + * ENSIP-23 UniversalResolver (viem's mainnet default). Do not point it + * at a legacy resolver. + * 2. ENSIP-15 normalization — the resolved name is wrapped in `ENSName`, + * which normalizes on creation (viem `normalize`). viem does not + * auto-normalize the names it returns, so the value object owns this. + * 3. Cross-chain (ENSIP-19) — `coinType` selects which chain's primary + * name to read. We default to `60` (legacy mainnet); a chain-specific + * coin type would extend this to L2 / multichain primary names. + * + * Performance: lookups fan out concurrently and `fromRpcUrls` builds the + * client with `batch: { multicall: true }`, so a page's lookups collapse + * into a single multicall request. The store owns that flag — it is set + * here rather than trusting callers to configure the client correctly. A + * per-address failure (RPC error, CCIP-Read timeout, or an un-normalizable + * name) degrades to "no name" rather than failing the whole page; ENS is + * enrichment, not core membership data. + */ +export class ViemENSStore implements ENSStore { + constructor(private readonly client: PublicClient) {} + + /** + * Builds a store backed by a mainnet viem client constructed from the + * mainnet entry (chain id 1) of the RPC URL map, with multicall batching + * enabled. Falls back to viem's default public endpoint when no mainnet + * URL is provided. + */ + static fromRpcUrls(rpcUrls: RpcUrls): ViemENSStore { + const client = createPublicClient({ + chain: mainnet, + transport: http(rpcUrls[mainnet.id]), + batch: { multicall: true }, + }); + return new ViemENSStore(client); + } + + public async lookUpPrimaryNames( + addresses: Address[], + ): Promise> { + const names = new Map(); + if (addresses.length === 0) { + return names; + } + + const resolved = await Promise.all( + addresses.map((address) => + this.resolvePrimaryName(address, LEGACY_MAINNET_COIN_TYPE), + ), + ); + + for (const entry of resolved) { + if (entry != null) { + names.set(entry.key, entry.name); + } + } + + return names; + } + + private async resolvePrimaryName( + address: Address, + coinType: bigint, + ): Promise<{ key: HexString; name: ENSName } | null> { + const key = address.toHexString(); + try { + const name = await this.client.getEnsName({ address: key, coinType }); + if (name == null) { + return null; + } + return { key, name: ENSName.fromString(name) }; + } catch { + return null; + } + } +} diff --git a/src/use-cases/GetTokenVotingMembershipUseCase.test.ts b/src/use-cases/GetTokenVotingMembershipUseCase.test.ts new file mode 100644 index 0000000..2165844 --- /dev/null +++ b/src/use-cases/GetTokenVotingMembershipUseCase.test.ts @@ -0,0 +1,153 @@ +import { ENSName } from '@/domain/ens/ENSName'; +import type { ENSStore } from '@/domain/ens/ENSStore'; +import type { + MemberStore, + TokenVotingMemberData, +} from '@/domain/member/MemberStore'; +import { TokenVotingMemberRecord } from '@/domain/member/TokenVotingMemberRecord'; +import { Address } from '@/domain/primitives'; +import { createPage } from '@/domain/primitives/pagination/Page'; +import { PageRequest } from '@/domain/primitives/pagination/PageRequest'; +import { VotingPower } from '@/domain/voting-power/VotingPower'; +import { GetTokenVotingMembershipUseCase } from './GetTokenVotingMembershipUseCase'; + +describe('GetTokenVotingMembershipUseCase', () => { + const memberAddressValue = '0x1234567890abcdef1234567890abcdef12345678'; + const memberAddress = Address.fromHexString(memberAddressValue); + const pluginAddress = Address.fromHexString( + '0x1111111111111111111111111111111111111111', + ); + const tokenContractAddress = Address.fromHexString( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ); + const firstActivityTimestamp = 1705320000; + const lastActivityTimestamp = 1718872200; + + // The member store returns record/metrics pairs (no ENS); here the + // activity window comes from the record's voting-power changes. + const buildData = (): TokenVotingMemberData => ({ + record: TokenVotingMemberRecord.create({ + address: memberAddress, + votingPower: VotingPower.fromBigInt(5000000000000000000n), + delegationCount: 4, + firstVotingPowerChangeTimestamp: firstActivityTimestamp, + lastVotingPowerChangeTimestamp: lastActivityTimestamp, + }), + metrics: null, + }); + + const buildMemberStore = ( + data: TokenVotingMemberData = buildData(), + ): MemberStore => ({ + findTokenVotingMembers: vi + .fn() + .mockResolvedValue(createPage([data], 1, 20, 1)), + }); + + const buildEnsStore = ( + names: Map = new Map(), + ): ENSStore => ({ + lookUpPrimaryNames: vi.fn().mockResolvedValue(names), + }); + + const page = PageRequest.create({ page: 1, pageSize: 20 }); + + it('returns a page of members', async () => { + const useCase = new GetTokenVotingMembershipUseCase( + buildMemberStore(), + buildEnsStore(), + ); + const result = await useCase.execute({ + pluginAddress, + tokenContractAddress, + page, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].address.equals(memberAddress)).toBe(true); + expect(result.items[0].votingPower.isZero).toBe(false); + expect(result.items[0].ens).toBeNull(); + expect(result.items[0].firstActivityTimestamp).toBe(firstActivityTimestamp); + expect(result.items[0].lastActivityTimestamp).toBe(lastActivityTimestamp); + expect(result.totalRecords).toBe(1); + }); + + it('passes plugin address, token address, and page to the member store', async () => { + const memberStore = buildMemberStore(); + const useCase = new GetTokenVotingMembershipUseCase( + memberStore, + buildEnsStore(), + ); + await useCase.execute({ pluginAddress, tokenContractAddress, page }); + + expect(memberStore.findTokenVotingMembers).toHaveBeenCalledWith( + pluginAddress, + tokenContractAddress, + page, + ); + }); + + it('enriches each member with the ENS name resolved for its address', async () => { + const names = new Map([ + [memberAddress.toHexString(), ENSName.fromString('alice.eth')], + ]); + const ensStore = buildEnsStore(names); + const useCase = new GetTokenVotingMembershipUseCase( + buildMemberStore(), + ensStore, + ); + + const result = await useCase.execute({ + pluginAddress, + tokenContractAddress, + page, + }); + + expect(result.items[0].ens?.toString()).toBe('alice.eth'); + // ENS lookups are scoped to the page's addresses. + expect(ensStore.lookUpPrimaryNames).toHaveBeenCalledWith([memberAddress]); + }); + + it('leaves ens null when the address has no resolved name', async () => { + const useCase = new GetTokenVotingMembershipUseCase( + buildMemberStore(), + buildEnsStore(new Map()), + ); + + const result = await useCase.execute({ + pluginAddress, + tokenContractAddress, + page, + }); + + expect(result.items[0].ens).toBeNull(); + }); + + it('wraps member-store errors', async () => { + const failingStore: MemberStore = { + findTokenVotingMembers: vi.fn().mockRejectedValue(new Error('db down')), + }; + const useCase = new GetTokenVotingMembershipUseCase( + failingStore, + buildEnsStore(), + ); + + await expect( + useCase.execute({ pluginAddress, tokenContractAddress, page }), + ).rejects.toThrow('Error while getting token-voting membership'); + }); + + it('wraps ENS-store errors', async () => { + const ensStore: ENSStore = { + lookUpPrimaryNames: vi.fn().mockRejectedValue(new Error('rpc down')), + }; + const useCase = new GetTokenVotingMembershipUseCase( + buildMemberStore(), + ensStore, + ); + + await expect( + useCase.execute({ pluginAddress, tokenContractAddress, page }), + ).rejects.toThrow('Error while getting token-voting membership'); + }); +}); diff --git a/src/use-cases/GetTokenVotingMembershipUseCase.ts b/src/use-cases/GetTokenVotingMembershipUseCase.ts new file mode 100644 index 0000000..e1688dc --- /dev/null +++ b/src/use-cases/GetTokenVotingMembershipUseCase.ts @@ -0,0 +1,67 @@ +import type { UseCase } from 'ddd-core-ts'; +import type { ENSStore } from '@/domain/ens/ENSStore'; +import type { MemberStore } from '@/domain/member/MemberStore'; +import { TokenVotingMember } from '@/domain/member/TokenVotingMember'; +import type { Address } from '@/domain/primitives'; +import type { Page } from '@/domain/primitives/pagination/Page'; +import { createPage } from '@/domain/primitives/pagination/Page'; +import type { PageRequest } from '@/domain/primitives/pagination/PageRequest'; + +export interface GetTokenVotingMembershipUseCaseProps { + pluginAddress: Address; + tokenContractAddress: Address; + page: PageRequest; +} + +/** + * Returns a page of members of an Aragon TokenVoting plugin, scoped + * to a specific token contract and ordered by current voting power + * descending. + */ +export class GetTokenVotingMembershipUseCase + implements + UseCase> +{ + public readonly code = 'GetTokenVotingMembershipUseCase'; + + constructor( + private readonly memberStore: MemberStore, + private readonly ensStore: ENSStore, + ) {} + + public async execute( + props: GetTokenVotingMembershipUseCaseProps, + ): Promise> { + try { + const memberPage = await this.memberStore.findTokenVotingMembers( + props.pluginAddress, + props.tokenContractAddress, + props.page, + ); + + const namesByAddress = await this.ensStore.lookUpPrimaryNames( + memberPage.items.map((data) => data.record.address), + ); + + const members = memberPage.items.map((data) => + TokenVotingMember.create( + data.record, + data.metrics, + // ENSStore keys its map by checksummed hex address. + namesByAddress.get(data.record.address.toHexString()) ?? null, + ), + ); + + return createPage( + members, + memberPage.page, + memberPage.pageSize, + memberPage.totalRecords, + ); + } catch (cause) { + throw new Error('Error while getting token-voting membership', { + cause, + }); + } + } +} diff --git a/test/AragonDomain.test.ts b/test/AragonDomain.test.ts deleted file mode 100644 index c0beb07..0000000 --- a/test/AragonDomain.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import assert from 'node:assert'; -import { AragonDomain, EnvioClient } from '../src'; - -/** - * Builds an AragonDomain backed by an EnvioClient whose `query` - * method is replaced with a FIFO queue of canned responses. The - * underlying GraphQLClient is constructed but never used at the - * network layer. - */ -function buildController(responses: unknown[]): AragonDomain { - const envio = new EnvioClient('https://unused.example.invalid'); - const queue = [...responses]; - vi.spyOn(envio, 'query').mockImplementation(async () => { - if (queue.length === 0) { - throw new Error('EnvioClient.query called more times than expected'); - } - return queue.shift() as never; - }); - return AragonDomain.load(envio); -} - -describe('AragonDomain', () => { - describe('getMemberProfileTextRecords', () => { - it('returns the live text records as a DTO list', async () => { - const controller = buildController([ - { - Domain: [ - { - id: 'domain-id', - name: 'ea1.aragon.eth', - resolver: { - version: '0', - texts: [ - { key: 'avatar', value: 'ipfs://x', version: '0' }, - { - key: 'url', - value: 'https://aragon.org', - version: '0', - }, - ], - }, - }, - ], - }, - ]); - - const response = await controller.getMemberProfileTextRecords({ - subdomain: 'ea1.aragon.eth', - }); - - assert( - response.success, - 'expected getMemberProfileTextRecords to succeed', - ); - expect(response.result).toEqual([ - { key: 'avatar', value: 'ipfs://x' }, - { key: 'url', value: 'https://aragon.org' }, - ]); - }); - - it('returns [] when the subdomain is unknown to the indexer', async () => { - const controller = buildController([{ Domain: [] }]); - - const response = await controller.getMemberProfileTextRecords({ - subdomain: 'ea1.aragon.eth', - }); - - assert(response.success); - expect(response.result).toEqual([]); - }); - - it('returns [] when the subdomain has no resolver yet', async () => { - const controller = buildController([ - { - Domain: [{ id: 'domain-id', name: 'ea1.aragon.eth', resolver: null }], - }, - ]); - - const response = await controller.getMemberProfileTextRecords({ - subdomain: 'ea1.aragon.eth', - }); - - assert(response.success); - expect(response.result).toEqual([]); - }); - - it('returns a failed response when the subdomain is not under .aragon.eth', async () => { - const controller = buildController([]); - - const response = await controller.getMemberProfileTextRecords({ - subdomain: 'vitalik.eth', - }); - - expect(response.success).toBe(false); - }); - }); -}); diff --git a/test/getMemberProfileTextRecords.test.ts b/test/getMemberProfileTextRecords.test.ts new file mode 100644 index 0000000..7e10e0a --- /dev/null +++ b/test/getMemberProfileTextRecords.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert'; +import { buildDomain } from './support/buildDomain'; +import { + domainResponse, + noResolverResponse, + unknownDomainResponse, +} from './support/fixtures/memberProfile'; + +const SUBDOMAIN = 'ea1.aragon.eth'; + +describe('AragonDomain.getMemberProfileTextRecords', () => { + it('returns the live text records as a DTO list', async () => { + const { domain } = buildDomain([ + domainResponse(SUBDOMAIN, [ + { key: 'avatar', value: 'ipfs://x' }, + { key: 'url', value: 'https://aragon.org' }, + ]), + ]); + + const response = await domain.getMemberProfileTextRecords({ + subdomain: SUBDOMAIN, + }); + + assert(response.success, 'expected getMemberProfileTextRecords to succeed'); + expect(response.result).toEqual([ + { key: 'avatar', value: 'ipfs://x' }, + { key: 'url', value: 'https://aragon.org' }, + ]); + }); + + it('returns [] when the subdomain is unknown to the indexer', async () => { + const { domain } = buildDomain([unknownDomainResponse()]); + + const response = await domain.getMemberProfileTextRecords({ + subdomain: SUBDOMAIN, + }); + + assert(response.success); + expect(response.result).toEqual([]); + }); + + it('returns [] when the subdomain has no resolver yet', async () => { + const { domain } = buildDomain([noResolverResponse(SUBDOMAIN)]); + + const response = await domain.getMemberProfileTextRecords({ + subdomain: SUBDOMAIN, + }); + + assert(response.success); + expect(response.result).toEqual([]); + }); + + it('returns a failed response when the subdomain is not under .aragon.eth', async () => { + // Rejected by the request mapper before any query is issued. + const { domain, query } = buildDomain([]); + + const response = await domain.getMemberProfileTextRecords({ + subdomain: 'vitalik.eth', + }); + + expect(response.success).toBe(false); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/test/getTokenVotingMembership.test.ts b/test/getTokenVotingMembership.test.ts new file mode 100644 index 0000000..95c94d8 --- /dev/null +++ b/test/getTokenVotingMembership.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert'; +import { createPublicClient } from 'viem'; +import { buildDomain } from './support/buildDomain'; +import { ALICE, BOB, PLUGIN, TOKEN } from './support/constants'; +import { + delegate, + findMembersResponse, +} from './support/fixtures/tokenVotingMembers'; + +// Stubbing `createPublicClient` so we can return a fake `getEnsName`that +// we can mock. +vi.mock('viem', async (importOriginal) => ({ + ...(await importOriginal()), + createPublicClient: vi.fn(), + http: vi.fn(), +})); + +function stubEnsNames(table: Record) { + const getEnsName = vi.fn( + async ({ address }: { address: string; coinType?: bigint }) => + table[address.toLowerCase()] ?? null, + ); + vi.mocked(createPublicClient).mockReturnValue({ getEnsName } as never); + return getEnsName; +} + +beforeEach(() => { + vi.clearAllMocks(); + stubEnsNames({}); +}); + +describe('AragonDomain.getTokenVotingMembership', () => { + it('returns a paginated DTO of token-voting members', async () => { + stubEnsNames({ [ALICE]: 'alice.eth' }); + const { domain } = buildDomain([ + findMembersResponse({ + delegates: [delegate(ALICE, { votingPower: '5000000000000000000' })], + }), + ]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 15, + }); + + assert(response.success, 'expected getTokenVotingMembership to succeed'); + expect(response.result.metadata).toEqual({ + page: 1, + pageSize: 15, + totalPages: 1, + totalRecords: 1, + }); + expect(response.result.data).toHaveLength(1); + expect(response.result.data[0]).toEqual( + expect.objectContaining({ + ens: 'alice.eth', + votingPower: '5000000000000000000', + }), + ); + expect(response.result.data[0].address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it('attaches the primary ENS name resolved via the ENS client', async () => { + const getEnsName = stubEnsNames({ [ALICE]: 'alice.eth' }); + const { domain } = buildDomain([ + findMembersResponse({ delegates: [delegate(ALICE), delegate(BOB)] }), + ]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 20, + }); + + assert(response.success); + // ALICE resolves; BOB has no primary name -> null. + expect(response.result.data.map((m) => m.ens)).toEqual(['alice.eth', null]); + expect(getEnsName).toHaveBeenCalledTimes(2); + }); + + it('reflects a larger chain-wide total in the pagination metadata', async () => { + const { domain } = buildDomain([ + findMembersResponse({ delegates: [delegate(ALICE)], totalRecords: 42 }), + ]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 20, + }); + + assert(response.success); + expect(response.result.metadata.totalRecords).toBe(42); + expect(response.result.metadata.totalPages).toBe(3); + }); + + it('resolves ENS out-of-band: one indexer query, names via the ENS client', async () => { + const getEnsName = stubEnsNames({ [ALICE]: 'alice.eth' }); + const { domain, query } = buildDomain([ + findMembersResponse({ delegates: [delegate(ALICE)] }), + ]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 20, + }); + + assert(response.success); + expect(response.result.data[0].ens).toBe('alice.eth'); + expect(query).toHaveBeenCalledTimes(1); + expect(getEnsName).toHaveBeenCalledTimes(1); + }); + + it('issues no ENS lookups when the page is empty', async () => { + const getEnsName = stubEnsNames({}); + const { domain, query } = buildDomain([findMembersResponse()]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 20, + }); + + assert(response.success); + expect(response.result.data).toHaveLength(0); + expect(query).toHaveBeenCalledTimes(1); + expect(getEnsName).not.toHaveBeenCalled(); + }); + + it('returns a failed response when the indexer query errors', async () => { + // Empty queue → the store's first query throws, surfacing as a + // failed ResultOrError rather than a rejected promise. + const { domain } = buildDomain([]); + + const response = await domain.getTokenVotingMembership({ + pluginAddress: PLUGIN, + tokenContractAddress: TOKEN, + page: 1, + pageSize: 20, + }); + + expect(response.success).toBe(false); + }); +}); diff --git a/test/support/buildDomain.ts b/test/support/buildDomain.ts new file mode 100644 index 0000000..aeca91b --- /dev/null +++ b/test/support/buildDomain.ts @@ -0,0 +1,33 @@ +import { AragonDomain, EnvioClient, type RpcUrls } from '../../src'; + +export interface BuiltDomain { + /** The public facade. Every integration test drives the stack through this. */ + domain: AragonDomain; + /** Spy over `EnvioClient.query` — assert call count / variables against it. */ + query: ReturnType; +} + +/** + * Builds an `AragonDomain` backed by an `EnvioClient` whose `query` method + * is replaced with a FIFO queue of canned responses (the GraphQLClient is + * constructed but never touches the network). + * + * Envio responses are consumed in call order. Draining past the end is a + * test bug, so it throws rather than returning `undefined`. + */ +export function buildDomain( + responses: unknown[], + rpcUrls: RpcUrls = {}, +): BuiltDomain { + const envio = new EnvioClient('https://unused.example.invalid'); + const queue = [...responses]; + const query = vi.fn(async () => { + if (queue.length === 0) { + throw new Error('EnvioClient.query called more times than expected'); + } + return queue.shift(); + }); + vi.spyOn(envio, 'query').mockImplementation(query as never); + + return { domain: AragonDomain.load(envio, rpcUrls), query }; +} diff --git a/test/support/constants.ts b/test/support/constants.ts new file mode 100644 index 0000000..58d0e52 --- /dev/null +++ b/test/support/constants.ts @@ -0,0 +1,9 @@ +/** + * Shared addresses and constants for the integration test suite. + */ + +export const ALICE = '0x0123456789abcdef0123456789abcdef01234567'; +export const BOB = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +export const PLUGIN = '0x17a1688c56087ade762721180e1cc1e831c73719'; +export const TOKEN = '0x0a830e9f2baa2ebaf8d33c0806283dea9c08952f'; diff --git a/test/support/fixtures/memberProfile.ts b/test/support/fixtures/memberProfile.ts new file mode 100644 index 0000000..f9842e8 --- /dev/null +++ b/test/support/fixtures/memberProfile.ts @@ -0,0 +1,44 @@ +/** + * Canned Envio responses for the `getMemberProfileTextRecords` flow. + * + * The store issues a single `Domain` query, so each scenario queues + * exactly one of these builders. + */ + +export interface TextRecord { + key: string; + value: string; + version?: string; +} + +/** + * A `Domain` response carrying a resolver with the given text records. + */ +export function domainResponse( + name: string, + texts: TextRecord[], + resolverVersion = '0', +) { + return { + Domain: [ + { + id: 'domain-id', + name, + resolver: { + version: resolverVersion, + texts: texts.map((t) => ({ version: '0', ...t })), + }, + }, + ], + }; +} + +/** The subdomain is unknown to the indexer — no `Domain` row at all. */ +export function unknownDomainResponse() { + return { Domain: [] }; +} + +/** The subdomain exists but has no resolver attached yet. */ +export function noResolverResponse(name: string) { + return { Domain: [{ id: 'domain-id', name, resolver: null }] }; +} diff --git a/test/support/fixtures/tokenVotingMembers.ts b/test/support/fixtures/tokenVotingMembers.ts new file mode 100644 index 0000000..2212e61 --- /dev/null +++ b/test/support/fixtures/tokenVotingMembers.ts @@ -0,0 +1,57 @@ +import { TOKEN } from '../constants'; + +/** + * Canned Envio responses for the `getTokenVotingMembership` flow. + */ + +export interface DelegateOverrides { + votingPower?: string; + delegationCount?: number; + firstVotingPowerChangeTimestamp?: string; + lastVotingPowerChangeTimestamp?: string; +} + +/** A single `ERC20VotesDelegate` row as the indexer returns it. */ +export function delegate(address: string, overrides: DelegateOverrides = {}) { + return { + id: `1-${TOKEN}-${address}`, + chainId: 1, + tokenContractAddress: TOKEN, + delegateAddress: address, + votingPower: overrides.votingPower ?? '5000000000000000000', + delegationCount: overrides.delegationCount ?? 1, + firstVotingPowerChangeTimestamp: + overrides.firstVotingPowerChangeTimestamp ?? '1700000000', + lastVotingPowerChangeTimestamp: + overrides.lastVotingPowerChangeTimestamp ?? '1700000100', + }; +} + +export interface FindMembersResponseInput { + delegates?: ReturnType[]; + metrics?: unknown[]; + /** + * The chain-wide total used for pagination. Defaults to the number + * of `delegates` on this page; override to simulate more pages. + */ + totalRecords?: number; +} + +/** + * The members query response: the page of delegates, the id-only + * set used for total-count, and the plugin's MemberGovernanceMetrics. + */ +export function findMembersResponse({ + delegates = [], + metrics = [], + totalRecords, +}: FindMembersResponseInput = {}) { + const total = totalRecords ?? delegates.length; + return { + ERC20VotesDelegate: delegates, + AllERC20VotesDelegate: Array.from({ length: total }, (_, i) => ({ + id: `total-${i}`, + })), + MemberGovernanceMetrics: metrics, + }; +} diff --git a/tsconfig.json b/tsconfig.json index f1be642..ac4fd35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "es2022", - "module": "commonjs", + "module": "preserve", + "moduleResolution": "bundler", "lib": ["es2022"], "strict": true, "esModuleInterop": true, diff --git a/vitest.config.ts b/vitest.config.ts index a78ef13..f42d840 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ 'src/**/index.ts', 'src/domain/member-profile/MemberProfileStore.ts', 'src/domain/primitives/units/EVMUnit.ts', + 'src/domain/member/MemberStore.ts', + 'src/infrastructure/controllers/AragonController/maps/domain/PageDTO.ts', ], thresholds: { statements: 100,