Skip to content
33 changes: 33 additions & 0 deletions scripts/claude-review.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash

# Script to run Claude Code review and post to GitHub PR
# Usage: ./scripts/claude-review.sh <pr-number>

if [ $# -eq 0 ]; then
echo "Usage: $0 <pr-number>"
exit 1
fi

PR_NUMBER=$1

# Check if ANTHROPIC_API_KEY is set
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "Error: ANTHROPIC_API_KEY environment variable is not set"
exit 1
fi

# Check if GITHUB_TOKEN is set
if [ -z "$GITHUB_TOKEN" ]; then
echo "Error: GITHUB_TOKEN environment variable is not set"
exit 1
fi

echo "Running Claude Code review for PR #$PR_NUMBER..."

# Run Claude Code review
claude-code review \
--pr $PR_NUMBER \
--format github-comment \
--post-comment

echo "Claude review completed and posted to PR #$PR_NUMBER"
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,94 @@ const getNetworksInfo = () => {

const networks = getNetworksInfo();

describe('getAnnouncementsUsingSubgraph input validation', () => {
test('should throw error for undefined subgraphUrl', async () => {
await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: undefined as unknown as string
})
).rejects.toThrow(GetAnnouncementsUsingSubgraphError);

await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: undefined as unknown as string
})
).rejects.toMatchObject({
message: 'subgraphUrl must be a non-empty string'
});
});

test('should throw error for empty string subgraphUrl', async () => {
await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: ''
})
).rejects.toThrow(GetAnnouncementsUsingSubgraphError);

await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: ' '
})
).rejects.toMatchObject({
message: 'subgraphUrl cannot be empty or whitespace'
});
});

test('should throw error for invalid URL format', async () => {
await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'not-a-url'
})
).rejects.toThrow(GetAnnouncementsUsingSubgraphError);
});

test('should throw error for non-HTTP URL', async () => {
await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'ftp://example.com/subgraph'
})
).rejects.toThrow(GetAnnouncementsUsingSubgraphError);
});

test('should throw error for invalid pageSize', async () => {
await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'https://example.com',
pageSize: -1
})
).rejects.toMatchObject({
message: 'pageSize must be a positive integer'
});

await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'https://example.com',
pageSize: 0
})
).rejects.toMatchObject({
message: 'pageSize must be a positive integer'
});

await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'https://example.com',
pageSize: 1.5
})
).rejects.toMatchObject({
message: 'pageSize must be a positive integer'
});

await expect(
getAnnouncementsUsingSubgraph({
subgraphUrl: 'https://example.com',
pageSize: 20000
})
).rejects.toMatchObject({
message: 'pageSize cannot exceed 10000 to avoid subgraph limits'
});
});
});

