diff --git a/README.md b/README.md index 7a9f9f2..a30af38 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,93 @@ -# SolPay - Node.js TypeScript Application +# SolPay API -A modern Node.js application built with TypeScript, featuring Express.js for the web server and comprehensive testing setup. +A Node.js TypeScript application for handling Solana transactions. -## Features +## Setup -- ๐Ÿš€ **TypeScript** - Full TypeScript support with strict type checking -- ๐Ÿงช **Testing** - Jest testing framework with TypeScript support -- ๐Ÿ”ง **Development** - Hot reload with ts-node for development -- ๐Ÿ“ฆ **Build System** - TypeScript compiler with source maps -- ๐ŸŽฏ **Modern ES2020** - Latest JavaScript features +### 1. Install Dependencies -## Prerequisites - -- Node.js (v16 or higher) -- npm or yarn - -## Installation - -1. Clone the repository: ```bash -git clone -cd solpay +npm install mysql2 ``` -2. Install dependencies: -```bash -npm install +### 2. Environment Variables + +Create a `.env` file in the root directory with the following variables: + +```env +# Server Configuration +PORT=3000 +NODE_ENV=development +LOG_LEVEL=info + +# Database Configuration +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=your_password_here +DB_NAME=solpay ``` -## Available Scripts +### 3. Database Setup -- **`npm run dev`** - Start development server with hot reload -- **`npm run build`** - Build the TypeScript code to JavaScript -- **`npm start`** - Start the production server (requires build first) -- **`npm run watch`** - Watch for changes and rebuild automatically -- **`npm test`** - Run tests -- **`npm run clean`** - Clean build output +Make sure your MySQL database is running and the `solpay` database exists with the `transactions` table. -## Development +## API Endpoints -To start developing: +### Create New Transaction +- **URL**: `GET /tx/new` +- **Method**: `GET` +- **Query Parameters**: + - `tx` (required): Transaction hash + - `target_address` (required): Target wallet address + - `amount` (required): Transaction amount + - `token_mint` (required): Token mint address + - `token_program` (required): Token program address + - `validated_at` (required): Validation timestamp + - `sender_address` (required): Sender wallet address + - `metadata` (optional): Additional metadata + +### Example Usage ```bash +curl "http://localhost:3000/tx/new?tx=abc123&target_address=target123&amount=1.5&token_mint=mint123&token_program=program123&validated_at=2024-01-01T00:00:00Z&sender_address=sender123&metadata=test" +``` + +### Get All Transactions +- **URL**: `GET /tx` +- **Method**: `GET` + +### Get Transaction by Hash +- **URL**: `GET /tx/:txHash` +- **Method**: `GET` + +## Running the Application + +```bash +# Development npm run dev -``` -This will start the server using ts-node, which automatically compiles TypeScript on the fly. - -## Building for Production - -```bash +# Build and run npm run build npm start ``` -## Project Structure +## Database Schema +The `transactions` table should have the following structure: + +```sql +CREATE TABLE transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + tx VARCHAR(255) NOT NULL, + target_address VARCHAR(255) NOT NULL, + amount VARCHAR(255) NOT NULL, + token_mint VARCHAR(255) NOT NULL, + token_program VARCHAR(255) NOT NULL, + validated_at VARCHAR(255) NOT NULL, + sender_address VARCHAR(255) NOT NULL, + metadata TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); ``` -solpay/ -โ”œโ”€โ”€ src/ # TypeScript source code -โ”œโ”€โ”€ dist/ # Compiled JavaScript (generated) -โ”œโ”€โ”€ tests/ # Test files -โ”œโ”€โ”€ package.json # Dependencies and scripts -โ”œโ”€โ”€ tsconfig.json # TypeScript configuration -โ”œโ”€โ”€ jest.config.js # Jest testing configuration -โ””โ”€โ”€ README.md # This file -``` - -## Testing - -Run tests with: - -```bash -npm test -``` - -Run tests with coverage: - -```bash -npm test -- --coverage -``` - -## TypeScript Configuration - -The project uses strict TypeScript settings for better code quality: - -- Strict mode enabled -- No implicit any types -- Source maps for debugging -- Declaration files generation -- ES2020 target - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests for new functionality -5. Ensure all tests pass -6. Submit a pull request - -## License - -MIT License - see LICENSE file for details diff --git a/VALIDATION_SERVICE.md b/VALIDATION_SERVICE.md new file mode 100644 index 0000000..14e71ca --- /dev/null +++ b/VALIDATION_SERVICE.md @@ -0,0 +1,112 @@ +# Transaction Validation Service + +The Transaction Validation Service automatically validates unvalidated transactions against blockchain data every 5 seconds. + +## Overview + +The service runs as a background process that: +1. Fetches all transactions with `validated_at = NULL` from the database +2. Retrieves transaction data from the Solana blockchain using the explorer utility +3. Compares database transaction data with blockchain data +4. Updates validated transactions with `validated_at = NOW()` + +## Features + +- **Automatic Validation**: Runs every 5 seconds without manual intervention +- **Blockchain Verification**: Compares transaction details with actual blockchain data +- **Data Integrity**: Ensures sender, receiver, amount, and token mint addresses match +- **Rate Limiting**: Includes delays between API calls to respect blockchain API limits +- **Graceful Shutdown**: Properly stops validation when the server shuts down +- **Monitoring**: Provides status endpoints to monitor validation progress + +## API Endpoints + +### Get Validation Status +``` +GET /validation/status +``` + +Returns: +```json +{ + "success": true, + "validationService": { + "isRunning": true, + "hasInterval": true + }, + "statistics": { + "unvalidated": 5, + "validated": 25, + "total": 30 + } +} +``` + +### Start Validation Service +``` +POST /validation/start +``` + +### Stop Validation Service +``` +POST /validation/stop +``` + +## Validation Logic + +A transaction is considered valid if: + +1. **Sender Address**: Database `sender_address` matches blockchain `sender` +2. **Receiver Address**: Database `target_address` matches blockchain `receiver` +3. **Amount**: Database `amount` matches blockchain `amount` +4. **Token Mint**: Database `token_mint` matches blockchain `mint` + +## Database Schema + +The `transactions` table must include: +- `validated_at` field (TIMESTAMP, can be NULL) +- `created_at` field (TIMESTAMP) +- `updated_at` field (TIMESTAMP) + +## Configuration + +The service automatically starts when the server starts and stops when the server shuts down. It can also be manually controlled via the API endpoints. + +## Error Handling + +- Failed validations are logged but don't stop the service +- Blockchain API errors are handled gracefully +- Database connection issues are logged +- The service continues running even if individual validations fail + +## Performance Considerations + +- Processes transactions sequentially to avoid overwhelming the blockchain API +- Includes 100ms delays between individual transaction validations +- Runs every 5 seconds to balance responsiveness with API usage +- Logs validation progress for monitoring and debugging + +## Testing + +Run the test script to verify the service works correctly: + +```bash +npx ts-node src/test-validation.ts +``` + +## Monitoring + +Monitor the service through: +- Application logs (look for validation-related log entries) +- `/validation/status` endpoint for real-time status +- Database queries on the `validated_at` field +- Transaction counts (validated vs unvalidated) + +## Troubleshooting + +Common issues and solutions: + +1. **Service not starting**: Check database connection and permissions +2. **No validations occurring**: Verify transactions exist with `validated_at = NULL` +3. **Validation failures**: Check blockchain API connectivity and transaction data integrity +4. **High API usage**: Consider increasing the validation interval or reducing batch sizes diff --git a/database-schema.sql b/database-schema.sql new file mode 100644 index 0000000..0d6075b --- /dev/null +++ b/database-schema.sql @@ -0,0 +1,37 @@ +-- SolPay Database Schema +-- This schema includes the validated_at field needed for transaction validation + +CREATE DATABASE IF NOT EXISTS solpay; +USE solpay; + +CREATE TABLE IF NOT EXISTS transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + tx VARCHAR(255) NOT NULL UNIQUE, + target_address VARCHAR(255) NOT NULL, + amount VARCHAR(255) NOT NULL, + token_mint VARCHAR(255) NOT NULL, + token_program VARCHAR(255) NOT NULL, + sender_address VARCHAR(255) NOT NULL, + metadata TEXT, + validated_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_tx (tx), + INDEX idx_validated_at (validated_at), + INDEX idx_sender_address (sender_address), + INDEX idx_target_address (target_address), + INDEX idx_created_at (created_at) +); + +-- Insert sample data for testing +INSERT INTO transactions (tx, target_address, amount, token_mint, token_program, sender_address, metadata, validated_at) VALUES +('sample_tx_1', 'receiver_address_1', '1000000', 'So11111111111111111111111111111111111111111', '11111111111111111111111111111111', 'sender_address_1', 'Sample transaction 1', NULL), +('sample_tx_2', 'receiver_address_2', '500000', 'So11111111111111111111111111111111111111111', '11111111111111111111111111111111', 'sender_address_2', 'Sample transaction 2', NULL), +('sample_tx_3', 'receiver_address_3', '750000', 'So11111111111111111111111111111111111111111', '11111111111111111111111111111111', 'sender_address_3', 'Sample transaction 3', NULL); + +-- Show the table structure +DESCRIBE transactions; + +-- Show sample data +SELECT * FROM transactions; diff --git a/package-lock.json b/package-lock.json index 7c61b5d..a07d9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@solana/web3.js": "^1.98.4", "dotenv": "^17.2.1", - "express": "^4.18.2" + "express": "^4.18.2", + "mysql2": "^3.14.3" }, "devDependencies": { "@types/express": "^5.0.3", @@ -1529,6 +1530,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2187,6 +2197,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2635,6 +2654,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2996,6 +3024,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3875,6 +3909,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3885,6 +3925,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4060,6 +4115,59 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.14.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz", + "integrity": "sha512-fD6MLV8XJ1KiNFIF0bS7Msl8eZyhlTDCDl75ajU5SJtpdx9ZPEACulJcqJWr1Y8OYyxsFc4j3+nflpmhxCU5aQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4681,6 +4789,11 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -4849,6 +4962,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 1c221e2..edc49fb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/express": "^5.0.3", "@types/jest": "^29.5.8", "@types/node": "^20.10.0", + "@types/mysql": "^2.15.24", "jest": "^29.7.0", "ts-jest": "^29.1.0", "ts-node": "^10.9.0", @@ -30,6 +31,7 @@ "dependencies": { "@solana/web3.js": "^1.98.4", "dotenv": "^17.2.1", - "express": "^4.18.2" + "express": "^4.18.2", + "mysql2": "^3.14.3" } } diff --git a/src/__tests__/explorer.test.ts b/src/__tests__/explorer.test.ts index cafe67f..541c7e0 100644 --- a/src/__tests__/explorer.test.ts +++ b/src/__tests__/explorer.test.ts @@ -1,3 +1,320 @@ -//spl token transfer tx : 2Dq3cW3D7z75QNfjiTfDu3GgS2gaqrqmoaqzDoomTkJqT2ThSyNDNf1tTz8SgXmzSon9FHCyN61EFkAdkJquhbJf -//sol transfer tx : 3c6AiQmRxnAKSQyQ3C6jPoEmhGPHieQ2uJWuBQjdMA2TPXMWeGtL6PmdUGrMdojFoazq1NUQnDb9qCq1o4sxYvZ8 +import { getTransaction, extractNativeSolTransferData, extractSplTokenTransferData, extractTokenTransferData } from '../utils/explorer'; + +// Mock the Solana connection +jest.mock('@solana/web3.js', () => ({ + Connection: jest.fn().mockImplementation(() => ({ + getParsedTransaction: jest.fn() + })) +})); + +// Mock the data module +jest.mock('../data', () => ({ + CLUSTER_API: 'https://api.devnet.solana.com' +})); + +describe('Explorer Utility Tests', () => { + // Sample transaction hashes from the comments + const SPL_TOKEN_TX_HASH = '2Dq3cW3D7z75QNfjiTfDu3GgS2gaqrqmoaqzDoomTkJqT2ThSyNDNf1tTz8SgXmzSon9FHCyN61EFkAdkJquhbJf'; + const SOL_TRANSFER_TX_HASH = '3c6AiQmRxnAKSQyQ3C6jPoEmhGPHieQ2uJWuBQjdMA2TPXMWeGtL6PmdUGrMdojFoazq1NUQnDb9qCq1o4sxYvZ8'; + + // Mock transaction data for SPL token transfer + const mockSplTokenTransaction = { + blockTime: 1640995200, + transaction: { + message: { + instructions: [ + { + program: 'spl-token', + parsed: { + type: 'transferChecked', + info: { + source: 'SourceWallet123', + destination: 'DestWallet456', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenAmount: { + amount: '1000000' + } + } + } + } + ], + recentBlockhash: 'mockBlockhash123' + } + } + }; + + // Mock transaction data for native SOL transfer + const mockSolTransaction = { + blockTime: 1640995200, + transaction: { + message: { + instructions: [ + { + program: 'system', + parsed: { + type: 'transfer', + info: { + source: 'SourceWallet789', + destination: 'DestWallet012', + lamports: 1000000000 + } + } + } + ], + recentBlockhash: 'mockBlockhash456' + } + } + }; + + // Mock transaction data for non-transfer transaction + const mockNonTransferTransaction = { + blockTime: 1640995200, + transaction: { + message: { + instructions: [ + { + program: 'other-program', + parsed: { + type: 'other-action' + } + } + ], + recentBlockhash: 'mockBlockhash789' + } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTransaction', () => { + it('should fetch and parse a transaction successfully', async () => { + const { Connection } = require('@solana/web3.js'); + const mockConnection = { + getParsedTransaction: jest.fn().mockResolvedValue(mockSplTokenTransaction) + }; + Connection.mockImplementation(() => mockConnection); + + const result = await getTransaction(SPL_TOKEN_TX_HASH); + + expect(mockConnection.getParsedTransaction).toHaveBeenCalledWith(SPL_TOKEN_TX_HASH, { + maxSupportedTransactionVersion: 0 + }); + expect(result).toBeDefined(); + }); + + it('should handle connection errors gracefully', async () => { + const { Connection } = require('@solana/web3.js'); + const mockConnection = { + getParsedTransaction: jest.fn().mockRejectedValue(new Error('Connection failed')) + }; + Connection.mockImplementation(() => mockConnection); + + await expect(getTransaction(SPL_TOKEN_TX_HASH)).rejects.toThrow('Connection failed'); + }); + }); + + describe('extractNativeSolTransferData', () => { + it('should extract native SOL transfer data correctly', () => { + const result = extractNativeSolTransferData(mockSolTransaction); + + expect(result).toEqual({ + sender: 'cocD4r4yNpHxPq7CzUebxEMyLki3X4d2Y3HcTX5ptUc', + receiver: 'SP13fTsRrdB2vwJBk88bddCkz4v9N1mHncmTyqkUTTp', + amount: 1000000000, + mint: 'So11111111111111111111111111111111111111111', + time: 1755106952, + blockhash: '55sznG5B254dnr4WiM1EBHDE5kUfHHyZiY7sBSGGsBYE' + }); + }); + + it('should return null for non-native SOL transfers', () => { + const result = extractNativeSolTransferData(mockSplTokenTransaction); + expect(result).toBeNull(); + }); + + it('should return null for non-transfer transactions', () => { + const result = extractNativeSolTransferData(mockNonTransferTransaction); + expect(result).toBeNull(); + }); + + it('should handle malformed transaction data gracefully', () => { + const malformedTransaction = { + transaction: { + message: { + instructions: [] + } + } + }; + + const result = extractNativeSolTransferData(malformedTransaction); + expect(result).toBeNull(); + }); + + it('should handle errors and return null', () => { + const invalidTransaction = null; + const result = extractNativeSolTransferData(invalidTransaction); + expect(result).toBeNull(); + }); + }); + + describe('extractSplTokenTransferData', () => { + it('should extract SPL token transfer data correctly', () => { + const result = extractSplTokenTransferData(mockSplTokenTransaction); + + expect(result).toEqual({ + sender: '4aHYndCFTBAg8zCLsdTF8mWUPdSXj9NqJXdti4BBdYKP', + receiver: '7jnoV3yYmaFFa3FYtpTHY4kz6wer8ntEUKZr9hmouets', + amount: 1000000000000, + mint: 'diNoZ1L9UEiJMv8i43BjZoPEe2tywwaBTBnKQYf28FT', + time: 1755106862, + blockhash: 'GvxNLLizh6oCodnuYX7NVb7xBrtTpAHDTsWMPGtSg2eV' + }); + }); + + it('should return null for non-SPL token transfers', () => { + const result = extractSplTokenTransferData(mockSolTransaction); + expect(result).toBeNull(); + }); + + it('should return null for non-transfer transactions', () => { + const result = extractSplTokenTransferData(mockNonTransferTransaction); + expect(result).toBeNull(); + }); + + it('should handle malformed transaction data gracefully', () => { + const malformedTransaction = { + transaction: { + message: { + instructions: [] + } + } + }; + + const result = extractSplTokenTransferData(malformedTransaction); + expect(result).toBeNull(); + }); + + it('should handle errors and return null', () => { + const invalidTransaction = null; + const result = extractSplTokenTransferData(invalidTransaction); + expect(result).toBeNull(); + }); + }); + + describe('extractTokenTransferData', () => { + it('should extract native SOL transfer data when available', () => { + const result = extractTokenTransferData(mockSolTransaction); + + expect(result).toEqual({ + sender: 'SourceWallet789', + receiver: 'DestWallet012', + amount: 1000000000, + mint: 'So11111111111111111111111111111111111111111', + time: 1640995200, + blockhash: 'mockBlockhash456' + }); + }); + + it('should extract SPL token transfer data when native SOL is not available', () => { + const result = extractTokenTransferData(mockSplTokenTransaction); + + expect(result).toEqual({ + sender: 'SourceWallet123', + receiver: 'DestWallet456', + amount: 1000000, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + time: 1640995200, + blockhash: 'mockBlockhash123' + }); + }); + + it('should return null for non-transfer transactions', () => { + const result = extractTokenTransferData(mockNonTransferTransaction); + expect(result).toBeNull(); + }); + + it('should prioritize native SOL transfers over SPL token transfers', () => { + // Create a transaction with both types of transfers + const mixedTransaction = { + blockTime: 1640995200, + transaction: { + message: { + instructions: [ + { + program: 'spl-token', + parsed: { + type: 'transferChecked', + info: { + source: 'SPLSource', + destination: 'SPLDest', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenAmount: { amount: '500000' } + } + } + }, + { + program: 'system', + parsed: { + type: 'transfer', + info: { + source: 'SOLSource', + destination: 'SOLDest', + lamports: 2000000000 + } + } + } + ], + recentBlockhash: 'mixedBlockhash' + } + } + }; + + const result = extractTokenTransferData(mixedTransaction); + + // Should return native SOL transfer data (prioritized) + expect(result).toEqual({ + sender: 'SOLSource', + receiver: 'SOLDest', + amount: 2000000000, + mint: 'So11111111111111111111111111111111111111111', + time: 1640995200, + blockhash: 'mixedBlockhash' + }); + }); + }); + + describe('Integration Tests with Sample Hashes', () => { + it('should handle SPL token transfer hash format', () => { + // Test that the SPL token hash format is recognized + expect(SPL_TOKEN_TX_HASH).toMatch(/^[A-Za-z0-9]{88}$/); + }); + + it('should handle SOL transfer hash format', () => { + // Test that the SOL transfer hash format is recognized + expect(SOL_TRANSFER_TX_HASH).toMatch(/^[A-Za-z0-9]{88}$/); + }); + + it('should process both hash types through the main function', async () => { + const { Connection } = require('@solana/web3.js'); + const mockConnection = { + getParsedTransaction: jest.fn() + .mockResolvedValueOnce(mockSplTokenTransaction) // First call for SPL token + .mockResolvedValueOnce(mockSolTransaction) // Second call for SOL + }; + Connection.mockImplementation(() => mockConnection); + + // Test SPL token transaction + const splResult = await getTransaction(SPL_TOKEN_TX_HASH); + expect(splResult).toBeDefined(); + expect(splResult?.mint).not.toBe('So11111111111111111111111111111111111111111'); + + // Test SOL transaction + const solResult = await getTransaction(SOL_TRANSFER_TX_HASH); + expect(solResult).toBeDefined(); + expect(solResult?.mint).toBe('So11111111111111111111111111111111111111111'); + }); + }); +}); diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..e1a7547 --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,19 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export interface DatabaseConfig { + host: string; + port: number; + user: string; + password: string; + database: string; +} + +export const databaseConfig: DatabaseConfig = { + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'solpay' +}; diff --git a/src/index.ts b/src/index.ts index e824e16..1d64f95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import express, { Request, Response } from 'express'; import { config } from './config'; import { logger } from './utils/logger'; +import transactionRoutes from './routes/transactionRoutes'; +import { getConnection } from './utils/database'; import { getTransaction } from './utils/explorer'; +import { TransactionValidationService } from './services/transactionValidationService'; logger.info("Starting server..."); const app = express(); const PORT = config.port; @@ -9,6 +12,13 @@ logger.info(`port: ${PORT}`); logger.info(`nodeEnv: ${config.nodeEnv}`); logger.info(`logLevel: ${config.logLevel}`); +getConnection().then(() => { + logger.info(`โœ… Connected to database`); +}).catch((err) => { + logger.error(`Error connecting to database: ${err}`); + process.exit(1); +}); + // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -31,16 +41,83 @@ app.get('/health', (_req: Request, res: Response) => { }); }); -app.get('/tx/:txHash', async (req: Request, res: Response) => { +app.get('/solana/tx/:txHash', async (req: Request, res: Response) => { const txHash = req.params.txHash; const transaction = await getTransaction(txHash); res.json(transaction); }); +// Validation service control endpoints +app.get('/validation/status', async (_req: Request, res: Response) => { + try { + const status = TransactionValidationService.getStatus(); + const { TransactionService } = await import('./services/transactionService'); + const unvalidatedCount = await TransactionService.getUnvalidatedTransactionsCount(); + const validatedCount = await TransactionService.getValidatedTransactionsCount(); + + res.json({ + success: true, + validationService: status, + statistics: { + unvalidated: unvalidatedCount, + validated: validatedCount, + total: unvalidatedCount + validatedCount + } + }); + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Failed to get validation status', + error: error.message + }); + } +}); + +app.post('/validation/start', (_req: Request, res: Response) => { + TransactionValidationService.startValidationService(); + res.json({ + success: true, + message: 'Transaction validation service started' + }); +}); + +app.post('/validation/stop', (_req: Request, res: Response) => { + TransactionValidationService.stopValidationService(); + res.json({ + success: true, + message: 'Transaction validation service stopped' + }); +}); + +// Use transaction routes +app.use('/tx', transactionRoutes); + // Start server -app.listen(PORT, () => { +const server = app.listen(PORT, () => { logger.info(`๐Ÿš€ Server is running on port ${PORT}`); logger.info(`๐Ÿ“– API documentation available at http://localhost:${PORT}`); + + // Start the transaction validation service + TransactionValidationService.startValidationService(); +}); + +// Graceful shutdown handling +process.on('SIGTERM', () => { + logger.info('SIGTERM received, shutting down gracefully...'); + TransactionValidationService.stopValidationService(); + server.close(() => { + logger.info('Server closed'); + process.exit(0); + }); +}); + +process.on('SIGINT', () => { + logger.info('SIGINT received, shutting down gracefully...'); + TransactionValidationService.stopValidationService(); + server.close(() => { + logger.info('Server closed'); + process.exit(0); + }); }); export default app; \ No newline at end of file diff --git a/src/routes/transactionRoutes.ts b/src/routes/transactionRoutes.ts new file mode 100644 index 0000000..f7079ba --- /dev/null +++ b/src/routes/transactionRoutes.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response } from 'express'; +import { TransactionService } from '../services/transactionService'; +import { CreateTransactionRequest, CreateTransactionResponse } from '../types/transaction'; +import { logger } from '../utils/logger'; + +const router = Router(); + +// GET /tx/new - Create a new transaction +router.get('/new', async (req: Request, res: Response) => { + try { + // Extract query parameters + const { + tx, + target_address, + amount, + token_mint, + token_program, + sender_address, + metadata + } = req.query; + + // Validate required fields + if (!tx || !target_address || !amount || !sender_address) { + const response: CreateTransactionResponse = { + success: false, + message: 'Missing required fields', + error: 'All fields are required: tx, target_address, amount, sender_address' + }; + return res.status(400).json(response); + } + + // Create transaction data object + const transactionData: CreateTransactionRequest = { + tx: tx as string, + target_address: target_address as string, + amount: amount as string, + token_mint: token_mint as string || 'So11111111111111111111111111111111111111111', + token_program: token_program as string || 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + sender_address: sender_address as string, + metadata: metadata as string || '' + }; + + // Create the transaction + const transaction = await TransactionService.createTransaction(transactionData); + + const response: CreateTransactionResponse = { + success: true, + message: 'Transaction created successfully', + transaction + }; + + logger.info(`New transaction created: ${tx}`); + return res.status(201).json(response); + + } catch (error: any) { + logger.error('Error in /tx/new endpoint:', error); + + const response: CreateTransactionResponse = { + success: false, + message: 'Failed to create transaction', + error: error instanceof Error ? error.message : 'Unknown error' + }; + + return res.status(500).json(response); + } +}); + +// GET /tx - Get all transactions +router.get('/', async (_req: Request, res: Response) => { + try { + const transactions = await TransactionService.getAllTransactions(); + return res.json({ + success: true, + count: transactions.length, + transactions + }); + } catch (error: any) { + logger.error('Error fetching transactions:', error); + return res.status(500).json({ + success: false, + message: 'Failed to fetch transactions', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// GET /tx/:txHash - Get transaction by tx hash +router.get('/:txHash', async (req: Request, res: Response) => { + try { + const txHash = req.params.txHash; + const transaction = await TransactionService.getTransactionByTx(txHash); + + if (!transaction) { + return res.status(404).json({ + success: false, + message: `Transaction ${txHash} not found` + }); + } + + return res.json({ + success: true, + transaction + }); + } catch (error: any) { + logger.error('Error fetching transaction:', error); + return res.status(500).json({ + success: false, + message: 'Failed to fetch transaction', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +export default router; diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts new file mode 100644 index 0000000..95d6942 --- /dev/null +++ b/src/services/transactionService.ts @@ -0,0 +1,82 @@ +import { query } from '../utils/database'; +import { Transaction, CreateTransactionRequest } from '../types/transaction'; +import { logger } from '../utils/logger'; + +export class TransactionService { + static async createTransaction(transactionData: CreateTransactionRequest): Promise { + const sql = ` + INSERT INTO transactions ( + tx, target_address, amount, token_mint, token_program, + sender_address, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + const params = [ + transactionData.tx, + transactionData.target_address, + transactionData.amount, + transactionData.token_mint, + transactionData.token_program, + transactionData.sender_address, + transactionData.metadata + ]; + + try { + await query(sql, params); + logger.info(`Transaction created with tx: ${transactionData.tx}`); + + // Return the created transaction + return transactionData; + } catch (error: any) { + logger.error('Error creating transaction:', error); + throw error; + } + } + + static async getTransactionByTx(tx: string): Promise { + const sql = 'SELECT * FROM transactions WHERE tx = ?'; + + try { + const result = await query(sql, [tx]); + return result.length > 0 ? result[0] : null; + } catch (error: any) { + logger.error('Error fetching transaction:', error); + throw error; + } + } + + static async getAllTransactions(): Promise { + const sql = 'SELECT * FROM transactions ORDER BY created_at DESC'; + + try { + return await query(sql); + } catch (error: any) { + logger.error('Error fetching all transactions:', error); + throw error; + } + } + + static async getUnvalidatedTransactionsCount(): Promise { + const sql = 'SELECT COUNT(*) as count FROM transactions WHERE validated_at IS NULL'; + + try { + const result = await query<{ count: number }>(sql); + return result[0]?.count || 0; + } catch (error: any) { + logger.error('Error fetching unvalidated transactions count:', error); + throw error; + } + } + + static async getValidatedTransactionsCount(): Promise { + const sql = 'SELECT COUNT(*) as count FROM transactions WHERE validated_at IS NOT NULL'; + + try { + const result = await query<{ count: number }>(sql); + return result[0]?.count || 0; + } catch (error: any) { + logger.error('Error fetching validated transactions count:', error); + throw error; + } + } +} diff --git a/src/services/transactionValidationService.ts b/src/services/transactionValidationService.ts new file mode 100644 index 0000000..3c44d74 --- /dev/null +++ b/src/services/transactionValidationService.ts @@ -0,0 +1,206 @@ +import { query } from '../utils/database'; +import { getTransaction } from '../utils/explorer'; +import { logger } from '../utils/logger'; +import { TransferData } from '../types'; + +export interface UnvalidatedTransaction { + id: number; + tx: string; + target_address: string; + amount: string; + token_mint: string; + token_program: string; + sender_address: string; + metadata: string; + created_at: Date; +} + +export class TransactionValidationService { + private static validationInterval: NodeJS.Timeout | null = null; + private static isRunning = false; + + /** + * Start the transaction validation service + */ + static startValidationService(): void { + if (this.isRunning) { + logger.warn('Transaction validation service is already running'); + return; + } + + logger.info('Starting transaction validation service...'); + this.isRunning = true; + + // Run validation immediately + this.validateUnvalidatedTransactions(); + + // Set up interval to run every 5 seconds + this.validationInterval = setInterval(() => { + this.validateUnvalidatedTransactions(); + }, 5000); + + logger.info('Transaction validation service started successfully'); + } + + /** + * Stop the transaction validation service + */ + static stopValidationService(): void { + if (!this.isRunning) { + logger.warn('Transaction validation service is not running'); + return; + } + + if (this.validationInterval) { + clearInterval(this.validationInterval); + this.validationInterval = null; + } + + this.isRunning = false; + logger.info('Transaction validation service stopped'); + } + + /** + * Get all unvalidated transactions (where validated_at is null) + */ + private static async getUnvalidatedTransactions(): Promise { + try { + const sql = 'SELECT * FROM transactions WHERE validated_at IS NULL ORDER BY created_at ASC'; + const transactions = await query(sql); + return transactions; + } catch (error: any) { + logger.error('Error fetching unvalidated transactions:', error); + return []; + } + } + + /** + * Update transaction as validated + */ + private static async markTransactionAsValidated(txHash: string): Promise { + try { + const sql = 'UPDATE transactions SET validated_at = NOW() WHERE tx = ?'; + await query(sql, [txHash]); + logger.info(`Transaction ${txHash} marked as validated`); + } catch (error: any) { + logger.error(`Error marking transaction ${txHash} as validated:`, error); + } + } + + /** + * Validate a single transaction against blockchain data + */ + private static async validateTransaction(transaction: UnvalidatedTransaction): Promise { + try { + logger.debug(`Validating transaction: ${transaction.tx}`); + + // Get blockchain data + const blockchainData = await getTransaction(transaction.tx); + + if (!blockchainData) { + logger.warn(`No blockchain data found for transaction: ${transaction.tx}`); + return false; + } + + // Compare transaction data with blockchain data + const isValid = this.compareTransactionData(transaction, blockchainData); + + if (isValid) { + await this.markTransactionAsValidated(transaction.tx); + logger.info(`Transaction ${transaction.tx} validated successfully`); + return true; + } else { + logger.warn(`Transaction ${transaction.tx} validation failed - data mismatch`); + return false; + } + } catch (error: any) { + logger.error(`Error validating transaction ${transaction.tx}:`, error); + return false; + } + } + + /** + * Compare transaction data with blockchain data + */ + private static compareTransactionData( + dbTransaction: UnvalidatedTransaction, + blockchainData: TransferData + ): boolean { + try { + // Check if sender addresses match + if (dbTransaction.sender_address !== blockchainData.sender) { + logger.debug(`Sender mismatch for ${dbTransaction.tx}: DB=${dbTransaction.sender_address}, BC=${blockchainData.sender}`); + return false; + } + + // Check if receiver addresses match + if (dbTransaction.target_address !== blockchainData.receiver) { + logger.debug(`Receiver mismatch for ${dbTransaction.tx}: DB=${dbTransaction.target_address}, BC=${blockchainData.receiver}`); + return false; + } + + // Check if amounts match (convert to string for comparison) + const dbAmount = dbTransaction.amount; + const bcAmount = blockchainData.amount.toString(); + + // Convert both amounts to BigInt for reliable comparison + const dbAmountBigInt = BigInt(dbAmount); + const bcAmountBigInt = BigInt(bcAmount); + + if (dbAmountBigInt !== bcAmountBigInt) { + logger.debug(`Amount mismatch for ${dbTransaction.tx}: DB=${dbAmount}, BC=${bcAmount}`); + return false; + } + + // Check if token mint addresses match + if (dbTransaction.token_mint !== blockchainData.mint) { + logger.debug(`Token mint mismatch for ${dbTransaction.tx}: DB=${dbTransaction.token_mint}, BC=${blockchainData.mint}`); + return false; + } + + logger.debug(`Transaction ${dbTransaction.tx} data matches blockchain data`); + return true; + } catch (error: any) { + logger.error(`Error comparing transaction data for ${dbTransaction.tx}:`, error); + return false; + } + } + + /** + * Main validation method that processes all unvalidated transactions + */ + private static async validateUnvalidatedTransactions(): Promise { + try { + const unvalidatedTransactions = await this.getUnvalidatedTransactions(); + + if (unvalidatedTransactions.length === 0) { + logger.debug('No unvalidated transactions found'); + return; + } + + logger.info(`Found ${unvalidatedTransactions.length} unvalidated transactions to process`); + + // Process transactions sequentially to avoid overwhelming the blockchain API + for (const transaction of unvalidatedTransactions) { + await this.validateTransaction(transaction); + + // Add a small delay between validations to be respectful to the blockchain API + await new Promise(resolve => setTimeout(resolve, 100)); + } + + logger.debug('Completed validation cycle'); + } catch (error: any) { + logger.error('Error in validation cycle:', error); + } + } + + /** + * Get service status + */ + static getStatus(): { isRunning: boolean; hasInterval: boolean } { + return { + isRunning: this.isRunning, + hasInterval: this.validationInterval !== null + }; + } +} diff --git a/src/types/transaction.ts b/src/types/transaction.ts new file mode 100644 index 0000000..63d74ce --- /dev/null +++ b/src/types/transaction.ts @@ -0,0 +1,29 @@ +export interface Transaction { + tx: string; + target_address: string; + amount: string; + token_mint: string; + token_program: string; + sender_address: string; + metadata: string; + validated_at?: Date | null; + created_at?: Date; + updated_at?: Date; +} + +export interface CreateTransactionRequest { + tx: string; + target_address: string; + amount: string; + token_mint: string; + token_program: string; + sender_address: string; + metadata: string; +} + +export interface CreateTransactionResponse { + success: boolean; + message: string; + transaction?: Transaction; + error?: string; +} diff --git a/src/utils/database.ts b/src/utils/database.ts new file mode 100644 index 0000000..dafdadb --- /dev/null +++ b/src/utils/database.ts @@ -0,0 +1,37 @@ +import mysql from 'mysql2/promise'; +import { databaseConfig } from '../config/database'; +import { logger } from './logger'; + +let connection: mysql.Connection | null = null; + +export async function getConnection(): Promise { + if (!connection) { + try { + connection = await mysql.createConnection(databaseConfig); + logger.info('Database connection established'); + } catch (error: any) { + logger.error('Failed to connect to database:', error); + throw error; + } + } + return connection; +} + +export async function closeConnection(): Promise { + if (connection) { + await connection.end(); + connection = null; + logger.info('Database connection closed'); + } +} + +export async function query(sql: string, params?: any[]): Promise { + const conn = await getConnection(); + try { + const [rows] = await conn.execute(sql, params); + return rows as T[]; + } catch (error: any) { + logger.error('Database query error:', error); + throw error; + } +} diff --git a/src/utils/explorer.ts b/src/utils/explorer.ts index 3428fca..e0b0410 100644 --- a/src/utils/explorer.ts +++ b/src/utils/explorer.ts @@ -3,7 +3,7 @@ import { Connection } from "@solana/web3.js"; import { TransferData } from "../types"; export async function getTransaction(txHash: string) { - const connection = new Connection(CLUSTER_API); + const connection = new Connection(CLUSTER_API, { commitment: 'finalized' }); const transaction = await connection.getParsedTransaction(txHash, { maxSupportedTransactionVersion: 0, diff --git a/start_server.sh b/start_server.sh new file mode 100755 index 0000000..801a100 --- /dev/null +++ b/start_server.sh @@ -0,0 +1,2 @@ +cd /root/NodeJS/solpay/ +npm run start \ No newline at end of file