diff --git a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts index 150b60a..1f12409 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts @@ -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 (url: string, body: object, requiresAuth = true): Promise => { +export const makePrivyRequest = async ( + url: string, + body: object = {}, + requiresAuth = true, + method: 'GET' | 'POST' = 'POST' +): Promise => { try { let headers: Record = { 'Content-Type': 'application/json', @@ -129,31 +140,31 @@ export const makePrivyRequest = async (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 => { + 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); diff --git a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts index 17104bf..534fe00 100644 --- a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts +++ b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts @@ -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 \ No newline at end of file diff --git a/src/Managing.Web3Proxy/test/plugins/getWalletBalances.test.ts b/src/Managing.Web3Proxy/test/plugins/getWalletBalances.test.ts new file mode 100644 index 0000000..078c447 --- /dev/null +++ b/src/Managing.Web3Proxy/test/plugins/getWalletBalances.test.ts @@ -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 + } + } +}) \ No newline at end of file diff --git a/src/Managing.Web3Proxy/test/plugins/privy.test.ts b/src/Managing.Web3Proxy/test/plugins/privy.test.ts index c2565f7..6f2d2c6 100644 --- a/src/Managing.Web3Proxy/test/plugins/privy.test.ts +++ b/src/Managing.Web3Proxy/test/plugins/privy.test.ts @@ -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')