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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/permissions-user-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

Adds `PermissionsUser.events` to the Omnigraph API, exposing the per-role-assignment event history (grants, revokes, role-bitmap mutations) for a specific `(contract, resource, user)` tuple.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ Fail fast and loudly on invalid inputs.
- **API boundaries:** Use the shared `errorResponse` helper (`apps/ensapi/src/lib/handlers/error-response.ts`) for all error responses in ENSApi (and equivalent pattern in other Hono apps). Mapping: validation (ZodError / Standard Schema) → 400 with `{ message, details }`; other known client errors → 4xx with `{ message }`; server errors → 500 with `{ message }`. Response shape: `{ message: string, details?: unknown }` (see `packages/ensnode-sdk/src/ensapi/api/shared/errors/response.ts`). A `code` field may be adopted later for machine-readable codes; do not add it inconsistently today.
- **Examples:** Validation at boundary: route uses `validate("json", MySchema)`; on failure → 400 + `{ message: "Invalid Input", details }`. Non-API: `const config = ConfigSchema.parse(env)` or `const parsed = MySchema.safeParse(input); if (!parsed.success) return fallback;`. Handler: `return errorResponse(c, err)` or `return errorResponse(c, "Not found", 404)`.

## Ponder

- Schema changes never require a migration step. Ponder only runs fully-compatible indexes against existing schemas; otherwise the index is dropped and rebuilt from scratch. Do not propose, plan, or write migration code for the ensindexer drizzle schema.
- Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar.
- Access entities by primary key only. Ponder's cache layer keys on PK; filters or complex selects force a flush to Postgres and are extremely unperformant in the hot path. If you need a non-PK lookup at index time, design the schema so the lookup key is the primary key.

## Workflow

- Add a changeset when your PR includes a logical change that should bump versions or be communicated in release notes: https://ensnode.io/docs/contributing/prs#changesets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants";
type EventJoinTable =
| typeof ensIndexerSchema.domainEvent
| typeof ensIndexerSchema.resolverEvent
| typeof ensIndexerSchema.permissionsEvent;
| typeof ensIndexerSchema.permissionsEvent
| typeof ensIndexerSchema.permissionsUserEvent;

