diff --git a/src/evm/index.ts b/src/evm/index.ts index fdea4aa..9fa71a3 100644 --- a/src/evm/index.ts +++ b/src/evm/index.ts @@ -1,112 +1,211 @@ -import axios from 'axios'; -import { createPublicClient, createWalletClient, http } from 'viem'; +import { createWalletClient, createPublicClient, http, type TypedDataDefinition, getAddress, verifyTypedData, Hex } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { mainnet } from 'viem/chains'; +import { base } from 'viem/chains'; import * as dotenv from 'dotenv'; dotenv.config(); -// Replace with your actual private key - NEVER hardcode in production code -const PRIVATE_KEY = process.env.EVM_PRIVATE_KEY as `0x${string}` +// The code demonstrates how to use the LayerZero API to fetch quotes and execute transactions. +// Setup: initialize wallet and client +const API = 'https://transfer.layerzero-api.com/v1'; +const API_KEY = process.env.STARGATE_API_KEY!; +const PRIVATE_KEY = process.env.EVM_PRIVATE_KEY as Hex; const account = privateKeyToAccount(PRIVATE_KEY); +const wallet = createWalletClient({ account, chain: base, transport: http() }); +const client = createPublicClient({ chain: base, transport: http() }); -// Initialize clients -const ethereumClient = createPublicClient({ - chain: mainnet, - transport: http() -}); - -const walletClient = createWalletClient({ - account, - chain: mainnet, - transport: http() -}); - -async function fetchStargateRoutes() { - try { - // Fetching route for USDC transfer from Ethereum to Polygon - https://docs.stargate.finance - const response = await axios.get('https://stargate.finance/api/v1/quotes', { - params: { - srcToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC on Ethereum - dstToken: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC on Polygon - srcAddress: '0x0C0d18aa99B02946C70EAC6d47b8009b993c9BfF', - dstAddress: '0x0C0d18aa99B02946C70EAC6d47b8009b993c9BfF', - srcChainKey: 'ethereum', // All chainKeys - https://stargate.finance/api/v1/chains - dstChainKey: 'polygon', - srcAmount: '1000000', // 1 USDC (6 decimals) - dstAmountMin: '900000' // Amount to receive deducted by Stargate fees (max 0.15%) - } - }); - - console.log('Stargate quotes data:', response.data); - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - console.error('Axios error:', error.message); - if (error.response) { - console.error('Response data:', error.response.data); - } - } else { - console.error('Unexpected error:', error); - } - throw error; +type AmountType = 'EXACT_SRC_AMOUNT'; +type FeeTolerance = { type: 'PERCENT'; amount?: number }; + +type GetQuotesInput = { + srcTokenAddress: string; + dstTokenAddress: string; + srcChainKey: string; + dstChainKey: string; + amount: string | bigint; + srcWalletAddress: string; + dstWalletAddress: string; + options: { + amountType?: AmountType; + feeTolerance?: FeeTolerance; + dstNativeDropAmount?: number | bigint; + }; +}; + +type QuoteHead = { id: string }; +type GetQuotesResult = { quotes: QuoteHead[] }; + +type EvmEncodedTx = { + chainId: number; + to: Hex; + data?: Hex; + value?: string | bigint; + from?: Hex; + gasLimit?: string | bigint; +}; + +type TransactionStep = { + type: 'TRANSACTION'; + chainKey: string; + chainType: 'EVM'; + description: string; + signerAddress: Hex; + transaction: { encoded: EvmEncodedTx }; +}; + +type SignatureStep = { + type: 'SIGNATURE'; + description: string; + chainKey?: string; + signerAddress: Hex; + signature: { type: 'EIP712'; typedData: TypedDataDefinition }; +}; + +type UserStep = TransactionStep | SignatureStep; +type BuildUserStepsResult = { + userSteps: UserStep[]; +}; + +type Status = 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | 'UNKNOWN'; +type GetStatusResult = { status: Status; explorerUrl?: string }; + +// Helper functions for API requests +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: { + 'x-api-key': API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${API}${path}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +// Core API operations for Stargate cross-chain transfers. +// Here, we fetch quotes for sending native tokens (ETH) from Base to Optimism. +// The options specify to use the exact source amount and include a fee tolerance of 2%. +async function fetchQuotes(): Promise { + const payload: GetQuotesInput = { + srcTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + dstTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + srcChainKey: 'base', + dstChainKey: 'optimism', + amount: '100000000000000', + srcWalletAddress: account.address, + dstWalletAddress: account.address, + options: { + amountType: 'EXACT_SRC_AMOUNT', + feeTolerance: { type: 'PERCENT', amount: 2 }, + dstNativeDropAmount: 0, + }, + }; + return postJson('/quotes', payload); +} + +// Builds user-interactive steps required to complete the transaction. +// Useful for both signature requests (like EIP-712) and direct EVM transactions. +// Can be integrated into a UI to guide users through signing messages or submitting transactions. +async function buildUserSteps(quoteId: string) { + return postJson('/build-user-steps', { quoteId }); +} + +// Submits signatures for a given quote. +// Required for EIP-712 messages that need to be signed by the user. +// Can be integrated into a UI to submit signatures after users have signed messages. +async function submitSignature(quoteId: string, signatures: string[]) { + await postJson>('/submit-signature', { quoteId, signatures }); +} + +// Checks the status of a transaction. +// Useful for monitoring the progress of a transaction. +// Can be integrated into a UI to display the status of a transaction. +async function getStatus(quoteId: string, txHash?: Hex) { + const query = txHash ? `?txHash=${txHash}` : ''; + return getJson(`/status/${encodeURIComponent(quoteId)}${query}`); +} + +// Execution logic +async function pollStatus(quoteId: string, txHash?: Hex) { + const deadline = Date.now() + 5 * 60_000; + for (;;) { + const { status } = await getStatus(quoteId, txHash); + if (status === 'SUCCEEDED' || status === 'FAILED' || status === 'UNKNOWN') return status; + if (Date.now() > deadline) return 'UNKNOWN'; + await new Promise((r) => setTimeout(r, 4_000)); } } -async function executeStargateTransaction() { - try { - // 1. Fetch quotes data - const routesData = await fetchStargateRoutes(); - - // 2. Get the first route (or implement your own selection logic) - // Here you can select from all the supported routes including StargateV2:Taxi, StargateBus or CCTP - // Supported routes are different for each token - // Each route contains all transactions required to execute the transfer given in executable order - const selectedRoute = routesData.quotes[0]; - if (!selectedRoute) { - throw new Error('No quotes available'); - } - - console.log('Selected route:', selectedRoute); - - // Execute all transactions in the route steps - for (let i = 0; i < selectedRoute.steps.length; i++) { - const executableTransaction = selectedRoute.steps[i].transaction; - console.log(`Executing step ${i + 1}/${selectedRoute.steps.length}:`, executableTransaction); - - // Create transaction object, only include value if it exists and is not empty - const txParams: Record = { - account, - to: executableTransaction.to, - data: executableTransaction.data, - }; - - // Only add value if it exists and is not empty - if (executableTransaction.value && executableTransaction.value !== '0') { - txParams.value = BigInt(executableTransaction.value); - } - - // Execute the transaction - const txHash = await walletClient.sendTransaction(txParams); - console.log(`Step ${i + 1} transaction hash: ${txHash}`); - - // Wait for transaction to be mined - const receipt = await ethereumClient.waitForTransactionReceipt({ hash: txHash }); - console.log(`Step ${i + 1} transaction confirmed:`, receipt); +async function executeEvmTransaction(step: TransactionStep) { + const tx = step.transaction.encoded; + const hash = await wallet.sendTransaction({ + account, + to: tx.to, + data: tx.data, + value: BigInt(tx.value ?? 0n), + }); + await client.waitForTransactionReceipt({ hash }); + return hash; +} + +// Normalizes message fields for EIP-712 signing. +export function mapMessageTypes( + message: any, +) { + return { + offerer: message.offerer, + recipient: message.recipient, + inputToken: message.inputToken, + outputToken: message.outputToken, + inputAmount: BigInt(message.inputAmount), + outputAmount: BigInt(message.outputAmount), + startTime: BigInt(message.startTime), + endTime: BigInt(message.endTime), + srcEid: message.srcEid, + dstEid: message.dstEid + } +} + +async function signEip712(step: SignatureStep) { + const typed = step.signature.typedData; + const signature = await wallet.signTypedData({ + account, + domain: typed.domain, + types: typed.types, + primaryType: typed.primaryType, + message: mapMessageTypes(typed.message), + }); + return signature; +} + +async function run() { + const quotes = await fetchQuotes(); + // You can implement a logic to choose the best quote here + const quote = quotes.quotes?.[0]; + if (!quote) throw new Error('No quote'); + // NOTE: build user steps is not supported yet + // const {userSteps} = await buildUserSteps(quote.id); + let txHash: Hex | undefined; + for (const step of (quote as unknown as {userSteps: UserStep[]}).userSteps) { + if (step.type === 'SIGNATURE') { + const signature = await signEip712(step); + await submitSignature(quote.id, [signature]); + } else if (step.type === 'TRANSACTION') { + txHash = await executeEvmTransaction(step); } - - console.log('All steps executed successfully'); - return true; - } catch (error) { - console.error('Error executing Stargate transaction:', error); - throw error; } + + const status = await pollStatus(quote.id, txHash); + console.log('Final status:', status); } -// Execute the transaction -void executeStargateTransaction() - .then(() => { - console.log('Successfully executed Stargate transaction'); - }) - .catch((err) => { - console.error('Failed to execute Stargate transaction:', err); - }); \ No newline at end of file +void run(); \ No newline at end of file diff --git a/src/solana/index.ts b/src/solana/index.ts index f46ae01..92ec63d 100644 --- a/src/solana/index.ts +++ b/src/solana/index.ts @@ -1,116 +1,164 @@ -import axios from 'axios'; import * as web3 from '@solana/web3.js'; +import bs58 from 'bs58'; import * as dotenv from 'dotenv'; -import * as bs58 from 'bs58'; dotenv.config(); -// Initialize Solana connection and wallet -const PRIVATE_KEY = process.env.SOLANA_PRIVATE_KEY as string; - -// Convert private key from string to Uint8Array -let privateKeyBytes; -try { - // Try to interpret as hex string (without 0x prefix) - privateKeyBytes = Buffer.from(PRIVATE_KEY.replace(/^0x/, ''), 'hex'); - - // Check if length is correct (should be 64 bytes for Ed25519) - if (privateKeyBytes.length !== 64) { - // Alternatively, try as base58 (Solana CLI format) - privateKeyBytes = Buffer.from(bs58.default.decode(PRIVATE_KEY)); +// Setup: initialize wallet and client +const API = 'https://transfer.layerzero-api.com/v1'; +const API_KEY = process.env.STARGATE_API_KEY!; +const PRIVATE_KEY = process.env.SOLANA_PRIVATE_KEY!; +const connection = new web3.Connection(web3.clusterApiUrl('mainnet-beta'), 'confirmed'); + +type FeeTolerance = { type: 'PERCENT'; amount?: number }; +type GetQuotesInput = { + srcChainKey: string; + dstChainKey: string; + srcTokenAddress: string; + dstTokenAddress: string; + srcWalletAddress: string; + dstWalletAddress: string; + amount: string | bigint; + options: { + amountType?: 'EXACT_SRC_AMOUNT'; + feeTolerance?: FeeTolerance; + dstNativeDropAmount?: number | bigint; + }; +}; + +type Quote = { id: string }; +type GetQuotesResult = { quotes: Quote[] }; + +type SolanaTxEncoded = { encoding: 'base64'; data: string }; +type UserTransactionStep = { + type: 'TRANSACTION'; + transaction: { encoded: SolanaTxEncoded }; +}; +type BuildUserStepsResult = { body: { userSteps: UserTransactionStep[] } }; + +type Status = 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | 'UNKNOWN'; +type GetStatusResult = { status: Status; explorerUrl?: string }; + +function parseSolanaSecretKey(raw: string): Uint8Array { + const isHex = /^0x[0-9a-fA-F]+$/.test(raw) || /^[0-9a-fA-F]+$/.test(raw); + if (isHex) { + const hex = raw.replace(/^0x/, ''); + const buf = Buffer.from(hex, 'hex'); + if (buf.length !== 64) throw new Error(`Expected 64-byte hex key, got ${buf.length}`); + return new Uint8Array(buf); } -} catch (error) { - console.error('Error parsing private key:', error); - throw new Error('Invalid private key format. Must be hex or base58 encoded.'); + const decoded = bs58.decode(raw); + if (decoded.length !== 64) throw new Error(`Expected 64-byte base58 key, got ${decoded.length}`); + return decoded; +} + +const keypair = web3.Keypair.fromSecretKey(parseSolanaSecretKey(PRIVATE_KEY)); + +// Helper functions for API requests +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(`${API}${path}`, { + method: 'POST', + headers: { + 'x-api-key': API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${API}${path}`, { + method: 'GET', + headers: { 'x-api-key': API_KEY }, + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +// Core API operations for Stargate cross-chain transfers. +// Here, we fetch quotes for sending OFT tokens (CAW) from Solana to Arbitrum. +// The options specify to use the exact source amount and include a fee tolerance of 2%. +async function fetchQuotes(): Promise { + const payload: GetQuotesInput = { + srcChainKey: 'solana', + dstChainKey: 'arbitrum', + srcTokenAddress: 'CAW777xcHVTQZ4CRwVQGB8CV1BVKPm5bNVxFJHWFKiH8', + dstTokenAddress: '0x16f1967565aaD72DD77588a332CE445e7cEF752b', + srcWalletAddress: keypair.publicKey.toBase58(), + dstWalletAddress: '0x6d9798053f498451bec79c0397f7f95b079bdcd6', + amount: '1000000000000', + options: { + amountType: 'EXACT_SRC_AMOUNT', + feeTolerance: { type: 'PERCENT', amount: 2 }, + dstNativeDropAmount: 0, + }, + }; + return postJson('/quotes', payload); } -// Make sure the key has correct length -if (privateKeyBytes.length !== 64) { - throw new Error(`Bad secret key size: ${privateKeyBytes.length}. Expected 64 bytes.`); +// Builds user-interactive steps required to complete the transaction. +// Useful for both signature requests (like EIP-712) and direct Solana transactions. +// Can be integrated into a UI to guide users through signing messages or submitting transactions. +async function buildUserSteps(quoteId: string) { + return postJson('/build-user-steps', { quoteId }); } -const keypair = web3.Keypair.fromSecretKey(privateKeyBytes); - -// Initialize Solana connection -const connection = new web3.Connection( - web3.clusterApiUrl('mainnet-beta'), - 'confirmed' -); - -// For sending FROM Solana TO another chain (e.g., Ethereum) -async function sendFromSolanaToOtherChain() { - try { - // This would require fetching a quotes with Solana as the source chain - - // 1. First, we would fetch the appropriate route - const response = await axios.get('https://stargate.finance/api/v1/quotes', { - params: { - srcToken: 'DEkqHyPN7GMRJ5cArtQFAWefqbZb33Hyf6s5iCwjEonT', // Token on Solana - dstToken: '0x5d3a1Ff2b6BAb83b63cd9AD0787074081a52ef34', // Token on destination chain - srcAddress: '9hWTHmE8T2fTeuog1K2ZzBtg8pfKhh3fcYAJUo54Vz37', // Source address - dstAddress: '0x9F1473c484Ce6b227538765b1c996DDfEc853DAA', // Destination address - srcChainKey: 'solana', - dstChainKey: 'optimism', - srcAmount: '3308758007', // Amount to send - dstAmountMin: '3215670426000000000' // Minimum to receive after fees - } +// Checks the status of a transaction. +// Useful for monitoring the progress of a transaction. +// Can be integrated into a UI to display the status of a transaction. +async function getStatus(quoteId: string, txSig?: string) { + const query = txSig ? `?txHash=${encodeURIComponent(txSig)}` : ''; + return getJson(`/status/${encodeURIComponent(quoteId)}${query}`); +} + +// Execution logic +async function pollStatus(quoteId: string, txSig?: string) { + const deadline = Date.now() + 5 * 60_000; + for (;;) { + const { status } = await getStatus(quoteId, txSig); + if (status === 'SUCCEEDED' || status === 'FAILED' || status === 'UNKNOWN') return status; + if (Date.now() > deadline) return 'UNKNOWN'; + await new Promise((r) => setTimeout(r, 4_000)); + } +} + +async function executeSolanaSteps(steps: UserTransactionStep[]) { + let signature: string | undefined; + for (const step of steps) { + if (step.type !== 'TRANSACTION') continue; + + const tx = step.transaction.encoded; + if (tx.encoding !== 'base64') continue; + + const raw = Buffer.from(tx.data, 'base64'); + const msg = web3.VersionedMessage.deserialize(raw); + const vtx = new web3.VersionedTransaction(msg); + vtx.sign([keypair]); + + signature = await connection.sendTransaction(vtx); + + const latest = await connection.getLatestBlockhash(); + await connection.confirmTransaction({ + signature, + blockhash: latest.blockhash, + lastValidBlockHeight: latest.lastValidBlockHeight, }); - - const quotesData = response.data; - console.log('Quotes for sending from Solana:', quotesData); - - if (!quotesData.quotes || quotesData.quotes.length === 0) { - throw new Error('No quotes available for sending from Solana'); - } - - const quote = quotesData.quotes[0]; - - // 2. Execute each step in the quote - for (let i = 0; i < quote.steps.length; i++) { - const step = quote.steps[i]; - console.log(`Executing step ${i + 1}/${quote.steps.length}:`, step); - - // For Solana, the transaction would typically be provided as serialized data - // We would deserialize, sign, and send it - if (step.transaction && step.transaction.data) { - // The format depends on Stargate's API response for Solana transactions - // This is a simplified example - actual implementation would depend on the API response format - - // If data is provided as a serialized transaction - const transactionBuffer = Buffer.from(step.transaction.data, 'base64'); - - // Use VersionedMessage.deserialize instead of Transaction.from - const versionedMessage = web3.VersionedMessage.deserialize(transactionBuffer); - const transaction = new web3.VersionedTransaction(versionedMessage); - - // Sign and send the transaction - transaction.sign([keypair]); - const signature = await connection.sendTransaction(transaction); - - // Wait for confirmation - const latestBlockHash = await connection.getLatestBlockhash(); - await connection.confirmTransaction({ - signature, - blockhash: latestBlockHash.blockhash, - lastValidBlockHeight: latestBlockHash.lastValidBlockHeight - }); - - console.log(`Transaction signature: ${signature}`); - } - } - - return true; - } catch (error) { - console.error('Error sending from Solana:', error); - throw error; } + + return signature; } +async function run() { + const quotes = await fetchQuotes(); + const quote = quotes.quotes?.[0]; + if (!quote) throw new Error('No quote'); -void sendFromSolanaToOtherChain() - .then(() => { - console.log('Successfully sent tokens from Solana'); - }) - .catch((err) => { - console.error('Failed to send tokens from Solana:', err); - }); + const { body } = await buildUserSteps(quote.id); // Solana requires user steps to get tx + const txSig = await executeSolanaSteps(body.userSteps); + + const status = await pollStatus(quote.id, txSig); + console.log('Final status:', status); +} +void run(); \ No newline at end of file