describe('getAnnouncementsUsingSubgraph with real subgraph', () => {
let testResults: TestResult[] = [];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GraphQLClient } from 'graphql-request';
import { validateSubgraphUrl } from '../../../utils/validation/validateSubgraphUrl';
import type { AnnouncementLog } from '../getAnnouncements/types';
import {
convertSubgraphEntityToAnnouncementLog,
Expand All @@ -11,6 +12,38 @@ import {
type SubgraphAnnouncementEntity
} from './types';

/**
* Validates input parameters for getAnnouncementsUsingSubgraph function.
*
* @param subgraphUrl - The subgraph URL to validate
* @param pageSize - The page size to validate
* @throws {GetAnnouncementsUsingSubgraphError} If any parameter is invalid
*/
function validateGetAnnouncementsParams(
subgraphUrl: string,
pageSize: number
): void {
// Validate subgraphUrl using shared utility
validateSubgraphUrl(subgraphUrl, GetAnnouncementsUsingSubgraphError);

// Validate pageSize
if (
typeof pageSize !== 'number' ||
pageSize <= 0 ||
!Number.isInteger(pageSize)
) {
throw new GetAnnouncementsUsingSubgraphError(
'pageSize must be a positive integer'
);
}

if (pageSize > 10000) {
throw new GetAnnouncementsUsingSubgraphError(
'pageSize cannot exceed 10000 to avoid subgraph limits'
);
}
}

/**
* Fetches announcement data from a specified subgraph URL.
*
Expand Down Expand Up @@ -40,6 +73,8 @@ async function getAnnouncementsUsingSubgraph({
filter = '',
pageSize = 1000
}: GetAnnouncementsUsingSubgraphParams): Promise<GetAnnouncementsUsingSubgraphReturnType> {
// Validate input parameters
validateGetAnnouncementsParams(subgraphUrl, pageSize);
const client = new GraphQLClient(subgraphUrl);
const gqlQuery = `
query GetAnnouncements($first: Int, $id_lt: ID) {
Expand Down Expand Up @@ -92,3 +127,4 @@ async function getAnnouncementsUsingSubgraph({
}

export default getAnnouncementsUsingSubgraph;
export { GetAnnouncementsUsingSubgraphError };
141 changes: 127 additions & 14 deletions src/lib/actions/getAnnouncementsUsingSubgraph/subgraphHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GraphQLClient } from 'graphql-request';
import { ERC5564_CONTRACT_ADDRESS } from '../../../config';
import type { AnnouncementLog } from '../getAnnouncements/types';
import { GetAnnouncementsUsingSubgraphError } from './getAnnouncementsUsingSubgraph';
import type { SubgraphAnnouncementEntity } from './types';

/**
Expand Down Expand Up @@ -97,35 +98,147 @@ export async function* fetchPages<T extends { id: string }>({
}
}

/**
* Validates a SubgraphAnnouncementEntity to ensure it has all required fields.
*
* @param entity - The entity to validate
* @throws {GetAnnouncementsUsingSubgraphError} If required fields are missing or invalid
*/
function validateSubgraphAnnouncementEntity(
entity: SubgraphAnnouncementEntity
): void {
if (!entity.id) {
throw new GetAnnouncementsUsingSubgraphError(
'Invalid announcement entity: missing id field'
);
}

const requiredFields = [
'blockNumber',
'caller',
'ephemeralPubKey',
'metadata',
'schemeId',
'stealthAddress',
'transactionHash'
];

for (const field of requiredFields) {
if (!entity[field as keyof SubgraphAnnouncementEntity]) {
throw new GetAnnouncementsUsingSubgraphError(
`Invalid announcement entity: missing required field '${field}'`
);
}
}

// Validate numeric fields
if (
entity.blockNumber &&
(Number.isNaN(Number(entity.blockNumber)) || Number(entity.blockNumber) < 0)
) {
throw new GetAnnouncementsUsingSubgraphError(
'Invalid announcement entity: blockNumber must be a non-negative number'
);
}

if (
entity.logIndex &&
(Number.isNaN(Number(entity.logIndex)) || Number(entity.logIndex) < 0)
) {
throw new GetAnnouncementsUsingSubgraphError(
'Invalid announcement entity: logIndex must be a non-negative number'
);
}

if (
entity.transactionIndex &&
(Number.isNaN(Number(entity.transactionIndex)) ||
Number(entity.transactionIndex) < 0)
) {
throw new GetAnnouncementsUsingSubgraphError(
'Invalid announcement entity: transactionIndex must be a non-negative number'
);
}

if (entity.schemeId && Number.isNaN(Number(entity.schemeId))) {
throw new GetAnnouncementsUsingSubgraphError(
'Invalid announcement entity: schemeId must be a valid number'
);
}

// Validate hex strings (basic validation - just check they start with 0x)
const hexFields = [
'caller',
'ephemeralPubKey',
'stealthAddress',
'transactionHash',
'blockHash',
'data',
'metadata'
];

for (const field of hexFields) {
const value = entity[field as keyof SubgraphAnnouncementEntity];
if (value && typeof value === 'string' && !value.startsWith('0x')) {
throw new GetAnnouncementsUsingSubgraphError(
`Invalid announcement entity: ${field} must be a valid hex string starting with '0x'`
);
}
}
}

/**
* Converts a SubgraphAnnouncementEntity to an AnnouncementLog for interoperability
* between `getAnnouncements` and `getAnnouncementsUsingSubgraph`.
*
* This function transforms the data structure returned by the subgraph into the
* standardized AnnouncementLog format used throughout the SDK. It ensures consistency
* in data representation regardless of whether announcements are fetched directly via logs
* or via a subgraph.
* or via a subgraph. Includes comprehensive validation of the entity data.
*
* @param {SubgraphAnnouncementEntity} entity - The announcement entity from the subgraph.
* @returns {AnnouncementLog} The converted announcement log in the standard format.
* @throws {Error} If the entity is missing required fields or has invalid data.
*/
export function convertSubgraphEntityToAnnouncementLog(
entity: SubgraphAnnouncementEntity
): AnnouncementLog {
// Validate the entity before conversion
validateSubgraphAnnouncementEntity(entity);

// After validation, we can safely assert that required fields exist
const validatedEntity = entity as Required<
Pick<
SubgraphAnnouncementEntity,
| 'blockNumber'
| 'caller'
| 'ephemeralPubKey'
| 'metadata'
| 'schemeId'
| 'stealthAddress'
| 'transactionHash'
>
> &
SubgraphAnnouncementEntity;

return {
address: ERC5564_CONTRACT_ADDRESS, // Contract address is the same for all chains
blockHash: entity.blockHash as `0x${string}`,
blockNumber: BigInt(entity.blockNumber),
logIndex: Number(entity.logIndex),
removed: entity.removed,
transactionHash: entity.transactionHash as `0x${string}`,
transactionIndex: Number(entity.transactionIndex),
topics: entity.topics as [`0x${string}`, ...`0x${string}`[]] | [],
data: entity.data as `0x${string}`,
schemeId: BigInt(entity.schemeId),
stealthAddress: entity.stealthAddress as `0x${string}`,
caller: entity.caller as `0x${string}`,
ephemeralPubKey: entity.ephemeralPubKey as `0x${string}`,
metadata: entity.metadata as `0x${string}`
// Optional fields with fallbacks (correct)
blockHash: (entity.blockHash ||
'0x0000000000000000000000000000000000000000000000000000000000000000') as `0x${string}`,
logIndex: Number(entity.logIndex || 0),
removed: entity.removed || false,
transactionIndex: Number(entity.transactionIndex || 0),
topics: (entity.topics || []) as [`0x${string}`, ...`0x${string}`[]] | [],
data: (entity.data || '0x') as `0x${string}`,

// Required fields (validation ensures they exist, so no fallbacks needed)
blockNumber: BigInt(validatedEntity.blockNumber),
transactionHash: validatedEntity.transactionHash as `0x${string}`,
schemeId: BigInt(validatedEntity.schemeId),
stealthAddress: validatedEntity.stealthAddress as `0x${string}`,
caller: validatedEntity.caller as `0x${string}`,
ephemeralPubKey: validatedEntity.ephemeralPubKey as `0x${string}`,
metadata: validatedEntity.metadata as `0x${string}`
};
}
29 changes: 15 additions & 14 deletions src/lib/actions/getAnnouncementsUsingSubgraph/types.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { GetAnnouncementsReturnType } from '../getAnnouncements/types';

export type SubgraphAnnouncementEntity = {
blockNumber: string;
caller: string;
ephemeralPubKey: string;
// Core required fields
id: string;
metadata: string;
schemeId: string;
stealthAddress: string;
transactionHash: string;
blockNumber?: string;
caller?: string;
ephemeralPubKey?: string;
metadata?: string;
schemeId?: string;
stealthAddress?: string;
transactionHash?: string;

// Additional log information
blockHash: string;
data: string;
logIndex: string;
removed: boolean;
topics: string[];
transactionIndex: string;
// Additional log information (may be missing in some subgraph implementations)
blockHash?: string;
data?: string;
logIndex?: string;
removed?: boolean;
topics?: string[];
transactionIndex?: string;
};

export type GetAnnouncementsUsingSubgraphParams = {
Expand Down
Loading