1080 lines
33 KiB
TypeScript
1080 lines
33 KiB
TypeScript
import fp from 'fastify-plugin'
|
|
import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'
|
|
import {z} from 'zod'
|
|
import canonicalize from 'canonicalize'
|
|
import crypto from 'crypto'
|
|
import {PrivyClient} from '@privy-io/server-auth'
|
|
import {ethers} from 'ethers'
|
|
import dotenv from 'dotenv'
|
|
import Token from '../../generated/gmxsdk/abis/Token.json' with {type: 'json'}
|
|
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 {Balance, Chain, Ticker} from '../../generated/ManagingApiTypes.js'
|
|
import {Address} from 'viem'
|
|
|
|
// Load environment variables
|
|
dotenv.config()
|
|
|
|
/**
|
|
* Privy Plugin
|
|
*
|
|
* This plugin adds functionality for interacting with the Privy API,
|
|
* including signing messages and token approvals.
|
|
*
|
|
* Token Approval Process:
|
|
* 1. Client sends a request to /approve-token with:
|
|
* - walletId: The user's wallet ID
|
|
* - address: The user's wallet address
|
|
* - ticker: The token ticker or enum value (as string)
|
|
* - amount: The amount to approve (optional, defaults to max amount)
|
|
* - chainId: The chain ID where the approval will take place
|
|
*
|
|
* 2. The server handles the request by:
|
|
* - Validating the input parameters
|
|
* - Getting the token data based on the ticker
|
|
* - Creating and sending an ERC20 approval transaction using ethers.js and Privy
|
|
* - Returning the transaction hash to the client
|
|
*/
|
|
|
|
|
|
declare module 'fastify' {
|
|
export interface FastifyRequest {
|
|
signPrivyMessage: typeof signPrivyMessage;
|
|
approveToken: typeof approveToken;
|
|
initAddress: typeof initAddress;
|
|
sendToken: typeof sendToken;
|
|
getWalletBalance: typeof getWalletBalance;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an initialized PrivyClient instance with configuration from environment variables
|
|
* @returns The configured PrivyClient instance
|
|
*/
|
|
export const getPrivyClient = (fastify?: FastifyInstance): PrivyClient => {
|
|
const appId = fastify?.config?.PRIVY_APP_ID || process.env.PRIVY_APP_ID;
|
|
const appSecret = fastify?.config?.PRIVY_APP_SECRET || process.env.PRIVY_APP_SECRET;
|
|
const authKey = fastify?.config?.PRIVY_AUTHORIZATION_KEY || process.env.PRIVY_AUTHORIZATION_KEY;
|
|
|
|
if (!appId || !appSecret || !authKey) {
|
|
console.error('Missing Privy environment variables:');
|
|
console.error('PRIVY_APP_ID:', appId ? 'present' : 'missing');
|
|
console.error('PRIVY_APP_SECRET:', appSecret ? 'present' : 'missing');
|
|
console.error('PRIVY_AUTHORIZATION_KEY:', authKey ? 'present' : 'missing');
|
|
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,
|
|
{
|
|
walletApi: {
|
|
authorizationPrivateKey: authKey
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Authentication function for Privy API calls
|
|
* @param url The URL for the API endpoint
|
|
* @param body The request body
|
|
* @returns The authorization signature
|
|
*/
|
|
export function getAuthorizationSignature({url, body}: {url: string; body: object}) {
|
|
const payload = {
|
|
version: 1,
|
|
method: 'POST',
|
|
url,
|
|
body,
|
|
headers: {
|
|
'privy-app-id': process.env.PRIVY_APP_ID
|
|
}
|
|
};
|
|
|
|
// JSON-canonicalize the payload and convert it to a buffer
|
|
// @ts-ignore - canonicalize is a callable function despite TypeScript error
|
|
const serializedPayload = canonicalize(payload) as string;
|
|
const serializedPayloadBuffer = Buffer.from(serializedPayload);
|
|
|
|
// Get the authorization key from environment variables
|
|
const privateKeyAsString = (process.env.PRIVY_AUTHORIZATION_KEY ?? "").replace('wallet-auth:', '');
|
|
|
|
// Convert private key to PEM format and create a key object
|
|
const privateKeyAsPem = `-----BEGIN PRIVATE KEY-----\n${privateKeyAsString}\n-----END PRIVATE KEY-----`;
|
|
const privateKey = crypto.createPrivateKey({
|
|
key: privateKeyAsPem,
|
|
format: 'pem',
|
|
});
|
|
|
|
// Sign the payload buffer with the private key
|
|
const signatureBuffer = crypto.sign('sha256', serializedPayloadBuffer, privateKey);
|
|
const signature = signatureBuffer.toString('base64');
|
|
return signature;
|
|
}
|
|
|
|
/**
|
|
* 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 (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,
|
|
method: 'GET' | 'POST' = 'POST'
|
|
): Promise<T> => {
|
|
try {
|
|
let headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'privy-app-id': process.env.PRIVY_APP_ID ?? "",
|
|
};
|
|
|
|
if (requiresAuth) {
|
|
// 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.Authorization = `Basic ${base64Auth}`;
|
|
}
|
|
|
|
const requestInit: RequestInit = {
|
|
method: method,
|
|
headers,
|
|
};
|
|
|
|
// 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, requestInit).then(res => {
|
|
return res;
|
|
}).catch(err => {
|
|
console.log("error", err);
|
|
throw err;
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Privy API request failed: ${response.status}`);
|
|
}
|
|
|
|
return await response.json() as T;
|
|
} catch (error) {
|
|
console.error('Error making Privy API request:', error);
|
|
throw new Error('Failed to make Privy API request');
|
|
}
|
|
};
|
|
|
|
// Schema for sign-message request
|
|
const signMessageSchema = z.object({
|
|
walletId: z.string().nonempty(),
|
|
message: z.string().nonempty(),
|
|
address: z.string().nonempty()
|
|
});
|
|
|
|
// Schema for token-approval request
|
|
const tokenApprovalSchema = z.object({
|
|
walletId: z.string().nonempty(),
|
|
address: z.string().nonempty(),
|
|
ticker: z.string().nonempty(),
|
|
amount: z.bigint().positive().optional(),
|
|
chainId: z.number().positive().optional()
|
|
});
|
|
|
|
// Schema for token-sending request
|
|
const tokenSendSchema = z.object({
|
|
senderAddress: z.string().nonempty(),
|
|
recipientAddress: z.string().nonempty(),
|
|
ticker: z.string().nonempty(),
|
|
amount: z.bigint().positive(),
|
|
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
|
|
* @returns The CAIP-2 identifier for the chain
|
|
*/
|
|
export const getChainName = (chainId: number): string => {
|
|
switch (chainId) {
|
|
case 1:
|
|
return 'eip155:1'; // Ethereum Mainnet
|
|
case 42161:
|
|
return 'eip155:42161'; // Arbitrum One
|
|
case 421613:
|
|
return 'eip155:421613'; // Arbitrum Goerli
|
|
case 8453:
|
|
return 'eip155:8453'; // Base
|
|
case 84531:
|
|
return 'eip155:84531'; // Base Goerli
|
|
default:
|
|
return `eip155:${chainId}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Signs a message using a Privy embedded wallet.
|
|
* @param walletId - The ID of the wallet to use for signing.
|
|
* @param message - The message to sign.
|
|
* @param address - The wallet address to use for signing.
|
|
* @returns The signature for the message.
|
|
*/
|
|
export const signMessage = async (walletId: string, message: string, address: string): Promise<string> => {
|
|
try {
|
|
const privy = getPrivyClient();
|
|
|
|
const {signature} = await privy.walletApi.ethereum.signMessage({
|
|
address: address,
|
|
chainType: 'ethereum',
|
|
message: message,
|
|
});
|
|
|
|
return signature;
|
|
} catch (error) {
|
|
console.error('Error signing message:', error);
|
|
throw new Error('Failed to sign message with embedded wallet');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Approves a token for spending using Privy wallet (implementation)
|
|
* @param walletId The wallet ID to use
|
|
* @param walletAddress The wallet address
|
|
* @param ticker The token ticker or enum value
|
|
* @param amount The amount to approve (optional, defaults to max amount)
|
|
* @param chainId The chain ID
|
|
* @param spenderAddress The address that will be allowed to spend the tokens
|
|
* @returns The transaction hash
|
|
*/
|
|
export const approveTokenImpl = async (
|
|
walletAddress: string,
|
|
ticker: string,
|
|
chainId?: number,
|
|
amount?: bigint,
|
|
): Promise<string> => {
|
|
try {
|
|
|
|
// Get token data from ticker
|
|
const tokenData = GetToken(ticker);
|
|
|
|
// Create contract interface for ERC20 token
|
|
const contractInterface = new ethers.Interface(Token.abi);
|
|
|
|
// Max uint256 value for unlimited approval
|
|
const approveAmount = amount ?
|
|
ethers.parseUnits(amount.toString(), tokenData.decimals) :
|
|
ethers.MaxUint256;
|
|
|
|
// Encode the approve function call
|
|
const data = contractInterface.encodeFunctionData("approve", [walletAddress, approveAmount]);
|
|
|
|
chainId = chainId ?? ARBITRUM;
|
|
|
|
// Get chain name in CAIP-2 format
|
|
const networkName = getChainName(chainId);
|
|
const privy = getPrivyClient();
|
|
|
|
// Send the transaction
|
|
const { hash } = await privy.walletApi.ethereum.sendTransaction({
|
|
address: walletAddress as Address,
|
|
chainType: 'ethereum',
|
|
caip2: networkName as string,
|
|
transaction: {
|
|
to: tokenData.address as Address,
|
|
data: data,
|
|
chainId: chainId,
|
|
},
|
|
} as any);
|
|
|
|
return hash;
|
|
} catch (error) {
|
|
console.error('Error approving token:', error);
|
|
throw new Error(`Failed to approve token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
function GetToken(symbol: string) {
|
|
return TOKENS[ARBITRUM].find(token => token.symbol === symbol)
|
|
}
|
|
|
|
/**
|
|
* Approves a token for spending using Privy wallet
|
|
* @param this The FastifyRequest instance
|
|
* @param reply The FastifyReply instance
|
|
* @param address The wallet address
|
|
* @param ticker The token ticker or enum value
|
|
* @param chainId The chain ID
|
|
* @param amount The amount to approve (optional, defaults to max amount)
|
|
* @returns The response object with success status and transaction hash
|
|
*/
|
|
export async function approveToken(
|
|
this: FastifyRequest,
|
|
reply: FastifyReply,
|
|
address: string,
|
|
ticker: string,
|
|
chainId: number,
|
|
amount?: bigint
|
|
) {
|
|
try {
|
|
// Validate the request parameters
|
|
tokenApprovalSchema.parse({
|
|
address,
|
|
ticker,
|
|
amount,
|
|
chainId
|
|
});
|
|
|
|
if (!address) {
|
|
throw new Error('Wallet address is required for token approval');
|
|
}
|
|
|
|
if (!chainId) {
|
|
throw new Error('Chain ID is required for token approval');
|
|
}
|
|
|
|
// Call the approveTokenImpl function
|
|
const hash = await approveTokenImpl(address, ticker, chainId, amount);
|
|
|
|
return {
|
|
success: true,
|
|
hash: hash
|
|
};
|
|
} 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'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signs a message using Privy embedded wallet
|
|
* @param this The FastifyRequest instance
|
|
* @param reply The FastifyReply instance
|
|
* @param walletId The wallet ID
|
|
* @param message The message to sign
|
|
* @param address The wallet address
|
|
* @returns The signature of the signed message
|
|
*/
|
|
export async function signPrivyMessage(
|
|
this: FastifyRequest,
|
|
reply: FastifyReply,
|
|
walletId: string,
|
|
message: string,
|
|
address: string
|
|
) {
|
|
try {
|
|
// Validate the request parameters
|
|
signMessageSchema.parse({ walletId, message, address });
|
|
|
|
// Call the signMessage function
|
|
const signature = await signMessage(walletId, message, address);
|
|
|
|
return {
|
|
success: true,
|
|
signature: signature
|
|
};
|
|
} 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'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Approves a specific contract address for spending using Privy wallet
|
|
* @param walletAddress The wallet address
|
|
* @param tokenAddress The token address to approve
|
|
* @param spenderAddress The contract address to approve
|
|
* @param chainId The chain ID
|
|
* @param amount The amount to approve (optional, defaults to max amount)
|
|
* @returns The transaction hash
|
|
*/
|
|
export const approveContractImpl = async (
|
|
walletAddress: string,
|
|
tokenAddress: string,
|
|
spenderAddress: string,
|
|
chainId?: number,
|
|
amount?: bigint,
|
|
): Promise<string> => {
|
|
try {
|
|
// Create contract interface for ERC20 token
|
|
const contractInterface = new ethers.Interface(Token.abi);
|
|
|
|
// Max uint256 value for unlimited approval
|
|
const approveAmount = amount ?? ethers.MaxUint256;
|
|
|
|
// Encode the approve function call
|
|
const data = contractInterface.encodeFunctionData("approve", [spenderAddress, approveAmount]);
|
|
|
|
chainId = chainId ?? ARBITRUM;
|
|
|
|
// Get chain name in CAIP-2 format
|
|
const networkName = getChainName(chainId);
|
|
const privy = getPrivyClient();
|
|
|
|
// Send the transaction
|
|
const { hash } = await privy.walletApi.ethereum.sendTransaction({
|
|
address: walletAddress as Address,
|
|
chainType: 'ethereum',
|
|
caip2: networkName as string,
|
|
transaction: {
|
|
to: tokenAddress as Address,
|
|
data: data,
|
|
chainId: chainId,
|
|
},
|
|
} as any);
|
|
|
|
return hash;
|
|
} catch (error) {
|
|
console.error('Error approving contract:', error);
|
|
throw new Error(`Failed to approve contract: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Gets the current allowance for a token for the GMX OrderVault contract
|
|
* @param ownerAddress The address of the token owner
|
|
* @param tokenAddress The address of the token contract
|
|
* @returns The current allowance as a BigNumber
|
|
*/
|
|
export const getTokenAllowance = async (
|
|
ownerAddress: string,
|
|
tokenAddress: string,
|
|
spenderAddress: string
|
|
): Promise<bigint> => {
|
|
try {
|
|
// use gmx sdk to get allowance
|
|
const sdk = await getClientForAddress(ownerAddress);
|
|
const allowance = await sdk.executeMulticall({
|
|
token: {
|
|
contractAddress: tokenAddress,
|
|
abiId: "ERC20",
|
|
calls: {
|
|
allowance: {
|
|
methodName: "allowance",
|
|
params: [ownerAddress, spenderAddress]
|
|
}
|
|
}
|
|
}
|
|
}).
|
|
then(res => {
|
|
return res.data.token.allowance.returnValues[0];
|
|
});
|
|
|
|
return allowance;
|
|
} catch (error) {
|
|
console.error('Error getting token allowance:', error);
|
|
throw new Error(`Failed to get token allowance: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Initializes a wallet address by approving USDC for GMX trading and GMX OrderVault
|
|
* @param address The wallet address to initialize
|
|
* @returns The transaction hashes for both approvals
|
|
*/
|
|
export const initAddressImpl = async (
|
|
address: string,
|
|
): Promise<{ usdcHash: string, orderVaultHash: string, exchangeRouterHash: string }> => {
|
|
try {
|
|
const sdk = await getClientForAddress(address);
|
|
|
|
const {tokensData} = await sdk.tokens.getTokensData();
|
|
const usdcTokenData = getTokenDataFromTicker(Ticker.USDC, tokensData);
|
|
const wrapperEtherData = getTokenDataFromTicker("WETH", tokensData);
|
|
let approveAmount = usdcTokenData.prices.maxPrice; // Large enough amount for trading
|
|
|
|
// Check approval for USDC
|
|
const usdcToken = GetToken('USDC');
|
|
const usdcAllowance = await getTokenAllowance(address, usdcToken.address, address);
|
|
|
|
let usdcHash = "";
|
|
if (usdcAllowance < approveAmount) {
|
|
// First approve USDC token for GMX trading
|
|
const usdcToken = GetToken('USDC');
|
|
usdcHash = await approveTokenImpl(
|
|
address,
|
|
usdcToken.symbol,
|
|
ARBITRUM,
|
|
usdcTokenData.prices.maxPrice
|
|
);
|
|
}else{
|
|
usdcHash = "Already allowed :" + usdcAllowance;
|
|
}
|
|
|
|
const orderVaultAllowance = await getTokenAllowance(address, usdcToken.address, CONTRACTS[ARBITRUM].OrderVault);
|
|
|
|
// Then approve GMX OrderVault with the correct amount
|
|
let orderVaultHash = "";
|
|
if (orderVaultAllowance < approveAmount) {
|
|
orderVaultHash = await approveContractImpl(
|
|
address,
|
|
usdcToken.address,
|
|
CONTRACTS[ARBITRUM].OrderVault,
|
|
ARBITRUM,
|
|
approveAmount
|
|
);
|
|
}else{
|
|
orderVaultHash = "Already allowed :" + orderVaultAllowance;
|
|
}
|
|
|
|
const wrapperEtherAllowance = await getTokenAllowance(address, wrapperEtherData.address, CONTRACTS[ARBITRUM].OrderVault);
|
|
|
|
let wrapperEtherHash = "";
|
|
if (wrapperEtherAllowance < approveAmount) {
|
|
wrapperEtherHash = await approveContractImpl(
|
|
address,
|
|
wrapperEtherData.address,
|
|
CONTRACTS[ARBITRUM].OrderVault,
|
|
ARBITRUM,
|
|
approveAmount
|
|
);
|
|
}else{
|
|
wrapperEtherHash = "Already allowed :" + wrapperEtherAllowance;
|
|
}
|
|
|
|
console.log('wrapperEtherAllowance', wrapperEtherAllowance)
|
|
|
|
const exchangeRouterAllowance = await getTokenAllowance(address, usdcToken.address, CONTRACTS[ARBITRUM].ExchangeRouter);
|
|
|
|
console.log('exchangeRouterAllowance', exchangeRouterAllowance)
|
|
|
|
let exchangeRouterHash = "";
|
|
if (exchangeRouterAllowance < approveAmount) {
|
|
exchangeRouterHash = await approveContractImpl(
|
|
address,
|
|
usdcToken.address,
|
|
CONTRACTS[ARBITRUM].ExchangeRouter,
|
|
ARBITRUM,
|
|
approveAmount
|
|
);
|
|
}else{
|
|
exchangeRouterHash = "Already allowed :" + exchangeRouterAllowance;
|
|
}
|
|
|
|
const wrapperEtherExchangeAllowance = await getTokenAllowance(address, wrapperEtherData.address, CONTRACTS[ARBITRUM].ExchangeRouter);
|
|
|
|
let wrapperEtherExchangeHash = "";
|
|
if (wrapperEtherExchangeAllowance < approveAmount) {
|
|
wrapperEtherExchangeHash = await approveContractImpl(
|
|
address,
|
|
wrapperEtherData.address,
|
|
CONTRACTS[ARBITRUM].ExchangeRouter,
|
|
ARBITRUM,
|
|
approveAmount
|
|
);
|
|
}else{
|
|
wrapperEtherExchangeHash = "Already allowed :" + wrapperEtherExchangeAllowance;
|
|
}
|
|
|
|
console.log('wrapperEtherExchangeAllowance', wrapperEtherExchangeAllowance)
|
|
|
|
const usdcSyntheticRouterAllowance = await getTokenAllowance(address, usdcToken.address, CONTRACTS[ARBITRUM].SyntheticsRouter);
|
|
|
|
let usdcSyntheticRouterHash = "";
|
|
if (usdcSyntheticRouterAllowance < approveAmount) {
|
|
usdcSyntheticRouterHash = await approveContractImpl(
|
|
address,
|
|
usdcToken.address,
|
|
CONTRACTS[ARBITRUM].SyntheticsRouter,
|
|
ARBITRUM,
|
|
approveAmount
|
|
);
|
|
}else{
|
|
usdcSyntheticRouterHash = "Already allowed :" + usdcSyntheticRouterAllowance;
|
|
}
|
|
|
|
console.log('usdcSyntheticRouterAllowance', usdcSyntheticRouterAllowance)
|
|
|
|
console.log('usdcHash', usdcHash)
|
|
console.log('orderVaultHash', orderVaultHash)
|
|
console.log('exchangeRouterHash', exchangeRouterHash)
|
|
|
|
return {
|
|
usdcHash,
|
|
orderVaultHash,
|
|
exchangeRouterHash
|
|
};
|
|
} catch (error) {
|
|
console.error('Error initializing address:', error);
|
|
throw new Error(`Failed to initialize address: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Initializes a wallet address for GMX trading
|
|
* @param this The FastifyRequest instance
|
|
* @param reply The FastifyReply instance
|
|
* @param address The wallet address to initialize
|
|
* @returns The response object with success status and transaction hashes
|
|
*/
|
|
export async function initAddress(
|
|
this: FastifyRequest,
|
|
reply: FastifyReply,
|
|
address: string
|
|
) {
|
|
try {
|
|
if (!address) {
|
|
throw new Error('Wallet address is required for initialization');
|
|
}
|
|
|
|
const { usdcHash, orderVaultHash } = await initAddressImpl(address);
|
|
|
|
return {
|
|
success: true,
|
|
usdcHash,
|
|
orderVaultHash
|
|
};
|
|
} catch (error) {
|
|
this.log.error(error);
|
|
|
|
reply.status(500);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'An unknown error occurred'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends tokens from one address to another using Privy wallet (implementation)
|
|
* @param senderAddress The sender's wallet address
|
|
* @param recipientAddress The recipient's wallet address
|
|
* @param ticker The token ticker or enum value
|
|
* @param amount The amount to send
|
|
* @param chainId The chain ID (optional, defaults to ARBITRUM)
|
|
* @returns The transaction hash
|
|
*/
|
|
export const sendTokenImpl = async (
|
|
senderAddress: string,
|
|
recipientAddress: string,
|
|
ticker: string,
|
|
amount: bigint,
|
|
chainId?: number,
|
|
): Promise<string> => {
|
|
try {
|
|
chainId = chainId ?? ARBITRUM;
|
|
const networkName = getChainName(chainId);
|
|
const privy = getPrivyClient();
|
|
|
|
if (ticker === 'ETH') {
|
|
// Native ETH transfer: no allowance, no data, value is amount as hex string
|
|
const { hash } = await privy.walletApi.ethereum.sendTransaction({
|
|
address: senderAddress as Address,
|
|
chainType: 'ethereum',
|
|
caip2: networkName as string,
|
|
transaction: {
|
|
to: recipientAddress as Address,
|
|
value: '0x' + amount.toString(16), // value in wei as hex string
|
|
chainId: chainId,
|
|
},
|
|
} as any);
|
|
return hash;
|
|
}
|
|
|
|
// ERC20 logic
|
|
// Get token data from ticker
|
|
const tokenData = GetToken(ticker);
|
|
if (!tokenData) {
|
|
throw new Error(`Token not found: ${ticker}`);
|
|
}
|
|
// Check if sender has sufficient allowance for the token transfer
|
|
const senderAllowance = await getTokenAllowance(senderAddress, tokenData.address, senderAddress);
|
|
// If insufficient allowance, approve the token first
|
|
if (senderAllowance < amount) {
|
|
console.log(`Insufficient allowance (${senderAllowance}). Approving token for amount: ${amount}`);
|
|
await approveContractImpl(
|
|
senderAddress,
|
|
tokenData.address,
|
|
senderAddress, // Approve self to spend tokens
|
|
chainId,
|
|
amount
|
|
);
|
|
console.log('Token approval completed');
|
|
}
|
|
// Create contract interface for ERC20 token
|
|
const contractInterface = new ethers.Interface(Token.abi);
|
|
// Amount is already in the smallest units (wei), so we don't need to convert it
|
|
const transferAmount = amount;
|
|
// Encode the transfer function call
|
|
const data = contractInterface.encodeFunctionData("transfer", [recipientAddress, transferAmount]);
|
|
// Send the transaction
|
|
const { hash } = await privy.walletApi.ethereum.sendTransaction({
|
|
address: senderAddress as Address,
|
|
chainType: 'ethereum',
|
|
caip2: networkName as string,
|
|
transaction: {
|
|
to: tokenData.address as Address,
|
|
data: data,
|
|
chainId: chainId,
|
|
},
|
|
} as any);
|
|
return hash;
|
|
} catch (error) {
|
|
console.error('Error sending token:', error);
|
|
throw new Error(`Failed to send token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param reply The FastifyReply instance
|
|
* @param senderAddress The sender's wallet address
|
|
* @param recipientAddress The recipient's wallet address
|
|
* @param ticker The token ticker or enum value
|
|
* @param amount The amount to send
|
|
* @param chainId The chain ID (optional, defaults to ARBITRUM)
|
|
* @returns The response object with success status and transaction hash
|
|
*/
|
|
export async function sendToken(
|
|
this: FastifyRequest,
|
|
reply: FastifyReply,
|
|
senderAddress: string,
|
|
recipientAddress: string,
|
|
ticker: string,
|
|
amount: bigint,
|
|
chainId?: number
|
|
) {
|
|
try {
|
|
// Validate the request parameters
|
|
tokenSendSchema.parse({
|
|
senderAddress,
|
|
recipientAddress,
|
|
ticker,
|
|
amount,
|
|
chainId
|
|
});
|
|
|
|
if (!senderAddress) {
|
|
throw new Error('Sender address is required for token transfer');
|
|
}
|
|
|
|
if (!recipientAddress) {
|
|
throw new Error('Recipient address is required for token transfer');
|
|
}
|
|
|
|
if (!ticker) {
|
|
throw new Error('Token ticker is required for token transfer');
|
|
}
|
|
|
|
if (!amount || amount <= 0n) {
|
|
throw new Error('Valid amount is required for token transfer');
|
|
}
|
|
|
|
// Call the sendTokenImpl function
|
|
const hash = await sendTokenImpl(senderAddress, recipientAddress, ticker, amount, chainId);
|
|
|
|
return {
|
|
success: true,
|
|
hash: hash
|
|
};
|
|
} 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'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The use of fastify-plugin is required to be able
|
|
* to export the decorators to the outer scope
|
|
*
|
|
* @see {@link https://github.com/fastify/fastify-plugin}
|
|
*/
|
|
export default fp(async (fastify) => {
|
|
// Decorate request with methods that use the Fastify instance
|
|
fastify.decorateRequest('signPrivyMessage', async function(this: FastifyRequest, reply: FastifyReply, walletId: string, message: string, address: string) {
|
|
return signPrivyMessage.call(this, reply, walletId, message, address);
|
|
});
|
|
|
|
fastify.decorateRequest('approveToken', async function(this: FastifyRequest, reply: FastifyReply, address: string, ticker: string, chainId: number, amount?: bigint) {
|
|
return approveToken.call(this, reply, address, ticker, chainId, amount);
|
|
});
|
|
|
|
fastify.decorateRequest('initAddress', async function(this: FastifyRequest, reply: FastifyReply, address: string) {
|
|
return initAddress.call(this, reply, address);
|
|
});
|
|
|
|
fastify.decorateRequest('sendToken', async function(this: FastifyRequest, reply: FastifyReply, senderAddress: string, recipientAddress: string, ticker: string, amount: bigint, chainId?: number) {
|
|
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);
|
|
fastify.log.info('Privy client initialized successfully');
|
|
} catch (error) {
|
|
fastify.log.error('Failed to initialize Privy client:', error);
|
|
throw error;
|
|
}
|
|
}, {
|
|
name: 'privy-plugin'
|
|
});
|
|
|