This commit is contained in:
Sewmina (server) 2025-08-14 10:41:04 +08:00
parent 502420a1b2
commit 7f8a87cfa2
15 changed files with 1234 additions and 90 deletions

154
README.md
View File

@ -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 <repository-url>
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

112
VALIDATION_SERVICE.md Normal file
View File

@ -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

37
database-schema.sql Normal file
View File

@ -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;

124
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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');
});
});
});

19
src/config/database.ts Normal file
View File

@ -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'
};

View File

@ -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;

View File

@ -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;

View File

@ -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<Transaction> {
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<Transaction | null> {
const sql = 'SELECT * FROM transactions WHERE tx = ?';
try {
const result = await query<Transaction>(sql, [tx]);
return result.length > 0 ? result[0] : null;
} catch (error: any) {
logger.error('Error fetching transaction:', error);
throw error;
}
}
static async getAllTransactions(): Promise<Transaction[]> {
const sql = 'SELECT * FROM transactions ORDER BY created_at DESC';
try {
return await query<Transaction>(sql);
} catch (error: any) {
logger.error('Error fetching all transactions:', error);
throw error;
}
}
static async getUnvalidatedTransactionsCount(): Promise<number> {
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<number> {
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;
}
}
}

View File

@ -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<UnvalidatedTransaction[]> {
try {
const sql = 'SELECT * FROM transactions WHERE validated_at IS NULL ORDER BY created_at ASC';
const transactions = await query<UnvalidatedTransaction>(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<void> {
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<boolean> {
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<void> {
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
};
}
}

29
src/types/transaction.ts Normal file
View File

@ -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;
}

37
src/utils/database.ts Normal file
View File

@ -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<mysql.Connection> {
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<void> {
if (connection) {
await connection.end();
connection = null;
logger.info('Database connection closed');
}
}
export async function query<T = any>(sql: string, params?: any[]): Promise<T[]> {
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;
}
}

View File

@ -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,

2
start_server.sh Executable file
View File

@ -0,0 +1,2 @@
cd /root/NodeJS/solpay/
npm run start