diff --git a/client/src/components/transaction-history.ts b/client/src/components/transaction-history.ts index db3b794..c2ddd27 100644 --- a/client/src/components/transaction-history.ts +++ b/client/src/components/transaction-history.ts @@ -44,6 +44,8 @@ export class TransactionHistory { private isReturningFromDetails = false; // Store callbacks for different navigation paths private goToWalletInfoCallback: (() => void) | null = null; // Callback to return to wallet info screen + // Filter flags for malicious transactions + private excludeMalicious = false; /** * Creates a new TransactionHistory component @@ -136,11 +138,46 @@ export class TransactionHistory { container.className = 'max-w-4xl mx-auto'; container.tabIndex = -1; - // Create title + // Create title and filter controls in the same row + const titleBar = document.createElement('div'); + titleBar.className = 'flex justify-between items-center mb-4'; + + const titleDiv = document.createElement('div'); + const title = document.createElement('h2'); - title.className = 'text-xl font-bold mb-4 text-gray-300'; + title.className = 'text-xl font-bold text-gray-300'; title.textContent = 'Blockchain Transactions'; - container.appendChild(title); + titleDiv.appendChild(title); + + // Create filter controls + const filterControls = document.createElement('div'); + filterControls.className = 'flex items-center space-x-2'; + + const filterLabel = document.createElement('span'); + filterLabel.className = 'text-sm text-gray-400'; + filterLabel.textContent = 'Hide malicious:'; + filterControls.appendChild(filterLabel); + + const toggleSwitch = document.createElement('label'); + toggleSwitch.className = 'relative inline-flex items-center cursor-pointer'; + toggleSwitch.innerHTML = ` + +
+ `; + filterControls.appendChild(toggleSwitch); + + // Set up toggle event + const toggleCheckbox = toggleSwitch.querySelector('#malicious-toggle') as HTMLInputElement; + toggleCheckbox.checked = this.excludeMalicious; + toggleCheckbox.addEventListener('change', async () => { + this.excludeMalicious = toggleCheckbox.checked; + this.transactionPage = 0; // Reset to first page when filtering changes + await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); + }); + + titleBar.appendChild(titleDiv); + titleBar.appendChild(filterControls); + container.appendChild(titleBar); // Create description const description = document.createElement('p'); @@ -169,16 +206,21 @@ export class TransactionHistory { this.transactionPage * this.transactionsPerPage ); + // Filter transactions if needed + const filteredTransactions = this.excludeMalicious + ? transactions.filter((tx: BlockchainTransaction) => !tx.analysis?.isMalicious) + : transactions; + // Store transactions for keyboard navigation - this.transactions = transactions; + this.transactions = filteredTransactions; this.selectedRowIndex = 0; // Reset selection when loading transactions // Remove loading indicator container.removeChild(loadingIndicator); - if (transactions.length === 0) { + if (filteredTransactions.length === 0) { // If we're on a page > 0 and there are no transactions, go back to previous page - if (this.transactionPage > 0) { + if (this.transactionPage > 0 && !this.excludeMalicious) { this.transactionPage--; // Re-render with the previous page await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); @@ -187,7 +229,9 @@ export class TransactionHistory { const noTxMessage = document.createElement('div'); noTxMessage.className = 'text-center py-8 text-gray-500 bg-gray-800 rounded-lg border border-gray-700 shadow-lg p-6'; - noTxMessage.textContent = 'No blockchain transactions found for this Safe wallet address'; + noTxMessage.textContent = this.excludeMalicious + ? 'No normal transactions found for this Safe wallet address' + : 'No blockchain transactions found for this Safe wallet address'; container.appendChild(noTxMessage); // Add back button for empty state @@ -216,6 +260,7 @@ export class TransactionHistory { Date Hash State Changes + Security `; table.appendChild(thead); @@ -223,10 +268,18 @@ export class TransactionHistory { // Create table body const tbody = document.createElement('tbody'); - transactions.forEach((tx: BlockchainTransaction, index: number) => { + filteredTransactions.forEach((tx: BlockchainTransaction, index: number) => { const tr = document.createElement('tr'); - tr.className = index % 2 === 0 ? 'bg-gray-800' : 'bg-gray-800/50'; - tr.classList.add('hover:bg-gray-700/50', 'cursor-pointer', 'border-b', 'border-gray-700'); + + // Add malicious transaction highlight if detected + if (tx.analysis?.isMalicious) { + tr.classList.add('bg-red-900/30', 'hover:bg-red-800/50'); + } else { + tr.className = index % 2 === 0 ? 'bg-gray-800' : 'bg-gray-800/50'; + tr.classList.add('hover:bg-gray-700/50'); + } + + tr.classList.add('cursor-pointer', 'border-b', 'border-gray-700'); // Make each row focusable tr.tabIndex = 0; tr.dataset.index = index.toString(); @@ -281,6 +334,35 @@ export class TransactionHistory { stateChangesHTML = 'No state changes'; } + // Generate analysis content for security column + let securityHTML = ''; + if (tx.analysis) { + const securityClass = tx.analysis.isMalicious + ? 'text-red-400' + : tx.analysis.confidence > 0.3 + ? 'text-yellow-400' + : 'text-green-400'; + + const warningIcon = tx.analysis.isMalicious + ? '' + : ''; + + const confidencePercent = Math.round(tx.analysis.confidence * 100); + + securityHTML = ` +
+ ${warningIcon} + ${tx.analysis.isMalicious ? 'Malicious' : 'Safe'} + (${confidencePercent}% confidence) +
+
+ ${tx.analysis.reason.length > 60 ? tx.analysis.reason.substring(0, 60) + '...' : tx.analysis.reason} +
+ `; + } else { + securityHTML = 'Not analyzed'; + } + tr.innerHTML = ` ${formattedDate} @@ -292,6 +374,7 @@ export class TransactionHistory { ${stateChangesHTML} + ${securityHTML} `; // Set up the transaction row click event with the right page tracking @@ -329,12 +412,12 @@ export class TransactionHistory { const nextButton = document.createElement('button'); nextButton.className = 'px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors'; nextButton.textContent = 'Next'; - nextButton.disabled = transactions.length < this.transactionsPerPage; + nextButton.disabled = filteredTransactions.length < this.transactionsPerPage; if (nextButton.disabled) { nextButton.classList.add('opacity-50', 'cursor-not-allowed'); } nextButton.addEventListener('click', async () => { - if (transactions.length >= this.transactionsPerPage) { + if (filteredTransactions.length >= this.transactionsPerPage) { this.transactionPage++; // Use consistent callbacks await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); @@ -391,7 +474,7 @@ export class TransactionHistory { this.setupKeyboardNavigation(); // Highlight the first row if there are transactions - if (transactions.length > 0) { + if (filteredTransactions.length > 0) { this.highlightRow(this.selectedRowIndex); // Focus the first transaction row @@ -480,6 +563,23 @@ export class TransactionHistory { const detailsContainer = document.createElement('div'); detailsContainer.className = 'bg-gray-800 rounded-lg border border-gray-700 shadow-lg p-6 mb-6'; + // If transaction is malicious, add a warning banner + if (tx.analysis?.isMalicious) { + const warningBanner = document.createElement('div'); + warningBanner.className = 'bg-red-900/50 border border-red-700 text-red-300 p-4 rounded-lg mb-6 flex items-start'; + warningBanner.innerHTML = ` + + + +
+

Potentially Malicious Transaction Detected

+

${tx.analysis.reason}

+

Confidence: ${Math.round(tx.analysis.confidence * 100)}%

+
+ `; + detailsContainer.appendChild(warningBanner); + } + // Display transaction info const title = document.createElement('h3'); title.className = 'text-lg font-medium text-gray-300 mb-6'; @@ -567,6 +667,7 @@ export class TransactionHistory { } } + // After the existing info grid display, add security analysis section detailsContainer.appendChild(infoGrid); // Add transaction data section if available @@ -616,6 +717,43 @@ export class TransactionHistory { detailsContainer.appendChild(dataContainer); } + // Add security analysis section if available + if (tx.analysis) { + const securityTitle = document.createElement('h4'); + securityTitle.className = 'text-md font-medium text-gray-300 mt-8 mb-4'; + securityTitle.textContent = 'Security Analysis'; + detailsContainer.appendChild(securityTitle); + + const securityContainer = document.createElement('div'); + securityContainer.className = 'bg-gray-900/50 rounded-lg border border-gray-700 p-4'; + + const statusColor = tx.analysis.isMalicious + ? 'text-red-400' + : tx.analysis.confidence > 0.3 + ? 'text-yellow-400' + : 'text-green-400'; + + securityContainer.innerHTML = ` +
+ Status: + ${tx.analysis.isMalicious ? 'Malicious' : 'Safe'} +
+
+ Confidence: +
+
+
+ ${Math.round(tx.analysis.confidence * 100)}% +
+
+ Reason: +

${tx.analysis.reason}

+
+ `; + + detailsContainer.appendChild(securityContainer); + } + container.appendChild(detailsContainer); // Add transaction execution section for future transactions diff --git a/client/src/components/transaction-history.ts.bak b/client/src/components/transaction-history.ts.bak new file mode 100644 index 0000000..a67a247 --- /dev/null +++ b/client/src/components/transaction-history.ts.bak @@ -0,0 +1,677 @@ +import { BlockchainTransaction } from '../types'; +import { ethers } from 'ethers'; + +/** + * Helper function to truncate address for display + */ +const truncateAddress = (address: string): string => { + if (!address || address.length < 10) return address || ''; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +}; + +/** + * Helper function to format token value based on decimals + */ +const formatTokenValue = (value: string, decimals: number): string => { + try { + // Convert from wei/token base units to token amount + const formatted = ethers.formatUnits(value, decimals); + // Remove trailing zeros + return formatted.replace(/\.?0+$/, ''); + } catch (error) { + console.warn('Error formatting token value:', error); + return value; + } +}; + +/** + * TransactionHistory component for displaying a list of blockchain transactions + */ +export class TransactionHistory { + private buffer: HTMLDivElement; + private transactionPage = 0; + private transactionsPerPage = 10; + private selectedNetwork: { chainId: number; blockExplorer?: string }; + private safeAddress: string; + private transactionService: any; // Will be properly typed in constructor + private selectedRowIndex = 0; // Keep track of the currently selected row + private transactions: BlockchainTransaction[] = []; // Store transactions for keyboard navigation + private keyboardListener: ((e: KeyboardEvent) => void) | null = null; // Store keyboard event listener + private onTransactionSelectCallback: ((tx: BlockchainTransaction) => void) | null = null; // Callback for transaction selection + private onBackClickCallback: (() => void) | null = null; // Callback for back button + private isDetailsView = false; // Whether we're in the details view or list view + // Flag to prevent multiple renders when returning from details + private isReturningFromDetails = false; + // Store callbacks for different navigation paths + private goToWalletInfoCallback: (() => void) | null = null; // Callback to return to wallet info screen + // Filter flags for malicious transactions + private excludeMalicious = false; + + /** + * Creates a new TransactionHistory component + * @param buffer The HTML element to render the component in + * @param safeAddress The Safe wallet address + * @param selectedNetwork The selected network configuration + * @param transactionService The transaction service to use for fetching transactions + */ + constructor( + buffer: HTMLDivElement, + safeAddress: string, + selectedNetwork: { chainId: number; blockExplorer?: string }, + transactionService: any + ) { + this.buffer = buffer; + this.safeAddress = safeAddress; + this.selectedNetwork = selectedNetwork; + this.transactionService = transactionService; + } + + /** + * Gets the etherscan URL for a transaction hash + * @param chainId The chain ID + * @param hash The transaction hash + * @param isTx Whether the hash is a transaction (true) or address (false) + * @returns The etherscan URL + */ + private getEtherscanUrl(chainId: number, hash: string, isTx: boolean = true): string { + // Map of chain IDs to Etherscan URLs + const etherscanUrls: Record = { + 1: 'https://etherscan.io', + 5: 'https://goerli.etherscan.io', + 11155111: 'https://sepolia.etherscan.io', + 137: 'https://polygonscan.com', + 80001: 'https://mumbai.polygonscan.com', + 8453: 'https://basescan.org', + 100: 'https://gnosisscan.io', + 10: 'https://optimistic.etherscan.io', + }; + + const baseUrl = etherscanUrls[chainId] || this.selectedNetwork.blockExplorer; + if (!baseUrl) return '#'; + + return `${baseUrl}/${isTx ? 'tx' : 'address'}/${hash}`; + } + + /** + * Renders the transaction history screen + */ + public async render( + onBackClick: () => void, + onTransactionSelect: (tx: BlockchainTransaction) => void + ): Promise { + // This is the callback to go back to wallet info screen + // Store it separately from the transaction list navigation + this.goToWalletInfoCallback = onBackClick; + + // Store callbacks if they are valid (not null) + if (onTransactionSelect) { + this.onTransactionSelectCallback = onTransactionSelect; + } + + // If we're not returning from details, set up normal navigation + if (!this.isReturningFromDetails) { + this.onBackClickCallback = onBackClick; + } else { + // We're returning from details, just reset the flag + this.isReturningFromDetails = false; + } + + this.isDetailsView = false; + + // Remove any existing keyboard listener + this.removeKeyboardListener(); + + if (!this.safeAddress) { + const errorMsg = document.createElement('div'); + errorMsg.className = 'error-message p-4 text-red-500'; + errorMsg.textContent = 'No Safe wallet connected. Please connect a wallet first with :c
'; + this.buffer.innerHTML = ''; + this.buffer.appendChild(errorMsg); + return; + } + + // Clear the buffer + this.buffer.innerHTML = ''; + + // Create container for the transaction history + const container = document.createElement('div'); + container.className = 'max-w-4xl mx-auto'; + container.tabIndex = -1; + + // Create title and filter controls in the same row + const titleBar = document.createElement('div'); + titleBar.className = 'flex justify-between items-center mb-4'; + + const titleDiv = document.createElement('div'); + + const title = document.createElement('h2'); + title.className = 'text-xl font-bold text-gray-300'; + title.textContent = 'Blockchain Transactions'; + titleDiv.appendChild(title); + + // Create filter controls + const filterControls = document.createElement('div'); + filterControls.className = 'flex items-center space-x-2'; + + const filterLabel = document.createElement('span'); + filterLabel.className = 'text-sm text-gray-400'; + filterLabel.textContent = 'Hide malicious:'; + filterControls.appendChild(filterLabel); + + const toggleSwitch = document.createElement('label'); + toggleSwitch.className = 'relative inline-flex items-center cursor-pointer'; + toggleSwitch.innerHTML = ` + +
+ `; + filterControls.appendChild(toggleSwitch); + + // Set up toggle event + const toggleCheckbox = toggleSwitch.querySelector('#malicious-toggle') as HTMLInputElement; + toggleCheckbox.checked = this.excludeMalicious; + toggleCheckbox.addEventListener('change', async () => { + this.excludeMalicious = toggleCheckbox.checked; + this.transactionPage = 0; // Reset to first page when filtering changes + await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); + + // After rendering, make sure the checkbox state is correctly set + const updatedToggle = document.querySelector('#malicious-toggle') as HTMLInputElement; + if (updatedToggle) { + updatedToggle.checked = this.excludeMalicious; + } + }); + + titleBar.appendChild(titleDiv); + titleBar.appendChild(filterControls); + container.appendChild(titleBar); + + // Create description + const description = document.createElement('p'); + description.className = 'text-sm text-gray-400 mb-6'; + description.textContent = 'Showing all blockchain transactions related to this Safe wallet address.'; + container.appendChild(description); + + // Create loading indicator + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'my-8 text-center text-gray-500'; + loadingIndicator.textContent = 'Loading transactions...'; + container.appendChild(loadingIndicator); + + // Add container to buffer + this.buffer.appendChild(container); + + try { + // Get chain ID from selected network + const chainId = this.selectedNetwork.chainId; + + // Fetch transactions + const transactions = await this.transactionService.getSafeTransactions( + this.safeAddress, + chainId, + this.transactionsPerPage, + this.transactionPage * this.transactionsPerPage + ); + + // Filter transactions if needed + const filteredTransactions = this.excludeMalicious + ? transactions.filter((tx: BlockchainTransaction) => !tx.analysis?.isMalicious) + : transactions; + + // Store transactions for keyboard navigation + this.transactions = filteredTransactions; + this.selectedRowIndex = 0; // Reset selection when loading transactions + + // Remove loading indicator + container.removeChild(loadingIndicator); + + if (filteredTransactions.length === 0) { + // If we're on a page > 0 and there are no transactions, go back to previous page + if (this.transactionPage > 0 && !this.excludeMalicious) { + this.transactionPage--; + // Re-render with the previous page + await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); + return; + } + + const noTxMessage = document.createElement('div'); + noTxMessage.className = 'text-center py-8 text-gray-500 bg-gray-800 rounded-lg border border-gray-700 shadow-lg p-6'; + noTxMessage.textContent = this.excludeMalicious + ? 'No normal transactions found for this Safe wallet address' + : 'No blockchain transactions found for this Safe wallet address'; + container.appendChild(noTxMessage); + + // Add back button for empty state + const backButton = document.createElement('button'); + backButton.className = 'mt-6 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors'; + backButton.textContent = 'Back to Safe Info'; + backButton.addEventListener('click', () => { + if (this.goToWalletInfoCallback) { + this.goToWalletInfoCallback(); + } + }); + container.appendChild(backButton); + return; + } + + // Create transactions table + const table = document.createElement('table'); + table.className = 'min-w-full bg-gray-800 rounded-lg border border-gray-700 shadow-lg overflow-hidden'; + table.id = 'transaction-table'; + + // Create table header + const thead = document.createElement('thead'); + thead.className = 'bg-gray-900 border-b border-gray-700'; + thead.innerHTML = ` + + Date + Hash + State Changes + Security + + `; + table.appendChild(thead); + + // Create table body + const tbody = document.createElement('tbody'); + + filteredTransactions.forEach((tx: BlockchainTransaction, index: number) => { + const tr = document.createElement('tr'); + + // Add malicious transaction highlight if detected + if (tx.analysis?.isMalicious) { + tr.classList.add('bg-red-900/30', 'hover:bg-red-800/50'); + } else { + tr.className = index % 2 === 0 ? 'bg-gray-800' : 'bg-gray-800/50'; + tr.classList.add('hover:bg-gray-700/50'); + } + + tr.classList.add('cursor-pointer', 'border-b', 'border-gray-700'); + // Make each row focusable + tr.tabIndex = 0; + tr.dataset.index = index.toString(); + tr.dataset.txHash = tx.txHash || tx.safeTxHash; + + // Format date + const date = new Date(tx.timestamp * 1000); + const formattedDate = date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + + // Get etherscan URL + const txHash = tx.executedTxHash || tx.txHash || tx.safeTxHash; + const etherscanUrl = this.getEtherscanUrl(this.selectedNetwork.chainId, txHash); + + // Generate state changes content + let stateChangesHTML = ''; + + if (tx.stateChanges && tx.stateChanges.length > 0) { + // Filter state changes to only show those related to safe wallet + // AND filter out 0 value ETH transactions (for multisig executions) + const relevantChanges = tx.stateChanges.filter(change => + (change.from.toLowerCase() === this.safeAddress.toLowerCase() || + change.to.toLowerCase() === this.safeAddress.toLowerCase()) && + // Filter out native currency (ETH) transactions with 0 value + !(change.tokenAddress === '0x0000000000000000000000000000000000000000' && + (change.value === '0' || change.value === '0x0' || parseInt(change.value, 16) === 0)) + ); + + if (relevantChanges.length === 0) { + stateChangesHTML = 'No relevant state changes'; + } else { + stateChangesHTML = relevantChanges.map(change => { + const isOutgoing = change.from.toLowerCase() === this.safeAddress.toLowerCase(); + const directionClass = isOutgoing ? 'text-red-400' : 'text-green-400'; + const formattedValue = formatTokenValue(change.value, change.tokenDecimals); + + return ` +
+ ${isOutgoing ? + '' : + '' + } + ${formattedValue} ${change.tokenSymbol} + ${isOutgoing ? + `to ${truncateAddress(change.to)}` : + `from ${truncateAddress(change.from)}` + } +
+ `; + }).join(''); + } + } else { + stateChangesHTML = 'No state changes'; + } + + // Generate analysis content for security column + let securityHTML = ''; + if (tx.analysis) { + const securityClass = tx.analysis.isMalicious + ? 'text-red-400' + : tx.analysis.confidence > 0.3 + ? 'text-yellow-400' + : 'text-green-400'; + + const warningIcon = tx.analysis.isMalicious + ? '' + : ''; + + const confidencePercent = Math.round(tx.analysis.confidence * 100); + + securityHTML = ` +
+ ${warningIcon} + ${tx.analysis.isMalicious ? 'Malicious' : 'Safe'} + (${confidencePercent}% confidence) +
+
+ ${tx.analysis.reason.length > 60 ? tx.analysis.reason.substring(0, 60) + '...' : tx.analysis.reason} +
+ `; + } else { + securityHTML = 'Not analyzed'; + } + + tr.innerHTML = ` + ${formattedDate} + + + ${truncateAddress(txHash)} + + + + + + ${stateChangesHTML} + ${securityHTML} + `; + + // Set up the transaction row click event with the right page tracking + this.setupTransactionRowClick(tx, index, tr); + + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + container.appendChild(table); + + // Create pagination controls + const paginationContainer = document.createElement('div'); + paginationContainer.className = 'flex justify-between items-center mt-4 text-sm'; + + const prevButton = document.createElement('button'); + prevButton.className = 'px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors'; + prevButton.textContent = 'Previous'; + prevButton.disabled = this.transactionPage === 0; + if (prevButton.disabled) { + prevButton.classList.add('opacity-50', 'cursor-not-allowed'); + } + prevButton.addEventListener('click', async () => { + if (this.transactionPage > 0) { + this.transactionPage--; + // Use consistent callbacks + await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); + } + }); + + const pageInfo = document.createElement('span'); + pageInfo.className = 'text-gray-400'; + pageInfo.textContent = `Page ${this.transactionPage + 1}`; + + const nextButton = document.createElement('button'); + nextButton.className = 'px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors'; + nextButton.textContent = 'Next'; + + // Fix for pagination issue when hiding malicious transactions + // When filtering malicious transactions, we need to check if we received + // the maximum number of transactions we requested, not just the filtered count + const fetchedMaxTransactions = filteredTransactions.length === this.transactionsPerPage; + nextButton.disabled = !fetchedMaxTransactions; + + if (nextButton.disabled) { + nextButton.classList.add('opacity-50', 'cursor-not-allowed'); + } + nextButton.addEventListener('click', async () => { + if (fetchedMaxTransactions) { + this.transactionPage++; + // Store the current filter state when changing pages + const currentExcludeMalicious = this.excludeMalicious; + // Use consistent callbacks + await this.render(this.goToWalletInfoCallback!, this.onTransactionSelectCallback!); + // Make sure the filter state remains the same after rendering + this.excludeMalicious = currentExcludeMalicious; + } else { + // Show notification that there are no more transactions + const notification = document.createElement('div'); + notification.className = 'fixed top-4 right-4 bg-gray-800 text-gray-300 px-4 py-2 rounded shadow-lg border border-gray-700'; + notification.textContent = 'No more transactions available'; + document.body.appendChild(notification); + setTimeout(() => { + document.body.removeChild(notification); + }, 3000); + } + }); + + paginationContainer.appendChild(prevButton); + paginationContainer.appendChild(pageInfo); + paginationContainer.appendChild(nextButton); + container.appendChild(paginationContainer); + + // Add helpful instruction text + const helpText = document.createElement('p'); + helpText.className = 'text-center text-gray-500 text-xs mt-4'; + helpText.textContent = 'Click on a transaction to view details'; + container.appendChild(helpText); + + // Add back button + const backButton = document.createElement('button'); + backButton.className = 'mt-6 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors'; + backButton.textContent = 'Back to Safe Info'; + backButton.addEventListener('click', () => { + // Go directly back to wallet info using the dedicated callback + if (this.goToWalletInfoCallback) { + this.goToWalletInfoCallback(); + } + }); + container.appendChild(backButton); + + // Add keyboard navigation instructions + const keyboardHelp = document.createElement('div'); + keyboardHelp.className = 'text-center text-gray-500 text-xs mt-4'; + keyboardHelp.innerHTML = ` +
Keyboard navigation:
+
+
/ Navigate transactions
+
Enter View details
+
Esc Back to list
+
/ Change page
+
+ `; + container.appendChild(keyboardHelp); + + // Setup keyboard navigation + this.setupKeyboardNavigation(); + + // Highlight the first row if there are transactions + if (filteredTransactions.length > 0) { + this.highlightRow(this.selectedRowIndex); + + // Focus the first transaction row + setTimeout(() => { + const firstRow = document.querySelector('#transaction-table tbody tr[data-index="0"]') as HTMLElement; + if (firstRow) { + firstRow.focus(); + } + }, 100); + } + + } catch (error) { + console.error('Error loading transactions:', error); + if (loadingIndicator.parentNode) { + loadingIndicator.textContent = 'Error loading transactions. Please try again.'; + loadingIndicator.className = 'my-8 text-center text-red-500'; + } + } + } + + /** + * Shows the transaction details screen for a specific transaction + */ + public showTransactionDetails( + tx: BlockchainTransaction, + onExecuteTransaction?: (txHash: string) => void + ): void { + // Save the current page number before showing details + const pageBeforeDetails = this.transactionPage; + + // Create a simplified back handler that preserves the page + this.onBackClickCallback = () => { + // Set the flag to prevent page reset in render() method + this.isReturningFromDetails = true; + + // Reset page to what it was before viewing details + this.transactionPage = pageBeforeDetails; + + // If we're on an empty page, go back to the previous page + if (this.transactions.length === 0 && this.transactionPage > 0) { + this.transactionPage--; + } + + // Just directly render the list view instead of calling onBackClick + // This avoids the double-render issue + if (this.goToWalletInfoCallback && this.onTransactionSelectCallback) { + this.render(this.goToWalletInfoCallback, this.onTransactionSelectCallback); + } + }; + + this.isDetailsView = true; + + // Remove existing keyboard listener and setup a new one for details view + this.removeKeyboardListener(); + + // Setup keyboard listener for details view - with Escape key to go back + this.keyboardListener = (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.onBackClickCallback) { + e.preventDefault(); + this.onBackClickCallback(); + } + }; + document.addEventListener('keydown', this.keyboardListener, true); + + // Clear the buffer + this.buffer.innerHTML = ''; + + // Create container + const container = document.createElement('div'); + container.className = 'max-w-4xl mx-auto'; + container.tabIndex = -1; + + // Add back button + const backButton = document.createElement('button'); + backButton.className = 'mb-4 text-blue-400 hover:text-blue-300 flex items-center'; + backButton.innerHTML = ` + + + + Back to Transactions + `; + backButton.addEventListener('click', this.onBackClickCallback); + container.appendChild(backButton); + + // Create details container + const detailsContainer = document.createElement('div'); + detailsContainer.className = 'bg-gray-800 rounded-lg border border-gray-700 shadow-lg p-6 mb-6'; + + // If transaction is malicious, add a warning banner + if (tx.analysis?.isMalicious) { + const warningBanner = document.createElement('div'); + warningBanner.className = 'bg-red-900/50 border border-red-700 text-red-300 p-4 rounded-lg mb-6 flex items-start'; + warningBanner.innerHTML = ` + + + +
+

Potentially Malicious Transaction Detected

+

${tx.analysis.reason}

+

Confidence: ${Math.round(tx.analysis.confidence * 100)}%

+
+ `; + detailsContainer.appendChild(warningBanner); + } + + // Display transaction info + const title = document.createElement('h3'); + title.className = 'text-lg font-medium text-gray-300 mb-6'; + title.textContent = `Transaction Details (${tx.dataDecoded?.method || 'Unknown'})`; + detailsContainer.appendChild(title); + + // Create transaction info grid + const infoGrid = document.createElement('div'); + infoGrid.className = 'grid grid-cols-1 md:grid-cols-2 gap-4 mb-6'; + + const fromAddress = tx.from || (tx.stateChanges && tx.stateChanges.length > 0 ? tx.stateChanges[0].from : 'Unknown'); + + // Add transaction details + this.addDetailRow(infoGrid, 'Transaction Hash', tx.txHash, true); + if (tx.executedTxHash && tx.executedTxHash !== tx.txHash) { + this.addDetailRow(infoGrid, 'Executed Hash', tx.executedTxHash, true); + } + + const date = new Date(tx.timestamp * 1000); + this.addDetailRow(infoGrid, 'Date', date.toLocaleString()); + this.addDetailRow(infoGrid, 'From', fromAddress, true); + this.addDetailRow(infoGrid, 'To', tx.to, true); + + if (tx.tokenInfo) { + this.addDetailRow(infoGrid, 'Token', tx.tokenInfo.name); + this.addDetailRow(infoGrid, 'Token Symbol', tx.tokenInfo.symbol); + this.addDetailRow(infoGrid, 'Token Contract', tx.tokenInfo.address, true); + } + + const formattedValue = tx.tokenInfo ? + `${formatTokenValue(tx.value, tx.tokenInfo.decimals)} ${tx.tokenInfo.symbol}` : + `${ethers.formatEther(tx.value)} ${this.getNativeTokenSymbol()}`; + this.addDetailRow(infoGrid, 'Value', formattedValue); + + // Add state changes section + if (tx.stateChanges && tx.stateChanges.length > 0) { + // Add state changes title + const stateChangesTitle = document.createElement('h4'); + stateChangesTitle.className = 'text-md font-medium text-gray-300 mt-6 mb-3 col-span-2'; + stateChangesTitle.textContent = 'State Changes'; + infoGrid.appendChild(stateChangesTitle); + + // Show all state changes relevant to the safe wallet + // AND filter out 0 value ETH transactions (for multisig executions) + const relevantChanges = tx.stateChanges.filter(change => + (change.from.toLowerCase() === this.safeAddress.toLowerCase() || + change.to.toLowerCase() === this.safeAddress.toLowerCase()) && + // Filter out native currency (ETH) transactions with 0 value + !(change.tokenAddress === '0x0000000000000000000000000000000000000000' && + (change.value === '0' || change.value === '0x0' || parseInt(change.value, 16) === 0)) + ); + + if (relevantChanges.length === 0) { + const noChanges = document.createElement('div'); + noChanges.className = 'text-gray-500 col-span-2'; + noChanges.textContent = 'No relevant state changes for this wallet'; + infoGrid.appendChild(noChanges); + } else { + const stateChangesContainer = document.createElement('div'); + stateChangesContainer.className = 'col-span-2 space-y-2'; + + relevantChanges.forEach(change => { + const isOutgoing = change.from.toLowerCase() === this.safeAddress.toLowerCase(); + const directionClass = isOutgoing ? 'text-red-400' : 'text-green-400'; + const formattedValue = formatTokenValue(change.value, change.tokenDecimals); + + const changeRow = document.createElement('div'); + changeRow.className = `flex items-center justify-between p-2 rounded bg-gray-700/50 ${directionClass}`; + changeRow.innerHTML = ` +
+ ${isOutgoing ? + '' : + '' + } + ${isOutgoing ? 'Sent to ' : 'Received from '} + ${truncateAddress(isOutgoing ? change.to : change.from)} +
+
${formattedValue} ${change.tokenSymbol}
+ ` \ No newline at end of file diff --git a/client/src/types/transaction.ts b/client/src/types/transaction.ts index de2a3a7..15a65a0 100644 --- a/client/src/types/transaction.ts +++ b/client/src/types/transaction.ts @@ -18,6 +18,12 @@ export interface TransactionRequest { }; } +export interface TransactionAnalysis { + isMalicious: boolean; + confidence: number; + reason: string; +} + export interface BlockchainTransaction { id: string; timestamp: number; @@ -61,4 +67,5 @@ export interface BlockchainTransaction { value: string; isStateChange: boolean; }[]; + analysis?: TransactionAnalysis; } \ No newline at end of file diff --git a/server/.env.example b/server/.env.example index 0c04a6c..6dc969a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,6 +7,10 @@ WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id # Alchemy API ALCHEMY_API_KEY=your_alchemy_api_key +# AI Services for Transaction Analysis +OPENAI_API_KEY=your_openai_api_key +GEMINI_API_KEY=your_gemini_api_key + # RPC URLs MAINNET_RPC_URL=https://eth.llamarpc.com GOERLI_RPC_URL=https://ethereum-goerli.publicnode.com diff --git a/server/package-lock.json b/server/package-lock.json index 8508836..254e4b0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@google/generative-ai": "^0.24.0", + "@langchain/core": "^0.3.43", + "@langchain/openai": "^0.4.9", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.1", @@ -16,9 +19,12 @@ "@nestjs/platform-socket.io": "^11.0.11", "@nestjs/websockets": "^11.0.11", "ethers": "^6.13.5", + "langchain": "^0.3.19", "node-fetch": "^2.7.0", + "openai": "^4.89.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -676,6 +682,11 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -848,6 +859,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/generative-ai": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz", + "integrity": "sha512-fnEITCGEB7NdX0BhoYZ/cq/7WPZ1QS5IzJJfC3Tg/OwkvBetMiVJciyaan297OvE4B9Jg1xvo0zIazX/9sGu1Q==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1789,6 +1808,81 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/core": { + "version": "0.3.43", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.43.tgz", + "integrity": "sha512-DwiSUwmZqcuOn7j8SFdeOH1nvaUqG7q8qn3LhobdQYEg5PmjLgd2yLr2KzuT/YWMBfjkOR+Di5K6HEdFmouTxg==", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.2.8 <0.4.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@langchain/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/openai": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.4.9.tgz", + "integrity": "sha512-NAsaionRHNdqaMjVLPkFCyjUDze+OqRHghA1Cn4fPoAafz+FXcl9c7LlEl9Xo0FH6/8yiCl7Rw2t780C/SBVxQ==", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^4.87.3", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.39 <0.4.0" + } + }, + "node_modules/@langchain/textsplitters": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@langchain/textsplitters/-/textsplitters-0.1.0.tgz", + "integrity": "sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==", + "dependencies": { + "js-tiktoken": "^1.0.12" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.21 <0.4.0" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -3061,6 +3155,15 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -3073,6 +3176,11 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -3122,6 +3230,11 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3639,6 +3752,17 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3689,6 +3813,17 @@ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3793,7 +3928,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3872,8 +4006,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-timsort": { "version": "1.0.3", @@ -3896,8 +4029,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/b4a": { "version": "1.6.7", @@ -4041,7 +4173,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4396,7 +4527,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4597,7 +4727,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4608,14 +4737,12 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -4685,6 +4812,14 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/console-table-printer": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.12.1.tgz", + "integrity": "sha512-wKGOQRRvdnd89pCeH96e2Fn4wkbenSP6LMHfjfyNLMbGuHEFbMqQNuxXqd0oXG9caIOQ1FTvc5Uijp9/4jujnQ==", + "dependencies": { + "simple-wcswidth": "^1.0.1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4833,6 +4968,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4914,7 +5057,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -5159,6 +5301,26 @@ "node": ">= 0.6" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -5218,7 +5380,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -5510,6 +5671,39 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6096,7 +6290,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -6120,7 +6313,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6129,7 +6321,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6137,6 +6328,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/formidable": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", @@ -6418,7 +6621,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -6447,7 +6649,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -6527,6 +6728,14 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", @@ -7527,6 +7736,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-tiktoken": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.19.tgz", + "integrity": "sha512-XC63YQeEcS47Y53gg950xiZ4IWmkfMe4p2V9OSaBt26q+p47WHn18izuXzSclCI73B7yGqtfRsT6jcZQI0y08g==", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7537,7 +7754,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -7611,6 +7827,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7638,6 +7862,123 @@ "node": ">=6" } }, + "node_modules/langchain": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-0.3.19.tgz", + "integrity": "sha512-aGhoTvTBS5ulatA67RHbJ4bcV5zcYRYdm5IH+hpX99RYSFXG24XF3ghSjhYi6sxW+SUnEQ99fJhA5kroVpKNhw==", + "dependencies": { + "@langchain/openai": ">=0.1.0 <0.5.0", + "@langchain/textsplitters": ">=0.0.0 <0.2.0", + "js-tiktoken": "^1.0.12", + "js-yaml": "^4.1.0", + "jsonpointer": "^5.0.1", + "langsmith": ">=0.2.8 <0.4.0", + "openapi-types": "^12.1.3", + "p-retry": "4", + "uuid": "^10.0.0", + "yaml": "^2.2.1", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/anthropic": "*", + "@langchain/aws": "*", + "@langchain/cerebras": "*", + "@langchain/cohere": "*", + "@langchain/core": ">=0.2.21 <0.4.0", + "@langchain/deepseek": "*", + "@langchain/google-genai": "*", + "@langchain/google-vertexai": "*", + "@langchain/google-vertexai-web": "*", + "@langchain/groq": "*", + "@langchain/mistralai": "*", + "@langchain/ollama": "*", + "@langchain/xai": "*", + "axios": "*", + "cheerio": "*", + "handlebars": "^4.7.8", + "peggy": "^3.0.2", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@langchain/anthropic": { + "optional": true + }, + "@langchain/aws": { + "optional": true + }, + "@langchain/cerebras": { + "optional": true + }, + "@langchain/cohere": { + "optional": true + }, + "@langchain/deepseek": { + "optional": true + }, + "@langchain/google-genai": { + "optional": true + }, + "@langchain/google-vertexai": { + "optional": true + }, + "@langchain/google-vertexai-web": { + "optional": true + }, + "@langchain/groq": { + "optional": true + }, + "@langchain/mistralai": { + "optional": true + }, + "@langchain/ollama": { + "optional": true + }, + "@langchain/xai": { + "optional": true + }, + "axios": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "peggy": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/langsmith": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.14.tgz", + "integrity": "sha512-MzoxdRkFFV/6140vpP5V2e2fkTG6x/0zIjw77bsRwAXEMjPRTUyDazfXeSyrS5uJvbLgxAXc+MF1h6vPWe6SXQ==", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "openai": "*" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + } + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8023,6 +8364,14 @@ "node": ">= 0.6" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -8058,6 +8407,24 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -8192,6 +8559,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.89.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.89.0.tgz", + "integrity": "sha512-XNI0q2l8/Os6jmojxaID5EhyQjxZgzR2gWcpEjYWK5hGKwE7AcifxEY7UNwFDDHJQXqeiosQ0CJwQN+rvnwdjA==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.83", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.83.tgz", + "integrity": "sha512-D69JeR5SfFS5H6FLbUaS0vE4r1dGhmMBbG4Ed6BNS4wkDK8GZjsdCShT5LCN59vOHEUHnFCY9J4aclXlIphMkA==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8271,6 +8690,14 @@ "node": ">=12.20" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8301,6 +8728,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -8931,6 +9396,14 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9053,7 +9526,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9266,6 +9738,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-wcswidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.0.1.tgz", + "integrity": "sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9323,6 +9800,26 @@ } } }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -9763,7 +10260,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10463,6 +10959,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -10534,6 +11042,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10848,9 +11364,11 @@ "dev": true }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -10890,6 +11408,17 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -10962,6 +11491,22 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/server/package.json b/server/package.json index b6f070e..5d5a8cd 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,9 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@google/generative-ai": "^0.24.0", + "@langchain/core": "^0.3.43", + "@langchain/openai": "^0.4.9", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.1", @@ -27,9 +30,12 @@ "@nestjs/platform-socket.io": "^11.0.11", "@nestjs/websockets": "^11.0.11", "ethers": "^6.13.5", + "langchain": "^0.3.19", "node-fetch": "^2.7.0", + "openai": "^4.89.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/server/src/transaction/services/ai/ai-config.ts b/server/src/transaction/services/ai/ai-config.ts new file mode 100644 index 0000000..be5a41c --- /dev/null +++ b/server/src/transaction/services/ai/ai-config.ts @@ -0,0 +1,18 @@ +export const AI_CONFIG = { + provider: 'gemini', // Set this to 'openai' or 'gemini' to switch providers + openai: { + modelName: 'gpt-4-turbo-preview', + temperature: 0, + maxTokens: 500, + }, + gemini: { + modelName: 'gemini-2.0-flash', + temperature: 0, + maxOutputTokens: 500, + }, + // Add other AI model configurations here + analysis: { + confidenceThreshold: 0.7, // Minimum confidence level to mark as malicious + batchSize: 10, // Maximum number of transactions to analyze in parallel + }, +}; \ No newline at end of file diff --git a/server/src/transaction/services/ai/ai-model.interface.ts b/server/src/transaction/services/ai/ai-model.interface.ts new file mode 100644 index 0000000..99a5116 --- /dev/null +++ b/server/src/transaction/services/ai/ai-model.interface.ts @@ -0,0 +1,58 @@ +export interface TransactionAnalysisResult { + /** + * Whether the transaction is potentially harmful (malicious, suspicious, spam, or phishing) + */ + isMalicious: boolean; + + /** + * Confidence level (0-1) of the analysis + */ + confidence: number; + + /** + * Detailed explanation including the specific type of threat detected + */ + reason: string; + + /** + * Analysis of internal transactions, if any were found + */ + internalTransactions?: { + isMalicious: boolean; + confidence: number; + reason: string; + transaction: { + from: string; + to: string; + value: string; + }; + }[]; +} + +export interface AIModelService { + /** + * Analyzes a transaction to determine if it's potentially harmful + * Detects malicious, suspicious, spam, or phishing transactions + * Also analyzes internal transactions if provided + */ + analyzeMaliciousTransaction( + transactionData: { + from: string; + to: string; + value: string; + data?: string; + timestamp: number; + internalTransactions?: Array<{ + from: string; + to: string; + value: string; + data?: string; + }>; + }, + context?: { + previousTransactions?: any[]; + accountInfo?: any; + safeAddress?: string; // To identify which internal txs are related to the safe + } + ): Promise; +} \ No newline at end of file diff --git a/server/src/transaction/services/ai/ai.module.ts b/server/src/transaction/services/ai/ai.module.ts new file mode 100644 index 0000000..aeabea4 --- /dev/null +++ b/server/src/transaction/services/ai/ai.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { OpenAIModelService } from './openai-model.service'; +import { GeminiModelService } from './gemini-model.service'; +import { TransactionAnalysisService } from '../transaction-analysis.service'; +import { AI_CONFIG } from './ai-config'; + +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: 'AIModelService', + useFactory: (configService: ConfigService) => { + // This factory allows us to switch AI providers based on configuration + return AI_CONFIG.provider === 'gemini' + ? new GeminiModelService(configService) + : new OpenAIModelService(configService); + }, + inject: [ConfigService], + }, + TransactionAnalysisService, + ], + exports: [TransactionAnalysisService], +}) +export class AIModule {} \ No newline at end of file diff --git a/server/src/transaction/services/ai/gemini-model.service.ts b/server/src/transaction/services/ai/gemini-model.service.ts new file mode 100644 index 0000000..c9f41f5 --- /dev/null +++ b/server/src/transaction/services/ai/gemini-model.service.ts @@ -0,0 +1,335 @@ +import { Injectable } from '@nestjs/common'; +import { AIModelService, TransactionAnalysisResult } from './ai-model.interface'; +import { ConfigService } from '@nestjs/config'; +import { AI_CONFIG } from './ai-config'; +import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from '@google/generative-ai'; + +@Injectable() +export class GeminiModelService implements AIModelService { + private model; + private generativeAI; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('GEMINI_API_KEY'); + if (!apiKey) { + throw new Error('GEMINI_API_KEY is not configured'); + } + + this.generativeAI = new GoogleGenerativeAI(apiKey); + this.model = this.generativeAI.getGenerativeModel({ + model: AI_CONFIG.gemini.modelName, + generationConfig: { + temperature: AI_CONFIG.gemini.temperature, + maxOutputTokens: AI_CONFIG.gemini.maxOutputTokens, + }, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_NONE, + }, + ], + }); + } + + async analyzeMaliciousTransaction( + transactionData: { + from: string; + to: string; + value: string; + data?: string; + timestamp: number; + internalTransactions?: Array<{ + from: string; + to: string; + value: string; + data?: string; + }>; + }, + context?: { + previousTransactions?: any[]; + accountInfo?: any; + safeAddress?: string; + } + ): Promise { + try { + // KNOWN SUSPICIOUS ADDRESSES - Auto-flag these + const suspiciousAddresses = [ + '0x8576acc5c05d6ce88f4e49bf65bdf0c62f91353c', // Test address + // Add more known scam addresses here + ]; + + // Auto-flag transactions with suspicious addresses + if (transactionData.to && suspiciousAddresses.includes(transactionData.to.toLowerCase())) { + return { + isMalicious: true, + confidence: 0.99, + reason: "This address is associated with known scams or suspicious activity." + }; + } + + // SPAM TOKEN DETECTION - Refined for Safe wallet transactions + // Note: Safe wallets commonly have legitimate 0 ETH value transactions for contract calls + const isLikelySpamToken = transactionData.data?.includes('0xa9059cbb') && + transactionData.value === '0' && + (!context?.safeAddress || // Not from a known Safe wallet + transactionData.data.includes('spam') || // Contains explicit spam indicators + transactionData.data.includes('airdrop')); + + const hasSpamKeywords = transactionData.data && ( + transactionData.data.toLowerCase().includes('airdrop') || + transactionData.data.toLowerCase().includes('claim') || + (transactionData.data.toLowerCase().includes('mint') && + transactionData.value === '0' && + !transactionData.data.toLowerCase().includes('multisig')) // Exclude multisig operations + ); + + if (isLikelySpamToken || hasSpamKeywords) { + return { + isMalicious: true, + confidence: 0.9, + reason: "Detected potential spam token transaction. This appears to be an airdrop or unsolicited token transfer commonly associated with scams." + }; + } + + // Check if we have any internal transactions to analyze + const hasInternalTxs = transactionData.internalTransactions && + transactionData.internalTransactions.length > 0; + + // Prepare internal transaction summary for the main prompt + let internalTxSummary = ""; + if (hasInternalTxs) { + const relevantInternalTxs = transactionData.internalTransactions! + .filter(tx => + // Only analyze internal txs related to the safe wallet + context?.safeAddress && + (tx.from.toLowerCase() === context.safeAddress.toLowerCase() || + tx.to.toLowerCase() === context.safeAddress.toLowerCase()) + ); + + if (relevantInternalTxs.length > 0) { + internalTxSummary = "Internal Transactions:\n" + + relevantInternalTxs.map((tx, idx) => + `${idx+1}. From: ${tx.from} To: ${tx.to} Value: ${tx.value}` + ).join("\n"); + } + } + + // SIMPLIFIED PROMPT - More direct instructions + const prompt = ` +You are analyzing blockchain transactions to identify malicious activity. + +IMPORTANT: This is a Safe (formerly Gnosis Safe) smart contract wallet transaction. Safe wallets commonly have: +- 0 ETH value transactions for contract calls, which are typically legitimate +- Multiple internal transactions as part of normal multisig operations +- Contract interactions that are part of normal wallet operation + +Main transaction details: +- From: ${transactionData.from || 'Unknown'} +- To: ${transactionData.to || 'Unknown'} +- Value: ${transactionData.value || '0'} +- Data: ${transactionData.data?.substring(0, 200) || 'No data'} + +${internalTxSummary} + +Classify this as MALICIOUS only if it shows obvious suspicious patterns such as: +1. Unsolicited token transfers or airdrops that are clearly not requested by the user +2. Unusual or excessive approval requests (especially for all tokens) +3. Interactions with known scam contracts +4. Unusual transfers to previously unused addresses +5. Contract calls that appear to drain funds +6. Phishing attempts via token approvals +7. Spam tokens with no legitimate use case + +For Safe wallets, do NOT flag normal operations like: +- Regular contract interactions with 0 ETH value +- Multisig transaction executions +- Token transfers initiated by the wallet owner +- DeFi protocol interactions + +FORMAT INSTRUCTIONS: +- Return ONLY a raw JSON object without markdown formatting (no \`\`\` tags) +- Do not include any explanation text before or after the JSON object +- Use exactly this format: + +{ + "isMalicious": false, + "confidence": 0.5, + "reason": "your analysis reason here" +} + `; + + // Make the API request with retry logic + let text; + let retryCount = 0; + const maxRetries = 2; + + while (retryCount <= maxRetries) { + try { + const result = await this.model.generateContent(prompt); + const response = await result.response; + text = response.text().trim(); + + // Check for common patterns that indicate a valid response + const hasJsonBlock = text.includes('```json') && text.includes('```'); + const hasJsonObject = text.includes('{') && text.includes('}'); + + if (hasJsonObject || hasJsonBlock) { + break; // Got what seems to be valid JSON, exit retry loop + } + + // If we're here, the response doesn't look like JSON + console.log(`Attempt ${retryCount + 1}: Response doesn't contain JSON, retrying...`); + retryCount++; + + if (retryCount <= maxRetries) { + // Wait a bit before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } catch (error) { + console.error(`API request error on attempt ${retryCount + 1}:`, error); + retryCount++; + + if (retryCount <= maxRetries) { + // Wait a bit before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + // Max retries reached, propagate the error + throw error; + } + } + } + + // Log raw response for debugging + console.log("Raw Gemini response:", text); + + // First, analyze main transaction + let mainAnalysis: TransactionAnalysisResult; + + // Extract and parse the JSON response + try { + // Clean the response to handle formatting issues + let cleanedText = text; + + // Remove markdown code blocks if present (```json...```) + if (cleanedText.includes('```')) { + // This regex extracts content between markdown code fences + const codeBlockMatch = cleanedText.match(/```(?:json)?\n?([\s\S]*?)```/); + if (codeBlockMatch && codeBlockMatch[1]) { + cleanedText = codeBlockMatch[1].trim(); + } else { + // Fallback: just remove the markdown markers + cleanedText = cleanedText.replace(/```json|```/g, '').trim(); + } + } + + // If still no clean JSON, extract anything between { and } + if (!cleanedText.startsWith('{') || !cleanedText.endsWith('}')) { + const jsonMatch = cleanedText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + cleanedText = jsonMatch[0].trim(); + } + } + + console.log("Cleaned JSON text:", cleanedText); + + // Try parsing the cleaned JSON + const parsedJson = JSON.parse(cleanedText); + console.log("Successfully parsed JSON:", parsedJson); + + // Return the actual AI analysis + mainAnalysis = { + isMalicious: Boolean(parsedJson.isMalicious), + confidence: Number(parsedJson.confidence) || 0.5, + reason: parsedJson.reason || "Analysis completed based on transaction properties." + }; + } catch (parseError) { + console.error("Failed to parse JSON response:", parseError); + + // If we can't parse JSON at all, default to non-malicious for Safe wallet transactions + // This is safer than falsely flagging legitimate transactions + mainAnalysis = { + isMalicious: false, + confidence: 0.3, + reason: "Unable to analyze transaction due to AI model response format issues. Treating as legitimate transaction." + }; + } + + // Now, analyze any internal transactions if present and add them to the result + if (hasInternalTxs && context?.safeAddress) { + const relevantInternalTxs = transactionData.internalTransactions!.filter(tx => + tx.from.toLowerCase() === context.safeAddress!.toLowerCase() || + tx.to.toLowerCase() === context.safeAddress!.toLowerCase() + ); + + if (relevantInternalTxs.length > 0) { + // If any internal tx is to a known suspicious address, mark it immediately + const internalAnalysis = relevantInternalTxs.map(tx => { + // Check if this internal tx is to/from a suspicious address + const isToSuspicious = suspiciousAddresses.includes(tx.to.toLowerCase()); + const isFromSuspicious = suspiciousAddresses.includes(tx.from.toLowerCase()); + + if (isToSuspicious || isFromSuspicious) { + return { + isMalicious: true, + confidence: 0.95, + reason: `Internal transaction ${isToSuspicious ? 'to' : 'from'} a known suspicious address.`, + transaction: { + from: tx.from, + to: tx.to, + value: tx.value + } + }; + } + + // Otherwise, inherit the main transaction's analysis for simplicity + // In a production system, you might want to analyze each internal tx separately + return { + isMalicious: mainAnalysis.isMalicious, + confidence: mainAnalysis.confidence, + reason: `Internal transaction associated with ${mainAnalysis.isMalicious ? 'suspicious' : 'legitimate'} main transaction.`, + transaction: { + from: tx.from, + to: tx.to, + value: tx.value + } + }; + }); + + // Add internal transaction analysis to the result + mainAnalysis.internalTransactions = internalAnalysis; + + // If any internal transaction is malicious, the whole transaction should be considered malicious + const anyInternalMalicious = internalAnalysis.some(tx => tx.isMalicious); + if (anyInternalMalicious && !mainAnalysis.isMalicious) { + mainAnalysis.isMalicious = true; + mainAnalysis.confidence = Math.max(...internalAnalysis.map(tx => tx.confidence)); + mainAnalysis.reason = "Transaction contains suspicious internal operations."; + } + } + } + + return mainAnalysis; + + } catch (error) { + console.error('Error analyzing transaction with Gemini:', error); + // When there's an error calling the LLM model, ignore the analysis and display the transaction as normal + return { + isMalicious: false, // Always show as normal transaction when there's an error + confidence: 0, + reason: 'AI analysis skipped due to service error.', + }; + } + } +} \ No newline at end of file diff --git a/server/src/transaction/services/ai/openai-model.service.ts b/server/src/transaction/services/ai/openai-model.service.ts new file mode 100644 index 0000000..b0cc2e7 --- /dev/null +++ b/server/src/transaction/services/ai/openai-model.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { AIModelService, TransactionAnalysisResult } from './ai-model.interface'; +import { ChatOpenAI } from '@langchain/openai'; +import { PromptTemplate } from '@langchain/core/prompts'; +import { RunnableSequence } from '@langchain/core/runnables'; +import { JsonOutputParser } from '@langchain/core/output_parsers'; +import { ConfigService } from '@nestjs/config'; +import { AI_CONFIG } from './ai-config'; + +@Injectable() +export class OpenAIModelService implements AIModelService { + private model: ChatOpenAI; + private chain: RunnableSequence; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('OPENAI_API_KEY'); + if (!apiKey) { + throw new Error('OPENAI_API_KEY is not configured'); + } + + this.model = new ChatOpenAI({ + modelName: AI_CONFIG.openai.modelName, + temperature: AI_CONFIG.openai.temperature, + maxTokens: AI_CONFIG.openai.maxTokens, + openAIApiKey: apiKey, + }); + + const prompt = PromptTemplate.fromTemplate(` + Analyze the following blockchain transaction for potentially harmful activity. + Identify if this transaction appears to be malicious, suspicious, spam, or phishing. + + Consider these patterns: + - Malicious: Attempts to steal funds, exploits, flash loans, or contract vulnerabilities + - Suspicious: Unusual transaction patterns, interaction with known suspicious addresses + - Spam: Worthless token airdrops, spam NFTs, dust attacks + - Phishing: Attempts to trick users into revealing private keys or approving malicious contracts + + Transaction Details: + From: {from} + To: {to} + Value: {value} + Data: {data} + Timestamp: {timestamp} + + Previous context (if available): + {context} + + Provide your analysis in the following JSON format: + {{ + "isMalicious": boolean, // true for any malicious, suspicious, spam, or phishing transaction + "confidence": number (0-1), + "reason": "detailed explanation including the specific type of threat detected (malicious/suspicious/spam/phishing)" + }} + `); + + this.chain = RunnableSequence.from([ + prompt, + this.model, + new JsonOutputParser(), + ]); + } + + async analyzeMaliciousTransaction( + transactionData: { + from: string; + to: string; + value: string; + data?: string; + timestamp: number; + }, + context?: { + previousTransactions?: any[]; + accountInfo?: any; + } + ): Promise { + try { + const result = await this.chain.invoke({ + from: transactionData.from || 'Unknown', + to: transactionData.to || 'Unknown', + value: transactionData.value || '0', + data: transactionData.data || 'No data', + timestamp: transactionData.timestamp || Date.now(), + context: context ? JSON.stringify(context) : 'No additional context provided', + }); + + return result; + } catch (error) { + console.error('Error analyzing transaction:', error); + return { + isMalicious: false, + confidence: 0, + reason: 'Error analyzing transaction: ' + error.message, + }; + } + } +} \ No newline at end of file diff --git a/server/src/transaction/services/transaction-analysis.service.ts b/server/src/transaction/services/transaction-analysis.service.ts new file mode 100644 index 0000000..4776742 --- /dev/null +++ b/server/src/transaction/services/transaction-analysis.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { AIModelService } from './ai/ai-model.interface'; +import { OpenAIModelService } from './ai/openai-model.service'; + +@Injectable() +export class TransactionAnalysisService { + constructor( + @Inject('AIModelService') + private aiModel: AIModelService + ) {} + + async analyzeMaliciousTransaction(transaction: any, context?: any) { + try { + // Extract internal transactions if they exist + const internalTransactions = transaction.internalTxs || []; + + // For Safe wallet transactions, provide the safe address in context + // This helps the AI model understand these are legitimate smart contract wallet transactions + const isSafeWallet = transaction.to && ( + // Typical Safe wallet module addresses often contain these signatures + transaction.to.toLowerCase().includes('safe') || + transaction.to.toLowerCase().includes('gnosis') || + transaction.data?.includes('multisig') || + // Check if this is a known Safe proxy contract + (context?.safeInfo?.isProxy === true) + ); + + const analyzedTx = { + from: transaction.from, + to: transaction.to, + value: transaction.value, + data: transaction.data, + timestamp: transaction.timestamp || Date.now(), + internalTransactions: internalTransactions.map(tx => ({ + from: tx.from, + to: tx.to, + value: tx.value, + data: tx.data || '' + })) + }; + + // Prepare context with Safe wallet information + const enrichedContext = { + ...context, + safeAddress: context?.safeAddress || transaction.from, + isSafeWallet: isSafeWallet, + contractType: isSafeWallet ? 'safe-wallet' : 'unknown' + }; + + // Run the AI analysis + const analysisResult = await this.aiModel.analyzeMaliciousTransaction( + analyzedTx, + enrichedContext + ); + + return { + ...transaction, + analysis: { + isMalicious: analysisResult.isMalicious, + confidence: analysisResult.confidence, + reason: analysisResult.reason, + internalTransactions: analysisResult.internalTransactions, + isSafeWallet: isSafeWallet + }, + }; + } catch (error) { + console.error('Error in transaction analysis:', error); + return { + ...transaction, + analysis: { + isMalicious: false, + confidence: 0, + reason: 'Analysis failed', + isSafeWallet: false + }, + }; + } + } + + async analyzeBatchTransactions(transactions: any[], context?: any) { + const results = await Promise.all( + transactions.map(tx => this.analyzeMaliciousTransaction(tx, context)) + ); + return results; + } +} \ No newline at end of file diff --git a/server/src/transaction/transaction.module.ts b/server/src/transaction/transaction.module.ts index b8aaba5..cfe6905 100644 --- a/server/src/transaction/transaction.module.ts +++ b/server/src/transaction/transaction.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TransactionService } from './transaction.service'; import { TransactionController } from './transaction.controller'; +import { AIModule } from './services/ai/ai.module'; @Module({ - providers: [TransactionService], + imports: [ConfigModule, AIModule], controllers: [TransactionController], + providers: [TransactionService], exports: [TransactionService], }) export class TransactionModule {} \ No newline at end of file diff --git a/server/src/transaction/transaction.service.ts b/server/src/transaction/transaction.service.ts index 2996c25..410f49d 100644 --- a/server/src/transaction/transaction.service.ts +++ b/server/src/transaction/transaction.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as fetch from 'node-fetch'; +import { TransactionAnalysisService } from './services/transaction-analysis.service'; export interface SafeTransaction { id: string; @@ -70,6 +71,7 @@ export class TransactionService { constructor( private readonly configService: ConfigService, + private readonly transactionAnalysisService: TransactionAnalysisService, ) { // Get a single API key for all networks const apiKey = this.configService.get('ETHERSCAN_API_KEY', 'PXN99XX2X2RY6GDK6IIC8MK9D7NI1Y7TI2'); @@ -142,26 +144,29 @@ export class TransactionService { 0 // Always fetch from beginning to properly deduplicate ); - // Log if no transactions were found - if (transactions.length === 0) { - this.logger.log(`No blockchain transactions found via Etherscan for ${safeAddress}`); - return []; - } + // Analyze transactions for malicious activity + const analyzedTransactions = await this.transactionAnalysisService.analyzeBatchTransactions( + transactions, + { + safeAddress, + chainId, + } + ); - // Store in cache + // Store analyzed transactions in cache this.txCache[cacheKey] = { - transactions, + transactions: analyzedTransactions, timestamp: Date.now() }; - this.logger.log(`Cached ${transactions.length} blockchain transactions for ${safeAddress}`); + this.logger.log(`Cached ${analyzedTransactions.length} analyzed blockchain transactions for ${safeAddress}`); // Return the requested page - const availableCount = transactions.length; + const availableCount = analyzedTransactions.length; if (skip >= availableCount) { return []; // No more transactions } - return transactions.slice(skip, Math.min(skip + first, availableCount)); + return analyzedTransactions.slice(skip, Math.min(skip + first, availableCount)); } catch (error) { this.logger.error(`Error fetching blockchain transactions: ${error.message}`, error.stack); return [];