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 [];