Skip to content

Commit 06741df

Browse files
authored
feat(adapter-stellar): add enriched role assignments with grant timestamps (#264)
Add getCurrentRolesEnriched() method that returns role assignments enriched with metadata about when each member was granted their role. This combines on-chain state with indexer history to provide timestamped permission data. Changes: - Add EnrichedRoleMember and EnrichedRoleAssignment types - Add queryLatestGrants() to StellarIndexerClient for batch querying grants - Add getCurrentRolesEnriched() to StellarAccessControlService - Graceful degradation when indexer is unavailable (returns data without timestamps) - Add comprehensive unit and integration tests
1 parent 4bca6ae commit 06741df

File tree

6 files changed

+950
-0
lines changed

6 files changed

+950
-0
lines changed

packages/adapter-stellar/src/access-control/indexer-client.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ interface IndexerRoleDiscoveryResponse {
6060
}>;
6161
}
6262

63+
/**
64+
* Grant information for a specific member
65+
*/
66+
export interface GrantInfo {
67+
/** ISO8601 timestamp of the grant */
68+
timestamp: string;
69+
/** Transaction ID of the grant */
70+
txId: string;
71+
/** Block/ledger number of the grant */
72+
ledger: number;
73+
}
74+
6375
/**
6476
* Options for querying history
6577
*/
@@ -303,6 +315,117 @@ export class StellarIndexerClient {
303315
}
304316
}
305317

318+
/**
319+
* Query the latest grant events for a set of members with a specific role
320+
*
321+
* Returns the most recent ROLE_GRANTED event for each member address.
322+
* This is used to enrich role assignments with grant timestamps.
323+
*
324+
* @param contractAddress The contract address
325+
* @param roleId The role identifier to query
326+
* @param memberAddresses Array of member addresses to look up
327+
* @returns Promise resolving to a Map of address -> GrantInfo
328+
* @throws IndexerUnavailable if indexer is not available
329+
* @throws OperationFailed if query fails
330+
*/
331+
async queryLatestGrants(
332+
contractAddress: string,
333+
roleId: string,
334+
memberAddresses: string[]
335+
): Promise<Map<string, GrantInfo>> {
336+
if (memberAddresses.length === 0) {
337+
return new Map();
338+
}
339+
340+
const isAvailable = await this.checkAvailability();
341+
if (!isAvailable) {
342+
throw new IndexerUnavailable(
343+
'Indexer not available for this network',
344+
contractAddress,
345+
this.networkConfig.id
346+
);
347+
}
348+
349+
const endpoints = this.resolveIndexerEndpoints();
350+
if (!endpoints.http) {
351+
throw new ConfigurationInvalid(
352+
'No indexer HTTP endpoint configured',
353+
contractAddress,
354+
'indexer.http'
355+
);
356+
}
357+
358+
logger.debug(
359+
LOG_SYSTEM,
360+
`Querying latest grants for ${memberAddresses.length} member(s) with role ${roleId}`
361+
);
362+
363+
const query = this.buildLatestGrantsQuery();
364+
const variables = {
365+
contract: contractAddress,
366+
role: roleId,
367+
accounts: memberAddresses,
368+
};
369+
370+
try {
371+
const response = await fetch(endpoints.http, {
372+
method: 'POST',
373+
headers: { 'Content-Type': 'application/json' },
374+
body: JSON.stringify({ query, variables }),
375+
});
376+
377+
if (!response.ok) {
378+
throw new OperationFailed(
379+
`Indexer query failed with status ${response.status}`,
380+
contractAddress,
381+
'queryLatestGrants'
382+
);
383+
}
384+
385+
const result = (await response.json()) as IndexerHistoryResponse;
386+
387+
if (result.errors && result.errors.length > 0) {
388+
const errorMessages = result.errors.map((e) => e.message).join('; ');
389+
throw new OperationFailed(
390+
`Indexer query errors: ${errorMessages}`,
391+
contractAddress,
392+
'queryLatestGrants'
393+
);
394+
}
395+
396+
if (!result.data?.accessControlEvents?.nodes) {
397+
logger.debug(LOG_SYSTEM, `No grant events found for role ${roleId}`);
398+
return new Map();
399+
}
400+
401+
// Build map of address -> latest grant info
402+
// Since we order by TIMESTAMP_DESC, we take the first occurrence per account
403+
const grantMap = new Map<string, GrantInfo>();
404+
for (const entry of result.data.accessControlEvents.nodes) {
405+
if (!grantMap.has(entry.account)) {
406+
grantMap.set(entry.account, {
407+
timestamp: entry.timestamp,
408+
txId: entry.txHash,
409+
ledger: parseInt(entry.blockHeight, 10),
410+
});
411+
}
412+
}
413+
414+
logger.debug(
415+
LOG_SYSTEM,
416+
`Found grant info for ${grantMap.size} of ${memberAddresses.length} member(s)`
417+
);
418+
419+
return grantMap;
420+
} catch (error) {
421+
logger.error(
422+
LOG_SYSTEM,
423+
`Failed to query latest grants: ${error instanceof Error ? error.message : String(error)}`
424+
);
425+
throw error;
426+
}
427+
}
428+
306429
/**
307430
* Resolve indexer endpoints with config precedence
308431
* Priority:
@@ -454,6 +577,34 @@ export class StellarIndexerClient {
454577
`;
455578
}
456579

580+
/**
581+
* Build GraphQL query for latest grants
582+
* Queries ROLE_GRANTED events for a specific role and set of accounts
583+
* Ordered by timestamp descending so first occurrence per account is the latest
584+
*/
585+
private buildLatestGrantsQuery(): string {
586+
return `
587+
query LatestGrants($contract: String!, $role: String!, $accounts: [String!]!) {
588+
accessControlEvents(
589+
filter: {
590+
contract: { equalTo: $contract }
591+
role: { equalTo: $role }
592+
account: { in: $accounts }
593+
type: { equalTo: ROLE_GRANTED }
594+
}
595+
orderBy: TIMESTAMP_DESC
596+
) {
597+
nodes {
598+
account
599+
txHash
600+
timestamp
601+
blockHeight
602+
}
603+
}
604+
}
605+
`;
606+
}
607+
457608
/**
458609
* Transform indexer entries to standard HistoryEntry format
459610
*/