/**
* Available filter options for find-events queries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
PermissionsUserId,
RegistryId,
} from "enssdk";
import { toEventSelector } from "viem";
import { pad, toEventSelector } from "viem";
import { beforeAll, describe, expect, it } from "vitest";

import { DatasourceNames, EnhancedAccessControlABI } from "@ensnode/datasources";
Expand Down Expand Up @@ -503,3 +503,97 @@ describe("Permissions.events filtering (EventsWhereInput)", () => {
expect(events.length).toBe(0);
});
});

describe("PermissionsUser.events", () => {
type PermissionsUserEventsResult = {
permissions: {
root: {
users: GraphQLConnection<PermissionUser & { events: GraphQLConnection<EventResult> }>;
};
};
};

const PermissionsUserEvents = gql`
query PermissionsUserEvents($contract: AccountIdInput!, $where: EventsWhereInput) {
permissions(by: { contract: $contract }) {
root {
users {
edges { node {
id resource user { address } roles
events(where: $where, first: 1000) { edges { node { ...EventFragment } } }
} }
}
}
}
}
${EventFragment}
`;

let users: (PermissionUser & { events: GraphQLConnection<EventResult> })[];

beforeAll(async () => {
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
contract: V2_ETH_REGISTRY,
});
users = flattenConnection(result.permissions.root.users);
expect(users.length).toBeGreaterThan(0);
});

it("returns events scoped to each PermissionsUser", () => {
for (const user of users) {
const events = flattenConnection(user.events);
expect(events.length).toBeGreaterThan(0);

// every event must be an EACRolesChanged on the contract
for (const event of events) {
expect(event.address).toBe(V2_ETH_REGISTRY.address);
expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR);
}
}
});

it("scopes each user's events to that (resource, user)", () => {
// EACRolesChanged(resource indexed, account indexed, ...) — so
// topics[1] == resource, topics[2] == padded user address
for (const user of users) {
const events = flattenConnection(user.events);
for (const event of events) {
expect(BigInt(event.topics[1])).toBe(BigInt(user.resource));
expect(event.topics[2]).toBe(pad(user.user.address, { size: 32 }));
}
}
});

it("filters by selector_in", async () => {
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
contract: V2_ETH_REGISTRY,
where: { selector_in: [EAC_ROLES_CHANGED_SELECTOR] },
});
const filteredUsers = flattenConnection(result.permissions.root.users);

for (const user of filteredUsers) {
const events = flattenConnection(user.events);
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR);
}
}
});

it("filters by empty selector_in returns no results", async () => {
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
contract: V2_ETH_REGISTRY,
where: { selector_in: [] },
});
const filteredUsers = flattenConnection(result.permissions.root.users);

for (const user of filteredUsers) {
expect(flattenConnection(user.events).length).toBe(0);
}
});
Comment thread
shrugs marked this conversation as resolved.

// requires integration-test-env to emit a grant followed by a revoke (newRoleBitmap = 0) for the
// same (resource, user). exercises the handler path where the permissionsUser row is deleted but
// both EACRolesChanged events must remain joined to the (now-removed) PermissionsUserId.
it.todo("preserves event history for a PermissionsUser whose roles were revoked to 0");
});
18 changes: 18 additions & 0 deletions apps/ensapi/src/omnigraph-api/schema/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,24 @@ PermissionsUserRef.implement({
nullable: false,
resolve: (parent) => parent.roles,
}),

//////////////////////////
// PermissionsUser.events
//////////////////////////
events: t.connection({
description: "All Events associated with this PermissionsUser.",
type: EventRef,
args: {
where: t.arg({ type: EventsWhereInput }),
},
resolve: (parent, args) =>
resolveFindEvents(args, {
through: {
table: ensIndexerSchema.permissionsUserEvent,
scope: eq(ensIndexerSchema.permissionsUserEvent.permissionsUserId, parent.id),
},
}),
}),
}),
});

Expand Down
19 changes: 18 additions & 1 deletion apps/ensindexer/src/lib/ensv2/event-db-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { type AccountId, type DomainId, makePermissionsId, makeResolverId } from "enssdk";
import {
type AccountId,
type DomainId,
makePermissionsId,
makeResolverId,
type PermissionsUserId,
} from "enssdk";
import type { Hash } from "viem";

import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder";
Expand Down Expand Up @@ -91,3 +97,14 @@ export async function ensurePermissionsEvent(
.values({ permissionsId: makePermissionsId(contract), eventId })
.onConflictDoNothing();
}

export async function ensurePermissionsUserEvent(
context: IndexingEngineContext,
permissionsUserId: PermissionsUserId,
eventId: string,
) {
await context.ensDb
.insert(ensIndexerSchema.permissionsUserEvent)
.values({ permissionsUserId, eventId })
.onConflictDoNothing();
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { isAddressEqual, zeroAddress } from "viem";
import { PluginName } from "@ensnode/ensnode-sdk";

import { ensureAccount } from "@/lib/ensv2/account-db-helpers";
import { ensureEvent, ensurePermissionsEvent } from "@/lib/ensv2/event-db-helpers";
import {
ensureEvent,
ensurePermissionsEvent,
ensurePermissionsUserEvent,
} from "@/lib/ensv2/event-db-helpers";
import { getThisAccountId } from "@/lib/get-this-account-id";
import {
addOnchainEventListener,
Expand Down Expand Up @@ -95,9 +99,10 @@ export default function () {
.onConflictDoUpdate({ roles });
}

// push event to permissions
// push event to permissions and permissions user
const eventId = await ensureEvent(context, event);
await ensurePermissionsEvent(context, contract, eventId);
await ensurePermissionsUserEvent(context, permissionsUserId, eventId);
},
);
}
9 changes: 9 additions & 0 deletions packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ export const permissionsEvent = onchainTable(
(t) => ({ pk: primaryKey({ columns: [t.permissionsId, t.eventId] }) }),
);

export const permissionsUserEvent = onchainTable(
"permissions_user_events",
(t) => ({
permissionsUserId: t.text().notNull().$type<PermissionsUserId>(),
eventId: t.text().notNull(),
}),
(t) => ({ pk: primaryKey({ columns: [t.permissionsUserId, t.eventId] }) }),
);

///////////
// Account
///////////
Expand Down
125 changes: 125 additions & 0 deletions packages/enssdk/src/omnigraph/generated/introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4008,6 +4008,51 @@ const introspection = {
"args": [],
"isDeprecated": false
},
{
"name": "events",
"type": {
"kind": "OBJECT",
"name": "PermissionsUserEventsConnection"
},
"args": [
{
"name": "after",
"type": {
"kind": "SCALAR",
"name": "String"
}
},
{
"name": "before",
"type": {
"kind": "SCALAR",
"name": "String"
}
},
{
"name": "first",
"type": {
"kind": "SCALAR",
"name": "Int"
}
},
{
"name": "last",
"type": {
"kind": "SCALAR",
"name": "Int"
}
},
{
"name": "where",
"type": {
"kind": "INPUT_OBJECT",
"name": "EventsWhereInput"
}
}
],
"isDeprecated": false
},
{
"name": "id",
"type": {
Expand Down Expand Up @@ -4059,6 +4104,86 @@ const introspection = {
],
"interfaces": []
},
{
"kind": "OBJECT",
"name": "PermissionsUserEventsConnection",
"fields": [
{
"name": "edges",
"type": {
"kind": "NON_NULL",
"ofType": {
"kind": "LIST",
"ofType": {
"kind": "NON_NULL",
"ofType": {
"kind": "OBJECT",
"name": "PermissionsUserEventsConnectionEdge"
}
}
}
},
"args": [],
"isDeprecated": false
},
{
"name": "pageInfo",
"type": {
"kind": "NON_NULL",
"ofType": {
"kind": "OBJECT",
"name": "PageInfo"
}
},
"args": [],
"isDeprecated": false
},
{
"name": "totalCount",
"type": {
"kind": "NON_NULL",
"ofType": {
"kind": "SCALAR",
"name": "Int"
}
},
"args": [],
"isDeprecated": false
}
],
"interfaces": []
},
{
"kind": "OBJECT",
"name": "PermissionsUserEventsConnectionEdge",
"fields": [
{
"name": "cursor",
"type": {
"kind": "NON_NULL",
"ofType": {
"kind": "SCALAR",
"name": "String"
}
},
"args": [],
"isDeprecated": false
},
{
"name": "node",
"type": {
"kind": "NON_NULL",
"ofType": {
"kind": "OBJECT",
"name": "Event"
}
},
"args": [],
"isDeprecated": false
}
],
"interfaces": []
},
{
"kind": "SCALAR",
"name": "PermissionsUserId"
Expand Down
14 changes: 14 additions & 0 deletions packages/enssdk/src/omnigraph/generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,9 @@ type PermissionsUser {
"""The contract within which these Permissions are granted."""
contract: AccountId!

"""All Events associated with this PermissionsUser."""
events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsUserEventsConnection

"""A unique reference to this PermissionsUser."""
id: PermissionsUserId!

Expand All @@ -858,6 +861,17 @@ type PermissionsUser {
user: Account!
}

type PermissionsUserEventsConnection {
edges: [PermissionsUserEventsConnectionEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}

type PermissionsUserEventsConnectionEdge {
cursor: String!
node: Event!
}

"""PermissionsUserId represents a enssdk#PermissionsUserId."""
scalar PermissionsUserId

Expand Down
Loading
Loading