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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
description:
globs:
alwaysApply: true
---
# Documentation

Always check here for how to interact with the API [docs](https://thegraph.com/docs/en/token-api/evm/get-balances-evm-by-address/)
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class TokenApiProvider extends ActionProvider<WalletProvider> { // Use WalletPro

@CreateAction({
name: "get-token-transfers",
description: "Fetches token transfers involving a specific address or contract. Can filter by sender, receiver, date range, etc.",
description: "Fetches token transfers involving a specific address or contract. Can filter by sender, receiver, date range, etc. If addressRole is not specified, defaults to showing incoming transfers (receiver). If no time filters are provided, automatically applies a 7-day lookback to prevent API timeouts.",
schema: GetTokenTransfersAgentParamsSchema,
})
async getTokenTransfers(
Expand All @@ -186,37 +186,71 @@ class TokenApiProvider extends ActionProvider<WalletProvider> { // Use WalletPro
): Promise<string> {
console.log(`Action: getTokenTransfers, Args: ${JSON.stringify(args)}`);

const { address, addressRole, fromAddress, toAddress, ...otherParams } = args;
const { address, addressRole, fromAddress, toAddress, age, startTime, endTime, ...otherParams } = args;

let finalToAddress: string | undefined = toAddress;
let finalFromAddress: string | undefined = fromAddress;

if (address) {
if (addressRole === "receiver" && !finalToAddress) {
// Default to "receiver" if no role is specified (most common use case)
const role = addressRole || "receiver";

if (role === "receiver" && !finalToAddress) {
finalToAddress = address;
} else if (addressRole === "sender" && !finalFromAddress) {
} else if (role === "sender" && !finalFromAddress) {
finalFromAddress = address;
} else if (addressRole === "either") {
// If role is 'either', and specific from/to are not set,
// this basic setup will use the address for 'to' in fetchTokenTransfers (first arg),
// and potentially for 'from' in its params if finalFromAddress is still undefined.
// A true 'either' might require two API calls or specific backend support.
} else if (role === "either") {
// For "either", we'll default to receiver for now
// TODO: In the future, this could make two API calls and merge results
if (!finalToAddress) finalToAddress = address;
if (!finalFromAddress) finalFromAddress = address;
console.log(`📝 Note: Using address as receiver for "either" role. Consider making separate queries for complete results.`);
}
}

// If after all logic, neither toAddress nor fromAddress is set, and no contract is specified,
// the query might be too broad. The utility has a placeholder for this check.
// More specific error message with guidance
if (!finalToAddress && !finalFromAddress && !otherParams.contract) {
return "Error: Token transfers query is too broad. Please specify an address (with role), from/to address, or a contract address.";
return `Error: Please specify one of the following:
- An address with a role (receiver/sender/either)
- A fromAddress (sender)
- A toAddress (receiver)
- A contract address to filter by

Examples:
- To see incoming transfers: specify addressRole as "receiver" (this is the default)
- To see outgoing transfers: specify addressRole as "sender"
- To see transfers for a specific token: provide the contract address`;
}

// Handle time filtering - prefer startTime/endTime over age to avoid timeouts
let finalStartTime: number | undefined = startTime;
let finalEndTime: number | undefined = endTime;
let finalAge: number | undefined = age;

// If age is provided but no explicit start/end times, convert age to timestamps
// This helps avoid API timeouts for large datasets
if (age && !startTime && !endTime) {
const now = Math.floor(Date.now() / 1000); // Current time in seconds
finalEndTime = now;
finalStartTime = now - (age * 24 * 60 * 60); // Convert days to seconds
finalAge = undefined; // Remove age since we're using timestamps
}

// If NO time filtering is provided at all, default to last 7 days to prevent timeouts
if (!age && !startTime && !endTime) {
const now = Math.floor(Date.now() / 1000);
finalEndTime = now;
finalStartTime = now - (7 * 24 * 60 * 60); // Default to 7 days
console.log(`📅 No time filter provided. Defaulting to last 7 days to prevent API timeout.`);
}

// Prepare parameters for the fetchTokenTransfers utility
// The utility expects `toAddress` as first arg, and other params (including `from`) in the second.
const utilityParams: Omit<TokenTransfersParams, 'to'> = {
...otherParams, // network_id, contract, limit, age, etc.
...otherParams, // network_id, contract, limit, etc.
from: finalFromAddress, // This can be undefined, and fetchTokenTransfers handles it
age: finalAge, // Only use age if startTime/endTime are not set
startTime: finalStartTime,
endTime: finalEndTime,
};

try {
Expand All @@ -228,7 +262,12 @@ class TokenApiProvider extends ActionProvider<WalletProvider> { // Use WalletPro
}

if (!response.data || !response.data.transfers || response.data.transfers.length === 0) {
return `No token transfers found matching the criteria.`;
const roleText = addressRole || "receiver";
return `No token transfers found for address ${address} as ${roleText} on ${otherParams.network_id || 'mainnet'}. Try:
- Different addressRole (sender/receiver/either)
- Different network
- Longer time period
- Check if the address has any token activity`;
}

// The response.data from fetchTokenTransfers includes { transfers: [], pagination: {}, ... }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export const TokenTransfersParamsSchema = z.object({
low_liquidity: z.boolean().optional(),
start_date: z.string().optional().describe("Start date for filtering in ISO format (YYYY-MM-DDTHH:mm:ssZ)"),
end_date: z.string().optional().describe("End date for filtering in ISO format (YYYY-MM-DDTHH:mm:ssZ)"),
startTime: z.number().optional().describe("Start timestamp (Unix timestamp in seconds)"),
endTime: z.number().optional().describe("End timestamp (Unix timestamp in seconds)"),
include_prices: z.boolean().optional(),
});

Expand Down Expand Up @@ -177,12 +179,28 @@ export const GetTokenTransfersAgentParamsSchema = TokenTransfersParamsSchema.ext
addressRole: z
.enum(["sender", "receiver", "either"])
.optional()
.describe("Role of the primary address: sender, receiver, or either."),
.describe(
"Role of the primary address: sender, receiver, or either. Defaults to 'receiver' (incoming transfers) if not specified.",
),

// Allow agent to specify from/to directly, overriding the primary address/role logic if needed.
fromAddress: z.string().optional().describe("Filter by sender address."),
toAddress: z.string().optional().describe("Filter by receiver address."),

// Time filtering options - prefer startTime/endTime over age for large datasets
startTime: z
.number()
.optional()
.describe(
"Start timestamp (Unix timestamp in seconds). Preferred over age parameter for large datasets to avoid timeouts.",
),
endTime: z
.number()
.optional()
.describe(
"End timestamp (Unix timestamp in seconds). Preferred over age parameter for large datasets to avoid timeouts.",
),

// contractAddress is already in TokenTransfersParamsSchema as 'contract'
// networkId is already in TokenTransfersParamsSchema as 'network_id'
// limit, page, age are also already there.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,37 @@ export type TokenBalance = z.infer<typeof import("./schemas").TokenBalanceSchema
// Define TokenInfo type from schema
export type TokenInfo = z.infer<typeof TokenInfoSchema>;

const NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
const API_PROXY_URL = `${NEXT_PUBLIC_BASE_URL}/api/token-proxy`; // Ensure this matches your proxy endpoint
// Determine the correct API proxy URL based on environment
function getApiProxyUrl(): string {
// If we're in a browser environment, use relative URL
if (typeof window !== "undefined") {
return "/api/token-proxy";
}

// Server-side: construct absolute URL
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL || process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";

return `${baseUrl}/api/token-proxy`;
}

const API_PROXY_URL = getApiProxyUrl();

/**
* Helper function to convert days ago to Unix timestamps
* @param daysAgo Number of days to look back from now
* @returns Object with startTime and endTime Unix timestamps
*/
export function convertDaysToTimestamps(daysAgo: number): { startTime: number; endTime: number } {
const now = Math.floor(Date.now() / 1000); // Current time in seconds
const startTime = now - daysAgo * 24 * 60 * 60; // Convert days to seconds
return {
startTime,
endTime: now,
};
}

/**
* Fetches token balances from the token API proxy.
Expand Down Expand Up @@ -197,7 +226,7 @@ export async function fetchTokenTransfers(
toAddress: string | undefined, // The address is primarily used as the 'to' parameter
params?: Omit<TokenTransfersParams, "to">, // Params excluding 'to', as toAddress takes precedence
): Promise<TokenTransfersApiResponse> {
const endpoint = "transfers/evm"; // Based on useTokenTransfers hook
const endpoint = "transfers/evm"; // Base endpoint as per The Graph documentation

const queryParams = new URLSearchParams();
queryParams.append("path", endpoint);
Expand All @@ -207,26 +236,50 @@ export async function fetchTokenTransfers(
...params, // Spread other parameters like from, contract, limit, age etc.
};

// Set the main address as 'to' parameter if provided
if (toAddress) {
apiParams.to = toAddress; // Set the 'to' parameter from the main address argument
apiParams.to = toAddress;
}

// It's crucial that network_id is passed if available in params
if (params?.network_id) {
apiParams.network_id = params.network_id;
}

// Handle time filtering - prioritize startTime/endTime over age
if (params?.startTime) {
apiParams.startTime = params.startTime;
}

if (params?.endTime) {
apiParams.endTime = params.endTime;
}

// Only include age if startTime/endTime are not provided to avoid conflicts
if (params?.age && !params?.startTime && !params?.endTime) {
apiParams.age = params.age;
}

// Add default ordering as per The Graph documentation
if (!apiParams.orderBy) {
apiParams.orderBy = "timestamp";
}

if (!apiParams.orderDirection) {
apiParams.orderDirection = "desc";
}

console.log(`🔍 API params being sent:`, JSON.stringify(apiParams, null, 2));

Object.entries(apiParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
queryParams.append(key, String(value));
}
});

// If no 'to' address is effectively provided (neither toAddress nor params.to) and endpoint requires it,
// the API might error or return broad results. Consider adding a check if toAddress is mandatory.
// Validate that we have some filtering criteria to avoid overly broad queries
if (!apiParams.to && !apiParams.from && !apiParams.contract) {
// Example check: if the query is too broad without a primary subject (to/from/contract)
// return { error: { message: "Address or contract parameter is required for token transfers", status: 400 } };
return { error: { message: "At least one of 'to', 'from', or 'contract' address must be specified", status: 400 } };
}

const url = `${API_PROXY_URL}?${queryParams.toString()}`;
Expand Down