packages/adapter-stellar/src/access-control/service.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type {
1010
AccessControlService,
1111
AccessSnapshot,
1212
ContractSchema,
13+
EnrichedRoleAssignment,
14+
EnrichedRoleMember,
1315
ExecutionConfig,
1416
HistoryEntry,
1517
OperationResult,
@@ -255,6 +257,129 @@ export class StellarAccessControlService implements AccessControlService {
255257
return readCurrentRoles(contractAddress, roleIds, this.networkConfig);
256258
}
257259

260+
/**
261+
* Gets current role assignments with enriched member information including grant timestamps
262+
*
263+
* This method returns role assignments with detailed metadata about when each member
264+
* was granted the role. If the indexer is unavailable, it gracefully degrades to
265+
* returning members without timestamp information.
266+
*
267+
* @param contractAddress The contract address
268+
* @returns Promise resolving to array of enriched role assignments
269+
* @throws ConfigurationInvalid if the contract address is invalid or contract not registered
270+
*/
271+
async getCurrentRolesEnriched(contractAddress: string): Promise<EnrichedRoleAssignment[]> {
272+
// Validate contract address
273+
validateContractAddress(contractAddress);
274+
275+
logger.info(
276+
'StellarAccessControlService.getCurrentRolesEnriched',
277+
`Reading enriched roles for ${contractAddress}`
278+
);
279+
280+
// First, get the current role assignments via on-chain queries
281+
const currentRoles = await this.getCurrentRoles(contractAddress);
282+
283+
if (currentRoles.length === 0) {
284+
return [];
285+
}
286+
287+
// Check indexer availability for enrichment
288+
const indexerAvailable = await this.indexerClient.checkAvailability();
289+
290+
if (!indexerAvailable) {
291+
logger.debug(
292+
'StellarAccessControlService.getCurrentRolesEnriched',
293+
'Indexer not available, returning roles without timestamps'
294+
);
295+
// Graceful degradation: return enriched structure without timestamps
296+
return this.convertToEnrichedWithoutTimestamps(currentRoles);
297+
}
298+
299+
// Enrich each role with grant timestamps from the indexer
300+
const enrichedAssignments: EnrichedRoleAssignment[] = [];
301+
302+
for (const roleAssignment of currentRoles) {
303+
const enrichedMembers = await this.enrichMembersWithGrantInfo(
304+
contractAddress,
305+
roleAssignment.role.id,
306+
roleAssignment.members
307+
);
308+
309+
enrichedAssignments.push({
310+
role: roleAssignment.role,
311+
members: enrichedMembers,
312+
});
313+
}
314+
315+
logger.debug(
316+
'StellarAccessControlService.getCurrentRolesEnriched',
317+
`Enriched ${enrichedAssignments.length} role(s) with grant timestamps`
318+
);
319+
320+
return enrichedAssignments;
321+
}
322+
323+
/**
324+
* Converts standard role assignments to enriched format without timestamps
325+
* Used when indexer is unavailable (graceful degradation)
326+
*/
327+
private convertToEnrichedWithoutTimestamps(
328+
roleAssignments: RoleAssignment[]
329+
): EnrichedRoleAssignment[] {
330+
return roleAssignments.map((assignment) => ({
331+
role: assignment.role,
332+
members: assignment.members.map((address) => ({
333+
address,
334+
// Timestamps are undefined when indexer is unavailable
335+
})),
336+
}));
337+
}
338+
339+
/**
340+
* Enriches member addresses with grant information from the indexer
341+
*/
342+
private async enrichMembersWithGrantInfo(
343+
contractAddress: string,
344+
roleId: string,
345+
memberAddresses: string[]
346+
): Promise<EnrichedRoleMember[]> {
347+
if (memberAddresses.length === 0) {
348+
return [];
349+
}
350+
351+
try {
352+
// Query indexer for grant information
353+
const grantInfoMap = await this.indexerClient.queryLatestGrants(
354+
contractAddress,
355+
roleId,
356+
memberAddresses
357+
);
358+
359+
// Build enriched members, using grant info when available
360+
return memberAddresses.map((address) => {
361+
const grantInfo = grantInfoMap.get(address);
362+
if (grantInfo) {
363+
return {
364+
address,
365+
grantedAt: grantInfo.timestamp,
366+
grantedTxId: grantInfo.txId,
367+
grantedLedger: grantInfo.ledger,
368+
};
369+
}
370+
// No grant info found (shouldn't happen for current members, but handle gracefully)
371+
return { address };
372+
});
373+
} catch (error) {
374+
logger.warn(
375+
'StellarAccessControlService.enrichMembersWithGrantInfo',
376+
`Failed to fetch grant info for role ${roleId}, returning members without timestamps: ${error instanceof Error ? error.message : String(error)}`
377+
);
378+
// Graceful degradation on error
379+
return memberAddresses.map((address) => ({ address }));
380+
}
381+
}
382+
258383
/**
259384
* Grants a role to an account
260385
*

0 commit comments

Comments
 (0)