Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cefa810
Look up member profile ENS records
asciiman May 21, 2026
98a8d00
Implement GetERC20MembershipUseCase
asciiman May 21, 2026
9168465
Changeset
asciiman May 21, 2026
5e76da9
Changeset
asciiman May 21, 2026
516a467
Upgrade to vitest
asciiman May 21, 2026
f48879b
Changeset
asciiman May 21, 2026
2ac58e3
Upgrade to vitest
asciiman May 21, 2026
1dc565c
Merge branch 'app-719-aragon-subdomain-look-up-member-profile-ens-rec…
asciiman May 21, 2026
0bf3dc8
Upgrade to vitest
asciiman May 21, 2026
16aeb92
Remove unneeded fields from query
asciiman May 21, 2026
367efec
JSdoc
asciiman May 22, 2026
3e6f154
Moves business logic from mapper to domain object
asciiman May 22, 2026
2e04f8c
Docs
asciiman May 26, 2026
d5522bf
Fix test
asciiman May 26, 2026
292e834
Improve readme
asciiman May 26, 2026
6638426
Filtered out old text versions and empty records on indexer
asciiman May 28, 2026
8ded8a6
Fix code review comments
asciiman May 28, 2026
27377d5
Fix pnpm lock
asciiman May 28, 2026
f8c5bd9
Lint
asciiman May 28, 2026
d0a20a4
Fixing type error
asciiman May 28, 2026
18170a7
Lint
asciiman May 28, 2026
d783dd1
Merge branch 'app-719-aragon-subdomain-look-up-member-profile-ens-rec…
asciiman May 28, 2026
f5d16fc
Merge branch 'main' into app-610-geterc20membership-end-to-end
asciiman Jun 12, 2026
853ff34
Align token voting namings
asciiman Jun 23, 2026
b8d436f
Lint
asciiman Jun 23, 2026
5a21541
Merge branch 'main' into app-610-geterc20membership-end-to-end
asciiman Jun 23, 2026
8bf47e1
Update changelog to aragon-domain
asciiman Jun 23, 2026
ad95b24
Integration tests
asciiman Jun 23, 2026
710d6aa
Handle ENS with viem instead of through the indexer
asciiman Jun 26, 2026
05cb5db
Move token voting member activity calculations to domain
asciiman Jun 26, 2026
8cb3a49
Disable changelog for snapshot
asciiman Jun 26, 2026
730b1a1
Merge branch 'main' into app-610-geterc20membership-end-to-end
asciiman Jul 3, 2026
3cde41c
Remove extra changeset
asciiman Jul 3, 2026
5471b85
Remove unused arg
asciiman Jul 3, 2026
f551a84
Clean up code
asciiman Jul 3, 2026
90fc64a
Jsdoc
asciiman Jul 3, 2026
e0d2826
Jsdoc
asciiman Jul 3, 2026
2649ca2
Fix errors
asciiman Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funky-streets-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/aragon-domain": minor
---

Implement ERC20 token voting membership use case
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
31 changes: 31 additions & 0 deletions src/domain/ens/ENSName.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions src/domain/ens/ENSName.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ENSNamePropsSchema>;
type ENSNameInput = z.input<typeof ENSNamePropsSchema>;

/**
* 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<ENSNameProps> {
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 });
}
}
13 changes: 13 additions & 0 deletions src/domain/ens/ENSStore.ts
Original file line number Diff line number Diff line change
@@ -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<Map<HexString, ENSName>>;
}
30 changes: 30 additions & 0 deletions src/domain/member/MemberGovernanceMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
45 changes: 45 additions & 0 deletions src/domain/member/MemberGovernanceMetrics.ts
Original file line number Diff line number Diff line change
@@ -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<MemberGovernanceMetricsProps> {
/**
* 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);
}
}
23 changes: 23 additions & 0 deletions src/domain/member/MemberStore.ts
Original file line number Diff line number Diff line change
@@ -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<Page<TokenVotingMemberData>>;
}
126 changes: 126 additions & 0 deletions src/domain/member/TokenVotingMember.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TokenVotingMemberRecord.create>[0]
> = {},
) =>
TokenVotingMemberRecord.create({
address,
votingPower: VotingPower.fromBigInt(1000000000000000000n),
delegationCount: 3,
firstVotingPowerChangeTimestamp: 1700000000,
lastVotingPowerChangeTimestamp: 1700000100,
...overrides,
});

const buildMetrics = (
overrides: Partial<
Parameters<typeof MemberGovernanceMetrics.create>[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);
});
});
});
Loading