Add new endpoint to retrieve balance

This commit is contained in:
2025-08-15 20:18:02 +07:00
parent cd93dede4e
commit b178f15beb
4 changed files with 393 additions and 26 deletions

View File

@@ -11,7 +11,7 @@ import {ARBITRUM} from '../../generated/gmxsdk/configs/chains.js'
import {TOKENS} from '../../generated/gmxsdk/configs/tokens.js'
import {CONTRACTS} from '../../generated/gmxsdk/configs/contracts.js'
import {getClientForAddress, getTokenDataFromTicker} from './gmx.js'
import {Ticker} from '../../generated/ManagingApiTypes.js'
import {Balance, Chain, Ticker} from '../../generated/ManagingApiTypes.js'
import {Address} from 'viem'
// Load environment variables
@@ -45,6 +45,7 @@ declare module 'fastify' {
approveToken: typeof approveToken;
initAddress: typeof initAddress;
sendToken: typeof sendToken;
getWalletBalance: typeof getWalletBalance;
}
}
@@ -65,6 +66,10 @@ export const getPrivyClient = (fastify?: FastifyInstance): PrivyClient => {
throw new Error('Missing required Privy environment variables');
}
console.log('appId', appId)
console.log('appSecret', appSecret)
console.log('authKey', authKey)
return new PrivyClient(
appId,
appSecret,
@@ -117,11 +122,17 @@ export function getAuthorizationSignature({url, body}: {url: string; body: objec
/**
* Makes a request to the Privy API with proper authentication and headers.
* @param url - The full URL for the API endpoint.
* @param body - The request body.
* @param body - The request body (optional for GET requests).
* @param requiresAuth - Whether the request requires authentication.
* @param method - The HTTP method (defaults to POST).
* @returns The response data.
*/
export const makePrivyRequest = async <T>(url: string, body: object, requiresAuth = true): Promise<T> => {
export const makePrivyRequest = async <T>(
url: string,
body: object = {},
requiresAuth = true,
method: 'GET' | 'POST' = 'POST'
): Promise<T> => {
try {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -129,31 +140,31 @@ export const makePrivyRequest = async <T>(url: string, body: object, requiresAut
};
if (requiresAuth) {
// Generate authorization signature
const authSig = await getAuthorizationSignature({
url,
body
});
// Create authentication string for basic auth
const basicAuthString = `${process.env.PRIVY_APP_ID}:${process.env.PRIVY_APP_SECRET}`;
// Create Basic Authentication header
const appId = process.env.PRIVY_APP_ID ?? "";
const appSecret = process.env.PRIVY_APP_SECRET ?? "";
const basicAuthString = `${appId}:${appSecret}`;
const base64Auth = Buffer.from(basicAuthString).toString('base64');
headers = {
...headers,
'privy-authorization-signature': authSig,
'Authorization': `Basic ${base64Auth}`
};
headers.Authorization = `Basic ${base64Auth}`;
}
const request = {
method: 'POST',
const requestInit: RequestInit = {
method: method,
headers,
body: JSON.stringify(body)
};
// Only add body for non-GET requests
if (method !== 'GET') {
requestInit.body = JSON.stringify(body);
}
console.log('url', url)
console.log('requestInit', requestInit)
// Make the API request
const response = await fetch(url, request).then(res => {
const response = await fetch(url, requestInit).then(res => {
return res;
}).catch(err => {
console.log("error", err);
@@ -196,6 +207,13 @@ const tokenSendSchema = z.object({
chainId: z.number().positive().optional()
});
// Schema for wallet balance request
const walletBalanceSchema = z.object({
address: z.string().nonempty(),
assets: z.array(z.nativeEnum(Ticker)).min(1), // Required: array of valid Ticker enum values
chains: z.array(z.string().nonempty()).min(1) // Required: array of chain names
});
/**
* Gets the chain name based on chain ID
* @param chainId The chain ID
@@ -732,6 +750,228 @@ export const sendTokenImpl = async (
}
};
/**
* Gets the wallet balance for a specific address using Privy API
* @param address The wallet address to get balance for
* @param assets Array of assets to filter by using Ticker enum (e.g. [Ticker.USDC, Ticker.ETH])
* @param chains Array of chain names to filter by (e.g. ['arbitrum', 'ethereum'])
* @returns Array of Balance objects
*/
export const getWalletBalanceImpl = async (
address: string,
assets: Ticker[],
chains: string[]
): Promise<Balance[]> => {
try {
// First, get the user by wallet address using Privy Client
const privy = getPrivyClient();
// Get user by wallet address
const user = await privy.getUserByWalletAddress(address);
if (!user) {
throw new Error(`User not found for wallet address: ${address}`);
}
// Find the embedded wallet for this address
const embeddedWallet = user.linkedAccounts.find(
(account: any) =>
account.type === 'wallet' &&
account.chainType === 'ethereum' &&
account.address?.toLowerCase() === address.toLowerCase()
);
if (!embeddedWallet) {
throw new Error(`Embedded wallet not found for address: ${address}`);
}
// Extract wallet ID from the embedded wallet
const walletId = (embeddedWallet as any).walletId || (embeddedWallet as any).id;
if (!walletId) {
throw new Error(`Wallet ID not found for embedded wallet`);
}
// Make authenticated request to get wallet balance
let balanceUrl = `https://auth.privy.io/api/v1/wallets/${walletId}/balance`;
// Add required query parameters for multiple assets and chains
const queryParams = new URLSearchParams();
// Add multiple asset parameters
assets.forEach(asset => {
queryParams.append('asset', asset.toLowerCase()); // Convert Ticker enum to lowercase
});
// Add multiple chain parameters
chains.forEach(chain => {
queryParams.append('chain', chain);
});
// Add include_currency parameter for USD pricing
queryParams.append('include_currency', 'usd');
balanceUrl += `?${queryParams.toString()}`;
const balanceResponse = await makePrivyRequest<{
balances: Array<{
chain: string;
asset: string;
raw_value: string;
raw_value_decimals: number;
display_values: {
[key: string]: string;
usd: string;
};
}>;
}>(balanceUrl, {}, true, 'GET');
console.log('balanceResponse', balanceResponse)
// Convert to Balance interface format (matching ManagingApiTypes.ts)
const balances: Balance[] = balanceResponse.balances
.map(balance => {
// Parse the raw value using decimals
const amount = parseFloat(balance.raw_value) / Math.pow(10, balance.raw_value_decimals);
// Get USD price from display_values
const usdValue = parseFloat(balance.display_values.usd);
// Calculate price per token (if amount > 0)
const price = amount > 0 ? usdValue / amount : 0;
// Get chain ID from chain name
const chainId = getChainIdFromName(balance.chain);
const caip2 = getChainName(chainId);
const chain: Chain = {
id: caip2,
rpcUrl: undefined,
name: getChainNameFromCaip2(caip2),
chainId: chainId
};
return {
tokenImage: '', // Not provided in the response
tokenName: balance.asset.toUpperCase(), // Use asset name (usdc -> USDC)
amount: amount,
price: price,
value: usdValue,
tokenAdress: '', // Not provided in this response format
chain: chain
};
});
console.log('balances', balances)
return balances;
} catch (error) {
console.error('Error getting wallet balance:', error);
throw new Error(`Failed to get wallet balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Helper function to get chain name from CAIP-2 identifier
* @param caip2 The CAIP-2 identifier
* @returns Human readable chain name
*/
const getChainNameFromCaip2 = (caip2: string): string => {
switch (caip2) {
case 'eip155:1':
return 'Ethereum Mainnet';
case 'eip155:42161':
return 'Arbitrum One';
case 'eip155:421613':
return 'Arbitrum Goerli';
case 'eip155:8453':
return 'Base';
case 'eip155:84531':
return 'Base Goerli';
default:
return `Chain ${caip2}`;
}
};
/**
* Helper function to get chain ID from chain name
* @param chainName The chain name (e.g., 'arbitrum', 'ethereum', 'base')
* @returns The corresponding chain ID
*/
const getChainIdFromName = (chainName: string): number => {
switch (chainName.toLowerCase()) {
case 'ethereum':
case 'mainnet':
return 1;
case 'arbitrum':
case 'arbitrum-one':
return 42161;
case 'arbitrum-goerli':
return 421613;
case 'base':
return 8453;
case 'base-goerli':
return 84531;
default:
// Default to Arbitrum if unknown chain
return 42161;
}
};
/**
* Gets wallet balance using Privy embedded wallet
* @param this The FastifyRequest instance
* @param reply The FastifyReply instance
* @param address The wallet address to get balance for
* @param assets Array of assets to filter by using Ticker enum (e.g. [Ticker.USDC, Ticker.ETH])
* @param chains Array of chain names to filter by (e.g. ['arbitrum', 'ethereum'])
* @returns The response object with success status and balances array
*/
export async function getWalletBalance(
this: FastifyRequest,
reply: FastifyReply,
address: string,
assets: Ticker[],
chains: string[]
) {
try {
// Validate the request parameters
walletBalanceSchema.parse({
address,
assets,
chains
});
if (!address) {
throw new Error('Wallet address is required for balance retrieval');
}
if (!assets || assets.length === 0) {
throw new Error('At least one asset is required for balance retrieval');
}
if (!chains || chains.length === 0) {
throw new Error('At least one chain is required for balance retrieval');
}
// Call the getWalletBalanceImpl function
const balances = await getWalletBalanceImpl(address, assets, chains);
return {
success: true,
balances: balances
};
} catch (error) {
this.log.error(error);
// Return appropriate error response
reply.status(error instanceof z.ZodError ? 400 : 500);
return {
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
};
}
}
/**
* Sends tokens from one address to another using Privy wallet
* @param this The FastifyRequest instance
@@ -821,6 +1061,10 @@ export default fp(async (fastify) => {
return sendToken.call(this, reply, senderAddress, recipientAddress, ticker, amount, chainId);
});
fastify.decorateRequest('getWalletBalance', async function(this: FastifyRequest, reply: FastifyReply, address: string, assets: Ticker[], chains: string[]) {
return getWalletBalance.call(this, reply, address, assets, chains);
});
// Test the Privy client initialization
try {
const testClient = getPrivyClient(fastify);

View File

@@ -2,6 +2,7 @@ import {FastifyPluginAsyncTypebox, Type} from '@fastify/type-provider-typebox'
import {handleError} from '../../../utils/errorHandler.js'
import {TOKENS} from '../../../generated/gmxsdk/configs/tokens.js'
import {ARBITRUM} from '../../../generated/gmxsdk/configs/chains.js'
import {Ticker} from '../../../generated/ManagingApiTypes.js'
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.post(
@@ -147,6 +148,64 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
}
}
)
fastify.get(
'/wallet-balance',
{
schema: {
querystring: Type.Object({
address: Type.String(),
asset: Type.Union([Type.String(), Type.Array(Type.String())]), // Can be single or array
chain: Type.Union([Type.String(), Type.Array(Type.String())]) // Can be single or array
}),
response: {
200: Type.Object({
success: Type.Boolean(),
balances: Type.Optional(Type.Array(Type.Object({
tokenImage: Type.Optional(Type.String()),
tokenName: Type.Optional(Type.String()),
amount: Type.Optional(Type.Number()),
price: Type.Optional(Type.Number()),
value: Type.Optional(Type.Number()),
tokenAdress: Type.Optional(Type.String()),
chain: Type.Optional(Type.Object({
id: Type.Optional(Type.String()),
rpcUrl: Type.Optional(Type.String()),
name: Type.Optional(Type.String()),
chainId: Type.Optional(Type.Number())
}))
}))),
error: Type.Optional(Type.String())
}),
400: Type.Object({
success: Type.Boolean(),
error: Type.String()
}),
500: Type.Object({
success: Type.Boolean(),
error: Type.String()
})
},
tags: ['Privy']
}
},
async function (request, reply) {
try {
const { address, asset, chain } = request.query;
// Convert single values to arrays if needed
const assetsArray = Array.isArray(asset) ? asset : [asset];
const chainsArray = Array.isArray(chain) ? chain : [chain];
// Convert asset strings to Ticker enums
const assetEnums = assetsArray.map(a => a as Ticker);
return await request.getWalletBalance(reply, address, assetEnums, chainsArray);
} catch (error) {
return handleError(request, reply, error, 'privy/wallet-balance');
}
}
)
}
export default plugin

View File

@@ -0,0 +1,70 @@
import {test} from 'node:test'
import assert from 'node:assert'
import {getWalletBalanceImpl} from '../../src/plugins/custom/privy.js'
import {Ticker} from '../../src/generated/ManagingApiTypes.js'
test('getWalletBalanceImpl should fetch wallet balance for valid address', async () => {
const testWalletId = 'cm7vxs99f0007blcl8cmzv74t'
// Note: Replace with actual wallet address associated with the wallet ID from Privy dashboard
const testAddress = '0x932167388dD9aad41149b3cA23eBD489E2E2DD78'
const assets = [Ticker.USDC, Ticker.ETH] // Required: array of assets using Ticker enum
const chains = ['arbitrum', 'ethereum'] // Required: array of chains
try {
const balances = await getWalletBalanceImpl(testAddress, assets, chains)
// Verify the response structure
assert.ok(Array.isArray(balances), 'Should return an array of balances')
// If balances exist, verify their structure
if (balances.length > 0) {
const balance = balances[0]
// Verify Balance interface properties (all optional)
if (balance.tokenName !== undefined) {
assert.equal(typeof balance.tokenName, 'string', 'tokenName should be a string')
}
if (balance.amount !== undefined) {
assert.equal(typeof balance.amount, 'number', 'amount should be a number')
}
if (balance.tokenAdress !== undefined) {
assert.equal(typeof balance.tokenAdress, 'string', 'tokenAdress should be a string')
}
if (balance.chain !== undefined && balance.chain !== null) {
assert.equal(typeof balance.chain, 'object', 'chain should be an object')
if (balance.chain.chainId !== undefined) {
// Chain ID should be either 42161 (Arbitrum) or 1 (Ethereum)
const validChainIds = [42161, 1];
assert.ok(validChainIds.includes(balance.chain.chainId), `chain.chainId should be one of ${validChainIds.join(', ')}, got ${balance.chain.chainId}`)
}
if (balance.chain.name !== undefined) {
assert.equal(typeof balance.chain.name, 'string', 'chain.name should be a string')
}
}
console.log(`✓ Found ${balances.length} balance(s) for wallet ${testWalletId}`)
console.log('Sample balance:', JSON.stringify(balances[0], null, 2))
} else {
console.log(`✓ No balances found for wallet ${testWalletId} on chains ${chains.join(', ')}`)
}
} catch (error) {
// If this is a real integration test, we might expect certain errors
console.log('Error details:', error.message)
// Common expected errors during testing:
if (error.message.includes('User not found for wallet address')) {
console.log('✓ Expected error: Wallet address not found in Privy (use real address from dashboard)')
} else if (error.message.includes('Failed to get wallet balance')) {
console.log('✓ Expected error: API call failed (check credentials and network)')
} else {
// Re-throw unexpected errors
throw error
}
}
})

View File

@@ -3,18 +3,12 @@ import privyPlugin, {getAuthorizationSignature} from '../../src/plugins/custom/p
import assert from 'node:assert'
import test from 'node:test'
// Set environment variables needed for the test
process.env.PRIVY_APP_ID = 'cm4db8x9t000ccn87pctvcg9j'
process.env.PRIVY_APP_SECRET = 'test-secret'
process.env.PRIVY_AUTHORIZATION_KEY = 'wallet-auth:MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgqOBE+hZld+PCaj051uOl0XpEwe3tKBC5tsYsKdnPymGhRANCAAQ2HyYUbLRcfj9obpViwjYU/S7FdNUehkcfjYdd+R2gH/1q0ZJx7mOF1zpiEbbBNRLuXzP0NPN6nonkI8umzLXZ'
test('getAuthorizationSignature generates valid signatures', async () => {
const url = 'https://api.privy.io/v1/wallets'
const body = { chain_type: 'ethereum' }
const signature = getAuthorizationSignature({ url, body })
// Basic validation - check if it's a non-empty string that looks like a base64 value
assert.ok(signature && typeof signature === 'string', 'Signature should be a string')
assert.ok(signature.length > 0, 'Signature should not be empty')