├── .cursor └── scratchpad.md ├── .env.example ├── .gitignore ├── LICENSE ├── README-DEPLOYMENT.md ├── README.md ├── base.apk ├── jest.config.cjs ├── package-lock.json ├── package.json ├── scripts └── rpi-deploy │ ├── build-app-production.sh │ ├── build-config.env.template │ ├── build-pi-image-osx.sh │ └── setup-build-environment.sh ├── src ├── app.ts ├── config │ └── index.ts ├── server.ts ├── services │ ├── addressProcessor.ts │ ├── alchemyService.ts │ ├── bridgeManager.ts │ ├── bridges │ │ └── layerswapBridgeProvider.ts │ ├── caip10Service.ts │ ├── connectionMonitorService.ts │ ├── ethereumService.ts │ ├── layerswapService.ts │ ├── nfcService.ts │ ├── paymentService.ts │ ├── priceCacheService.ts │ ├── priceService.ts │ ├── realtimeTransactionMonitor.ts │ └── transactionMonitoringService.ts ├── types │ ├── bridge.ts │ └── index.ts └── web │ └── index.html ├── tests ├── basic.test.ts ├── mocks │ ├── nfc-pcsc.mock.ts │ └── ws.mock.ts ├── setup.ts └── utils │ └── testHelpers.ts ├── tsconfig.json └── types ├── nfc-pcsc.d.ts └── pcsclite.d.ts /.cursor/scratchpad.md: -------------------------------------------------------------------------------- 1 | # NFC Payment Terminal - Raspberry Pi Bootable Image Creation 2 | 3 | ## Background and Motivation 4 | 5 | The user has requested to create a bootable MicroSD card image that can be inserted into a Raspberry Pi and will automatically: 6 | - Boot into Ubuntu/Raspberry Pi OS 7 | - Launch the NFC payment terminal application fullscreen on a 7" touchscreen 8 | - Have all dependencies pre-installed and configured 9 | - Require zero manual setup after inserting the SD card 10 | 11 | The current application is a Node.js/TypeScript-based NFC payment terminal that supports multiple blockchain networks (Ethereum, Base, Arbitrum, Optimism, Polygon, Starknet) with real-time transaction monitoring. 12 | 13 | ## Key Challenges and Analysis 14 | 15 | ### Technical Challenges: 16 | 1. **Custom OS Image Creation**: Need to create a modified Ubuntu/Raspberry Pi OS image with pre-installed software 17 | 2. **Hardware Compatibility**: Ensure NFC reader (nfc-pcsc) works on ARM64 architecture 18 | 3. **Display Configuration**: Configure 7" touchscreen for fullscreen operation 19 | 4. **Auto-start Configuration**: Set up systemd services for automatic application launch 20 | 5. **Network Configuration**: Handle WiFi setup for blockchain connectivity 21 | 6. **Security Considerations**: Secure the system while maintaining functionality 22 | 7. **Cross-platform Build**: Build process needs to work on macOS for ARM64 target 23 | 24 | ### Hardware Requirements Analysis: 25 | - Raspberry Pi 4B (recommended for performance) 26 | - 7" Official Raspberry Pi Touchscreen or compatible 27 | - NFC reader compatible with nfc-pcsc library 28 | - MicroSD card (32GB+ recommended) 29 | 30 | ### Software Stack Requirements: 31 | - Ubuntu Server 22.04 LTS ARM64 or Raspberry Pi OS Lite 32 | - Node.js 18+ (ARM64 build) 33 | - NFC libraries (libnfc, pcsclite) 34 | - Chromium browser for kiosk mode 35 | - systemd services for auto-start 36 | 37 | ## High-level Task Breakdown 38 | 39 | ### Phase 1: Environment Setup and Image Preparation 40 | - [ ] **Task 1.1**: Create build environment script 41 | - Success Criteria: Script sets up all required tools (qemu, debootstrap, etc.) 42 | - [ ] **Task 1.2**: Download and prepare base Ubuntu ARM64 image 43 | - Success Criteria: Clean base image ready for customization 44 | - [ ] **Task 1.3**: Set up cross-compilation environment 45 | - Success Criteria: Can build ARM64 binaries from macOS 46 | 47 | ### Phase 2: Application Preparation and Bundling 48 | - [ ] **Task 2.1**: Create production build script 49 | - Success Criteria: Application builds successfully with all dependencies 50 | - [ ] **Task 2.2**: Bundle application with Node.js runtime 51 | - Success Criteria: Self-contained application package created 52 | - [ ] **Task 2.3**: Create environment configuration system 53 | - Success Criteria: .env template with WiFi credentials and Alchemy API key ready for deployment 54 | - [ ] **Task 2.4**: Create WiFi configuration injection system 55 | - Success Criteria: Build script can embed WiFi credentials into image 56 | 57 | ### Phase 3: System Configuration and Services 58 | - [ ] **Task 3.1**: Create systemd service files 59 | - Success Criteria: Application auto-starts on boot 60 | - [ ] **Task 3.2**: Configure WiFi auto-connect service 61 | - Success Criteria: Device automatically connects to pre-configured WiFi on boot 62 | - [ ] **Task 3.3**: Configure display and touchscreen settings 63 | - Success Criteria: 7" screen works fullscreen without manual intervention 64 | - [ ] **Task 3.4**: Set up kiosk mode (browser-based UI) 65 | - Success Criteria: Application runs fullscreen in browser kiosk mode 66 | - [ ] **Task 3.5**: Configure NFC hardware support 67 | - Success Criteria: NFC reader works automatically on boot 68 | - [ ] **Task 3.6**: Create environment variable loading system 69 | - Success Criteria: .env file loaded correctly by application service 70 | 71 | ### Phase 4: Image Creation and Customization 72 | - [ ] **Task 4.1**: Create custom image build script 73 | - Success Criteria: Automated script creates bootable image 74 | - [ ] **Task 4.2**: Install and configure all dependencies in image 75 | - Success Criteria: All required packages installed and configured 76 | - [ ] **Task 4.3**: Embed application and services in image 77 | - Success Criteria: Application ready to run on first boot 78 | - [ ] **Task 4.4**: Configure first-boot setup scripts 79 | - Success Criteria: WiFi and other settings can be configured on first boot 80 | 81 | ### Phase 5: Testing and Optimization 82 | - [ ] **Task 5.1**: Create test suite for image validation 83 | - Success Criteria: Automated tests verify image functionality 84 | - [ ] **Task 5.2**: Optimize image size and boot time 85 | - Success Criteria: Image fits on 32GB SD card, boots in <60 seconds 86 | - [ ] **Task 5.3**: Create user documentation 87 | - Success Criteria: Clear instructions for deployment and configuration 88 | 89 | ### Phase 6: Build Automation 90 | - [ ] **Task 6.1**: Create configuration validation system 91 | - Success Criteria: Build fails if MERCHANT_ETH_ADDRESS is still default 0x000... value 92 | - [ ] **Task 6.2**: Create master build script with configuration injection 93 | - Success Criteria: Single command creates ready-to-flash image with embedded WiFi/API credentials 94 | - [ ] **Task 6.3**: Create configuration template system 95 | - Success Criteria: Easy customization for different WiFi networks, API keys, and blockchain settings 96 | - [ ] **Task 6.4**: Create deployment configuration guide 97 | - Success Criteria: Clear instructions for setting WiFi credentials and API keys before build 98 | - [ ] **Task 6.5**: Create flashing instructions 99 | - Success Criteria: Simple process to write configured image to SD card 100 | 101 | ## Technical Architecture Plan 102 | 103 | ### Image Structure: 104 | ``` 105 | Custom Raspberry Pi OS Image 106 | ├── Boot Partition (FAT32) 107 | │ ├── kernel, initrd, device tree 108 | │ └── config.txt (display/hardware config) 109 | ├── Root Partition (ext4) 110 | │ ├── /opt/nfc-terminal/ 111 | │ │ ├── app/ (Node.js application) 112 | │ │ ├── node_modules/ 113 | │ │ ├── .env (API keys, config) 114 | │ │ └── config/ 115 | │ ├── /etc/systemd/system/ 116 | │ │ ├── nfc-terminal.service 117 | │ │ ├── display-setup.service 118 | │ │ └── wifi-connect.service 119 | │ ├── /etc/wpa_supplicant/ 120 | │ │ └── wpa_supplicant.conf (WiFi credentials) 121 | │ └── /home/pi/ (user data) 122 | ``` 123 | 124 | ### Service Dependencies: 125 | 1. `wifi-connect.service` → Connect to pre-configured WiFi 126 | 2. `display-setup.service` → Configure 7" screen 127 | 3. `network-online.target` → Ensure internet connectivity 128 | 4. `nfc-terminal.service` → Start application (depends on network) 129 | 5. `chromium-kiosk.service` → Launch browser in kiosk mode 130 | 131 | ## Proposed Build Tools and Methods 132 | 133 | ### Option 1: Ubuntu Core/Snap-based (Recommended) 134 | - Use Ubuntu Core for embedded systems 135 | - Package application as snap for easy updates 136 | - Built-in security and update mechanisms 137 | 138 | ### Option 2: Custom Buildroot Image 139 | - Minimal Linux distribution 140 | - Faster boot times 141 | - More complex to set up 142 | 143 | ### Option 3: Modified Raspberry Pi OS 144 | - Based on Debian, well-supported hardware 145 | - Easier NFC driver compatibility 146 | - Larger image size 147 | 148 | **Recommendation**: Start with Option 3 (Modified Raspberry Pi OS) for faster development, then potentially move to Option 1 for production. 149 | 150 | ## Configuration Management Strategy 151 | 152 | ### Pre-Build Configuration Files: 153 | 1. **`build-config.env`** - Master configuration file for build process 154 | ```bash 155 | WIFI_SSID="YourWiFiNetwork" 156 | WIFI_PASSWORD="YourWiFiPassword" 157 | ALCHEMY_API_KEY="your_alchemy_api_key_here" 158 | BLOCKCHAIN_NETWORKS="ethereum,base,arbitrum,optimism,polygon,starknet" 159 | MERCHANT_ETH_ADDRESS="0x00000000000000000000000000000000000000000000000000" 160 | ``` 161 | 162 | 2. **`app-config.template.env`** - Template for application environment variables 163 | ```bash 164 | ALCHEMY_API_KEY=${ALCHEMY_API_KEY} 165 | MERCHANT_ETH_ADDRESS=${MERCHANT_ETH_ADDRESS} 166 | NODE_ENV=production 167 | PORT=3000 168 | LOG_LEVEL=info 169 | ``` 170 | 171 | ### Deployment Workflow: 172 | 1. User creates `build-config.env` with their WiFi, API credentials, and merchant address 173 | 2. Build script validates configuration: 174 | - **FAILS BUILD** if `MERCHANT_ETH_ADDRESS` is still default `0x000...` value 175 | - Validates Ethereum address format 176 | - Ensures all required fields are present 177 | 3. Build script reads config and injects into image: 178 | - WiFi credentials → `/etc/wpa_supplicant/wpa_supplicant.conf` 179 | - API keys & merchant address → `/opt/nfc-terminal/.env` 180 | 4. Image boots with pre-configured connectivity and credentials 181 | 182 | ### Security Considerations: 183 | - Credentials embedded in image (acceptable for kiosk deployment) 184 | - **Build validation prevents accidental deployment with default merchant address** 185 | - Ethereum address format validation before build 186 | - Option to encrypt .env file if needed 187 | - WiFi credentials stored in standard wpa_supplicant format 188 | 189 | ### Validation Rules: 190 | - `MERCHANT_ETH_ADDRESS` must not be `0x000000000000000000000000000000000000000000000000` 191 | - `MERCHANT_ETH_ADDRESS` must be valid Ethereum address format (42 chars, starts with 0x) 192 | - `ALCHEMY_API_KEY` must be present and non-empty 193 | - `WIFI_SSID` and `WIFI_PASSWORD` must be present 194 | 195 | ## Project Status Board 196 | 197 | ### Current Status / Progress Tracking 198 | - [x] Project analysis and planning completed 199 | - [x] Environment setup (Task 1.1 ✅) 200 | - [x] Application bundling (Tasks 2.1-2.4 ✅) 201 | - [x] System configuration (Tasks 3.1-3.6 ✅) 202 | - [x] Image creation (Tasks 4.1-4.4 ✅) 203 | - [x] Build automation (Tasks 6.1-6.2 ✅) 204 | - [ ] Testing and validation 205 | - [x] Documentation (Task 6.4 ✅) 206 | 207 | ### Next Steps 208 | 1. Set up build environment on macOS 209 | 2. Create configuration template system (WiFi + .env) 210 | 3. Create application production build 211 | 4. Develop image customization scripts with credential injection 212 | 213 | ## Executor's Feedback or Assistance Requests 214 | 215 | ### 🔄 Current Task: Adding Disconnected Icon for Alchemy Connection Errors 216 | 217 | **✅ Task Completed**: Connection Status Integration with Charge Button 218 | - **UI Enhancement**: Integrated connection status directly into charge button: 219 | - **Connected**: Green gradient button, fully functional 220 | - **Disconnected**: Yellow/orange gradient button with pulse animation, disabled 221 | - **Disconnect Icon**: Network disconnect icon (📡❌) appears on left of "CHARGE" text when disconnected 222 | - **Removed**: Top-right header and connection indicator (cleaner UI) 223 | - **Backend Monitoring**: Created `ConnectionMonitorService` with: 224 | - 30-second interval health checks to Alchemy API 225 | - Network timeout handling (10-second timeout) 226 | - Error categorization (timeout, network, API errors) 227 | - WebSocket broadcasting of status changes 228 | - **Smart Button Logic**: 229 | - Button disabled when: no amount entered, processing payment, OR disconnected 230 | - Connection status takes priority - button stays disabled until connection restored 231 | - Visual feedback through color change and icon display 232 | 233 | **Files Modified**: 234 | - `src/web/index.html` - Integrated connection status into charge button UI and logic 235 | - `src/services/connectionMonitorService.ts` - New service for monitoring Alchemy connectivity 236 | - `src/server.ts` - Integrated monitoring service and WebSocket broadcasting 237 | 238 | **Testing Status**: ✅ Build successful, ready for manual testing 239 | 240 | **Success Criteria Met**: 241 | - ✅ Charge button shows disconnection status directly (no separate indicator) 242 | - ✅ Button becomes yellow and disabled when disconnected 243 | - ✅ Network disconnect icon appears on left of "CHARGE" text 244 | - ✅ Monitors actual Alchemy API connectivity with real-time updates 245 | - ✅ Clean UI with no unnecessary header elements 246 | 247 | ### Completed Tasks (Executor Report): 248 | 249 | ✅ **Task 1.1 - Build Environment Setup**: Created `setup-build-environment.sh` with: 250 | - macOS compatibility checks 251 | - Homebrew and tool installation (qemu, dosfstools, e2fsprogs) 252 | - Configuration validation system 253 | - Disk utilities for image manipulation 254 | 255 | ✅ **Tasks 2.1-2.4 - Application Production Build**: Created `build-app-production.sh` with: 256 | - TypeScript compilation and production bundling 257 | - Complete systemd service files (wifi-connect, nfc-terminal, display-setup, chromium-kiosk) 258 | - Environment variable injection system 259 | - Pi installation scripts and auto-configuration 260 | 261 | ✅ **Tasks 3.1-3.6 - System Configuration**: Implemented: 262 | - WiFi auto-connect with wpa_supplicant configuration 263 | - 7" display configuration for kiosk mode 264 | - Auto-login and X11 startup scripts 265 | - NFC hardware support with pcscd 266 | - Complete service dependency chain 267 | 268 | ✅ **Tasks 4.1-4.4 & 6.1-6.2 - Master Build Script**: Created `build-pi-image.sh` with: 269 | - **Critical validation system** (fails build if MERCHANT_ETH_ADDRESS = default) 270 | - Automated Raspberry Pi OS download and customization 271 | - Complete chroot installation of Node.js and dependencies 272 | - WiFi credential and API key injection 273 | - Image compression and deployment instructions 274 | 275 | ### Current Status: 276 | **🎉 IMPLEMENTATION COMPLETE** - All core functionality delivered: 277 | 278 | 1. **Single Command Deployment**: `./build-pi-image.sh` creates complete bootable image 279 | 2. **Safety Validation**: Build fails if merchant address not configured 280 | 3. **Auto-Configuration**: WiFi, API keys, and services pre-configured 281 | 4. **Kiosk Mode**: Boots directly to fullscreen NFC terminal 282 | 5. **Hardware Support**: 7" touchscreen and NFC reader ready 283 | 284 | ### Issues Encountered and Resolved: 285 | 286 | **🔧 macOS Compatibility Issue**: 287 | - **Problem**: Original build script failed due to `fdisk -l` syntax differences on macOS and lack of ext2/ext4 filesystem support 288 | - **Root Cause**: macOS uses BSD fdisk with different options, and cannot mount Linux filesystems natively 289 | - **Solution**: Created `build-pi-image-docker.sh` that runs the entire build process in an Ubuntu Docker container 290 | - **Result**: Full macOS compatibility maintained with Linux build environment 291 | 292 | ### Files Updated: 293 | - `build/fdisk-util.sh` - Added macOS-compatible disk utilities with hdiutil 294 | - `scripts/rpi-deploy/build-pi-image-docker.sh` - Complete Docker-based build script for macOS 295 | - `scripts/rpi-deploy/build-pi-image.sh` - Updated with ACR1252U-M1 driver support 296 | - `scripts/rpi-deploy/build-app-production.sh` - Added ACR1252U-M1 driver installation 297 | - `README-DEPLOYMENT.md` - Updated for new directory structure and ACR1252U-M1 support 298 | - `README.md` - Added Raspberry Pi deployment section with hardware requirements 299 | 300 | ### Recent Updates (Directory Restructure): 301 | **🔄 Directory Structure Update**: 302 | - **All build scripts moved** to `scripts/rpi-deploy/` directory 303 | - **Updated documentation** to reflect new file locations 304 | - **Added cd instructions** for proper workflow 305 | - **Fixed path references**: `build/` directory local to scripts, `dist/` at root level 306 | 307 | **📡 ACR1252U-M1 NFC Reader Support**: 308 | - **Specific driver installation** for ACR1252U-M1 model 309 | - **ACS PCSC drivers** included in build process 310 | - **Hardware compatibility section** added to documentation 311 | - **Troubleshooting guide** for NFC reader issues 312 | 313 | **📖 Documentation Updates**: 314 | - **Main README** now includes Raspberry Pi deployment section 315 | - **Complete deployment guide** linked and updated 316 | - **Hardware requirements** clearly specified 317 | - **Step-by-step instructions** updated for new directory structure 318 | 319 | ### Critical Build Fixes Applied (December 2024): 320 | **🔧 NFC Terminal Service Path Fix**: 321 | - **Problem**: Service was trying to run `app/server.js` but file is actually `server.js` 322 | - **Solution**: Updated `nfc-terminal.service` in `build-app-production.sh` to use correct path 323 | - **Changes Made**: 324 | - Changed `ExecStart=/usr/bin/node app/server.js` to `ExecStart=/usr/bin/node server.js` 325 | - Updated pre-start check from `ls -la /opt/nfc-terminal/app/` to `ls -la /opt/nfc-terminal/server.js` 326 | - Added `-` prefix to `EnvironmentFile` to make .env file optional 327 | 328 | **🏠 File Ownership Fix**: 329 | - **Problem**: `start-kiosk.sh` and other files in `/home/freepay/` had incorrect ownership 330 | - **Solution**: Added explicit ownership setting in Docker build script 331 | - **Changes Made**: Added `chown -R 1000:1000 "$MOUNT_ROOT/home/freepay"` after file copying 332 | 333 | **✅ Verified Working Configuration**: 334 | - NFC terminal service starts successfully and responds on http://localhost:3000 335 | - GUI service launches X11 and Chromium in kiosk mode 336 | - All files have correct ownership and permissions 337 | - System boots directly to fullscreen NFC terminal interface 338 | 339 | **🖥️ Portrait Mode Display Support**: 340 | - **Problem**: User requested 90-degree rotation for vertical orientation 341 | - **Initial Solution**: Counterclockwise rotation with `display_rotate=3` 342 | - **Touchscreen Issue**: Touch coordinates didn't map correctly with counterclockwise rotation 343 | - **Final Solution**: Switched to clockwise rotation for better touchscreen compatibility 344 | - **Changes Made**: 345 | - Boot-level rotation: `display_rotate=1` (90° clockwise) in `/boot/config.txt` 346 | - X11 software rotation: `xrandr --rotate right` in GUI startup scripts 347 | - Touch screen transformation: Updated for clockwise rotation with `TransformationMatrix "0 1 0 -1 0 1 0 0 1"` 348 | - Touch inversion: `InvertY=true` for clockwise portrait mode 349 | - Both `.xinitrc` and `start-kiosk.sh` updated with clockwise rotation commands 350 | - **Benefit**: Clockwise rotation provides better touchscreen coordinate mapping for most displays 351 | 352 | **🔧 Comprehensive Build Improvements (December 2024)**: 353 | - **Problem**: Manual fixes required after image deployment for missing GUI files and packages 354 | - **Solution**: Enhanced build scripts to include all necessary components automatically 355 | - **Changes Made**: 356 | - **File Verification**: Added checks to ensure `start-kiosk.sh` and other critical files are properly copied during build 357 | - **Package Verification**: Added verification of essential GUI packages (chromium-browser, openbox, unclutter, xinit, curl) 358 | - **Enhanced Service Checks**: Updated `start-gui.service` with comprehensive pre-start validation 359 | - **User Setup Robustness**: Added emergency user creation and verification in chroot environment 360 | - **X11 Configuration**: Enhanced X11 wrapper setup with directory creation and verification 361 | - **Comprehensive Debug Script**: Enhanced GUI debug script with file checks, package verification, and user permissions 362 | - **Build-time Validation**: Added verification steps throughout the build process to catch issues early 363 | - **Benefits**: Future images will include all fixes automatically, eliminating need for manual post-deployment scripts 364 | 365 | ### Docker Build Issue Resolved: 366 | 367 | **🐳 Docker Credential Problem**: 368 | - **Issue**: `docker-credential-desktop: executable file not found` on macOS 369 | - **Root Cause**: Docker Desktop credential helper path issues 370 | - **Solution 1**: Created `build-pi-image-simple.sh` - bypasses Docker entirely 371 | - **Solution 2**: Temporary Docker config fix for advanced users 372 | - **Result**: Multiple working build paths for macOS users 373 | 374 | ### Files Added: 375 | - `scripts/rpi-deploy/build-pi-image-simple.sh` - macOS-friendly simple build 376 | - `complete-build-manual.sh` - Generated step-by-step completion guide 377 | 378 | ### ❌ NEW CRITICAL ISSUE IDENTIFIED - Package Installation Failure 379 | 380 | **🚨 Build Process Issue**: Latest Docker build created broken image with missing packages 381 | - **Root Cause**: Package installation in chroot environment failed during Docker build 382 | - **Symptoms**: Pi boots but shows "read only file system" error, no SSH access 383 | - **Missing Packages**: chromium-browser, openbox, xinit, openssh-server, Node.js 384 | - **Impact**: Services fail to start → systemd protective read-only filesystem mount → inaccessible Pi 385 | 386 | **🔧 Immediate Fixes Implemented**: 387 | 1. **fix-readonly-filesystem.sh** - Console recovery script for Pi with filesystem issues 388 | 2. **fix-missing-packages.sh** - Automated package installation script for broken Pi images 389 | 3. **Enhanced build-pi-image-docker.sh** with: 390 | - DNS configuration fixes for chroot environment 391 | - Package installation retries with timeout handling 392 | - Multiple fallback options for each package 393 | - Comprehensive package verification before completing build 394 | - Build abortion if critical packages missing (prevents broken images) 395 | 396 | **🏗️ Build Script Improvements**: 397 | - Added robust DNS settings (`8.8.8.8`, `1.1.1.1`) in chroot 398 | - Package installation with retries and 30-second timeouts 399 | - Individual package fallbacks (chromium → firefox-esr, default nodejs repo) 400 | - Critical package validation before image finalization 401 | - Error handling prevents creation of non-functional images 402 | 403 | **Recovery Options for Existing Broken Image**: 404 | 1. **Manual Recovery**: Connect keyboard/monitor to Pi → run recovery scripts 405 | 2. **Package Fix**: Transfer `fix-missing-packages.sh` to Pi and execute 406 | 3. **Rebuild** (recommended): Use enhanced build scripts for new image 407 | 408 | **Status**: Build scripts updated to prevent future package installation failures 409 | 410 | ## Lessons Learned 411 | 412 | ### Technical Implementation Insights: 413 | 414 | 1. **Configuration Validation is Critical**: The merchant address validation prevents costly deployment mistakes 415 | 2. **Systemd Service Dependencies**: Proper service ordering (WiFi → Network → App → UI) is essential for reliable startup 416 | 3. **Chroot Installation**: Installing packages in chroot environment is more reliable than cross-compilation for ARM64 417 | 4. **Image Size Management**: Adding 2GB to base image provides sufficient space for all dependencies 418 | 5. **Kiosk Mode Setup**: X11 auto-start with Chromium kiosk requires careful timing and dependency management 419 | 420 | ### Build Process Optimizations: 421 | 422 | 1. **Incremental Builds**: Base image download is cached to speed up subsequent builds 423 | 424 | ### Security Improvements: 425 | 426 | **🔒 Docker Security Enhancement**: 427 | - **Security Issue Identified**: Original Docker approach used `-v /dev:/dev` which exposed ALL host devices to container (major security risk) 428 | - **Problem**: Could potentially allow container to modify host storage devices, filesystems, or other hardware 429 | - **Solution Implemented**: Two-tier security approach: 430 | 1. **Preferred Method**: Host-managed loop devices - Host creates loop device and passes only specific devices to container 431 | 2. **Fallback Method**: Minimal privileged Docker with only essential capabilities 432 | - **Security Benefits**: 433 | - No exposure of host storage devices to container 434 | - Loop device creation managed by trusted host environment 435 | - Container runs with minimal required permissions 436 | - Automatic cleanup of loop devices on completion 437 | - **Technical Implementation**: Created separate `docker-build-script-host-loop.sh` for the safer approach 438 | - **Result**: Fully automated build with strong security boundaries 439 | 440 | --- 441 | 442 | **Planner's Assessment**: This is a complex but achievable project. The key is breaking it down into manageable phases and ensuring each component works before moving to the next. The biggest challenges will be cross-platform compatibility and hardware driver integration. 443 | 444 | **Estimated Timeline**: 2-3 days for initial working version, 1 week for polished, production-ready system. 445 | 446 | **Risk Factors**: 447 | - NFC driver compatibility on ARM64 448 | - Touchscreen driver issues 449 | - Network connectivity configuration 450 | - Boot time optimization 451 | 452 | ## Lessons Learned 453 | 454 | ### Technical Lessons from Boot Issues (December 2024) 455 | 456 | **Service Path Validation**: 457 | - Always verify that systemd service `ExecStart` paths match actual file locations 458 | - Test service files against the actual deployed file structure, not assumed structure 459 | - Use full debugging checks in service pre-start commands to catch missing files early 460 | 461 | **File Ownership in Chroot Builds**: 462 | - When copying files to mounted filesystems before chroot, set ownership using UIDs (1000:1000) not usernames 463 | - Username-based ownership only works after the user exists in the target system 464 | - Always set ownership both before AND after user creation for reliability 465 | 466 | **Build Process Testing**: 467 | - Always test that the built image actually boots and runs the intended services 468 | - Create diagnostic scripts during build to help debug boot issues 469 | - Include service status checks and file verification in diagnostic output 470 | 471 | **Debugging Boot Issues**: 472 | - Missing executable files often cause "No such file or directory" errors 473 | - Check both file existence AND correct paths in service definitions 474 | - Use journalctl and systemctl status to identify service startup failures 475 | - SSH access is critical for debugging - ensure it works before GUI attempts 476 | 477 | **File Structure Consistency**: 478 | - Document and verify where application files are actually installed vs. where services expect them 479 | - Ensure build scripts match the actual runtime file layout 480 | - Test file paths in both build-time and runtime contexts 481 | 482 | **Package Installation in Docker Chroot Environment** (December 2024): 483 | - DNS resolution can fail in Docker chroot environments, causing package installation failures 484 | - Always configure DNS (`nameserver 8.8.8.8`) before package operations in chroot 485 | - Use retries and timeouts for package downloads due to network instability in containers 486 | - Validate critical packages are installed before finalizing image build 487 | - Failed package installation can lead to broken images that appear to boot but have missing functionality 488 | - Missing GUI packages cause service failures that can trigger read-only filesystem protection 489 | - Always abort build process if essential packages fail to install rather than creating broken images -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Alchemy API key for blockchain interactions 2 | # Get your key at: https://www.alchemy.com/ 3 | ALCHEMY_API_KEY=YOUR_ALCHEMY_API_KEY_HERE 4 | 5 | # Merchant wallet address that will receive payments 6 | # This is the Ethereum address where all payments will be sent 7 | MERCHANT_ADDRESS=0xYOUR_MERCHANT_WALLET_ADDRESS_HERE 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | 5 | # Build outputs 6 | dist/ 7 | build/ 8 | 9 | # Environment files 10 | .env 11 | .env.local 12 | .env.*.local 13 | 14 | # Logs 15 | server.log 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage/ 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Dependency directories 35 | jspm_packages/ 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional eslint cache 41 | .eslintcache 42 | 43 | # Microbundle cache 44 | .rpt2_cache/ 45 | .rts2_cache_cjs/ 46 | .rts2_cache_es/ 47 | .rts2_cache_umd/ 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # parcel-bundler cache (https://parceljs.org/) 59 | .cache 60 | .parcel-cache 61 | 62 | # Next.js build output 63 | .next 64 | 65 | # Nuxt.js build / generate output 66 | .nuxt 67 | 68 | # Storybook build outputs 69 | .out 70 | .storybook-out 71 | 72 | # Temporary folders 73 | tmp/ 74 | temp/ 75 | 76 | # Editor directories and files 77 | .vscode/ 78 | .idea/ 79 | *.swp 80 | *.swo 81 | *~ 82 | 83 | # OS generated files 84 | .DS_Store 85 | .DS_Store? 86 | ._* 87 | .Spotlight-V100 88 | .Trashes 89 | ehthumbs.db 90 | Thumbs.db 91 | 92 | # Project specific 93 | working-nfc-service.ts 94 | build-config.env 95 | *.img 96 | *.img.gz -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tim Robinson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README-DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # NFC Payment Terminal - Raspberry Pi Deployment Guide 2 | 3 | This guide explains how to create a bootable Raspberry Pi image with your NFC payment terminal pre-installed and configured. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ### Prerequisites 8 | - macOS or Linux (for build environment) 9 | - Docker Desktop (for macOS builds) 10 | - 32GB+ MicroSD card 11 | - Raspberry Pi 4B 12 | - 5" HDMI LCD touchscreen display (800x480) 13 | - ACR1252U - USB NFC Reader III (P/N: ACR1252U-M1) 14 | 15 | ### 1. Navigate to Deployment Scripts 16 | ```bash 17 | # Change to the deployment directory 18 | cd scripts/rpi-deploy 19 | ``` 20 | 21 | ### 2. Initial Setup 22 | ```bash 23 | # Setup build environment 24 | ./setup-build-environment.sh 25 | ``` 26 | 27 | ### 3. Configure Your Deployment 28 | ```bash 29 | # Copy template and edit with your settings 30 | cp build-config.env.template build-config.env 31 | ``` 32 | 33 | Edit `build-config.env` with your actual values: 34 | ```bash 35 | # WiFi Configuration 36 | WIFI_SSID="YourWiFiNetwork" 37 | WIFI_PASSWORD="YourWiFiPassword" 38 | 39 | # Blockchain Configuration 40 | ALCHEMY_API_KEY="your_alchemy_api_key_here" 41 | MERCHANT_ETH_ADDRESS="0x1234567890123456789012345678901234567890" # YOUR ACTUAL ADDRESS 42 | 43 | # SSH Access Configuration (optional - defaults shown) 44 | SSH_USERNAME="freepay" # Default: freepay 45 | SSH_PASSWORD="freepay" # Default: freepay 46 | SSH_ENABLE_PASSWORD_AUTH="true" # Enable SSH password authentication 47 | 48 | # Supported Networks 49 | BLOCKCHAIN_NETWORKS="ethereum,base,arbitrum,optimism,polygon,starknet" 50 | ``` 51 | 52 | ⚠️ **CRITICAL**: Replace `MERCHANT_ETH_ADDRESS` with your actual Ethereum wallet address. The build will **fail** if you leave the default `0x000...` value. 53 | 54 | ### 4. Build the Image 55 | 56 | **For macOS:** 57 | ```bash 58 | # Uses Docker for full automation (takes 30-60 minutes, may have issues) 59 | ./build-pi-image-osx.sh 60 | ``` 61 | 62 | **For Linux:** 63 | 64 | *Currently Untested, following macOS instructions may work better* 65 | 66 | ```bash 67 | # Direct build (fastest, full automation) 68 | ./build-pi-image.sh 69 | ``` 70 | 71 | > **Note**: macOS doesn't natively support ext2/ext4 filesystems. The simple approach creates everything needed and provides clear manual steps for SD card completion. 72 | 73 | ### 5. Flash and Deploy 74 | ```bash 75 | # Flash the created image to SD card using Raspberry Pi Imager 76 | # File will be named: nfc-terminal-YYYYMMDD.img.gz 77 | ``` 78 | 79 | ### Requirements for macOS Build 80 | 1. **Docker Desktop** - Install from https://docker.com/products/docker-desktop 81 | 2. **Sufficient disk space** - ~10GB for base images and build artifacts 82 | 3. **Time** - Docker build takes longer but is more reliable 83 | 84 | ## 📡 ACR1252U-M1 NFC Reader Support 85 | 86 | This deployment is specifically configured for the **ACR1252U-M1 NFC reader**, which is automatically detected and configured during the build process. 87 | 88 | ### What's Included: 89 | - **ACS PCSC drivers** for ACR1252U-M1 compatibility 90 | - **Automatic device detection** when plugged via USB 91 | - **Contact/Contactless support** for various card types 92 | - **LED indicator support** for transaction feedback 93 | 94 | ### Supported Card Types: 95 | - **ISO 14443 Type A/B** (most payment cards) 96 | - **MIFARE Classic/Ultralight** 97 | - **FeliCa** cards 98 | - **NFC Forum Type 1-4** tags 99 | 100 | ### Hardware Setup: 101 | 1. Connect ACR1252U-M1 via USB to Raspberry Pi 102 | 2. The device will be automatically detected on boot 103 | 3. Green LED indicates ready status 104 | 4. Blue LED flashes during card reads 105 | 106 | ### Troubleshooting ACR1252U-M1: 107 | ```bash 108 | # Check if reader is detected 109 | lsusb | grep ACS 110 | 111 | # Check PCSC daemon status 112 | sudo systemctl status pcscd 113 | 114 | # List connected readers 115 | pcsc_scan 116 | ``` 117 | 118 | ## 📁 Generated Files 119 | 120 | After running the build process from `scripts/rpi-deploy/`, you'll have: 121 | 122 | ``` 123 | scripts/rpi-deploy/ 124 | ├── setup-build-environment.sh # Environment setup 125 | ├── build-app-production.sh # Application builder 126 | ├── build-pi-image.sh # Direct build script (Linux) 127 | ├── build-pi-image-docker.sh # Docker build script (macOS) 128 | ├── build-config.env.template # Configuration template 129 | ├── build-config.env # Your actual config (create this) 130 | ├── nfc-terminal-YYYYMMDD.img.gz # Final bootable image 131 | └── build/ # Build artifacts 132 | ├── app-bundle/ # Compiled application 133 | ├── images/ # Base Raspberry Pi OS 134 | ├── Dockerfile # Docker build environment 135 | └── logs/ # Build logs 136 | ``` 137 | 138 | ## 🖥️ First Boot Experience 139 | 140 | When you power on the Pi with the flashed SD card: 141 | 142 | 1. **Boot Process** (60-90 seconds) 143 | - Raspberry Pi OS starts 144 | - WiFi connects automatically 145 | - Services start in sequence 146 | 147 | 2. **Display Initialization** 148 | - 7" screen activates 149 | - Auto-login as `pi` user 150 | - X11 starts automatically 151 | 152 | 3. **Application Launch** 153 | - NFC terminal application starts 154 | - Chromium opens in kiosk mode 155 | - Fullscreen payment interface appears 156 | 157 | 4. **Ready for Use** 158 | - NFC reader active and ready 159 | - Connected to all blockchain networks 160 | - Payments directed to your merchant address 161 | 162 | ## 🔍 Troubleshooting 163 | 164 | ### Build Issues: 165 | 166 | **"MERCHANT_ETH_ADDRESS is still set to default value!"** 167 | - Edit `build-config.env` and set your actual Ethereum address 168 | - Address must be 42 characters starting with `0x` 169 | 170 | **"Docker not found"** 171 | - Install Docker Desktop from https://docker.com/products/docker-desktop 172 | - Start Docker Desktop before running build 173 | 174 | **"docker-credential-desktop: executable file not found"** 175 | - Temporarily fix Docker credentials: 176 | ```bash 177 | cp ~/.docker/config.json ~/.docker/config.json.backup 178 | # Edit ~/.docker/config.json and remove the "credsStore": "desktop" line 179 | # OR use the simple build approach: ./build-pi-image-simple.sh 180 | ``` 181 | 182 | **"Cannot download base image"** 183 | - Check internet connection 184 | - Verify disk space (need ~8GB free) 185 | 186 | ### Runtime Issues: 187 | 188 | **WiFi not connecting:** 189 | - Verify SSID and password in your config 190 | - Check WiFi country code (default: US) 191 | - SSH into Pi and check `sudo wpa_cli status` 192 | 193 | **Application not starting:** 194 | - Check logs: `sudo journalctl -u nfc-terminal.service` 195 | - Verify Alchemy API key is correct 196 | - Ensure NFC reader is connected 197 | 198 | **Display issues:** 199 | - Verify 7" screen connection 200 | - Check `sudo dmesg | grep -i display` 201 | - May need to adjust `config.txt` for different screens 202 | 203 | ### Debug Access: 204 | 205 | SSH is enabled with custom user: 206 | ```bash 207 | ssh freepay@ 208 | # Default password: freepay 209 | ``` 210 | 211 | The system also retains the default pi user: 212 | ```bash 213 | ssh pi@ 214 | # Default password: raspberry 215 | ``` 216 | 217 | View service status: 218 | ```bash 219 | sudo systemctl status nfc-terminal.service 220 | sudo systemctl status wifi-connect.service 221 | sudo journalctl -u nfc-terminal.service -f 222 | ``` 223 | 224 | ## 🔒 Security Notes 225 | 226 | - **Change default password** after first boot 227 | - **WiFi credentials** are stored in plain text (acceptable for kiosk) 228 | - **API keys** are embedded in image (secure for single deployment) 229 | - **SSH enabled** for debugging (disable if not needed) 230 | 231 | ## 📞 Support 232 | 233 | If you encounter issues: 234 | 235 | 1. Use Docker build script for macOS compatibility 236 | 2. Check this troubleshooting guide 237 | 3. Review build logs in `build/logs/` 238 | 4. Test with a fresh SD card 239 | 5. Verify all hardware connections 240 | 241 | --- 242 | 243 | **Total build time**: 30-60 minutes (longer on macOS with Docker) 244 | **Deployment time**: 5 minutes to flash + 2 minutes first boot 245 | **Result**: Fully functional NFC payment terminal ready for customers -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NFC Payment Terminal 2 | 3 | A multi-chain NFC payment terminal that processes cryptocurrency payments across 5 blockchain networks with real-time transaction monitoring and comprehensive history tracking. 4 | 5 | ## 🌐 Supported Networks 6 | 7 | - **Ethereum** 8 | - **Base** 9 | - **Arbitrum** 10 | - **Optimism** 11 | - **Polygon** 12 | 13 | ### 🎯 **Smart Payment Priority** 14 | 15 | Rather than negotiate a chain / token combo with the merchant, the payment terminal handles it automatically. First it figures out a chain the merchant supports that you also have funds on, then sends a transaction with ETH or an ERC-20 token with this priority: 16 | 17 | ``` 18 | L2 Stablecoin > L2 Other > L2 ETH > L1 Stablecoin > L1 Other > L1 ETH 19 | ``` 20 | 21 | ## 🚀 Quick Start 22 | 23 | 1. **Install dependencies:** 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | 2. **Environment setup:** 29 | ```bash 30 | cp .env.example .env 31 | # Edit .env and set your values: 32 | # - ALCHEMY_API_KEY: Get from https://www.alchemy.com/ 33 | # - MERCHANT_ADDRESS: Your Ethereum wallet address to receive payments 34 | ``` 35 | 36 | 3. **Run the terminal:** 37 | ```bash 38 | npm start 39 | ``` 40 | 41 | 4. **Open the interface:** 42 | Navigate to `http://localhost:3000` 43 | 44 | ## ⚙️ Configuration 45 | 46 | ### Required Environment Variables 47 | 48 | Create a `.env` file with the following variables: 49 | 50 | ```env 51 | # Alchemy API key for blockchain interactions (required) 52 | ALCHEMY_API_KEY=your_alchemy_api_key_here 53 | 54 | # Merchant wallet address to receive payments (required) 55 | MERCHANT_ADDRESS=0xYourWalletAddressHere 56 | ``` 57 | 58 | **Important:** 59 | - The `MERCHANT_ADDRESS` is where all payments will be sent across all supported chains 60 | - Make sure this is an address you control and have the private keys for 61 | - The same address will be used on all networks (Ethereum, Base, Arbitrum, etc.) 62 | 63 | ## 🏗️ Architecture 64 | 65 | ``` 66 | src/ 67 | ├── server.ts # Express server & WebSocket handler 68 | ├── app.ts # Main application orchestrator 69 | ├── web/index.html # Payment terminal UI 70 | ├── config/index.ts # Multi-chain configuration 71 | └── services/ 72 | ├── nfcService.ts # NFC reader & wallet scanning 73 | ├── alchemyService.ts # Multi-chain balance & monitoring 74 | ├── paymentService.ts # Payment selection & EIP-681 generation 75 | ├── ethereumService.ts # Address validation utilities 76 | └── addressProcessor.ts # Duplicate processing prevention 77 | scripts/ 78 | └── rpi-deploy/ 79 | ├── setup-build-environment.sh # Install dependencies to allow building a Raspberry Pi image 80 | └── build-pi-image-osx.sh # Build a Raspberry Pi image 81 | ``` 82 | 83 | ## 💡 Usage 84 | 85 | ### **Processing Payments** 86 | 1. Enter amount using the keypad (cents-based: "150" = $1.50) 87 | 2. Tap "Charge" to initiate payment 88 | 3. Customer taps NFC device to send payment 89 | 4. Real-time monitoring confirms transaction 90 | 5. "Approved" message with block explorer link 91 | 92 | ### **Transaction History** 93 | 1. Tap the 📜 history button on the keypad 94 | 2. View all transactions or scan a wallet for specific history 95 | 3. Tap "📱 Scan Wallet for History" and have customer tap their device 96 | 4. Browse filtered transactions for that specific wallet 97 | 98 | 99 | ## 🔄 Payment Flow 100 | 101 | 1. **NFC Detection** → Customer taps device 102 | 2. **Multi-Chain Fetching** → Portfolio analysis across all 6 chains 103 | 3. **Smart Selection** → Optimal payment token based on priority system 104 | 4. **EIP-681 Generation** → Payment request with chain ID 105 | 5. **Real-Time Monitoring** → WebSocket/polling for transaction confirmation 106 | 6. **History Logging** → Transaction stored with full metadata 107 | 108 | ## 🛡️ Transaction Monitoring 109 | 110 | - **WebSocket monitoring** for Ethereum, Base, Arbitrum, Optimism, Polygon 111 | - **Polling-based monitoring** fallback 112 | - **Automatic timeout** after 5 minutes 113 | - **Block explorer integration** for transaction verification 114 | - **Status tracking**: detected → confirmed → failed 115 | 116 | ## 🍓 Raspberry Pi Deployment 117 | 118 | This NFC payment terminal can be deployed as a **plug-and-play kiosk** on Raspberry Pi hardware for production use. 119 | 120 | ### **Hardware Requirements** 121 | - Raspberry Pi 4B (4GB+ RAM recommended) 122 | - 7" Official Raspberry Pi Touchscreen 123 | - **ACR1252U-M1 NFC Reader** (specifically supported) 124 | - 32GB+ MicroSD card 125 | 126 | ### **Deployment Features** 127 | - **One-command build** creates bootable SD card image 128 | - **Pre-configured WiFi** and API credentials 129 | - **Automatic startup** with fullscreen kiosk mode 130 | - **Safety validation** prevents deployment with test addresses 131 | - **macOS and Linux** build support 132 | 133 | ### **Quick Deploy** 134 | ```bash 135 | # Navigate to deployment scripts 136 | cd scripts/rpi-deploy 137 | 138 | # Configure your deployment 139 | cp build-config.env.template build-config.env 140 | # Edit build-config.env with your WiFi, API key, and merchant address 141 | 142 | # Build bootable image (macOS) 143 | ./build-pi-image-osx.sh 144 | 145 | # Flash the generated nfc-terminal-.img.gz file to SD card using Raspberry Pi Imager and boot! 146 | ``` 147 | 148 | 📖 **[Complete Deployment Guide](README-DEPLOYMENT.md)** -------------------------------------------------------------------------------- /base.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreePayPOS/merchant-app/4e2da40f830a33b70ca985c789aa97b1f82ed5bb/base.apk -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/default-esm', 3 | extensionsToTreatAsEsm: ['.ts'], 4 | globals: { 5 | 'ts-jest': { 6 | useESM: true, 7 | tsconfig: { 8 | noImplicitAny: false, 9 | strict: false, 10 | esModuleInterop: true, 11 | allowSyntheticDefaultImports: true, 12 | }, 13 | }, 14 | }, 15 | testEnvironment: 'node', 16 | roots: ['/src', '/tests'], 17 | testMatch: [ 18 | '**/__tests__/**/*.ts', 19 | '**/?(*.)+(spec|test).ts' 20 | ], 21 | collectCoverageFrom: [ 22 | 'src/**/*.ts', 23 | '!src/**/*.d.ts', 24 | '!src/server.ts', // Exclude server entry point from coverage 25 | ], 26 | coverageDirectory: 'coverage', 27 | coverageReporters: ['text', 'lcov', 'html'], 28 | setupFilesAfterEnv: ['/tests/setup.ts'], 29 | testTimeout: 10000, 30 | // Mock external modules 31 | moduleNameMapper: { 32 | '^(\\.{1,2}/.*)\\.js$': '$1', 33 | '^nfc-pcsc$': '/tests/mocks/nfc-pcsc.mock.ts', 34 | '^ws$': '/tests/mocks/ws.mock.ts', 35 | }, 36 | // Clear mocks between tests 37 | clearMocks: true, 38 | restoreMocks: true, 39 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osxreader", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "dist/app.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "clean": "rm -rf dist", 9 | "prestart": "npm run clean && npm run build", 10 | "start:backend": "NODE_OPTIONS='--trace-warnings --trace-uncaught --trace-exit --trace-sigint --stack-trace-limit=100' node --loader ts-node/esm src/server.ts", 11 | "start": "concurrently \"npm run start:backend\" \"wait-on http://localhost:3000 && open http://localhost:3000\"", 12 | "lint": "eslint . --ext .ts", 13 | "test": "node --experimental-vm-modules node_modules/.bin/jest", 14 | "test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch", 15 | "test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "description": "", 21 | "devDependencies": { 22 | "@jest/globals": "^29.7.0", 23 | "@types/express": "^4.17.21", 24 | "@types/jest": "^29.5.12", 25 | "@types/node": "^20.12.12", 26 | "@types/ws": "^8.5.10", 27 | "concurrently": "^8.2.2", 28 | "eslint": "^8.57.0", 29 | "jest": "^29.7.0", 30 | "nodemon": "^3.1.0", 31 | "ts-jest": "^29.1.2", 32 | "ts-node": "^10.9.2", 33 | "typescript": "^5.4.5", 34 | "wait-on": "^7.2.0" 35 | }, 36 | "dependencies": { 37 | "alchemy-sdk": "^3.6.0", 38 | "dotenv": "^16.5.0", 39 | "express": "^4.19.2", 40 | "nfc-pcsc": "^0.8.1", 41 | "ws": "^8.17.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /scripts/rpi-deploy/build-config.env.template: -------------------------------------------------------------------------------- 1 | # WiFi Configuration 2 | WIFI_SSID="YourWiFiNetwork" 3 | WIFI_PASSWORD="YourWiFiPassword" 4 | 5 | # Blockchain Configuration 6 | ALCHEMY_API_KEY="your_alchemy_api_key_here" 7 | MERCHANT_ETH_ADDRESS="0x000000000000000000000000000000000000000000000000" 8 | 9 | # Supported Networks 10 | BLOCKCHAIN_NETWORKS="ethereum,base,arbitrum,optimism,polygon,starknet" 11 | 12 | # SSH Access Configuration 13 | SSH_USERNAME="freepay" 14 | SSH_PASSWORD="freepay" 15 | SSH_ENABLE_PASSWORD_AUTH="true" 16 | 17 | # Optional: Application Settings 18 | NODE_ENV="production" 19 | LOG_LEVEL="info" 20 | PORT="3000" 21 | -------------------------------------------------------------------------------- /scripts/rpi-deploy/setup-build-environment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "🔧 Setting up Raspberry Pi Image Build Environment on macOS..." 5 | 6 | # Check if running on macOS 7 | if [[ "$OSTYPE" != "darwin"* ]]; then 8 | echo "❌ This script is designed for macOS. For Linux, use apt-get/yum equivalents." 9 | exit 1 10 | fi 11 | 12 | # Install Homebrew if not present 13 | if ! command -v brew &> /dev/null; then 14 | echo "📦 Installing Homebrew..." 15 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 16 | fi 17 | 18 | # Install required tools 19 | echo "📦 Installing required packages..." 20 | brew update 21 | 22 | # Essential tools for image manipulation 23 | brew install wget 24 | brew install qemu 25 | brew install dosfstools 26 | brew install e2fsprogs 27 | 28 | # Install Node.js if not present (for application building) 29 | if ! command -v node &> /dev/null; then 30 | echo "📦 Installing Node.js..." 31 | brew install node 32 | fi 33 | 34 | # Create build directories 35 | echo "📁 Creating build directories..." 36 | mkdir -p build/{images,mount,work,config} 37 | mkdir -p build/logs 38 | 39 | # Download required utilities 40 | echo "📦 Downloading additional utilities..." 41 | # Download fdisk utility that works with loop devices 42 | if [ ! -f "build/fdisk-util.sh" ]; then 43 | cat > build/fdisk-util.sh << 'EOF' 44 | #!/bin/bash 45 | # Utility functions for disk operations 46 | get_partition_info() { 47 | local image=$1 48 | fdisk -l "$image" | grep -E "^$image" 49 | } 50 | 51 | mount_image_partitions() { 52 | local image=$1 53 | local mount_base=$2 54 | 55 | # Get partition info 56 | local boot_offset=$(fdisk -l "$image" | grep "${image}1" | awk '{print $2}') 57 | local root_offset=$(fdisk -l "$image" | grep "${image}2" | awk '{print $2}') 58 | 59 | # Convert sectors to bytes (sector size = 512) 60 | boot_offset=$((boot_offset * 512)) 61 | root_offset=$((root_offset * 512)) 62 | 63 | # Create mount points 64 | mkdir -p "${mount_base}/boot" "${mount_base}/root" 65 | 66 | # Mount partitions 67 | sudo mount -o loop,offset=$boot_offset "$image" "${mount_base}/boot" 68 | sudo mount -o loop,offset=$root_offset "$image" "${mount_base}/root" 69 | } 70 | 71 | unmount_image_partitions() { 72 | local mount_base=$1 73 | sudo umount "${mount_base}/boot" 2>/dev/null || true 74 | sudo umount "${mount_base}/root" 2>/dev/null || true 75 | } 76 | EOF 77 | chmod +x build/fdisk-util.sh 78 | fi 79 | 80 | # Create configuration validation script 81 | echo "📦 Creating configuration validation script..." 82 | cat > build/validate-config.sh << 'EOF' 83 | #!/bin/bash 84 | set -e 85 | 86 | validate_config() { 87 | local config_file=$1 88 | 89 | if [ ! -f "$config_file" ]; then 90 | echo "❌ Configuration file not found: $config_file" 91 | echo "Please create build-config.env with your settings." 92 | return 1 93 | fi 94 | 95 | source "$config_file" 96 | 97 | # Check required variables 98 | local errors=0 99 | 100 | if [ -z "$WIFI_SSID" ]; then 101 | echo "❌ WIFI_SSID is required" 102 | errors=$((errors + 1)) 103 | fi 104 | 105 | if [ -z "$WIFI_PASSWORD" ]; then 106 | echo "❌ WIFI_PASSWORD is required" 107 | errors=$((errors + 1)) 108 | fi 109 | 110 | if [ -z "$ALCHEMY_API_KEY" ]; then 111 | echo "❌ ALCHEMY_API_KEY is required" 112 | errors=$((errors + 1)) 113 | fi 114 | 115 | if [ -z "$MERCHANT_ETH_ADDRESS" ]; then 116 | echo "❌ MERCHANT_ETH_ADDRESS is required" 117 | errors=$((errors + 1)) 118 | fi 119 | 120 | # Validate merchant address format 121 | if [ "$MERCHANT_ETH_ADDRESS" = "0x000000000000000000000000000000000000000000000000" ]; then 122 | echo "❌ MERCHANT_ETH_ADDRESS is still set to default value!" 123 | echo " Please set your actual merchant wallet address." 124 | echo " Current: $MERCHANT_ETH_ADDRESS" 125 | errors=$((errors + 1)) 126 | fi 127 | 128 | # Validate Ethereum address format (42 chars, starts with 0x) 129 | if [[ ! "$MERCHANT_ETH_ADDRESS" =~ ^0x[a-fA-F0-9]{40}$ ]]; then 130 | echo "❌ MERCHANT_ETH_ADDRESS is not a valid Ethereum address format" 131 | echo " Expected: 0x followed by 40 hexadecimal characters" 132 | echo " Current: $MERCHANT_ETH_ADDRESS" 133 | errors=$((errors + 1)) 134 | fi 135 | 136 | if [ $errors -gt 0 ]; then 137 | echo "" 138 | echo "❌ BUILD FAILED: Configuration validation errors detected" 139 | echo "Please fix the above issues in your build-config.env file" 140 | return 1 141 | fi 142 | 143 | echo "✅ Configuration validation passed" 144 | return 0 145 | } 146 | 147 | # Export function for use in other scripts 148 | export -f validate_config 149 | EOF 150 | chmod +x build/validate-config.sh 151 | 152 | # Test QEMU installation 153 | echo "🧪 Testing QEMU installation..." 154 | if qemu-system-aarch64 --version > /dev/null 2>&1; then 155 | echo "✅ QEMU ARM64 emulation ready" 156 | else 157 | echo "⚠️ QEMU ARM64 may not be fully configured" 158 | fi 159 | 160 | # Create sample configuration file 161 | echo "📝 Creating configuration template..." 162 | cat > build-config.env.template << 'EOF' 163 | # WiFi Configuration 164 | WIFI_SSID="YourWiFiNetwork" 165 | WIFI_PASSWORD="YourWiFiPassword" 166 | 167 | # Blockchain Configuration 168 | ALCHEMY_API_KEY="your_alchemy_api_key_here" 169 | MERCHANT_ETH_ADDRESS="0x000000000000000000000000000000000000000000000000" 170 | 171 | # Supported Networks 172 | BLOCKCHAIN_NETWORKS="ethereum,base,arbitrum,optimism,polygon,starknet" 173 | 174 | # Optional: Application Settings 175 | NODE_ENV="production" 176 | LOG_LEVEL="info" 177 | PORT="3000" 178 | EOF 179 | 180 | echo "" 181 | echo "✅ Build environment setup complete!" 182 | echo "" 183 | echo "Next steps:" 184 | echo "1. Copy build-config.env.template to build-config.env" 185 | echo "2. Edit build-config.env with your actual WiFi and API credentials" 186 | echo "3. Run the image build script (coming next)" 187 | echo "" 188 | echo "Files created:" 189 | echo " - build/ directory with subdirectories" 190 | echo " - build/fdisk-util.sh (disk utilities)" 191 | echo " - build/validate-config.sh (configuration validation)" 192 | echo " - build-config.env.template (configuration template)" -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { NFCService } from './services/nfcService.js'; 2 | import { PriceCacheService } from './services/priceCacheService.js'; 3 | 4 | /** 5 | * Main application orchestrator 6 | */ 7 | export class App { 8 | private nfcService: NFCService; 9 | 10 | constructor() { 11 | // Initialize services that don't depend on dynamic data from server 12 | this.nfcService = new NFCService(); 13 | } 14 | 15 | /** 16 | * Initialize core services like price caching and start NFC listeners. 17 | */ 18 | async initializeServices(): Promise { 19 | console.log('🚀 Initializing App Services...'); 20 | await PriceCacheService.initialize(); 21 | this.nfcService.startListening(); // Renamed from start() for clarity 22 | } 23 | 24 | /** 25 | * Process a payment request for a given amount. 26 | * This will arm the NFC service to expect a tap. 27 | * @param amount The amount to charge in USD. 28 | * @returns Promise resolving with payment result. 29 | */ 30 | async processPayment(amount: number): Promise<{ success: boolean; message: string; errorType?: string; paymentInfo?: any }> { 31 | if (!this.nfcService) { 32 | console.error('NFC Service not initialized in App!'); 33 | return { success: false, message: 'NFC Service not ready', errorType: 'NFC_SERVICE_ERROR' }; 34 | } 35 | console.log(`App: Processing payment for $${amount}`); 36 | return this.nfcService.armForPaymentAndAwaitTap(amount); 37 | } 38 | 39 | /** 40 | * Scan an NFC device to get wallet address for transaction history filtering. 41 | * @returns Promise resolving with scan result containing wallet address. 42 | */ 43 | async scanWalletAddress(): Promise<{ success: boolean; message: string; address?: string; errorType?: string }> { 44 | if (!this.nfcService) { 45 | console.error('NFC Service not initialized in App!'); 46 | return { success: false, message: 'NFC Service not ready', errorType: 'NFC_SERVICE_ERROR' }; 47 | } 48 | console.log('App: Starting wallet address scan'); 49 | return this.nfcService.scanForWalletAddress(); 50 | } 51 | 52 | /** 53 | * Cancel any ongoing NFC operations (payment or wallet scan). 54 | */ 55 | cancelCurrentOperation(): void { 56 | if (!this.nfcService) { 57 | console.error('NFC Service not initialized in App!'); 58 | return; 59 | } 60 | console.log('App: Cancelling current NFC operation'); 61 | this.nfcService.cancelCurrentOperation(); 62 | } 63 | 64 | /** 65 | * Stop core services gracefully. 66 | */ 67 | stopServices(): void { 68 | console.log('🛑 Stopping App Services...'); 69 | PriceCacheService.stop(); 70 | if (this.nfcService) { 71 | this.nfcService.stopListening(); // NFCService would need this method 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | export const AID = 'F046524545504159'; // F0 + FREEPAY in HEX format 5 | 6 | // Validate required environment variables 7 | if (!process.env.MERCHANT_ADDRESS) { 8 | throw new Error('MERCHANT_ADDRESS environment variable is required. Please set it in your .env file.'); 9 | } 10 | 11 | if (!process.env.ALCHEMY_API_KEY) { 12 | throw new Error('ALCHEMY_API_KEY environment variable is required. Please set it in your .env file.'); 13 | } 14 | 15 | if (!process.env.LAYERSWAP_API_KEY) { 16 | throw new Error('LAYERSWAP_API_KEY environment variable is required. Please set it in your .env file.'); 17 | } 18 | 19 | // Recipient address for payments - loaded from environment variable 20 | export const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS; 21 | 22 | // Processing configuration 23 | export const COOLDOWN_DURATION = 30000; // 30 seconds cooldown after processing 24 | // export const TARGET_USD = 10; // $10 target payment - This will now be dynamic 25 | 26 | // API configuration 27 | export const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY; 28 | export const LAYERSWAP_API_KEY = process.env.LAYERSWAP_API_KEY; 29 | 30 | // Parse merchant chains from environment variable 31 | // If not set, merchant accepts all chains 32 | export const MERCHANT_CHAINS = process.env.MERCHANT_CHAINS 33 | ? process.env.MERCHANT_CHAINS 34 | .split(',') 35 | .map(chain => chain.trim().toLowerCase()) 36 | : null; // null means accept all chains 37 | 38 | // Multi-chain Alchemy configuration 39 | export interface ChainConfig { 40 | id: number; 41 | name: string; 42 | displayName: string; 43 | alchemyNetwork: string; 44 | alchemyUrl: string; 45 | nativeToken: { 46 | symbol: string; 47 | name: string; 48 | decimals: number; 49 | }; 50 | coingeckoId: string; 51 | } 52 | 53 | export const SUPPORTED_CHAINS: ChainConfig[] = [ 54 | { 55 | id: 1, 56 | name: 'ethereum', 57 | displayName: 'Ethereum', 58 | alchemyNetwork: 'eth-mainnet', 59 | alchemyUrl: `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, 60 | nativeToken: { 61 | symbol: 'ETH', 62 | name: 'Ethereum', 63 | decimals: 18 64 | }, 65 | coingeckoId: 'ethereum' 66 | }, 67 | { 68 | id: 8453, 69 | name: 'base', 70 | displayName: 'Base', 71 | alchemyNetwork: 'base-mainnet', 72 | alchemyUrl: `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, 73 | nativeToken: { 74 | symbol: 'ETH', 75 | name: 'Ethereum', 76 | decimals: 18 77 | }, 78 | coingeckoId: 'ethereum' 79 | }, 80 | { 81 | id: 42161, 82 | name: 'arbitrum', 83 | displayName: 'Arbitrum One', 84 | alchemyNetwork: 'arb-mainnet', 85 | alchemyUrl: `https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, 86 | nativeToken: { 87 | symbol: 'ETH', 88 | name: 'Ethereum', 89 | decimals: 18 90 | }, 91 | coingeckoId: 'ethereum' 92 | }, 93 | { 94 | id: 10, 95 | name: 'optimism', 96 | displayName: 'Optimism', 97 | alchemyNetwork: 'opt-mainnet', 98 | alchemyUrl: `https://opt-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, 99 | nativeToken: { 100 | symbol: 'ETH', 101 | name: 'Ethereum', 102 | decimals: 18 103 | }, 104 | coingeckoId: 'ethereum' 105 | }, 106 | { 107 | id: 137, 108 | name: 'polygon', 109 | displayName: 'Polygon', 110 | alchemyNetwork: 'polygon-mainnet', 111 | alchemyUrl: `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, 112 | nativeToken: { 113 | symbol: 'MATIC', 114 | name: 'Polygon', 115 | decimals: 18 116 | }, 117 | coingeckoId: 'matic-network' 118 | }, 119 | { 120 | id: 393402133025423, 121 | name: 'starknet', 122 | displayName: 'Starknet', 123 | alchemyNetwork: 'starknet-mainnet', 124 | alchemyUrl: `https://starknet-mainnet.g.alchemy.com/starknet/version/rpc/v0_6/${ALCHEMY_API_KEY}`, 125 | nativeToken: { 126 | symbol: 'ETH', 127 | name: 'Ethereum', 128 | decimals: 18 129 | }, 130 | coingeckoId: 'ethereum' 131 | } 132 | ]; 133 | 134 | // Legacy single-chain config (deprecated - use SUPPORTED_CHAINS) 135 | export const ALCHEMY_BASE_URL = `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`; 136 | 137 | // Alchemy Prices API base URL 138 | export const ALCHEMY_PRICES_API_BASE_URL = 'https://api.g.alchemy.com/prices/v1'; 139 | 140 | export const config = { 141 | ALCHEMY_API_KEY: process.env.ALCHEMY_API_KEY!, 142 | // ... other existing config values ... 143 | }; -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express, { Request, Response, NextFunction } from 'express'; // Corrected import for Request and Response types 3 | import http from 'http'; 4 | import WebSocket, { WebSocketServer } from 'ws'; 5 | import path from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | import { App } from './app.js'; // App class will be refactored 8 | import { AlchemyService } from './services/alchemyService.js'; 9 | import { SUPPORTED_CHAINS, ChainConfig, MERCHANT_ADDRESS } from './config/index.js'; 10 | import { TransactionMonitoringService } from './services/transactionMonitoringService.js'; 11 | import { RealtimeTransactionMonitor } from './services/realtimeTransactionMonitor.js'; 12 | import { ConnectionMonitorService } from './services/connectionMonitorService.js'; 13 | import { BridgeManager } from './services/bridgeManager.js'; 14 | 15 | // Resolve __dirname for ES modules 16 | const __filename = fileURLToPath(import.meta.url); 17 | const __dirname = path.dirname(__filename); 18 | 19 | const PORT = process.env.PORT || 3000; 20 | 21 | // Initialize Express app and HTTP server 22 | const expressApp = express(); 23 | const server = http.createServer(expressApp); 24 | 25 | // Initialize WebSocket server 26 | const wss = new WebSocketServer({ server }); 27 | 28 | // Store connected WebSocket clients 29 | const clients = new Set(); 30 | 31 | // Main application instance (controls NFC, etc.) 32 | const nfcApp = new App(); 33 | 34 | // Middleware to parse JSON bodies 35 | expressApp.use(express.json()); 36 | 37 | // Serve static files from the 'src/web' directory 38 | const webDir = path.join(__dirname, 'web'); 39 | expressApp.use(express.static(webDir)); 40 | console.log(`🌐 Serving static files from: ${webDir}`); 41 | 42 | // Store active payment monitoring sessions 43 | interface PaymentSession { 44 | amount: number; 45 | merchantAddress: string; 46 | startTime: number; 47 | timeout: NodeJS.Timeout; 48 | expectedToken?: { 49 | symbol: string; 50 | address: string; 51 | amountExact: bigint; // Use BigInt for exact amount 52 | decimals: number; 53 | }; 54 | } 55 | 56 | // Store transaction history 57 | interface TransactionRecord { 58 | id: string; 59 | amount: number; 60 | fromAddress?: string; 61 | toAddress: string; 62 | chainId: number; 63 | chainName: string; 64 | tokenSymbol?: string; 65 | txHash?: string; 66 | explorerUrl?: string; 67 | status: 'detected' | 'confirmed' | 'failed'; 68 | timestamp: number; 69 | } 70 | 71 | const activePayments = new Map(); 72 | const transactionHistory: TransactionRecord[] = []; 73 | 74 | // WebSocket connection handling 75 | wss.on('connection', (ws) => { 76 | console.log('🟢 Client connected to WebSocket'); 77 | clients.add(ws); 78 | ws.send(JSON.stringify({ type: 'status', message: 'Connected to payment terminal.' })); 79 | 80 | ws.on('message', (message) => { 81 | console.log('💻 Received WebSocket message from client:', message.toString()); 82 | }); 83 | ws.on('close', () => { 84 | console.log('🔴 Client disconnected from WebSocket'); 85 | clients.delete(ws); 86 | }); 87 | ws.on('error', (error) => { 88 | console.error('WebSocket error:', error); 89 | clients.delete(ws); 90 | }); 91 | }); 92 | 93 | // Function to broadcast messages to all connected WebSocket clients 94 | export function broadcast(message: object) { 95 | const data = JSON.stringify(message); 96 | clients.forEach((client) => { 97 | if (client.readyState === WebSocket.OPEN) { 98 | try { 99 | client.send(data); 100 | } catch (error) { 101 | console.error('Error sending message to client:', error); 102 | } 103 | } 104 | }); 105 | } 106 | 107 | // Explicitly define the async handler type for clarity 108 | type AsyncRequestHandler = (req: Request, res: Response, next?: NextFunction) => Promise; 109 | 110 | // Function to monitor transaction for a payment 111 | async function monitorTransaction( 112 | merchantAddress: string, 113 | usdAmount: number, 114 | chainId: number = 1, 115 | chainName: string = "Ethereum", 116 | expectedPayment?: { 117 | tokenSymbol: string; 118 | tokenAddress: string; 119 | requiredAmount: bigint; // Use BigInt for exact amount 120 | decimals: number; 121 | } 122 | ) { 123 | console.log(`🔍 Starting transaction monitoring for ${merchantAddress}, amount: $${usdAmount}`); 124 | 125 | if (expectedPayment) { 126 | const displayAmount = Number(expectedPayment.requiredAmount) / Math.pow(10, expectedPayment.decimals); 127 | console.log(`💰 Expecting exactly $${usdAmount.toFixed(2)} USD (${displayAmount} ${expectedPayment.tokenSymbol}) on ${chainName}`); 128 | console.log(`🔢 Exact amount (smallest units): ${expectedPayment.requiredAmount.toString()}`); 129 | console.log(`🎯 Token address: ${expectedPayment.tokenAddress}`); 130 | } else { 131 | console.log(`💰 Waiting for payment of $${usdAmount} USD (any token) on ${chainName}`); 132 | } 133 | 134 | const startTime = Date.now(); 135 | const timeout = setTimeout(() => { 136 | console.log(`⏰ Payment timeout for ${merchantAddress} - No payment received after 5 minutes on ${chainName}`); 137 | broadcast({ type: 'payment_failure', message: 'Payment timeout - no transaction detected', errorType: 'PAYMENT_TIMEOUT' }); 138 | activePayments.delete(merchantAddress); 139 | TransactionMonitoringService.stopMonitoring(); 140 | RealtimeTransactionMonitor.stopMonitoring(); 141 | }, 300000); // 5 minutes timeout 142 | 143 | activePayments.set(merchantAddress, { 144 | amount: usdAmount, 145 | merchantAddress, 146 | startTime, 147 | timeout, 148 | expectedToken: expectedPayment ? { 149 | symbol: expectedPayment.tokenSymbol, 150 | address: expectedPayment.tokenAddress.toLowerCase(), 151 | amountExact: expectedPayment.requiredAmount, 152 | decimals: expectedPayment.decimals 153 | } : undefined 154 | }); 155 | 156 | try { 157 | if (expectedPayment) { 158 | // Try real-time WebSocket monitoring first, with fallback to polling 159 | try { 160 | console.log(`🚀 Starting real-time WebSocket monitoring for ${chainName}`); 161 | await RealtimeTransactionMonitor.startMonitoring( 162 | merchantAddress, // Pass the recipient address (could be merchant or bridge) 163 | expectedPayment.tokenAddress, 164 | expectedPayment.requiredAmount, 165 | expectedPayment.tokenSymbol, 166 | expectedPayment.decimals, 167 | usdAmount, // Pass merchant USD amount 168 | chainId, 169 | chainName, 170 | // Success callback 171 | (txHash: string, tokenSymbol: string, tokenAddress: string, decimals: number) => { 172 | console.log(`✅ Payment CONFIRMED! Transaction: ${txHash}`); 173 | 174 | // Generate block explorer URL 175 | const getBlockExplorerUrl = (chainId: number, txHash: string): string => { 176 | const explorerMap: {[key: number]: string} = { 177 | 1: 'https://eth.blockscout.com/tx/', 178 | 8453: 'https://base.blockscout.com/tx/', 179 | 42161: 'https://arbitrum.blockscout.com/tx/', 180 | 10: 'https://optimism.blockscout.com/tx/', 181 | 137: 'https://polygon.blockscout.com/tx/', 182 | 393402133025423: 'https://starkscan.co/tx/' 183 | }; 184 | const baseUrl = explorerMap[chainId]; 185 | return baseUrl ? `${baseUrl}${txHash}` : `https://eth.blockscout.com/tx/${txHash}`; 186 | }; 187 | 188 | const explorerUrl = getBlockExplorerUrl(chainId, txHash); 189 | const displayAmount = Number(expectedPayment.requiredAmount) / Math.pow(10, decimals); 190 | 191 | // Create transaction record 192 | const transactionRecord: TransactionRecord = { 193 | id: `${txHash}-${Date.now()}`, 194 | amount: displayAmount, 195 | toAddress: merchantAddress, 196 | chainId, 197 | chainName, 198 | tokenSymbol: tokenSymbol, 199 | txHash, 200 | explorerUrl, 201 | status: 'confirmed', 202 | timestamp: Date.now() 203 | }; 204 | 205 | transactionHistory.unshift(transactionRecord); 206 | 207 | // Keep only last 500 transactions 208 | if (transactionHistory.length > 500) { 209 | transactionHistory.splice(500); 210 | } 211 | 212 | clearTimeout(timeout); 213 | activePayments.delete(merchantAddress); 214 | broadcast({ 215 | type: 'transaction_confirmed', 216 | message: `Approved`, 217 | transactionHash: txHash, 218 | amount: displayAmount, 219 | chainName, 220 | chainId, 221 | transaction: transactionRecord 222 | }); 223 | }, 224 | // Error callback 225 | (error: string) => { 226 | console.error(`❌ Payment monitoring error: ${error}`); 227 | clearTimeout(timeout); 228 | activePayments.delete(merchantAddress); 229 | broadcast({ 230 | type: 'payment_failure', 231 | message: `Payment monitoring failed: ${error}`, 232 | errorType: 'MONITORING_ERROR' 233 | }); 234 | } 235 | ); 236 | } catch (realtimeError) { 237 | console.warn(`⚠️ Real-time monitoring failed, falling back to polling:`, realtimeError); 238 | 239 | // Fallback to original polling-based monitoring 240 | await TransactionMonitoringService.startMonitoring( 241 | merchantAddress, // Pass the recipient address (could be merchant or bridge) 242 | expectedPayment.tokenAddress, 243 | expectedPayment.requiredAmount, 244 | expectedPayment.tokenSymbol, 245 | expectedPayment.decimals, 246 | usdAmount, 247 | chainId, 248 | chainName, 249 | // Success callback (same as above) 250 | (txHash: string, tokenSymbol: string, tokenAddress: string, decimals: number) => { 251 | console.log(`✅ Payment CONFIRMED via polling! Transaction: ${txHash}`); 252 | 253 | const getBlockExplorerUrl = (chainId: number, txHash: string): string => { 254 | const explorerMap: {[key: number]: string} = { 255 | 1: 'https://eth.blockscout.com/tx/', 256 | 8453: 'https://base.blockscout.com/tx/', 257 | 42161: 'https://arbitrum.blockscout.com/tx/', 258 | 10: 'https://optimism.blockscout.com/tx/', 259 | 137: 'https://polygon.blockscout.com/tx/', 260 | 393402133025423: 'https://starkscan.co/tx/' 261 | }; 262 | const baseUrl = explorerMap[chainId]; 263 | return baseUrl ? `${baseUrl}${txHash}` : `https://eth.blockscout.com/tx/${txHash}`; 264 | }; 265 | 266 | const explorerUrl = getBlockExplorerUrl(chainId, txHash); 267 | const displayAmount = Number(expectedPayment.requiredAmount) / Math.pow(10, decimals); 268 | 269 | const transactionRecord: TransactionRecord = { 270 | id: `${txHash}-${Date.now()}`, 271 | amount: displayAmount, 272 | toAddress: merchantAddress, 273 | chainId, 274 | chainName, 275 | tokenSymbol: tokenSymbol, 276 | txHash, 277 | explorerUrl, 278 | status: 'confirmed', 279 | timestamp: Date.now() 280 | }; 281 | 282 | transactionHistory.unshift(transactionRecord); 283 | 284 | if (transactionHistory.length > 500) { 285 | transactionHistory.splice(500); 286 | } 287 | 288 | clearTimeout(timeout); 289 | activePayments.delete(merchantAddress); 290 | broadcast({ 291 | type: 'transaction_confirmed', 292 | message: `Approved`, 293 | transactionHash: txHash, 294 | amount: displayAmount, 295 | chainName, 296 | chainId, 297 | transaction: transactionRecord 298 | }); 299 | }, 300 | // Error callback 301 | (error: string) => { 302 | console.error(`❌ Polling monitoring error: ${error}`); 303 | clearTimeout(timeout); 304 | activePayments.delete(merchantAddress); 305 | broadcast({ 306 | type: 'payment_failure', 307 | message: `Payment monitoring failed: ${error}`, 308 | errorType: 'MONITORING_ERROR' 309 | }); 310 | } 311 | ); 312 | } 313 | } else { 314 | // Fallback to legacy monitoring for backward compatibility 315 | console.log(`⚠️ Using legacy monitoring (no exact payment requirements)`); 316 | // Keep old monitoring code as fallback... 317 | } 318 | 319 | console.log(`🎯 Transaction monitoring active for ${chainName} (Chain ID: ${chainId})`); 320 | 321 | } catch (error) { 322 | console.error(`Error setting up transaction monitoring on ${chainName}:`, error); 323 | clearTimeout(timeout); 324 | activePayments.delete(merchantAddress); 325 | broadcast({ 326 | type: 'payment_failure', 327 | message: `Failed to monitor transaction on ${chainName}: ${error instanceof Error ? error.message : 'Unknown error'}`, 328 | errorType: 'MONITORING_ERROR' 329 | }); 330 | throw error; 331 | } 332 | } 333 | 334 | const initiatePaymentHandler: AsyncRequestHandler = async (req, res) => { 335 | const { amount } = req.body; 336 | const merchantAddress = MERCHANT_ADDRESS; 337 | 338 | if (typeof amount !== 'number' || amount <= 0 || isNaN(amount)) { 339 | broadcast({ type: 'status', message: 'Invalid amount received from UI.', isError: true }); 340 | res.status(400).json({ error: 'Invalid amount' }); 341 | return; 342 | } 343 | 344 | if (!merchantAddress || !AlchemyService.isEthereumAddress(merchantAddress)) { 345 | broadcast({ type: 'status', message: 'Invalid merchant address.', isError: true }); 346 | res.status(400).json({ error: 'Invalid merchant address' }); 347 | return; 348 | } 349 | 350 | console.log(`💸 Payment initiated for $${amount.toFixed(2)} from Web UI to ${merchantAddress}`); 351 | broadcast({ type: 'status', message: `Waiting for phone tap...` }); 352 | 353 | try { 354 | // This method in App will trigger NFCService.armForPaymentAndAwaitTap 355 | const paymentResult = await nfcApp.processPayment(amount); 356 | 357 | if (paymentResult.success && paymentResult.paymentInfo) { 358 | console.log(`✅ Payment request sent successfully: ${paymentResult.message}`); 359 | console.log(`⛓️ Payment sent on: ${paymentResult.paymentInfo.chainName} (Chain ID: ${paymentResult.paymentInfo.chainId})`); 360 | 361 | // Determine the target address based on whether it's a Layerswap transaction 362 | const targetAddress = paymentResult.paymentInfo.isLayerswap 363 | ? paymentResult.paymentInfo.layerswapDepositAddress! 364 | : merchantAddress; 365 | 366 | if (paymentResult.paymentInfo.isLayerswap) { 367 | console.log(`💱 This is a Layerswap payment`); 368 | console.log(`🔄 Swap ID: ${paymentResult.paymentInfo.layerswapSwapId}`); 369 | console.log(`📍 Monitoring Layerswap deposit address: ${paymentResult.paymentInfo.layerswapDepositAddress}`); 370 | } 371 | 372 | // Monitor the transaction with the appropriate target address 373 | try { 374 | await monitorTransaction( 375 | targetAddress, 376 | amount, 377 | paymentResult.paymentInfo.chainId, 378 | paymentResult.paymentInfo.chainName, 379 | { 380 | tokenSymbol: paymentResult.paymentInfo.selectedToken.symbol, 381 | tokenAddress: paymentResult.paymentInfo.selectedToken.address, 382 | requiredAmount: paymentResult.paymentInfo.requiredAmount, 383 | decimals: paymentResult.paymentInfo.selectedToken.decimals 384 | } 385 | ); 386 | console.log(`🔍 Monitoring started for ${paymentResult.paymentInfo.chainName} payment of exactly ${paymentResult.paymentInfo.requiredAmount} smallest units of ${paymentResult.paymentInfo.selectedToken.symbol}`); 387 | broadcast({ 388 | type: 'monitoring_started', 389 | message: `Monitoring ${paymentResult.paymentInfo.chainName} for payment...`, 390 | chainName: paymentResult.paymentInfo.chainName, 391 | chainId: paymentResult.paymentInfo.chainId, 392 | isLayerswap: paymentResult.paymentInfo.isLayerswap 393 | }); 394 | } catch (monitoringError) { 395 | console.error(`❌ Failed to start monitoring on ${paymentResult.paymentInfo.chainName}:`, monitoringError); 396 | 397 | // Fallback: try to monitor on Ethereum mainnet (without specific token requirements) 398 | console.log(`🔄 Falling back to Ethereum mainnet monitoring...`); 399 | try { 400 | await monitorTransaction(merchantAddress, amount, 1, "Ethereum (fallback)"); 401 | broadcast({ 402 | type: 'status', 403 | message: `Payment sent on ${paymentResult.paymentInfo.chainName}. Monitoring Ethereum mainnet as fallback.`, 404 | isWarning: true 405 | }); 406 | } catch (fallbackError) { 407 | console.error(`❌ Fallback monitoring also failed:`, fallbackError); 408 | broadcast({ 409 | type: 'payment_failure', 410 | message: 'Payment sent but monitoring failed. Please verify manually.', 411 | errorType: 'MONITORING_ERROR' 412 | }); 413 | } 414 | } 415 | 416 | broadcast({ type: 'payment_success', message: paymentResult.message, amount }); 417 | res.json({ success: true, message: paymentResult.message }); 418 | } else if (paymentResult.success) { 419 | // Fallback to Ethereum monitoring if no payment info 420 | console.log(`✅ Payment successful: ${paymentResult.message}`); 421 | console.log(`🔄 No chain information available, defaulting to Ethereum monitoring`); 422 | 423 | try { 424 | await monitorTransaction(merchantAddress, amount, 1, "Ethereum (default)"); 425 | broadcast({ 426 | type: 'monitoring_started', 427 | message: 'Monitoring Ethereum for payment...', 428 | chainName: "Ethereum", 429 | chainId: 1 430 | }); 431 | } catch (monitoringError) { 432 | console.error(`❌ Failed to start Ethereum monitoring:`, monitoringError); 433 | broadcast({ 434 | type: 'payment_failure', 435 | message: 'Payment sent but monitoring failed. Please verify manually.', 436 | errorType: 'MONITORING_ERROR' 437 | }); 438 | } 439 | 440 | broadcast({ type: 'payment_success', message: paymentResult.message, amount }); 441 | res.json({ success: true, message: paymentResult.message }); 442 | } else { 443 | console.log(`❌ Payment failed: ${paymentResult.message}, Type: ${paymentResult.errorType}`); 444 | broadcast({ type: 'payment_failure', message: paymentResult.message, errorType: paymentResult.errorType }); 445 | const statusCode = paymentResult.errorType === 'PHONE_MOVED_TOO_QUICKLY' ? 409 : 500; 446 | res.status(statusCode).json({ success: false, message: paymentResult.message, errorType: paymentResult.errorType }); 447 | } 448 | } catch (error: any) { 449 | console.error('Error in /initiate-payment endpoint:', error); 450 | const errorMessage = error.message || 'Internal server error during payment processing.'; 451 | broadcast({ type: 'payment_failure', message: `Server error: ${errorMessage}`, errorType: 'SERVER_ERROR' }); 452 | res.status(500).json({ error: 'Internal server error' }); 453 | } 454 | }; 455 | 456 | // HTTP endpoint to initiate payment 457 | expressApp.post('/initiate-payment', initiatePaymentHandler); 458 | 459 | // Endpoint to get transaction history 460 | expressApp.get('/transaction-history', (req, res) => { 461 | res.json(transactionHistory); 462 | }); 463 | 464 | // Endpoint to scan wallet for history filtering 465 | const scanWalletHandler: AsyncRequestHandler = async (req, res) => { 466 | try { 467 | console.log('📱 Starting wallet scan for transaction history...'); 468 | broadcast({ type: 'status', message: 'Tap wallet to view history...' }); 469 | 470 | // Use NFC to scan for wallet address 471 | const scanResult = await nfcApp.scanWalletAddress(); 472 | 473 | if (scanResult.success && scanResult.address) { 474 | console.log(`✅ Wallet scanned successfully: ${scanResult.address}`); 475 | broadcast({ 476 | type: 'wallet_scanned', 477 | address: scanResult.address, 478 | message: `Wallet found: ${scanResult.address.slice(0, 6)}...${scanResult.address.slice(-4)}` 479 | }); 480 | res.json({ success: true, address: scanResult.address }); 481 | } else { 482 | console.log(`❌ Wallet scan failed: ${scanResult.message}`); 483 | broadcast({ 484 | type: 'status', 485 | message: scanResult.message || 'Failed to scan wallet', 486 | isError: true 487 | }); 488 | res.status(400).json({ success: false, message: scanResult.message }); 489 | } 490 | } catch (error: any) { 491 | console.error('Error in wallet scan:', error); 492 | const errorMessage = error.message || 'Failed to scan wallet'; 493 | broadcast({ type: 'status', message: errorMessage, isError: true }); 494 | res.status(500).json({ success: false, message: errorMessage }); 495 | } 496 | }; 497 | 498 | expressApp.post('/scan-wallet', scanWalletHandler); 499 | 500 | // Endpoint to cancel ongoing payment operations 501 | const cancelPaymentHandler: AsyncRequestHandler = async (req, res) => { 502 | try { 503 | console.log('🚫 Payment cancellation requested by user'); 504 | 505 | // Cancel any ongoing NFC operations 506 | nfcApp.cancelCurrentOperation(); 507 | 508 | // Stop all transaction monitoring services 509 | console.log('🛑 Stopping transaction monitoring services...'); 510 | TransactionMonitoringService.stopMonitoring(); 511 | RealtimeTransactionMonitor.stopMonitoring(); 512 | 513 | // Clear all active payment monitoring sessions 514 | activePayments.forEach((session, merchantAddress) => { 515 | console.log(`⏰ Clearing payment timeout for ${merchantAddress}`); 516 | clearTimeout(session.timeout); 517 | }); 518 | activePayments.clear(); 519 | 520 | broadcast({ 521 | type: 'payment_cancelled', 522 | message: 'Payment cancelled' 523 | }); 524 | 525 | res.json({ success: true, message: 'Payment cancelled successfully' }); 526 | 527 | } catch (error: any) { 528 | console.error('Error cancelling payment:', error); 529 | const errorMessage = error.message || 'Failed to cancel payment'; 530 | res.status(500).json({ success: false, message: errorMessage }); 531 | } 532 | }; 533 | 534 | expressApp.post('/cancel-payment', cancelPaymentHandler); 535 | 536 | // Debug endpoint to check supported chains and active subscriptions 537 | expressApp.get('/debug/chains', (req, res) => { 538 | const supportedChains = SUPPORTED_CHAINS.map(chain => ({ 539 | id: chain.id, 540 | name: chain.name, 541 | displayName: chain.displayName, 542 | nativeToken: chain.nativeToken 543 | })); 544 | 545 | const activeSubscriptions = AlchemyService.getActiveSubscriptions(); 546 | 547 | res.json({ 548 | supportedChains, 549 | activeSubscriptions, 550 | totalChains: supportedChains.length, 551 | totalSubscriptions: activeSubscriptions.length 552 | }); 553 | }); 554 | 555 | // Add global error handlers 556 | process.on('uncaughtException', (error) => { 557 | if (error.message.includes('Cannot process ISO 14443-4 tag')) { 558 | console.log('💳 Payment card detected - ignoring'); 559 | return; 560 | } 561 | console.error('❌ Uncaught Exception:', error); 562 | }); 563 | 564 | process.on('unhandledRejection', (reason, promise) => { 565 | console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); 566 | }); 567 | 568 | // Start the main application logic (NFC, Price Cache) 569 | async function startServerAndApp() { 570 | try { 571 | // Initialize AlchemyService first 572 | try { 573 | AlchemyService.initialize(); 574 | console.log('✅ AlchemyService initialized successfully'); 575 | 576 | // Start connection monitoring 577 | console.log('🔍 Starting connection monitoring...'); 578 | ConnectionMonitorService.startMonitoring((status) => { 579 | // Broadcast connection status to all connected clients 580 | broadcast({ 581 | type: 'connection_status', 582 | connected: status.connected, 583 | message: status.errorMessage || (status.connected ? 'Connected' : 'Disconnected'), 584 | timestamp: status.lastCheck 585 | }); 586 | }); 587 | console.log('✅ Connection monitoring started'); 588 | } catch (error) { 589 | console.error('❌ Failed to initialize AlchemyService:', error); 590 | throw error; 591 | } 592 | 593 | // Initialize BridgeManager (handles all bridge providers including Layerswap) 594 | try { 595 | console.log('🌉 Initializing bridge providers...'); 596 | await BridgeManager.initialize(); 597 | console.log('✅ Bridge providers initialized.'); 598 | } catch (error) { 599 | console.error('⚠️ Failed to initialize BridgeManager, continuing without cross-chain support:', error); 600 | // Continue without bridge support if initialization fails 601 | } 602 | 603 | // Initialize PriceCacheService and start NFC listeners via App class 604 | await nfcApp.initializeServices(); 605 | console.log('🔌 NFC Application services (including Price Cache) initialized.'); 606 | 607 | // Start the HTTP server 608 | server.listen(PORT, () => { 609 | console.log(`📡 HTTP & WebSocket Server running at http://localhost:${PORT}`); 610 | console.log(`✅ NFC Payment Terminal is READY. Open http://localhost:${PORT} in your browser.`); 611 | }); 612 | 613 | } catch (error) { 614 | console.error('❌ Failed to start main application:', error); 615 | process.exit(1); 616 | } 617 | } 618 | 619 | // Handle immediate shutdown 620 | function shutdown(signal: string) { 621 | console.log(`\n👋 Received ${signal}. Shutting down immediately.`); 622 | process.exit(0); 623 | } 624 | 625 | process.on('SIGINT', () => shutdown('SIGINT')); 626 | process.on('SIGTERM', () => shutdown('SIGTERM')); 627 | 628 | startServerAndApp(); -------------------------------------------------------------------------------- /src/services/addressProcessor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service for managing address processing state 3 | */ 4 | export class AddressProcessor { 5 | private static processingAddresses = new Set(); 6 | 7 | /** 8 | * Check if an address can be processed (not already being processed) 9 | */ 10 | static canProcessAddress(address: string): boolean { 11 | const normalizedAddress = address.toLowerCase(); 12 | 13 | // Check if already being processed 14 | if (this.processingAddresses.has(normalizedAddress)) { 15 | console.log(`⏳ Address ${address} is already being processed, please wait...`); 16 | console.log(`🔍 Currently processing addresses:`, Array.from(this.processingAddresses)); 17 | return false; 18 | } 19 | 20 | return true; 21 | } 22 | 23 | /** 24 | * Get the specific reason why an address cannot be processed 25 | */ 26 | static getProcessingBlockReason(address: string): string | null { 27 | const normalizedAddress = address.toLowerCase(); 28 | 29 | // Check if already being processed 30 | if (this.processingAddresses.has(normalizedAddress)) { 31 | return 'Address is already being processed'; 32 | } 33 | 34 | return null; // Can be processed 35 | } 36 | 37 | /** 38 | * Mark an address as being processed 39 | */ 40 | static startProcessing(address: string): void { 41 | const normalizedAddress = address.toLowerCase(); 42 | this.processingAddresses.add(normalizedAddress); 43 | console.log(`🔄 Starting to process address: ${address}`); 44 | console.log(`📊 Total addresses being processed: ${this.processingAddresses.size}`); 45 | } 46 | 47 | /** 48 | * Mark address processing as complete 49 | */ 50 | static finishProcessing(address: string): void { 51 | const normalizedAddress = address.toLowerCase(); 52 | const wasProcessing = this.processingAddresses.has(normalizedAddress); 53 | this.processingAddresses.delete(normalizedAddress); 54 | console.log(`✅ Finished processing address: ${address} (was processing: ${wasProcessing})`); 55 | console.log(`📊 Remaining addresses being processed: ${this.processingAddresses.size}`); 56 | console.log(`📱 Ready for next tap\n`); 57 | } 58 | 59 | /** 60 | * Clear all processing states (emergency cleanup) 61 | */ 62 | static clearAllProcessing(): void { 63 | const addressCount = this.processingAddresses.size; 64 | if (addressCount > 0) { 65 | console.log(`🧹 Clearing ${addressCount} stuck address(es) from processing state`); 66 | console.log(`🔍 Addresses being cleared:`, Array.from(this.processingAddresses)); 67 | this.processingAddresses.clear(); 68 | } else { 69 | console.log(`🧹 No stuck addresses to clear`); 70 | } 71 | } 72 | 73 | /** 74 | * Debug method to show current state 75 | */ 76 | static debugState(): void { 77 | console.log(`📊 AddressProcessor Debug State:`); 78 | console.log(` Processing addresses (${this.processingAddresses.size}):`, Array.from(this.processingAddresses)); 79 | } 80 | } -------------------------------------------------------------------------------- /src/services/bridgeManager.ts: -------------------------------------------------------------------------------- 1 | import { BridgeProvider, BridgeRoute, BridgeSwapResult } from '../types/bridge.js'; 2 | import { LayerswapBridgeProvider } from './bridges/layerswapBridgeProvider.js'; 3 | 4 | /** 5 | * Manages multiple bridge providers for cross-chain payments 6 | */ 7 | export class BridgeManager { 8 | private static providers: BridgeProvider[] = []; 9 | private static initialized = false; 10 | 11 | /** 12 | * Initialize all bridge providers 13 | */ 14 | static async initialize(): Promise { 15 | if (this.initialized) { 16 | return; 17 | } 18 | 19 | console.log('🌉 Initializing bridge providers...'); 20 | 21 | // Add Layerswap as the first provider 22 | const layerswap = new LayerswapBridgeProvider(); 23 | 24 | try { 25 | await layerswap.initialize(); 26 | this.providers.push(layerswap); 27 | console.log(`✅ Initialized ${layerswap.name} bridge provider`); 28 | } catch (error) { 29 | console.error(`⚠️ Failed to initialize ${layerswap.name}:`, error); 30 | } 31 | 32 | // Future bridge providers can be added here 33 | // Example: 34 | // const acrossBridge = new AcrossBridgeProvider(); 35 | // try { 36 | // await acrossBridge.initialize(); 37 | // this.providers.push(acrossBridge); 38 | // } catch (error) { 39 | // console.error(`Failed to initialize ${acrossBridge.name}:`, error); 40 | // } 41 | 42 | this.initialized = true; 43 | console.log(`🌉 Bridge manager initialized with ${this.providers.length} provider(s)`); 44 | } 45 | 46 | /** 47 | * Check if a chain is supported by the merchant (across all providers) 48 | */ 49 | static isMerchantSupportedChain(chainId: number): boolean { 50 | // If any provider says the chain is supported, it's supported 51 | return this.providers.some(provider => provider.isMerchantSupportedChain(chainId)); 52 | } 53 | 54 | /** 55 | * Find the best route across all bridge providers 56 | */ 57 | static async findBestRoute(sourceChainId: number, tokenSymbol: string): Promise<{ 58 | provider: BridgeProvider; 59 | route: BridgeRoute; 60 | } | null> { 61 | console.log(`🔍 Searching for bridge routes from chain ${sourceChainId} for ${tokenSymbol}...`); 62 | 63 | // Check all providers in parallel 64 | const routePromises = this.providers.map(async (provider) => { 65 | try { 66 | const route = await provider.checkRoute(sourceChainId, tokenSymbol); 67 | return { provider, route }; 68 | } catch (error) { 69 | console.error(`Error checking route with ${provider.name}:`, error); 70 | return { provider, route: null }; 71 | } 72 | }); 73 | 74 | const results = await Promise.all(routePromises); 75 | 76 | // Filter out null routes and find the best one 77 | const validRoutes = results.filter(result => result.route && result.route.hasRoute); 78 | 79 | if (validRoutes.length === 0) { 80 | console.log('❌ No bridge routes found'); 81 | return null; 82 | } 83 | 84 | // For now, just return the first valid route 85 | // In the future, we could compare fees, speed, etc. 86 | const bestRoute = validRoutes[0]; 87 | console.log(`✅ Found route via ${bestRoute.provider.name} to ${bestRoute.route!.destinationNetwork}`); 88 | 89 | return { 90 | provider: bestRoute.provider, 91 | route: bestRoute.route! 92 | }; 93 | } 94 | 95 | /** 96 | * Create a swap using the specified provider 97 | */ 98 | static async createSwap( 99 | provider: BridgeProvider, 100 | route: BridgeRoute, 101 | amount: number 102 | ): Promise { 103 | try { 104 | console.log(`💱 Creating swap via ${provider.name}...`); 105 | const result = await provider.createSwap(route, amount); 106 | 107 | if (result) { 108 | console.log(`✅ Swap created successfully via ${provider.name}`); 109 | console.log(`🔄 Swap ID: ${result.swapId}`); 110 | } 111 | 112 | return result; 113 | } catch (error) { 114 | console.error(`❌ Failed to create swap with ${provider.name}:`, error); 115 | return null; 116 | } 117 | } 118 | 119 | /** 120 | * Get all available providers 121 | */ 122 | static getProviders(): BridgeProvider[] { 123 | return [...this.providers]; 124 | } 125 | 126 | /** 127 | * Check if any providers are available 128 | */ 129 | static hasProviders(): boolean { 130 | return this.providers.length > 0; 131 | } 132 | } -------------------------------------------------------------------------------- /src/services/bridges/layerswapBridgeProvider.ts: -------------------------------------------------------------------------------- 1 | import { BridgeProvider, BridgeRoute, BridgeSwapResult } from '../../types/bridge.js'; 2 | import { LayerswapService } from '../layerswapService.js'; 3 | 4 | /** 5 | * Layerswap implementation of the BridgeProvider interface 6 | */ 7 | export class LayerswapBridgeProvider implements BridgeProvider { 8 | name = 'Layerswap'; 9 | 10 | async initialize(): Promise { 11 | await LayerswapService.initialize(); 12 | } 13 | 14 | isMerchantSupportedChain(chainId: number): boolean { 15 | return LayerswapService.isMerchantSupportedChain(chainId); 16 | } 17 | 18 | async checkRoute(sourceChainId: number, tokenSymbol: string): Promise { 19 | const result = await LayerswapService.checkRoute(sourceChainId, tokenSymbol); 20 | 21 | if (!result.hasRoute || !result.destinationChainId || !result.destinationNetwork) { 22 | return null; 23 | } 24 | 25 | return { 26 | hasRoute: true, 27 | bridgeName: this.name, 28 | sourceChainId, 29 | destinationChainId: result.destinationChainId, 30 | destinationNetwork: result.destinationNetwork, 31 | tokenSymbol 32 | }; 33 | } 34 | 35 | async createSwap(route: BridgeRoute, amount: number): Promise { 36 | const result = await LayerswapService.createSwap( 37 | route.sourceChainId, 38 | route.destinationChainId, 39 | route.tokenSymbol, 40 | amount 41 | ); 42 | 43 | if (!result) { 44 | return null; 45 | } 46 | 47 | return { 48 | ...result, 49 | bridgeName: this.name 50 | }; 51 | } 52 | } -------------------------------------------------------------------------------- /src/services/caip10Service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CAIP-10 address handling service 3 | * Implements Chain Agnostic Improvement Proposal 10 for blockchain account identifiers 4 | * Format: namespace:reference:address 5 | * Example: eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb 6 | */ 7 | export class CAIP10Service { 8 | /** 9 | * Parse a CAIP-10 address into its components 10 | */ 11 | static parseCAIP10Address(caip10Address: string): { 12 | namespace: string; 13 | reference: string; 14 | address: string; 15 | chainId?: number; 16 | } | null { 17 | const parts = caip10Address.trim().split(':'); 18 | 19 | if (parts.length !== 3) { 20 | return null; 21 | } 22 | 23 | const [namespace, reference, address] = parts; 24 | 25 | // For EIP-155 (Ethereum), the reference is the chain ID 26 | const chainId = namespace === 'eip155' ? parseInt(reference, 10) : undefined; 27 | 28 | return { 29 | namespace, 30 | reference, 31 | address, 32 | chainId 33 | }; 34 | } 35 | 36 | /** 37 | * Check if a string is a valid CAIP-10 address 38 | */ 39 | static isCAIP10Address(str: string): boolean { 40 | const parsed = this.parseCAIP10Address(str); 41 | if (!parsed) return false; 42 | 43 | // Validate namespace (should be lowercase) 44 | if (parsed.namespace !== parsed.namespace.toLowerCase()) return false; 45 | 46 | // For EIP-155, validate the address format 47 | if (parsed.namespace === 'eip155') { 48 | // Address should be 40 hex characters with optional 0x prefix 49 | const hexPattern = /^(0x)?[0-9a-fA-F]{40}$/; 50 | return hexPattern.test(parsed.address); 51 | } 52 | 53 | // For other namespaces, just ensure address is not empty 54 | return parsed.address.length > 0; 55 | } 56 | 57 | /** 58 | * Extract Ethereum address from CAIP-10 format 59 | * Returns null if not an EIP-155 address 60 | */ 61 | static extractEthereumAddress(caip10Address: string): string | null { 62 | const parsed = this.parseCAIP10Address(caip10Address); 63 | 64 | if (!parsed || parsed.namespace !== 'eip155') { 65 | return null; 66 | } 67 | 68 | // Ensure address has 0x prefix 69 | const address = parsed.address.toLowerCase(); 70 | return address.startsWith('0x') ? address : `0x${address}`; 71 | } 72 | 73 | /** 74 | * Convert a regular Ethereum address to CAIP-10 format 75 | */ 76 | static toCAIP10Address(address: string, chainId: number = 1): string { 77 | // Remove 0x prefix if present 78 | const cleanAddress = address.toLowerCase().replace(/^0x/, ''); 79 | return `eip155:${chainId}:0x${cleanAddress}`; 80 | } 81 | } -------------------------------------------------------------------------------- /src/services/connectionMonitorService.ts: -------------------------------------------------------------------------------- 1 | import { AlchemyService } from './alchemyService.js'; 2 | import { SUPPORTED_CHAINS } from '../config/index.js'; 3 | 4 | interface ConnectionStatus { 5 | connected: boolean; 6 | lastCheck: number; 7 | errorMessage?: string; 8 | } 9 | 10 | export class ConnectionMonitorService { 11 | private static isMonitoring = false; 12 | private static monitorInterval: NodeJS.Timeout | null = null; 13 | private static connectionStatus: ConnectionStatus = { connected: true, lastCheck: Date.now() }; 14 | private static onStatusChange?: (status: ConnectionStatus) => void; 15 | 16 | /** 17 | * Start monitoring Alchemy connection status 18 | */ 19 | static startMonitoring(onStatusChange: (status: ConnectionStatus) => void) { 20 | if (this.isMonitoring) return; 21 | 22 | this.onStatusChange = onStatusChange; 23 | this.isMonitoring = true; 24 | 25 | console.log('🔍 Starting Alchemy connection monitoring...'); 26 | 27 | // Check immediately 28 | this.checkConnection(); 29 | 30 | // Check every 30 seconds 31 | this.monitorInterval = setInterval(() => { 32 | this.checkConnection(); 33 | }, 30000); 34 | } 35 | 36 | /** 37 | * Stop monitoring 38 | */ 39 | static stopMonitoring() { 40 | if (this.monitorInterval) { 41 | clearInterval(this.monitorInterval); 42 | this.monitorInterval = null; 43 | } 44 | this.isMonitoring = false; 45 | console.log('⏹️ Stopped Alchemy connection monitoring'); 46 | } 47 | 48 | /** 49 | * Check connection status by attempting a simple API call to Alchemy 50 | */ 51 | private static async checkConnection() { 52 | try { 53 | // Test connection by trying to validate an Ethereum address 54 | // This is a simple API call that doesn't require much data 55 | const testAddress = '0x0000000000000000000000000000000000000000'; 56 | const isValid = AlchemyService.isEthereumAddress(testAddress); 57 | 58 | if (!isValid) { 59 | throw new Error('AlchemyService basic validation failed'); 60 | } 61 | 62 | // Try a simple fetch to one of the Alchemy endpoints 63 | const mainnetChain = SUPPORTED_CHAINS.find(chain => chain.id === 1); 64 | if (!mainnetChain) { 65 | throw new Error('No mainnet chain configuration found'); 66 | } 67 | 68 | const response = await fetch(mainnetChain.alchemyUrl, { 69 | method: 'POST', 70 | headers: { 71 | 'Content-Type': 'application/json', 72 | }, 73 | body: JSON.stringify({ 74 | jsonrpc: '2.0', 75 | id: 1, 76 | method: 'eth_blockNumber', 77 | params: [] 78 | }), 79 | // Add timeout 80 | signal: AbortSignal.timeout(10000) // 10 second timeout 81 | }); 82 | 83 | if (!response.ok) { 84 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 85 | } 86 | 87 | const data = await response.json(); 88 | if (!data.result) { 89 | throw new Error('No result in Alchemy response'); 90 | } 91 | 92 | // Connection successful 93 | this.updateStatus(true); 94 | 95 | } catch (error) { 96 | console.error('❌ Alchemy connection check failed:', error); 97 | 98 | let errorMessage = 'Connection failed'; 99 | if (error instanceof Error) { 100 | if (error.name === 'TimeoutError') { 101 | errorMessage = 'Connection timeout'; 102 | } else if (error.message.includes('fetch')) { 103 | errorMessage = 'Network error'; 104 | } else { 105 | errorMessage = error.message; 106 | } 107 | } 108 | 109 | this.updateStatus(false, errorMessage); 110 | } 111 | } 112 | 113 | /** 114 | * Update connection status and notify if changed 115 | */ 116 | private static updateStatus(connected: boolean, errorMessage?: string) { 117 | const previousStatus = this.connectionStatus.connected; 118 | 119 | this.connectionStatus = { 120 | connected, 121 | lastCheck: Date.now(), 122 | errorMessage 123 | }; 124 | 125 | // Notify if status changed 126 | if (previousStatus !== connected) { 127 | console.log(`🔄 Connection status changed: ${connected ? 'Connected' : 'Disconnected'}`); 128 | if (this.onStatusChange) { 129 | this.onStatusChange(this.connectionStatus); 130 | } 131 | } 132 | } 133 | 134 | /** 135 | * Get current connection status 136 | */ 137 | static getStatus(): ConnectionStatus { 138 | return { ...this.connectionStatus }; 139 | } 140 | 141 | /** 142 | * Force a connection check 143 | */ 144 | static async forceCheck(): Promise { 145 | await this.checkConnection(); 146 | return this.getStatus(); 147 | } 148 | } -------------------------------------------------------------------------------- /src/services/ethereumService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ethereum utility service for address validation and normalization 3 | */ 4 | export class EthereumService { 5 | /** 6 | * Check if a string is a valid Ethereum address (40 hex characters) 7 | */ 8 | static isEthereumAddress(str: string): boolean { 9 | // Remove any whitespace and convert to lowercase 10 | const cleaned = str.trim().toLowerCase(); 11 | 12 | // Check if it's exactly 40 hex characters (optionally with 0x prefix) 13 | const hexPattern = /^(0x)?[0-9a-f]{40}$/; 14 | return hexPattern.test(cleaned); 15 | } 16 | 17 | /** 18 | * Normalize Ethereum address (ensure it starts with 0x) 19 | */ 20 | static normalizeEthereumAddress(address: string): string { 21 | const cleaned = address.trim().toLowerCase(); 22 | return cleaned.startsWith('0x') ? cleaned : `0x${cleaned}`; 23 | } 24 | 25 | /** 26 | * Check if an address is the ETH placeholder address 27 | */ 28 | static isEthAddress(address: string): boolean { 29 | return address === '0x0000000000000000000000000000000000000000'; 30 | } 31 | } -------------------------------------------------------------------------------- /src/services/layerswapService.ts: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import { LAYERSWAP_API_KEY, MERCHANT_ADDRESS, MERCHANT_CHAINS, SUPPORTED_CHAINS } from '../config/index.js'; 3 | 4 | interface LayerswapNetwork { 5 | name: string; 6 | display_name: string; 7 | chain_id: string; 8 | tokens: LayerswapToken[]; 9 | } 10 | 11 | interface LayerswapToken { 12 | symbol: string; 13 | contract: string | null; 14 | decimals: number; 15 | } 16 | 17 | interface LayerswapQuote { 18 | source_network: string; 19 | destination_network: string; 20 | source_token: string; 21 | destination_token: string; 22 | receive_amount: number; 23 | total_fee_in_usd: number; 24 | } 25 | 26 | interface LayerswapSwap { 27 | id: string; 28 | destination_address: string; 29 | deposit_address?: string; // Some API responses include this directly 30 | status: string; 31 | requested_amount: number; 32 | use_deposit_address?: boolean; 33 | } 34 | 35 | interface LayerswapDepositAction { 36 | to_address: string; 37 | amount: number; 38 | call_data: string; 39 | token: LayerswapToken; 40 | network: LayerswapNetwork; 41 | } 42 | 43 | export class LayerswapService { 44 | private static supportedNetworks: LayerswapNetwork[] | null = null; 45 | private static merchantNetworkNames: string[] = []; 46 | 47 | /** 48 | * Initialize the service by fetching supported networks and validating merchant chains 49 | */ 50 | static async initialize(): Promise { 51 | console.log('🔄 Initializing Layerswap service...'); 52 | 53 | try { 54 | // Fetch supported networks from Layerswap 55 | await this.fetchSupportedNetworks(); 56 | 57 | // Map merchant chain names to Layerswap network names 58 | this.merchantNetworkNames = await this.mapMerchantChainsToLayerswap(); 59 | 60 | console.log('✅ Layerswap service initialized'); 61 | console.log(`📍 Merchant accepts payments on: ${this.merchantNetworkNames.join(', ')}`); 62 | } catch (error) { 63 | console.error('❌ Failed to initialize Layerswap service:', error); 64 | throw error; 65 | } 66 | } 67 | 68 | /** 69 | * Fetch supported networks from Layerswap V2 API 70 | */ 71 | private static async fetchSupportedNetworks(): Promise { 72 | return new Promise((resolve, reject) => { 73 | const options = { 74 | hostname: 'api.layerswap.io', 75 | port: 443, 76 | path: '/api/v2/networks', 77 | method: 'GET', 78 | headers: { 79 | 'X-LS-APIKEY': LAYERSWAP_API_KEY, 80 | 'Content-Type': 'application/json' 81 | } 82 | }; 83 | 84 | const req = https.request(options, (res) => { 85 | let data = ''; 86 | res.on('data', (chunk) => data += chunk); 87 | res.on('end', () => { 88 | if (res.statusCode === 200) { 89 | try { 90 | const response = JSON.parse(data); 91 | this.supportedNetworks = response.data || response; 92 | console.log(`✅ Fetched ${this.supportedNetworks!.length} networks from Layerswap`); 93 | resolve(); 94 | } catch (e) { 95 | reject(new Error('Failed to parse Layerswap networks response')); 96 | } 97 | } else { 98 | reject(new Error(`Failed to fetch networks: ${res.statusCode}`)); 99 | } 100 | }); 101 | }); 102 | 103 | req.on('error', reject); 104 | req.end(); 105 | }); 106 | } 107 | 108 | /** 109 | * Map merchant chain names to Layerswap network names 110 | */ 111 | private static async mapMerchantChainsToLayerswap(): Promise { 112 | if (!this.supportedNetworks) { 113 | throw new Error('Networks not loaded'); 114 | } 115 | 116 | // If MERCHANT_CHAINS is null, merchant accepts all chains 117 | if (!MERCHANT_CHAINS) { 118 | // Return all networks that we support in our app 119 | const allNetworks = this.supportedNetworks 120 | .filter(network => { 121 | // Only include networks that are in our SUPPORTED_CHAINS 122 | const chainId = parseInt(network.chain_id); 123 | return SUPPORTED_CHAINS.some(chain => chain.id === chainId); 124 | }) 125 | .map(network => network.name); 126 | 127 | console.log(`✅ Merchant accepts all chains. Available networks: ${allNetworks.join(', ')}`); 128 | return allNetworks; 129 | } 130 | 131 | const mappedNetworks: string[] = []; 132 | const unmappedChains: string[] = []; 133 | 134 | for (const merchantChain of MERCHANT_CHAINS) { 135 | // Find matching network in Layerswap 136 | const layerswapNetwork = this.supportedNetworks.find(network => { 137 | const networkNameLower = network.name.toLowerCase(); 138 | const displayNameLower = network.display_name.toLowerCase(); 139 | 140 | // Check if merchant chain matches network name or display name 141 | return networkNameLower.includes(merchantChain) || 142 | displayNameLower === merchantChain || 143 | (merchantChain === 'arbitrum' && networkNameLower === 'arbitrum_mainnet') || 144 | (merchantChain === 'optimism' && networkNameLower === 'optimism_mainnet') || 145 | (merchantChain === 'base' && networkNameLower === 'base_mainnet') || 146 | (merchantChain === 'polygon' && networkNameLower === 'polygon_mainnet'); 147 | }); 148 | 149 | if (layerswapNetwork) { 150 | mappedNetworks.push(layerswapNetwork.name); 151 | console.log(`✅ Mapped merchant chain '${merchantChain}' to Layerswap network '${layerswapNetwork.name}'`); 152 | } else { 153 | unmappedChains.push(merchantChain); 154 | } 155 | } 156 | 157 | if (unmappedChains.length > 0) { 158 | throw new Error(`The following merchant chains are not supported by Layerswap: ${unmappedChains.join(', ')}`); 159 | } 160 | 161 | return mappedNetworks; 162 | } 163 | 164 | /** 165 | * Check if a chain is supported by the merchant 166 | */ 167 | static isMerchantSupportedChain(chainId: number): boolean { 168 | // If MERCHANT_CHAINS is null, merchant accepts all chains 169 | if (!MERCHANT_CHAINS) return true; 170 | 171 | const chain = SUPPORTED_CHAINS.find(c => c.id === chainId); 172 | if (!chain) return false; 173 | 174 | // Check if this chain name is in merchant chains 175 | return MERCHANT_CHAINS.includes(chain.name.toLowerCase()); 176 | } 177 | 178 | /** 179 | * Get Layerswap network name for a chain ID 180 | */ 181 | static getLayerswapNetworkName(chainId: number): string | null { 182 | if (!this.supportedNetworks) return null; 183 | 184 | const chain = SUPPORTED_CHAINS.find(c => c.id === chainId); 185 | if (!chain) return null; 186 | 187 | const network = this.supportedNetworks.find(n => 188 | n.chain_id === chainId.toString() 189 | ); 190 | 191 | return network?.name || null; 192 | } 193 | 194 | /** 195 | * Check if there's a route from source chain to any merchant chain for a token 196 | */ 197 | static async checkRoute(sourceChainId: number, tokenSymbol: string): Promise<{ 198 | hasRoute: boolean; 199 | destinationNetwork?: string; 200 | destinationChainId?: number; 201 | }> { 202 | const sourceNetwork = this.getLayerswapNetworkName(sourceChainId); 203 | if (!sourceNetwork) { 204 | return { hasRoute: false }; 205 | } 206 | 207 | // Check routes to each merchant network 208 | for (const merchantNetwork of this.merchantNetworkNames) { 209 | try { 210 | const quote = await this.getQuote( 211 | sourceNetwork, 212 | merchantNetwork, 213 | tokenSymbol, 214 | tokenSymbol, 215 | 1 // Test with 1 unit 216 | ); 217 | 218 | if (quote) { 219 | // Find the chain ID for this merchant network 220 | const network = this.supportedNetworks?.find(n => n.name === merchantNetwork); 221 | const chainId = network ? parseInt(network.chain_id) : undefined; 222 | 223 | return { 224 | hasRoute: true, 225 | destinationNetwork: merchantNetwork, 226 | destinationChainId: chainId 227 | }; 228 | } 229 | } catch (e) { 230 | // Continue checking other merchant networks 231 | continue; 232 | } 233 | } 234 | 235 | return { hasRoute: false }; 236 | } 237 | 238 | /** 239 | * Get a quote from Layerswap 240 | */ 241 | private static async getQuote( 242 | sourceNetwork: string, 243 | destinationNetwork: string, 244 | sourceToken: string, 245 | destinationToken: string, 246 | amount: number 247 | ): Promise { 248 | return new Promise((resolve) => { 249 | const queryParams = new URLSearchParams({ 250 | source_network: sourceNetwork, 251 | destination_network: destinationNetwork, 252 | source_token: sourceToken, 253 | destination_token: destinationToken, 254 | amount: amount.toString(), 255 | refuel: 'false', 256 | use_deposit_address: 'true' 257 | }); 258 | 259 | const options = { 260 | hostname: 'api.layerswap.io', 261 | port: 443, 262 | path: `/api/v2/quote?${queryParams}`, 263 | method: 'GET', 264 | headers: { 265 | 'X-LS-APIKEY': LAYERSWAP_API_KEY, 266 | 'Content-Type': 'application/json' 267 | } 268 | }; 269 | 270 | const req = https.request(options, (res) => { 271 | let data = ''; 272 | res.on('data', (chunk) => data += chunk); 273 | res.on('end', () => { 274 | if (res.statusCode === 200 && data) { 275 | try { 276 | const quote = JSON.parse(data); 277 | resolve(quote); 278 | } catch (e) { 279 | resolve(null); 280 | } 281 | } else { 282 | resolve(null); 283 | } 284 | }); 285 | }); 286 | 287 | req.on('error', () => resolve(null)); 288 | req.end(); 289 | }); 290 | } 291 | 292 | /** 293 | * Create a swap on Layerswap 294 | */ 295 | static async createSwap( 296 | sourceChainId: number, 297 | destinationChainId: number, 298 | tokenSymbol: string, 299 | amount: number 300 | ): Promise<{ 301 | swapId: string; 302 | depositAddress: string; 303 | depositAmount: number; 304 | callData: string; 305 | tokenContract: string; 306 | } | null> { 307 | const sourceNetwork = this.getLayerswapNetworkName(sourceChainId); 308 | const destinationNetwork = this.getLayerswapNetworkName(destinationChainId); 309 | 310 | if (!sourceNetwork || !destinationNetwork) { 311 | console.error('Failed to map chain IDs to Layerswap networks'); 312 | return null; 313 | } 314 | 315 | return new Promise((resolve) => { 316 | const swapData = { 317 | source_network: sourceNetwork, 318 | destination_network: destinationNetwork, 319 | source_token: tokenSymbol, 320 | destination_token: tokenSymbol, 321 | destination_address: MERCHANT_ADDRESS, 322 | amount: amount, 323 | quote_id: null, 324 | use_deposit_address: true, 325 | reference_id: Date.now().toString() 326 | }; 327 | 328 | console.log('\n📤 Sending swap request to Layerswap:'); 329 | console.log(JSON.stringify(swapData, null, 2)); 330 | 331 | const options = { 332 | hostname: 'api.layerswap.io', 333 | port: 443, 334 | path: '/api/v2/swaps', 335 | method: 'POST', 336 | headers: { 337 | 'X-LS-APIKEY': LAYERSWAP_API_KEY, 338 | 'Content-Type': 'application/json' 339 | } 340 | }; 341 | 342 | const req = https.request(options, (res) => { 343 | let data = ''; 344 | res.on('data', (chunk) => data += chunk); 345 | res.on('end', () => { 346 | console.log(`\n📥 Layerswap API Response:`); 347 | console.log(` Status: ${res.statusCode}`); 348 | console.log(` Headers:`, res.headers); 349 | 350 | if (res.statusCode === 200 || res.statusCode === 201) { 351 | try { 352 | console.log(` Raw response length: ${data.length} bytes`); 353 | const response = JSON.parse(data); 354 | 355 | console.log(`\n📋 Response structure:`); 356 | console.log(` Has 'data' field: ${!!response.data}`); 357 | console.log(` Response keys: ${Object.keys(response).join(', ')}`); 358 | 359 | if (response.data) { 360 | console.log(` Data keys: ${Object.keys(response.data).join(', ')}`); 361 | console.log(` Has swap: ${!!response.data.swap}`); 362 | console.log(` Has deposit_actions: ${!!response.data.deposit_actions}`); 363 | 364 | if (response.data.deposit_actions) { 365 | console.log(` Deposit actions count: ${response.data.deposit_actions.length}`); 366 | if (response.data.deposit_actions.length > 0) { 367 | const action = response.data.deposit_actions[0]; 368 | console.log(` First deposit action keys: ${Object.keys(action).join(', ')}`); 369 | console.log(` Has call_data: ${!!action.call_data}`); 370 | console.log(` Action type: ${action.type}`); 371 | console.log(` To address: ${action.to_address}`); 372 | console.log(` Amount: ${action.amount}`); 373 | } 374 | } 375 | 376 | if (response.data.swap) { 377 | console.log(`\n📄 Swap details:`); 378 | console.log(` ID: ${response.data.swap.id}`); 379 | console.log(` Status: ${response.data.swap.status}`); 380 | console.log(` Requested amount: ${response.data.swap.requested_amount}`); 381 | console.log(` Use deposit address: ${response.data.swap.use_deposit_address}`); 382 | } 383 | } 384 | 385 | // Log the full response for debugging 386 | console.log(`\n🔍 Full API response:`, JSON.stringify(response, null, 2)); 387 | 388 | const swap = response.data?.swap; 389 | const depositAction = response.data?.deposit_actions?.[0]; 390 | 391 | if (depositAction && depositAction.call_data) { 392 | // Decode the calldata to get the deposit address 393 | const toAddress = '0x' + depositAction.call_data.slice(34, 74); 394 | 395 | console.log(`\n✅ Successfully extracted deposit info:`); 396 | console.log(` Swap ID: ${swap.id}`); 397 | console.log(` Deposit address: ${toAddress}`); 398 | console.log(` Amount: ${swap.requested_amount}`); 399 | 400 | resolve({ 401 | swapId: swap.id, 402 | depositAddress: toAddress, 403 | depositAmount: swap.requested_amount, 404 | callData: depositAction.call_data, 405 | tokenContract: depositAction.token.contract || '' 406 | }); 407 | } else if (depositAction && depositAction.to_address) { 408 | // Sometimes the deposit address is provided directly 409 | console.log(`\n✅ Using direct deposit address:`); 410 | console.log(` Swap ID: ${swap.id}`); 411 | console.log(` Deposit address: ${depositAction.to_address}`); 412 | console.log(` Amount: ${swap.requested_amount}`); 413 | 414 | resolve({ 415 | swapId: swap.id, 416 | depositAddress: depositAction.to_address, 417 | depositAmount: swap.requested_amount, 418 | callData: depositAction.call_data || '', 419 | tokenContract: depositAction.token?.contract || '' 420 | }); 421 | } else if (swap && swap.deposit_address) { 422 | // Sometimes the deposit address is on the swap object itself 423 | console.log('\n✅ Using deposit address from swap object:'); 424 | console.log(` Swap ID: ${swap.id}`); 425 | console.log(` Deposit address: ${swap.deposit_address}`); 426 | console.log(` Amount: ${swap.requested_amount}`); 427 | 428 | resolve({ 429 | swapId: swap.id, 430 | depositAddress: swap.deposit_address, 431 | depositAmount: swap.requested_amount, 432 | callData: '', 433 | tokenContract: '' 434 | }); 435 | } else { 436 | console.error('\n❌ No valid deposit action in swap response'); 437 | console.error('Expected deposit_actions array with call_data or to_address, or swap.deposit_address'); 438 | console.error('Available swap fields:', swap ? Object.keys(swap).join(', ') : 'No swap object'); 439 | resolve(null); 440 | } 441 | } catch (e) { 442 | console.error('\n❌ Failed to parse swap response:', e); 443 | console.error('Raw response:', data.substring(0, 500)); 444 | resolve(null); 445 | } 446 | } else { 447 | console.error(`\n❌ Swap creation failed: ${res.statusCode}`); 448 | console.error('Response body:', data || '(empty)'); 449 | 450 | // Try to parse error details if available 451 | if (data) { 452 | try { 453 | const errorResponse = JSON.parse(data); 454 | console.error('Error details:', JSON.stringify(errorResponse, null, 2)); 455 | } catch (e) { 456 | console.error('Raw error response:', data); 457 | } 458 | } 459 | 460 | // Log request details for debugging 461 | console.error('\n🔍 Request details that failed:'); 462 | console.error(` URL: https://${options.hostname}${options.path}`); 463 | console.error(` Method: ${options.method}`); 464 | console.error(` Headers:`, options.headers); 465 | 466 | resolve(null); 467 | } 468 | }); 469 | }); 470 | 471 | req.on('error', (error) => { 472 | console.error('Request error:', error); 473 | resolve(null); 474 | }); 475 | 476 | req.write(JSON.stringify(swapData)); 477 | req.end(); 478 | }); 479 | } 480 | } -------------------------------------------------------------------------------- /src/services/nfcService.ts: -------------------------------------------------------------------------------- 1 | import { NFC, Reader } from 'nfc-pcsc'; 2 | import { AID } from '../config/index.js'; 3 | import { CardData } from '../types/index.js'; 4 | import { EthereumService } from './ethereumService.js'; 5 | import { AddressProcessor } from './addressProcessor.js'; 6 | import { AlchemyService } from './alchemyService.js'; 7 | import { PaymentService } from './paymentService.js'; 8 | import { CAIP10Service } from './caip10Service.js'; 9 | import { broadcast } from '../server.js'; 10 | 11 | /** 12 | * Service for handling NFC reader operations 13 | */ 14 | export class NFCService { 15 | private nfc: NFC; 16 | private paymentArmed: boolean = false; 17 | private walletScanArmed: boolean = false; 18 | private currentPaymentAmount: number | null = null; 19 | private cardHandlerPromise: Promise<{ success: boolean; message: string; errorType?: string; paymentInfo?: any }> | null = null; 20 | private cardHandlerResolve: ((result: { success: boolean; message: string; errorType?: string; paymentInfo?: any }) => void) | null = null; 21 | private walletScanPromise: Promise<{ success: boolean; message: string; address?: string; errorType?: string }> | null = null; 22 | private walletScanResolve: ((result: { success: boolean; message: string; address?: string; errorType?: string }) => void) | null = null; 23 | 24 | // Add instance tracking 25 | private static instanceCount = 0; 26 | private instanceId: number; 27 | 28 | constructor() { 29 | NFCService.instanceCount++; 30 | this.instanceId = NFCService.instanceCount; 31 | console.log(`🏗️ DEBUG: Creating NFCService instance #${this.instanceId} (total instances: ${NFCService.instanceCount})`); 32 | 33 | this.nfc = new NFC(); 34 | this.setupNFC(); 35 | } 36 | 37 | /** 38 | * Setup NFC readers and event handlers 39 | */ 40 | private setupNFC(): void { 41 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - Setting up NFC readers`); 42 | this.nfc.on('reader', (reader: Reader) => { 43 | console.log(`💳 Instance #${this.instanceId} - NFC Reader Detected:`, reader.name); 44 | reader.aid = AID; // ★ IMPORTANT ★ Set AID immediately 45 | console.log(`🔑 Instance #${this.instanceId} - AID set for reader:`, AID); 46 | broadcast({ type: 'nfc_status', message: `Reader connected: ${reader.name}`}); 47 | this.setupReaderEvents(reader); 48 | }); 49 | } 50 | 51 | /** 52 | * Setup event handlers for a specific reader 53 | */ 54 | private setupReaderEvents(reader: Reader): void { 55 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - Setting up event handlers for reader: ${reader.name}`); 56 | 57 | // Use arrow functions to preserve 'this' context 58 | (reader as any).on('card', async (card: CardData) => { 59 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - Card event handler called, this.paymentArmed = ${this.paymentArmed}`); 60 | await this.handleCard(reader, card); 61 | }); 62 | 63 | (reader as any).on('error', (err: Error) => { 64 | if (err.message.includes('Cannot process ISO 14443-4 tag')) { 65 | console.log(`💳 Instance #${this.instanceId} - Payment card detected - ignoring tap`); 66 | broadcast({ type: 'nfc_status', message: 'Payment card detected - not supported' }); 67 | return; 68 | } 69 | console.error(`❌ Instance #${this.instanceId} - Reader error:`, err); 70 | }); 71 | 72 | (reader as any).on('end', () => { 73 | console.log(`🔌 Instance #${this.instanceId} - Reader disconnected:`, reader.name); 74 | broadcast({ type: 'nfc_status', message: `Reader disconnected: ${reader.name}` }); 75 | }); 76 | } 77 | 78 | /** 79 | * Handle card detection and processing 80 | */ 81 | private async handleCard(reader: Reader, card: CardData): Promise { 82 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - Card event handler called, this.paymentArmed = ${this.paymentArmed}`); 83 | console.log('📱 Card Detected:', { 84 | type: card.type, 85 | standard: card.standard 86 | }); 87 | 88 | // Debug: Log current armed state when card is detected 89 | console.log(`🔍 DEBUG: Instance #${this.instanceId} - Armed state check - paymentArmed: ${this.paymentArmed}, walletScanArmed: ${this.walletScanArmed}`); 90 | console.log(`🔍 DEBUG: Instance #${this.instanceId} - Current payment amount: ${this.currentPaymentAmount}`); 91 | console.log(`🔍 DEBUG: Instance #${this.instanceId} - Card handler resolve exists: ${!!this.cardHandlerResolve}`); 92 | 93 | if (!this.paymentArmed && !this.walletScanArmed) { 94 | console.log(`💤 Instance #${this.instanceId} - Reader not armed for payment or wallet scan, ignoring tap`); 95 | broadcast({ type: 'nfc_status', message: 'Reader not armed' }); 96 | return; 97 | } 98 | 99 | let processedAddress: string | null = null; 100 | 101 | try { 102 | // Always send wallet:address command as NDEF URI to get the wallet address 103 | const walletUri = 'wallet:address'; 104 | console.log(`📡 Sending NDEF URI: ${walletUri}`); 105 | 106 | // Use PaymentService's createNDEFUriRecord to format the URI 107 | const ndefMessage = PaymentService.createNDEFUriRecord(walletUri); 108 | 109 | // @ts-expect-error Argument of type '{}' is not assignable to parameter of type 'never'. 110 | const resp = await reader.transmit(ndefMessage, 256, {}); 111 | 112 | // Check if we got a valid response 113 | if (!resp || resp.length === 0) { 114 | throw new Error('No response from device'); 115 | } 116 | 117 | const phoneResponse = resp.toString(); 118 | console.log('📱 Phone says →', phoneResponse); 119 | 120 | // Check if this is a CAIP-10 address or regular Ethereum address 121 | let ethAddress: string | null = null; 122 | 123 | if (CAIP10Service.isCAIP10Address(phoneResponse)) { 124 | // Extract Ethereum address from CAIP-10 format 125 | ethAddress = CAIP10Service.extractEthereumAddress(phoneResponse); 126 | if (ethAddress) { 127 | console.log(`✓ Extracted Ethereum address from CAIP-10: ${ethAddress}`); 128 | processedAddress = ethAddress; 129 | } 130 | } else if (EthereumService.isEthereumAddress(phoneResponse)) { 131 | // Handle legacy plain Ethereum address format 132 | ethAddress = EthereumService.normalizeEthereumAddress(phoneResponse); 133 | processedAddress = ethAddress; 134 | } 135 | 136 | if (this.walletScanArmed) { 137 | await this.processWalletScan(phoneResponse, reader); 138 | } else if (this.paymentArmed && this.currentPaymentAmount !== null) { 139 | await this.processPhoneResponse(phoneResponse, reader, this.currentPaymentAmount); 140 | } 141 | 142 | } catch (e) { 143 | console.error('❌ Error processing card:', e); 144 | 145 | // Clean up any address that might be stuck in processing state 146 | if (processedAddress) { 147 | AddressProcessor.finishProcessing(processedAddress); 148 | } 149 | 150 | if (this.cardHandlerResolve) { 151 | this.cardHandlerResolve({ success: false, message: 'Error processing card', errorType: 'CARD_ERROR' }); 152 | this.cardHandlerResolve = null; 153 | } 154 | } 155 | // Note: Do NOT close the reader here - it needs to stay connected for future card detections 156 | } 157 | 158 | /** 159 | * Process the response from the phone 160 | */ 161 | private async processPhoneResponse(phoneResponse: string, reader: Reader, amount: number): Promise { 162 | let ethAddress: string | null = null; 163 | let chainId: number = 1; // Default to Ethereum mainnet 164 | 165 | // Check if this is a CAIP-10 address or regular Ethereum address 166 | if (CAIP10Service.isCAIP10Address(phoneResponse)) { 167 | const parsed = CAIP10Service.parseCAIP10Address(phoneResponse); 168 | if (parsed && parsed.namespace === 'eip155') { 169 | ethAddress = CAIP10Service.extractEthereumAddress(phoneResponse); 170 | chainId = parsed.chainId || 1; 171 | console.log(`✓ Detected CAIP-10 Ethereum address: ${ethAddress} on chain ${chainId}`); 172 | } 173 | } else if (EthereumService.isEthereumAddress(phoneResponse)) { 174 | // Handle legacy plain Ethereum address format 175 | ethAddress = EthereumService.normalizeEthereumAddress(phoneResponse); 176 | console.log(`✓ Detected Ethereum address: ${ethAddress}`); 177 | } 178 | 179 | if (ethAddress) { 180 | const transactionFlowStart = Date.now(); 181 | console.log(`⏱️ [PROFILE] Starting transaction flow for $${amount} payment`); 182 | 183 | // Check if the address can be processed 184 | if (!AddressProcessor.canProcessAddress(ethAddress)) { 185 | const blockReason = AddressProcessor.getProcessingBlockReason(ethAddress); 186 | console.log(`🚫 Address ${ethAddress} cannot be processed: ${blockReason}`); 187 | if (this.cardHandlerResolve) { 188 | this.cardHandlerResolve({ success: false, message: blockReason || 'Address cannot be processed', errorType: 'DUPLICATE_ADDRESS' }); 189 | this.cardHandlerResolve = null; 190 | } 191 | return; 192 | } 193 | 194 | // Mark the address as being processed 195 | console.log(`🔄 Starting to process address: ${ethAddress}`); 196 | AddressProcessor.startProcessing(ethAddress); 197 | 198 | let paymentSuccessful = false; 199 | 200 | try { 201 | // Update UI to show loading tokens 202 | broadcast({ type: 'status', message: 'Loading tokens...' }); 203 | 204 | let portfolio; 205 | try { 206 | // Fetch balances from Alchemy API across all supported chains 207 | const balanceFetchStart = Date.now(); 208 | portfolio = await AlchemyService.fetchMultiChainBalances(ethAddress); 209 | const balanceFetchTime = Date.now() - balanceFetchStart; 210 | console.log(`⏱️ [PROFILE] Total balance fetch time: ${balanceFetchTime}ms`); 211 | } catch (fetchError: any) { 212 | console.error('💥 Error fetching tokens from Alchemy:', fetchError); 213 | throw new Error('FAILED_TO_FETCH_TOKENS'); 214 | } 215 | 216 | // Calculate and send payment request using all tokens across all chains 217 | const paymentStart = Date.now(); 218 | const paymentInfo = await PaymentService.calculateAndSendPayment(portfolio.allTokens, reader, amount); 219 | const paymentTime = Date.now() - paymentStart; 220 | console.log(`⏱️ [PROFILE] Total payment processing time: ${paymentTime}ms`); 221 | 222 | // Update UI to show waiting for payment 223 | broadcast({ type: 'status', message: 'Waiting for payment...' }); 224 | 225 | paymentSuccessful = true; // Payment request was sent successfully 226 | 227 | const totalTransactionTime = Date.now() - transactionFlowStart; 228 | console.log(`⏱️ [PROFILE] COMPLETE TRANSACTION FLOW: ${totalTransactionTime}ms`); 229 | console.log(`⏱️ [PROFILE] BREAKDOWN: Balance fetch: ${Date.now() - transactionFlowStart - paymentTime}ms, Payment: ${paymentTime}ms`); 230 | 231 | if (this.cardHandlerResolve) { 232 | this.cardHandlerResolve({ 233 | success: true, 234 | message: `Payment request for $${amount.toFixed(2)} sent to ${ethAddress}`, 235 | paymentInfo 236 | }); 237 | this.cardHandlerResolve = null; 238 | } 239 | 240 | } catch (balanceError: any) { 241 | console.error('💥 Error processing address balances/payment:', balanceError); 242 | console.log(`🧹 Cleaning up address ${ethAddress} due to error: ${balanceError.message}`); 243 | 244 | if (balanceError.message === 'PHONE_MOVED_TOO_QUICKLY') { 245 | // For phone moved too quickly, just broadcast the error but keep waiting for another tap 246 | console.log('📱💨 Phone moved too quickly - broadcasting error but staying armed for retry'); 247 | broadcast({ 248 | type: 'payment_failure', 249 | message: 'Phone moved too quickly', 250 | errorType: 'PHONE_MOVED_TOO_QUICKLY' 251 | }); 252 | 253 | // Don't resolve the promise - keep waiting for another tap 254 | // Just clean up the current address processing 255 | AddressProcessor.finishProcessing(ethAddress); 256 | return; // Exit without resolving the promise 257 | } 258 | 259 | paymentSuccessful = false; 260 | 261 | if (this.cardHandlerResolve) { 262 | // Check for specific error types and handle them appropriately 263 | let errorMessage: string; 264 | let errorType: string; 265 | 266 | if (balanceError.message === 'FAILED_TO_FETCH_TOKENS') { 267 | errorMessage = 'Failed to fetch tokens'; 268 | errorType = 'TOKEN_FETCH_ERROR'; 269 | } else if (balanceError.message === "Customer doesn't have enough funds") { 270 | errorMessage = balanceError.message; 271 | errorType = 'PAYMENT_ERROR'; 272 | } else { 273 | errorMessage = 'Error processing payment'; 274 | errorType = 'PAYMENT_ERROR'; 275 | } 276 | 277 | this.cardHandlerResolve({ success: false, message: errorMessage, errorType: errorType }); 278 | this.cardHandlerResolve = null; 279 | } 280 | } finally { 281 | // Mark the address processing as complete 282 | console.log(`🏁 Finishing processing for address: ${ethAddress} (successful: ${paymentSuccessful})`); 283 | 284 | // No more cooldown - just finish processing for all cases 285 | if (ethAddress) { 286 | AddressProcessor.finishProcessing(ethAddress); 287 | } 288 | } 289 | } else { 290 | console.log('📱 Response is not a valid Ethereum address'); 291 | if (this.cardHandlerResolve) { 292 | this.cardHandlerResolve({ success: false, message: 'Invalid or non-Ethereum address', errorType: 'INVALID_ADDRESS' }); 293 | this.cardHandlerResolve = null; 294 | } 295 | } 296 | } 297 | 298 | /** 299 | * Start the NFC service 300 | */ 301 | public startListening(): void { 302 | console.log('🟢 NFCService: Starting to listen for readers...'); 303 | console.log('📡 NFC Service is now listening for readers.'); 304 | } 305 | 306 | /** 307 | * Arm the service for payment and wait for a card tap 308 | */ 309 | public async armForPaymentAndAwaitTap(amount: number): Promise<{ success: boolean; message: string; errorType?: string; paymentInfo?: any }> { 310 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - Arming payment service for $${amount.toFixed(2)}`); 311 | 312 | // Clean up any leftover state from previous sessions 313 | if (this.paymentArmed || this.cardHandlerResolve || this.cardHandlerPromise) { 314 | console.log(`⚠️ WARNING: Instance #${this.instanceId} - Found leftover payment state, cleaning up...`); 315 | console.log(`🔍 Previous state - paymentArmed: ${this.paymentArmed}, cardHandlerResolve: ${!!this.cardHandlerResolve}, cardHandlerPromise: ${!!this.cardHandlerPromise}`); 316 | this.disarmPayment(); 317 | } 318 | 319 | this.paymentArmed = true; 320 | this.currentPaymentAmount = amount; 321 | console.log(`💰 NFCService: Instance #${this.instanceId} - Armed for payment of $${amount.toFixed(2)}. Waiting for tap...`); 322 | console.log(`🔍 DEBUG: Instance #${this.instanceId} - After arming - paymentArmed: ${this.paymentArmed}, amount: ${this.currentPaymentAmount}`); 323 | 324 | // Debug: Show current address processing state 325 | AddressProcessor.debugState(); 326 | 327 | // Create a promise that will be resolved when a card is processed 328 | this.cardHandlerPromise = new Promise((resolve) => { 329 | this.cardHandlerResolve = resolve; 330 | }); 331 | 332 | // Set a timeout for the payment (30 seconds) 333 | const timeoutId = setTimeout(() => { 334 | console.log(`⏰ DEBUG: Payment timeout reached, disarming...`); 335 | if (this.cardHandlerResolve) { 336 | this.cardHandlerResolve({ success: false, message: 'Payment timeout', errorType: 'TIMEOUT' }); 337 | this.cardHandlerResolve = null; 338 | } 339 | this.disarmPayment(); 340 | }, 30000); 341 | 342 | try { 343 | const result = await this.cardHandlerPromise; 344 | console.log(`🔧 DEBUG: Card handler promise resolved, clearing timeout and disarming`); 345 | clearTimeout(timeoutId); 346 | this.disarmPayment(); 347 | return result; 348 | } catch (error) { 349 | console.log(`🔧 DEBUG: Card handler promise error, clearing timeout and disarming`); 350 | clearTimeout(timeoutId); 351 | this.disarmPayment(); 352 | return { success: false, message: 'Payment processing error', errorType: 'PROCESSING_ERROR' }; 353 | } 354 | } 355 | 356 | /** 357 | * Disarm the payment service 358 | */ 359 | private disarmPayment(): void { 360 | console.log(`🔧 DEBUG: Instance #${this.instanceId} - disarmPayment() called - was armed: ${this.paymentArmed}`); 361 | this.paymentArmed = false; 362 | this.currentPaymentAmount = null; 363 | this.cardHandlerPromise = null; 364 | this.cardHandlerResolve = null; 365 | 366 | // Clean up any stuck address processing states when disarming 367 | // This is a safety measure to ensure addresses don't stay locked 368 | console.log(`🧹 Instance #${this.instanceId} - Cleaning up any stuck address processing states...`); 369 | AddressProcessor.clearAllProcessing(); 370 | } 371 | 372 | /** 373 | * Process wallet address scan response 374 | */ 375 | private async processWalletScan(phoneResponse: string, reader: Reader): Promise { 376 | let ethAddress: string | null = null; 377 | let chainId: number | undefined; 378 | 379 | // Check if this is a CAIP-10 address or regular Ethereum address 380 | if (CAIP10Service.isCAIP10Address(phoneResponse)) { 381 | const parsed = CAIP10Service.parseCAIP10Address(phoneResponse); 382 | if (parsed && parsed.namespace === 'eip155') { 383 | ethAddress = CAIP10Service.extractEthereumAddress(phoneResponse); 384 | chainId = parsed.chainId; 385 | console.log(`✓ Wallet CAIP-10 address scanned: ${phoneResponse}`); 386 | console.log(` → Ethereum address: ${ethAddress} on chain ${chainId}`); 387 | } else { 388 | console.log(`⚠️ Non-Ethereum CAIP-10 address: ${phoneResponse}`); 389 | } 390 | } else if (EthereumService.isEthereumAddress(phoneResponse)) { 391 | // Handle legacy plain Ethereum address format 392 | ethAddress = EthereumService.normalizeEthereumAddress(phoneResponse); 393 | console.log(`✓ Wallet address scanned: ${ethAddress}`); 394 | } 395 | 396 | if (ethAddress) { 397 | if (this.walletScanResolve) { 398 | this.walletScanResolve({ 399 | success: true, 400 | message: `Wallet scanned successfully`, 401 | address: ethAddress 402 | }); 403 | this.walletScanResolve = null; 404 | } 405 | } else { 406 | console.log('📱 Response is not a valid Ethereum address'); 407 | if (this.walletScanResolve) { 408 | this.walletScanResolve({ 409 | success: false, 410 | message: 'Invalid or non-Ethereum address', 411 | errorType: 'INVALID_ADDRESS' 412 | }); 413 | this.walletScanResolve = null; 414 | } 415 | } 416 | } 417 | 418 | /** 419 | * Scan for wallet address (for transaction history filtering) 420 | */ 421 | public async scanForWalletAddress(): Promise<{ success: boolean; message: string; address?: string; errorType?: string }> { 422 | this.walletScanArmed = true; 423 | console.log('🔍 NFCService: Armed for wallet address scan. Waiting for tap...'); 424 | 425 | // Create a promise that will be resolved when a wallet is scanned 426 | this.walletScanPromise = new Promise((resolve) => { 427 | this.walletScanResolve = resolve; 428 | }); 429 | 430 | // Set a timeout for the scan (30 seconds) 431 | const timeoutId = setTimeout(() => { 432 | if (this.walletScanResolve) { 433 | this.walletScanResolve({ success: false, message: 'Wallet scan timeout', errorType: 'TIMEOUT' }); 434 | this.walletScanResolve = null; 435 | } 436 | this.disarmWalletScan(); 437 | }, 30000); 438 | 439 | try { 440 | const result = await this.walletScanPromise; 441 | clearTimeout(timeoutId); 442 | this.disarmWalletScan(); 443 | return result; 444 | } catch (error) { 445 | clearTimeout(timeoutId); 446 | this.disarmWalletScan(); 447 | return { success: false, message: 'Wallet scan processing error', errorType: 'PROCESSING_ERROR' }; 448 | } 449 | } 450 | 451 | /** 452 | * Disarm the wallet scan service 453 | */ 454 | private disarmWalletScan(): void { 455 | this.walletScanArmed = false; 456 | this.walletScanPromise = null; 457 | this.walletScanResolve = null; 458 | } 459 | 460 | /** 461 | * Cancel any ongoing operations (payment or wallet scan) 462 | */ 463 | public cancelCurrentOperation(): void { 464 | console.log('🚫 Cancelling current NFC operation...'); 465 | console.log(`🔧 DEBUG: cancelCurrentOperation() - paymentArmed: ${this.paymentArmed}, walletScanArmed: ${this.walletScanArmed}`); 466 | 467 | // Cancel payment operation if active 468 | if (this.paymentArmed && this.cardHandlerResolve) { 469 | console.log('🚫 Cancelling ongoing payment operation'); 470 | this.cardHandlerResolve({ 471 | success: false, 472 | message: 'Payment cancelled by user', 473 | errorType: 'USER_CANCELLED' 474 | }); 475 | this.cardHandlerResolve = null; 476 | this.disarmPayment(); 477 | } 478 | 479 | // Cancel wallet scan operation if active 480 | if (this.walletScanArmed && this.walletScanResolve) { 481 | console.log('🚫 Cancelling ongoing wallet scan operation'); 482 | this.walletScanResolve({ 483 | success: false, 484 | message: 'Wallet scan cancelled by user', 485 | errorType: 'USER_CANCELLED' 486 | }); 487 | this.walletScanResolve = null; 488 | this.disarmWalletScan(); 489 | } 490 | 491 | // Clean up any stuck address processing states 492 | AddressProcessor.clearAllProcessing(); 493 | 494 | console.log('✅ NFC operation cancelled successfully'); 495 | } 496 | 497 | /** 498 | * Stop the NFC service 499 | */ 500 | public stopListening(): void { 501 | console.log('🔴 NFCService: Stopping listeners...'); 502 | // Add any cleanup logic here if needed 503 | } 504 | } -------------------------------------------------------------------------------- /src/services/paymentService.ts: -------------------------------------------------------------------------------- 1 | import { Reader } from 'nfc-pcsc'; 2 | import { MERCHANT_ADDRESS, SUPPORTED_CHAINS } from '../config/index.js'; 3 | import { TokenWithPrice } from '../types/index.js'; 4 | import { EthereumService } from './ethereumService.js'; 5 | import { BridgeManager } from './bridgeManager.js'; 6 | 7 | // Export the payment result type for use in other modules 8 | export interface PaymentResult { 9 | selectedToken: TokenWithPrice; 10 | requiredAmount: bigint; 11 | chainId: number; 12 | chainName: string; 13 | isLayerswap?: boolean; 14 | layerswapDepositAddress?: string; 15 | layerswapSwapId?: string; 16 | } 17 | 18 | /** 19 | * Service for handling payment requests and EIP-681 URI generation 20 | */ 21 | export class PaymentService { 22 | /** 23 | * Get chain name from chain ID for logging 24 | */ 25 | private static getChainName(chainId: number): string { 26 | const chain = SUPPORTED_CHAINS.find(c => c.id === chainId); 27 | return chain ? chain.displayName : `Chain ${chainId}`; 28 | } 29 | 30 | /** 31 | * Generate EIP-681 format URI for payment request with chain ID support 32 | */ 33 | static generateEIP681Uri(amount: bigint, tokenAddress: string, chainId: number): string { 34 | const amountString = amount.toString(); 35 | 36 | if (EthereumService.isEthAddress(tokenAddress)) { 37 | // ETH payment request with chain ID 38 | // Format: ethereum:@?value= 39 | return `ethereum:${MERCHANT_ADDRESS}@${chainId}?value=${amountString}`; 40 | } else { 41 | // ERC-20 token payment request with chain ID 42 | // Format: ethereum:@/transfer?address=&uint256= 43 | return `ethereum:${tokenAddress}@${chainId}/transfer?address=${MERCHANT_ADDRESS}&uint256=${amountString}`; 44 | } 45 | } 46 | 47 | 48 | /** 49 | * Create NDEF URI record for any URI 50 | * This formats the URI so Android will automatically open it with appropriate apps 51 | */ 52 | static createNDEFUriRecord(uri: string): Buffer { 53 | // NDEF URI Record structure: 54 | // - Record Header: TNF (3 bits) + flags (5 bits) 55 | // - Type Length: 1 byte 56 | // - Payload Length: 1-4 bytes 57 | // - Type: "U" for URI 58 | // - Payload: URI abbreviation code + URI 59 | 60 | const uriBytes = Buffer.from(uri, 'utf8'); 61 | 62 | // URI abbreviation codes - 0x00 means no abbreviation (full URI) 63 | const uriAbbreviation = 0x00; 64 | 65 | // NDEF Record Header 66 | // TNF = 001 (Well Known), MB=1 (Message Begin), ME=1 (Message End), SR=1 (Short Record) 67 | const recordHeader = 0xD1; // 11010001 binary 68 | 69 | // Type Length (always 1 for URI records) 70 | const typeLength = 0x01; 71 | 72 | // Payload Length (URI abbreviation byte + URI bytes) 73 | const payloadLength = uriBytes.length + 1; 74 | 75 | // Type field ("U" for URI) 76 | const recordType = Buffer.from('U', 'ascii'); 77 | 78 | // Create the complete NDEF message 79 | const ndefMessage = Buffer.concat([ 80 | Buffer.from([recordHeader]), // Record header 81 | Buffer.from([typeLength]), // Type length 82 | Buffer.from([payloadLength]), // Payload length 83 | recordType, // Type ("U") 84 | Buffer.from([uriAbbreviation]), // URI abbreviation code 85 | uriBytes // The actual URI 86 | ]); 87 | 88 | return ndefMessage; 89 | } 90 | 91 | /** 92 | * Send payment request via NFC using NDEF formatting 93 | * This will make Android automatically open the URI with wallet apps 94 | */ 95 | static async sendPaymentRequest(reader: Reader, amount: bigint, tokenAddress: string, decimals: number, chainId: number): Promise { 96 | try { 97 | const eip681Uri = this.generateEIP681Uri(amount, tokenAddress, chainId); 98 | 99 | const chainName = this.getChainName(chainId); 100 | console.log(`\n💳 Sending EIP-681 payment request for ${chainName} (Chain ID: ${chainId}):`); 101 | console.log(`📄 URI: ${eip681Uri}`); 102 | 103 | // Create NDEF URI record 104 | const ndefMessage = this.createNDEFUriRecord(eip681Uri); 105 | 106 | console.log(`📡 NDEF Message (${ndefMessage.length} bytes): ${ndefMessage.toString('hex')}`); 107 | 108 | // Send the NDEF formatted URI 109 | // @ts-expect-error Argument of type '{}' is not assignable to parameter of type 'never'. 110 | const response = await reader.transmit(ndefMessage, 256, {}); 111 | 112 | if (response && response.length > 0) { 113 | console.log(`✅ NDEF payment request sent successfully for ${chainName}!`); 114 | console.log('📱 Wallet app should now open with transaction details...'); 115 | const phoneResponse = response.toString(); 116 | if (phoneResponse) { 117 | console.log(`📱 Phone response: ${phoneResponse}`); 118 | } 119 | } else { 120 | console.log(`❌ No response received from device`); 121 | } 122 | } catch (error: any) { 123 | console.error('Error sending payment request:', error); 124 | 125 | // Check for specific NFC transmission errors that indicate phone moved too quickly 126 | if (error.code === 'failure' && 127 | (error.message?.includes('An error occurred while transmitting') || 128 | error.message?.includes('TransmitError') || 129 | error.previous?.message?.includes('SCardTransmit error') || 130 | error.previous?.message?.includes('Transaction failed'))) { 131 | console.log('📱💨 Phone moved too quickly during payment request transmission'); 132 | throw new Error('PHONE_MOVED_TOO_QUICKLY'); 133 | } 134 | 135 | // Re-throw other errors as-is 136 | throw error; 137 | } 138 | } 139 | 140 | /** 141 | * Calculate payment options and send payment request 142 | */ 143 | static async calculateAndSendPayment(tokensWithPrices: TokenWithPrice[], reader: Reader, targetUSD: number): Promise { 144 | const startTime = Date.now(); 145 | console.log(`⏱️ [PROFILE] Starting calculateAndSendPayment for $${targetUSD} with ${tokensWithPrices.length} tokens`); 146 | 147 | // Filter tokens that have sufficient balance for targetUSD payment 148 | const viableTokens = tokensWithPrices.filter(token => 149 | token.priceUSD > 0 && token.valueUSD >= targetUSD 150 | ); 151 | 152 | if (viableTokens.length === 0) { 153 | console.log(`\n❌ No tokens found with sufficient balance for $${targetUSD} payment`); 154 | throw new Error(`Customer doesn't have enough funds`); 155 | } 156 | 157 | console.log(`\n💰 PAYMENT OPTIONS ($${targetUSD}):`); 158 | console.log(`🎯 Priority Order: L2 Stablecoin > L2 Other > L2 ETH > L1 Stablecoin > L1 Other > L1 ETH\n`); 159 | 160 | // Group by priority categories for better display 161 | const L1_CHAINS = [1]; // Ethereum mainnet 162 | const L2_CHAINS = [8453, 42161, 10, 137, 393402133025423]; // Base, Arbitrum, Optimism, Polygon, Starknet 163 | 164 | const isStablecoin = (token: TokenWithPrice): boolean => { 165 | return /^(USDC|USDT|DAI|BUSD|FRAX|LUSD|USDCE|USDC\.E|USDT\.E|DAI\.E)$/i.test(token.symbol); 166 | }; 167 | 168 | const categorizeForDisplay = (tokens: TokenWithPrice[]) => { 169 | const categories = { 170 | 'L2 Stablecoins (Priority 1)': [] as TokenWithPrice[], 171 | 'L2 Other Tokens (Priority 2)': [] as TokenWithPrice[], 172 | 'L2 ETH/Native (Priority 3)': [] as TokenWithPrice[], 173 | 'L1 Stablecoins (Priority 4)': [] as TokenWithPrice[], 174 | 'L1 Other Tokens (Priority 5)': [] as TokenWithPrice[], 175 | 'L1 ETH (Priority 6)': [] as TokenWithPrice[] 176 | }; 177 | 178 | tokens.forEach(token => { 179 | const isL2 = L2_CHAINS.includes(token.chainId); 180 | 181 | if (isL2) { 182 | if (isStablecoin(token)) { 183 | categories['L2 Stablecoins (Priority 1)'].push(token); 184 | } else if (token.isNativeToken) { 185 | categories['L2 ETH/Native (Priority 3)'].push(token); 186 | } else { 187 | categories['L2 Other Tokens (Priority 2)'].push(token); 188 | } 189 | } else { 190 | if (isStablecoin(token)) { 191 | categories['L1 Stablecoins (Priority 4)'].push(token); 192 | } else if (token.isNativeToken) { 193 | categories['L1 ETH (Priority 6)'].push(token); 194 | } else { 195 | categories['L1 Other Tokens (Priority 5)'].push(token); 196 | } 197 | } 198 | }); 199 | 200 | return categories; 201 | }; 202 | 203 | const tokensByPriority = categorizeForDisplay(viableTokens); 204 | 205 | let optionIndex = 1; 206 | Object.entries(tokensByPriority).forEach(([categoryName, tokens]) => { 207 | if (tokens.length > 0) { 208 | console.log(`\n🏆 ${categoryName}:`); 209 | tokens.forEach(token => { 210 | const requiredAmountFloat = targetUSD / token.priceUSD; 211 | console.log(` ${optionIndex}. ${requiredAmountFloat.toFixed(6)} ${token.symbol} (${token.chainDisplayName})`); 212 | optionIndex++; 213 | }); 214 | } 215 | }); 216 | 217 | // Smart payment selection: prefer L2 stablecoins, then follow priority order 218 | const selectedToken = this.selectBestPaymentToken(viableTokens); 219 | const selectionTime = Date.now() - startTime; 220 | console.log(`⏱️ [PROFILE] Token selection and analysis completed in ${selectionTime}ms`); 221 | 222 | // Calculate exact amount in smallest units using BigInt arithmetic 223 | const targetUSDCents = Math.round(targetUSD * 1e8); // Convert to 8 decimal precision 224 | const priceUSDCents = Math.round(selectedToken.priceUSD * 1e8); 225 | const requiredAmount = (BigInt(targetUSDCents) * BigInt(10 ** selectedToken.decimals)) / BigInt(priceUSDCents); 226 | 227 | // Convert to display format 228 | const displayAmount = Number(requiredAmount) / Math.pow(10, selectedToken.decimals); 229 | 230 | console.log(`\n🎯 SELECTED PAYMENT:`); 231 | console.log(`💰 Merchant amount: $${targetUSD.toFixed(2)} USD`); 232 | console.log(`💳 Token: ${selectedToken.symbol}`); 233 | console.log(`🔢 Token amount: ${displayAmount} ${selectedToken.symbol}`); 234 | console.log(`📊 Exact amount: ${requiredAmount.toString()} smallest units`); 235 | console.log(`⛓️ Chain: ${selectedToken.chainDisplayName} (Chain ID: ${selectedToken.chainId})`); 236 | console.log(`💵 Price: $${selectedToken.priceUSD.toFixed(4)} per ${selectedToken.symbol}`); 237 | 238 | // Check if merchant supports this chain 239 | const isMerchantChain = BridgeManager.isMerchantSupportedChain(selectedToken.chainId); 240 | 241 | if (!isMerchantChain) { 242 | console.log(`\n🔄 Chain ${selectedToken.chainDisplayName} not supported by merchant, checking bridge routes...`); 243 | 244 | // Find a bridge route 245 | const routeResult = await BridgeManager.findBestRoute(selectedToken.chainId, selectedToken.symbol); 246 | 247 | if (!routeResult) { 248 | throw new Error(`Payment not possible: ${selectedToken.symbol} on ${selectedToken.chainDisplayName} cannot be routed to merchant chains`); 249 | } 250 | 251 | console.log(`✅ Found route via ${routeResult.provider.name} to ${routeResult.route.destinationNetwork}`); 252 | 253 | // Create the bridge swap 254 | const swapResult = await BridgeManager.createSwap(routeResult.provider, routeResult.route, displayAmount); 255 | 256 | if (!swapResult) { 257 | throw new Error(`Failed to create cross-chain payment route via ${routeResult.provider.name}`); 258 | } 259 | 260 | console.log(`\n💱 CROSS-CHAIN PAYMENT via ${swapResult.bridgeName}:`); 261 | console.log(`🔄 Swap ID: ${swapResult.swapId}`); 262 | console.log(`📍 Send ${displayAmount} ${selectedToken.symbol} to: ${swapResult.depositAddress}`); 263 | console.log(`🎯 Merchant will receive on: ${routeResult.route.destinationNetwork}`); 264 | 265 | // For bridge payments, we send to the bridge deposit address 266 | const swapAmount = BigInt(Math.round(swapResult.depositAmount * Math.pow(10, selectedToken.decimals))); 267 | 268 | // Create custom EIP-681 URI for bridge payment 269 | let paymentUri: string; 270 | if (EthereumService.isEthAddress(selectedToken.address)) { 271 | // ETH payment 272 | paymentUri = `ethereum:${swapResult.depositAddress}@${selectedToken.chainId}?value=${swapAmount.toString()}`; 273 | } else { 274 | // ERC-20 token payment 275 | paymentUri = `ethereum:${selectedToken.address}@${selectedToken.chainId}/transfer?address=${swapResult.depositAddress}&uint256=${swapAmount.toString()}`; 276 | } 277 | 278 | console.log(`\n💳 Sending ${swapResult.bridgeName} payment request:`); 279 | console.log(`📄 URI: ${paymentUri}`); 280 | 281 | const nfcTransmissionStart = Date.now(); 282 | const ndefMessage = this.createNDEFUriRecord(paymentUri); 283 | // @ts-expect-error Argument of type '{}' is not assignable to parameter of type 'never'. 284 | await reader.transmit(ndefMessage, 256, {}); 285 | const nfcTransmissionTime = Date.now() - nfcTransmissionStart; 286 | 287 | console.log(`⏱️ [PROFILE] NFC payment request transmission completed in ${nfcTransmissionTime}ms`); 288 | console.log(`✅ ${swapResult.bridgeName} payment request sent`); 289 | console.log(`📱 Customer will pay to ${swapResult.bridgeName}, merchant receives on ${routeResult.route.destinationNetwork}`); 290 | 291 | const totalTime = Date.now() - startTime; 292 | console.log(`⏱️ [PROFILE] calculateAndSendPayment (with bridge) completed in ${totalTime}ms`); 293 | 294 | // Return information needed for monitoring bridge payment 295 | return { 296 | selectedToken, 297 | requiredAmount: swapAmount, 298 | chainId: selectedToken.chainId, 299 | chainName: selectedToken.chainDisplayName, 300 | isLayerswap: swapResult.bridgeName === 'Layerswap', // For backward compatibility 301 | layerswapDepositAddress: swapResult.depositAddress, 302 | layerswapSwapId: swapResult.swapId 303 | }; 304 | } 305 | 306 | // Normal payment flow (merchant supports the chain) 307 | console.log(`🔍 Payment will be monitored on: ${selectedToken.chainDisplayName}`); 308 | 309 | // Send payment request using the exact amount 310 | const nfcTransmissionStart = Date.now(); 311 | await this.sendPaymentRequest(reader, requiredAmount, selectedToken.address, selectedToken.decimals, selectedToken.chainId); 312 | const nfcTransmissionTime = Date.now() - nfcTransmissionStart; 313 | console.log(`⏱️ [PROFILE] NFC payment request transmission completed in ${nfcTransmissionTime}ms`); 314 | 315 | console.log(`✅ Payment request sent for exactly ${requiredAmount.toString()} smallest units`); 316 | console.log(`📱 Customer will be asked to pay ${displayAmount} ${selectedToken.symbol}`); 317 | 318 | const totalTime = Date.now() - startTime; 319 | console.log(`⏱️ [PROFILE] calculateAndSendPayment completed in ${totalTime}ms`); 320 | 321 | // Return information needed for monitoring 322 | return { 323 | selectedToken, 324 | requiredAmount, // BigInt amount in smallest units 325 | chainId: selectedToken.chainId, 326 | chainName: selectedToken.chainDisplayName 327 | }; 328 | } 329 | 330 | /** 331 | * Smart token selection for payments with L2-first priority 332 | * Priority order: L2 Stablecoin > L2 Other > L2 ETH > L1 Stablecoin > L1 Other > L1 ETH 333 | */ 334 | private static selectBestPaymentToken(viableTokens: TokenWithPrice[]): TokenWithPrice { 335 | // Define L1 and L2 chains 336 | const L1_CHAINS = [1]; // Ethereum mainnet 337 | const L2_CHAINS = [8453, 42161, 10, 137, 393402133025423]; // Base, Arbitrum, Optimism, Polygon, Starknet 338 | 339 | // Helper function to check if token is a stablecoin 340 | const isStablecoin = (token: TokenWithPrice): boolean => { 341 | return /^(USDC|USDT|DAI|BUSD|FRAX|LUSD|USDCE|USDC\.E|USDT\.E|DAI\.E)$/i.test(token.symbol); 342 | }; 343 | 344 | // Helper function to check if token is ETH (native token) 345 | const isETH = (token: TokenWithPrice): boolean => { 346 | return token.isNativeToken && token.symbol === 'ETH'; 347 | }; 348 | 349 | // Helper function to check if token is MATIC (Polygon native) 350 | const isMATIC = (token: TokenWithPrice): boolean => { 351 | return token.isNativeToken && token.symbol === 'MATIC'; 352 | }; 353 | 354 | // Helper function to check if token is "other" (not stablecoin, not native) 355 | const isOther = (token: TokenWithPrice): boolean => { 356 | return !isStablecoin(token) && !token.isNativeToken; 357 | }; 358 | 359 | // Categorize tokens by chain type and token type 360 | const categorizeTokens = (tokens: TokenWithPrice[]) => { 361 | const categories = { 362 | l2Stablecoins: [] as TokenWithPrice[], 363 | l2Other: [] as TokenWithPrice[], 364 | l2ETH: [] as TokenWithPrice[], 365 | l2Native: [] as TokenWithPrice[], // For MATIC on Polygon 366 | l1Stablecoins: [] as TokenWithPrice[], 367 | l1Other: [] as TokenWithPrice[], 368 | l1ETH: [] as TokenWithPrice[] 369 | }; 370 | 371 | tokens.forEach(token => { 372 | const isL2 = L2_CHAINS.includes(token.chainId); 373 | 374 | if (isL2) { 375 | if (isStablecoin(token)) { 376 | categories.l2Stablecoins.push(token); 377 | } else if (isETH(token)) { 378 | categories.l2ETH.push(token); 379 | } else if (isMATIC(token)) { 380 | categories.l2Native.push(token); 381 | } else if (isOther(token)) { 382 | categories.l2Other.push(token); 383 | } 384 | } else { 385 | // L1 tokens 386 | if (isStablecoin(token)) { 387 | categories.l1Stablecoins.push(token); 388 | } else if (isETH(token)) { 389 | categories.l1ETH.push(token); 390 | } else if (isOther(token)) { 391 | categories.l1Other.push(token); 392 | } 393 | } 394 | }); 395 | 396 | return categories; 397 | }; 398 | 399 | const categories = categorizeTokens(viableTokens); 400 | 401 | // Display selection summary 402 | console.log(`\n🧮 TOKEN SELECTION ANALYSIS:`); 403 | console.log(` L2 Stablecoins: ${categories.l2Stablecoins.length} tokens`); 404 | console.log(` L2 Other Tokens: ${categories.l2Other.length} tokens`); 405 | console.log(` L2 ETH/Native: ${categories.l2ETH.length + categories.l2Native.length} tokens`); 406 | console.log(` L1 Stablecoins: ${categories.l1Stablecoins.length} tokens`); 407 | console.log(` L1 Other Tokens: ${categories.l1Other.length} tokens`); 408 | console.log(` L1 ETH: ${categories.l1ETH.length} tokens`); 409 | 410 | // Sort each category by value (highest first) for best selection within each priority level 411 | const sortByValue = (a: TokenWithPrice, b: TokenWithPrice) => b.valueUSD - a.valueUSD; 412 | 413 | Object.values(categories).forEach(category => { 414 | category.sort(sortByValue); 415 | }); 416 | 417 | // Priority selection logic 418 | if (categories.l2Stablecoins.length > 0) { 419 | const selected = categories.l2Stablecoins[0]; 420 | console.log(`💡 Preferred payment: L2 Stablecoin - ${selected.symbol} on ${selected.chainDisplayName}`); 421 | return selected; 422 | } 423 | 424 | if (categories.l2Other.length > 0) { 425 | const selected = categories.l2Other[0]; 426 | console.log(`💡 Preferred payment: L2 Other Token - ${selected.symbol} on ${selected.chainDisplayName}`); 427 | return selected; 428 | } 429 | 430 | if (categories.l2ETH.length > 0) { 431 | const selected = categories.l2ETH[0]; 432 | console.log(`💡 Preferred payment: L2 ETH - ${selected.symbol} on ${selected.chainDisplayName}`); 433 | return selected; 434 | } 435 | 436 | if (categories.l2Native.length > 0) { 437 | const selected = categories.l2Native[0]; 438 | console.log(`💡 Preferred payment: L2 Native Token - ${selected.symbol} on ${selected.chainDisplayName}`); 439 | return selected; 440 | } 441 | 442 | if (categories.l1Stablecoins.length > 0) { 443 | const selected = categories.l1Stablecoins[0]; 444 | console.log(`💡 Preferred payment: L1 Stablecoin - ${selected.symbol} on ${selected.chainDisplayName}`); 445 | return selected; 446 | } 447 | 448 | if (categories.l1Other.length > 0) { 449 | const selected = categories.l1Other[0]; 450 | console.log(`💡 Preferred payment: L1 Other Token - ${selected.symbol} on ${selected.chainDisplayName}`); 451 | return selected; 452 | } 453 | 454 | if (categories.l1ETH.length > 0) { 455 | const selected = categories.l1ETH[0]; 456 | console.log(`💡 Preferred payment: L1 ETH - ${selected.symbol} on ${selected.chainDisplayName}`); 457 | return selected; 458 | } 459 | 460 | // Fallback (should not happen if viableTokens is not empty) 461 | console.log(`💡 Fallback: Using first available token - ${viableTokens[0].symbol}`); 462 | return viableTokens[0]; 463 | } 464 | } -------------------------------------------------------------------------------- /src/services/priceCacheService.ts: -------------------------------------------------------------------------------- 1 | import { Alchemy } from 'alchemy-sdk'; 2 | import { ALCHEMY_API_KEY } from '../config/index.js'; 3 | 4 | /** 5 | * Service for caching ETH price and refreshing it periodically using Alchemy SDK 6 | */ 7 | export class PriceCacheService { 8 | private static cachedEthPrice: number = 0; 9 | private static lastFetchTime: number = 0; 10 | private static refreshInterval: NodeJS.Timeout | null = null; 11 | private static readonly REFRESH_INTERVAL_MS = 60000; // 1 minute 12 | private static alchemy = new Alchemy({ apiKey: ALCHEMY_API_KEY }); 13 | 14 | /** 15 | * Initialize the price cache service 16 | */ 17 | static async initialize(): Promise { 18 | 19 | // Fetch initial ETH price 20 | await this.fetchAndCacheEthPrice(); 21 | 22 | // Set up periodic refresh 23 | this.startPeriodicRefresh(); 24 | 25 | } 26 | 27 | /** 28 | * Start periodic price refresh 29 | */ 30 | private static startPeriodicRefresh(): void { 31 | if (this.refreshInterval) { 32 | clearInterval(this.refreshInterval); 33 | } 34 | 35 | this.refreshInterval = setInterval(async () => { 36 | await this.fetchAndCacheEthPrice(); 37 | }, this.REFRESH_INTERVAL_MS); 38 | } 39 | 40 | /** 41 | * Get cached ETH price 42 | */ 43 | static getCachedEthPrice(): number { 44 | const ageMs = Date.now() - this.lastFetchTime; 45 | return this.cachedEthPrice; 46 | } 47 | 48 | /** 49 | * Fetch ETH price and update cache 50 | */ 51 | private static async fetchAndCacheEthPrice(): Promise { 52 | try { 53 | const priceData = await this.alchemy.prices.getTokenPriceBySymbol(['ETH']); 54 | 55 | if (!priceData) { 56 | console.error('❌ DEBUG: No priceData received from Alchemy SDK for cache'); 57 | return; 58 | } 59 | 60 | if (!priceData.data) { 61 | console.error('❌ DEBUG: No data field in ETH cache priceData:', priceData); 62 | return; 63 | } 64 | 65 | if (priceData.data.length === 0) { 66 | console.error('❌ DEBUG: Empty data array in ETH cache priceData'); 67 | return; 68 | } 69 | 70 | const ethData = priceData.data.find((d: any) => d.symbol === 'ETH'); 71 | if (!ethData) { 72 | console.error('❌ DEBUG: No ETH symbol found in cache response. Available symbols:', priceData.data.map((d: any) => d.symbol)); 73 | return; 74 | } 75 | 76 | if (ethData.error) { 77 | console.error('❌ DEBUG: ETH cache data has error:', ethData.error); 78 | return; 79 | } 80 | 81 | if (!ethData.prices) { 82 | console.error('❌ DEBUG: ETH cache data has no prices field:', ethData); 83 | return; 84 | } 85 | 86 | if (ethData.prices.length === 0) { 87 | console.error('❌ DEBUG: ETH cache data has empty prices array'); 88 | return; 89 | } 90 | 91 | const usdPrice = ethData.prices.find((p: any) => p.currency === 'usd'); 92 | if (!usdPrice) { 93 | console.error('❌ DEBUG: No USD price found in ETH cache data. Available currencies:', ethData.prices.map((p: any) => p.currency)); 94 | return; 95 | } 96 | 97 | if (!usdPrice.value) { 98 | console.error('❌ DEBUG: USD price has no value in cache data:', usdPrice); 99 | return; 100 | } 101 | 102 | const ethPrice = parseFloat(usdPrice.value); 103 | if (isNaN(ethPrice)) { 104 | console.error(`❌ DEBUG: Cannot parse ETH cache price value '${usdPrice.value}'`); 105 | return; 106 | } 107 | 108 | if (ethPrice > 0) { 109 | const oldPrice = this.cachedEthPrice; 110 | const oldTime = this.lastFetchTime; 111 | 112 | this.cachedEthPrice = ethPrice; 113 | this.lastFetchTime = Date.now(); 114 | 115 | const timestamp = new Date().toLocaleTimeString(); 116 | } else { 117 | console.error('❌ DEBUG: Invalid ETH price received from Alchemy SDK (price <= 0):', ethPrice); 118 | } 119 | } catch (error) { 120 | console.error('❌ DEBUG: Exception in fetchAndCacheEthPrice:', error); 121 | if (error instanceof Error) { 122 | console.error(`❌ DEBUG: Error message:`, error.message); 123 | console.error(`❌ DEBUG: Error stack:`, error.stack); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Get last fetch time 130 | */ 131 | static getLastFetchTime(): number { 132 | return this.lastFetchTime; 133 | } 134 | 135 | /** 136 | * Clean up resources 137 | */ 138 | static cleanup(): void { 139 | if (this.refreshInterval) { 140 | clearInterval(this.refreshInterval); 141 | this.refreshInterval = null; 142 | } else { 143 | } 144 | } 145 | 146 | /** 147 | * Stop periodic refresh (for cleanup) 148 | */ 149 | static stop(): void { 150 | console.log(`🛑 DEBUG: Stopping price cache service...`); 151 | if (this.refreshInterval) { 152 | clearInterval(this.refreshInterval); 153 | this.refreshInterval = null; 154 | console.log('🛑 Price cache service stopped'); 155 | } else { 156 | console.log('🔍 DEBUG: Price cache service was not running'); 157 | } 158 | } 159 | 160 | /** 161 | * Force refresh ETH price 162 | */ 163 | static async forceRefresh(): Promise { 164 | await this.fetchAndCacheEthPrice(); 165 | } 166 | 167 | /** 168 | * Get cache status 169 | */ 170 | static getCacheStatus(): {price: number, lastFetch: Date, isStale: boolean} { 171 | const now = Date.now(); 172 | const ageMs = now - this.lastFetchTime; 173 | const isStale = ageMs > (this.REFRESH_INTERVAL_MS * 2); // Consider stale if > 2 minutes old 174 | 175 | const status = { 176 | price: this.cachedEthPrice, 177 | lastFetch: new Date(this.lastFetchTime), 178 | isStale 179 | }; 180 | 181 | console.log(`🔍 DEBUG: Cache status:`, { 182 | price: `$${status.price.toFixed(2)}`, 183 | lastFetch: status.lastFetch.toISOString(), 184 | ageMs: ageMs, 185 | ageSec: (ageMs / 1000).toFixed(1), 186 | isStale: status.isStale, 187 | staleThreshold: `${(this.REFRESH_INTERVAL_MS * 2) / 1000}s` 188 | }); 189 | 190 | return status; 191 | } 192 | } -------------------------------------------------------------------------------- /src/services/priceService.ts: -------------------------------------------------------------------------------- 1 | import { Alchemy, Network } from 'alchemy-sdk'; 2 | import { ALCHEMY_API_KEY, SUPPORTED_CHAINS } from '../config/index.js'; 3 | import { PriceCacheService } from './priceCacheService.js'; 4 | 5 | /** 6 | * Service for fetching token prices from Alchemy Prices API using Alchemy SDK 7 | */ 8 | export class PriceService { 9 | private static alchemy = new Alchemy({ apiKey: ALCHEMY_API_KEY }); 10 | 11 | /** 12 | * Map chain names to Alchemy Network enum values 13 | */ 14 | private static getAlchemyNetwork(chainName: string): Network | null { 15 | const networkMap: {[key: string]: Network} = { 16 | 'ethereum': Network.ETH_MAINNET, 17 | 'base': Network.BASE_MAINNET, 18 | 'arbitrum': Network.ARB_MAINNET, 19 | 'optimism': Network.OPT_MAINNET, 20 | 'polygon': Network.MATIC_MAINNET 21 | }; 22 | 23 | return networkMap[chainName] || null; 24 | } 25 | 26 | /** 27 | * Get token prices from Alchemy for a specific chain using contract addresses 28 | */ 29 | static async getTokenPricesForChain(tokenAddresses: string[], chainName: string): Promise<{[address: string]: number}> { 30 | const startTime = Date.now(); 31 | 32 | try { 33 | if (tokenAddresses.length === 0) { 34 | return {}; 35 | } 36 | 37 | console.log(`⏱️ [PROFILE] Starting price fetch for ${tokenAddresses.length} tokens on ${chainName}`); 38 | 39 | const chain = SUPPORTED_CHAINS.find(c => c.name === chainName); 40 | if (!chain) { 41 | console.log(`❌ No supported chain configuration for: ${chainName}`); 42 | return {}; 43 | } 44 | 45 | const alchemyNetwork = this.getAlchemyNetwork(chainName); 46 | if (!alchemyNetwork) { 47 | console.log(`❌ No Alchemy network mapping for chain: ${chainName}`); 48 | return {}; 49 | } 50 | 51 | // Prepare addresses with network info for Alchemy SDK 52 | const addressesWithNetwork = tokenAddresses.map(address => ({ 53 | network: alchemyNetwork, 54 | address: address.toLowerCase() 55 | })); 56 | 57 | const priceData = await this.alchemy.prices.getTokenPriceByAddress(addressesWithNetwork); 58 | 59 | const prices: {[address: string]: number} = {}; 60 | 61 | if (!priceData?.data) { 62 | return {}; 63 | } 64 | 65 | for (let i = 0; i < priceData.data.length; i++) { 66 | const tokenData = priceData.data[i]; 67 | 68 | if (tokenData.error || !tokenData.prices || tokenData.prices.length === 0) { 69 | continue; 70 | } 71 | 72 | const usdPrice = tokenData.prices.find((p: any) => p.currency === 'usd'); 73 | if (!usdPrice?.value) { 74 | continue; 75 | } 76 | 77 | const priceValue = parseFloat(usdPrice.value); 78 | if (!isNaN(priceValue)) { 79 | prices[tokenData.address.toLowerCase()] = priceValue; 80 | } 81 | } 82 | 83 | const duration = Date.now() - startTime; 84 | console.log(`⏱️ [PROFILE] Price fetch for ${chainName} completed in ${duration}ms (${Object.keys(prices).length}/${tokenAddresses.length} prices found)`); 85 | 86 | return prices; 87 | } catch (error) { 88 | console.log(`❌ Error fetching prices for ${chainName}:`, error); 89 | return {}; 90 | } 91 | } 92 | 93 | /** 94 | * Get token prices for multiple chains in parallel using Alchemy SDK 95 | */ 96 | static async getMultiChainTokenPrices(chainTokens: {[chainName: string]: string[]}): Promise<{[chainName: string]: {[address: string]: number}}> { 97 | const startTime = Date.now(); 98 | console.log(`⏱️ [PROFILE] Starting getMultiChainTokenPrices for chains:`, Object.keys(chainTokens)); 99 | 100 | const pricePromises = Object.entries(chainTokens).map(async ([chainName, addresses]) => { 101 | console.log(`🔍 DEBUG: Processing chain ${chainName} with ${addresses.length} addresses`); 102 | const prices = await this.getTokenPricesForChain(addresses, chainName); 103 | return [chainName, prices] as [string, {[address: string]: number}]; 104 | }); 105 | 106 | const results = await Promise.all(pricePromises); 107 | 108 | const chainPrices: {[chainName: string]: {[address: string]: number}} = {}; 109 | for (const [chainName, prices] of results) { 110 | chainPrices[chainName] = prices; 111 | console.log(`🔍 DEBUG: Chain ${chainName} final result: ${Object.keys(prices).length} prices`); 112 | } 113 | 114 | const duration = Date.now() - startTime; 115 | console.log(`⏱️ [PROFILE] Multi-chain price fetch completed in ${duration}ms`); 116 | return chainPrices; 117 | } 118 | 119 | /** 120 | * Get token prices from Alchemy (legacy single-chain method) 121 | */ 122 | static async getTokenPrices(tokenAddresses: string[]): Promise<{[address: string]: number}> { 123 | console.log(`🔍 DEBUG: Legacy getTokenPrices called with ${tokenAddresses.length} addresses`); 124 | return this.getTokenPricesForChain(tokenAddresses, 'ethereum'); 125 | } 126 | 127 | /** 128 | * Get ETH price using Alchemy SDK by symbol 129 | */ 130 | static async getEthPrice(): Promise { 131 | try { 132 | // Try to get from cache first 133 | const cachedPrice = PriceCacheService.getCachedEthPrice(); 134 | 135 | if (cachedPrice > 0) { 136 | return cachedPrice; 137 | } 138 | 139 | // Fetch from Alchemy SDK by symbol 140 | const priceData = await this.alchemy.prices.getTokenPriceBySymbol(['ETH']); 141 | 142 | if (!priceData?.data || priceData.data.length === 0) { 143 | return cachedPrice; 144 | } 145 | 146 | const ethData = priceData.data.find((d: any) => d.symbol === 'ETH'); 147 | if (!ethData || ethData.error || !ethData.prices || ethData.prices.length === 0) { 148 | return cachedPrice; 149 | } 150 | 151 | const usdPrice = ethData.prices.find((p: any) => p.currency === 'usd'); 152 | if (!usdPrice?.value) { 153 | return cachedPrice; 154 | } 155 | 156 | const price = parseFloat(usdPrice.value); 157 | if (isNaN(price)) { 158 | return cachedPrice; 159 | } 160 | 161 | console.log(`✅ ETH price fetched: $${price.toFixed(2)}`); 162 | return price; 163 | } catch (error) { 164 | console.log('❌ Error fetching ETH price:', error); 165 | const cachedPrice = PriceCacheService.getCachedEthPrice(); 166 | return cachedPrice; 167 | } 168 | } 169 | 170 | /** 171 | * Get native token prices for multiple chains in parallel using Alchemy SDK 172 | */ 173 | static async getNativeTokenPrices(chainIds: string[]): Promise<{[chainId: string]: number}> { 174 | try { 175 | console.log(`🔍 DEBUG: Starting getNativeTokenPrices for chain IDs:`, chainIds); 176 | 177 | // Get unique native token symbols from supported chains 178 | const chainIdToSymbol: {[chainId: string]: string} = {}; 179 | const uniqueSymbols = new Set(); 180 | 181 | for (const chainId of chainIds) { 182 | const chain = SUPPORTED_CHAINS.find(c => c.id.toString() === chainId); 183 | if (chain) { 184 | chainIdToSymbol[chainId] = chain.nativeToken.symbol; 185 | uniqueSymbols.add(chain.nativeToken.symbol); 186 | console.log(`🔍 DEBUG: Chain ID ${chainId} -> Symbol ${chain.nativeToken.symbol}`); 187 | } else { 188 | console.log(`❌ DEBUG: No chain found for ID ${chainId}`); 189 | } 190 | } 191 | 192 | if (uniqueSymbols.size === 0) { 193 | console.log('❌ DEBUG: No supported chains found for provided chain IDs'); 194 | return {}; 195 | } 196 | 197 | const symbolArray = Array.from(uniqueSymbols); 198 | console.log(`📡 DEBUG: Fetching native token prices for symbols: ${symbolArray.join(', ')}`); 199 | console.log(`🔍 DEBUG: API Key configured:`, ALCHEMY_API_KEY ? `Yes (${ALCHEMY_API_KEY.substring(0, 8)}...)` : 'No'); 200 | 201 | // Fetch prices for all unique symbols using Alchemy SDK 202 | const priceData = await this.alchemy.prices.getTokenPriceBySymbol(symbolArray); 203 | 204 | console.log(`📦 DEBUG: Raw native token price response:`, JSON.stringify(priceData, null, 2)); 205 | 206 | // Build symbol to price mapping 207 | const symbolPrices: {[symbol: string]: number} = {}; 208 | 209 | if (!priceData) { 210 | console.log(`❌ DEBUG: No priceData received for native tokens`); 211 | return {}; 212 | } 213 | 214 | if (!priceData.data) { 215 | console.log(`❌ DEBUG: No data field in native token priceData:`, priceData); 216 | return {}; 217 | } 218 | 219 | console.log(`🔍 DEBUG: Processing ${priceData.data.length} native token responses...`); 220 | 221 | for (const tokenData of priceData.data) { 222 | console.log(`🔍 DEBUG: Processing native token:`, JSON.stringify(tokenData, null, 2)); 223 | 224 | if (tokenData.error) { 225 | console.log(`❌ DEBUG: Native token ${tokenData.symbol} has error:`, tokenData.error); 226 | continue; 227 | } 228 | 229 | if (!tokenData.prices || tokenData.prices.length === 0) { 230 | console.log(`❌ DEBUG: Native token ${tokenData.symbol} has no prices`); 231 | continue; 232 | } 233 | 234 | const usdPrice = tokenData.prices.find((p: any) => p.currency === 'usd'); 235 | if (!usdPrice || !usdPrice.value) { 236 | console.log(`❌ DEBUG: No USD price for native token ${tokenData.symbol}`); 237 | continue; 238 | } 239 | 240 | const priceValue = parseFloat(usdPrice.value); 241 | if (isNaN(priceValue)) { 242 | console.log(`❌ DEBUG: Cannot parse price for native token ${tokenData.symbol}: ${usdPrice.value}`); 243 | continue; 244 | } 245 | 246 | console.log(`✅ DEBUG: Native token ${tokenData.symbol} price: $${priceValue}`); 247 | symbolPrices[tokenData.symbol] = priceValue; 248 | } 249 | 250 | console.log(`🔍 DEBUG: Symbol prices:`, symbolPrices); 251 | 252 | // Map back to chain IDs 253 | const prices: {[chainId: string]: number} = {}; 254 | for (const chainId of chainIds) { 255 | const symbol = chainIdToSymbol[chainId]; 256 | if (symbol && symbolPrices[symbol] !== undefined) { 257 | prices[chainId] = symbolPrices[symbol]; 258 | console.log(`✅ DEBUG: Chain ID ${chainId} (${symbol}): $${symbolPrices[symbol]}`); 259 | } else { 260 | prices[chainId] = 0; 261 | console.log(`❌ DEBUG: No price found for chain ID ${chainId} (symbol: ${symbol})`); 262 | } 263 | } 264 | 265 | console.log(`✅ DEBUG: Final native token prices:`, prices); 266 | return prices; 267 | } catch (error) { 268 | console.log('❌ DEBUG: Exception in getNativeTokenPrices:', error); 269 | if (error instanceof Error) { 270 | console.log(`❌ DEBUG: Error message:`, error.message); 271 | console.log(`❌ DEBUG: Error stack:`, error.stack); 272 | } 273 | return {}; 274 | } 275 | } 276 | } -------------------------------------------------------------------------------- /src/services/transactionMonitoringService.ts: -------------------------------------------------------------------------------- 1 | import { Alchemy, Network, Utils, AssetTransfersResponse, AssetTransfersResult, AssetTransfersCategory } from 'alchemy-sdk'; 2 | import { MERCHANT_ADDRESS, config } from '../config/index.js'; 3 | 4 | interface PaymentSession { 5 | recipientAddress: string; 6 | expectedAmount: bigint; // Expected amount in smallest units as BigInt 7 | tokenAddress: string; // Contract address for ERC-20, or recipient address for ETH 8 | tokenSymbol: string; 9 | tokenDecimals: number; 10 | merchantUSD: number; // Original USD amount merchant entered 11 | chainId: number; 12 | chainName: string; 13 | onPaymentReceived: (txHash: string, tokenSymbol: string, tokenAddress: string, decimals: number) => void; 14 | onError: (error: string) => void; 15 | } 16 | 17 | export class TransactionMonitoringService { 18 | private static currentSession: PaymentSession | null = null; 19 | private static monitoringInterval: NodeJS.Timeout | null = null; 20 | private static alchemyClients: Map = new Map(); 21 | private static readonly POLLING_INTERVAL = 3000; // 3 seconds 22 | private static lastCheckedBlock: number = 0; 23 | 24 | /** 25 | * Start monitoring for a specific payment 26 | */ 27 | static async startMonitoring( 28 | recipientAddress: string, 29 | tokenAddress: string, 30 | expectedAmount: bigint, 31 | tokenSymbol: string, 32 | tokenDecimals: number, 33 | merchantUSD: number, 34 | chainId: number, 35 | chainName: string, 36 | callback: (txHash: string, tokenSymbol: string, tokenAddress: string, decimals: number) => void, 37 | errorCallback: (error: string) => void 38 | ): Promise { 39 | console.log(`\n🔍 STARTING PAYMENT MONITORING`); 40 | console.log(`💰 Merchant amount: $${merchantUSD.toFixed(2)} USD`); 41 | console.log(`💳 Expected token: ${tokenSymbol}`); 42 | console.log(`🔢 Expected amount: ${expectedAmount.toString()} smallest units`); 43 | console.log(`📊 Display amount: ${Number(expectedAmount) / Math.pow(10, tokenDecimals)} ${tokenSymbol}`); 44 | console.log(`⛓️ Chain: ${chainName} (ID: ${chainId})`); 45 | console.log(`🏠 Recipient: ${recipientAddress}`); 46 | console.log(`📄 Token contract: ${tokenAddress}`); 47 | 48 | // Store the monitoring session 49 | this.currentSession = { 50 | recipientAddress, 51 | expectedAmount, 52 | tokenAddress, 53 | tokenSymbol, 54 | tokenDecimals, 55 | merchantUSD, 56 | chainId, 57 | chainName, 58 | onPaymentReceived: callback, 59 | onError: errorCallback 60 | }; 61 | 62 | // Get Alchemy client for this chain 63 | const alchemy = this.getAlchemyClient(chainId); 64 | if (!alchemy) { 65 | console.error(`❌ No Alchemy client available for chain ${chainId}`); 66 | errorCallback(`Unsupported chain: ${chainName}`); 67 | return; 68 | } 69 | 70 | try { 71 | // Get current block number and set starting point 72 | const currentBlock = await alchemy.core.getBlockNumber(); 73 | this.lastCheckedBlock = Math.max(0, currentBlock - 2); // Start 2 blocks behind to avoid "past head" errors 74 | 75 | console.log(`🔄 Starting monitoring from block ${this.lastCheckedBlock} (current: ${currentBlock})`); 76 | 77 | // Start polling 78 | this.monitoringInterval = setInterval(async () => { 79 | try { 80 | await this.checkForPayments(); 81 | } catch (error) { 82 | console.error('Error during payment monitoring:', error); 83 | } 84 | }, this.POLLING_INTERVAL); 85 | 86 | console.log(`✅ Payment monitoring started - polling every ${this.POLLING_INTERVAL}ms`); 87 | } catch (error) { 88 | console.error('Failed to start monitoring:', error); 89 | errorCallback('Failed to start payment monitoring'); 90 | } 91 | } 92 | 93 | /** 94 | * Check for payments using Alchemy's Asset Transfers API 95 | */ 96 | private static async checkForPayments(): Promise { 97 | if (!this.currentSession) { 98 | console.log('❌ No active monitoring session'); 99 | return; 100 | } 101 | 102 | const alchemy = this.getAlchemyClient(this.currentSession.chainId); 103 | if (!alchemy) { 104 | console.error(`❌ No Alchemy client for chain ${this.currentSession.chainId}`); 105 | return; 106 | } 107 | 108 | try { 109 | // Get current block number 110 | const currentBlock = await alchemy.core.getBlockNumber(); 111 | const fromBlock = this.lastCheckedBlock + 1; 112 | const toBlock = Math.min(currentBlock - 1, fromBlock + 100); // Stay 1 block behind head, check max 100 blocks 113 | 114 | if (fromBlock > toBlock) { 115 | // No new blocks to check 116 | return; 117 | } 118 | 119 | console.log(`🔍 Checking blocks ${fromBlock} to ${toBlock} (current head: ${currentBlock})`); 120 | 121 | // Query asset transfers to our recipient address 122 | const transfers = await alchemy.core.getAssetTransfers({ 123 | fromBlock: Utils.hexlify(fromBlock), 124 | toBlock: Utils.hexlify(toBlock), 125 | toAddress: this.currentSession.recipientAddress, 126 | category: [AssetTransfersCategory.EXTERNAL, AssetTransfersCategory.ERC20], // Both ETH and ERC-20 token transfers 127 | withMetadata: true 128 | }); 129 | 130 | // Check each transfer to see if it matches our expected payment 131 | for (const transfer of transfers.transfers) { 132 | await this.processTransfer(transfer); 133 | } 134 | 135 | // Update last checked block 136 | this.lastCheckedBlock = toBlock; 137 | 138 | } catch (error: any) { 139 | // Handle "toBlock is past head" gracefully by staying further behind 140 | if (error.message?.includes('toBlock is past head')) { 141 | console.log('⚠️ Staying further behind blockchain head to avoid past head errors'); 142 | this.lastCheckedBlock = Math.max(0, this.lastCheckedBlock - 1); 143 | } else { 144 | console.error('Error checking for payments:', error); 145 | // Don't stop monitoring for transient errors 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * Process a single asset transfer to see if it's our expected payment 152 | */ 153 | private static async processTransfer(transfer: AssetTransfersResult): Promise { 154 | if (!this.currentSession) return; 155 | 156 | const session = this.currentSession; 157 | 158 | console.log(`\n📥 INCOMING TRANSFER:`); 159 | console.log(`💳 Asset: ${transfer.asset || 'ETH'}`); 160 | console.log(`💰 Raw value: ${transfer.rawContract?.value || transfer.value || '0'}`); 161 | console.log(`🔗 TX: ${transfer.hash}`); 162 | console.log(`📄 Contract: ${transfer.rawContract?.address || 'ETH'}`); 163 | 164 | // Get the raw transfer amount and contract address 165 | const transferAmount = BigInt(transfer.rawContract?.value || transfer.value || '0'); 166 | const transferTokenAddress = transfer.rawContract?.address?.toLowerCase() || session.recipientAddress.toLowerCase(); 167 | const expectedTokenAddress = session.tokenAddress.toLowerCase(); 168 | 169 | console.log(`\n🔍 PAYMENT VERIFICATION:`); 170 | console.log(`💰 Merchant requested: $${session.merchantUSD.toFixed(2)} USD`); 171 | console.log(`🎯 Expected token: ${session.tokenSymbol} (${expectedTokenAddress})`); 172 | console.log(`🎯 Expected amount: ${session.expectedAmount.toString()} smallest units`); 173 | console.log(`📨 Received token: ${transfer.asset || 'ETH'} (${transferTokenAddress})`); 174 | console.log(`📨 Received amount: ${transferAmount.toString()} smallest units`); 175 | 176 | // Verify token address matches 177 | const tokenMatches = transferTokenAddress === expectedTokenAddress; 178 | console.log(`✅ Token match: ${tokenMatches ? 'YES' : 'NO'}`); 179 | 180 | // Verify exact amount matches using BigInt comparison 181 | const amountMatches = transferAmount === session.expectedAmount; 182 | console.log(`✅ Amount match: ${amountMatches ? 'YES' : 'NO'}`); 183 | 184 | if (tokenMatches && amountMatches) { 185 | console.log(`\n🎉 PAYMENT CONFIRMED!`); 186 | console.log(`💰 Received exactly $${session.merchantUSD.toFixed(2)} USD worth of ${session.tokenSymbol}`); 187 | console.log(`🔗 Transaction: ${transfer.hash}`); 188 | 189 | // Stop monitoring and trigger success callback 190 | this.stopMonitoring(); 191 | session.onPaymentReceived(transfer.hash, session.tokenSymbol, session.tokenAddress, session.tokenDecimals); 192 | } else { 193 | console.log(`❌ Payment verification failed - continuing to monitor...`); 194 | 195 | if (!tokenMatches) { 196 | console.log(` Expected token: ${session.tokenSymbol} at ${expectedTokenAddress}`); 197 | console.log(` Received token: ${transfer.asset} at ${transferTokenAddress}`); 198 | } 199 | 200 | if (!amountMatches) { 201 | const expectedDisplay = Number(session.expectedAmount) / Math.pow(10, session.tokenDecimals); 202 | const receivedDisplay = Number(transferAmount) / Math.pow(10, session.tokenDecimals); 203 | console.log(` Expected: ${expectedDisplay} ${session.tokenSymbol} (${session.expectedAmount.toString()} units)`); 204 | console.log(` Received: ${receivedDisplay} ${transfer.asset || 'ETH'} (${transferAmount.toString()} units)`); 205 | } 206 | } 207 | } 208 | 209 | /** 210 | * Stop monitoring 211 | */ 212 | static stopMonitoring(): void { 213 | console.log('\n🛑 Stopping payment monitoring...'); 214 | 215 | if (this.monitoringInterval) { 216 | clearInterval(this.monitoringInterval); 217 | this.monitoringInterval = null; 218 | } 219 | 220 | this.currentSession = null; 221 | this.lastCheckedBlock = 0; 222 | 223 | console.log('✅ Payment monitoring stopped'); 224 | } 225 | 226 | /** 227 | * Map chain IDs to Alchemy Network enums (same as AlchemyService) 228 | */ 229 | private static getAlchemyNetwork(chainId: number): Network | null { 230 | const networkMap: {[key: number]: Network} = { 231 | 1: Network.ETH_MAINNET, // Ethereum 232 | 8453: Network.BASE_MAINNET, // Base 233 | 42161: Network.ARB_MAINNET, // Arbitrum 234 | 10: Network.OPT_MAINNET, // Optimism 235 | 137: Network.MATIC_MAINNET // Polygon 236 | }; 237 | 238 | return networkMap[chainId] || null; 239 | } 240 | 241 | /** 242 | * Get or create Alchemy client for a specific chain 243 | */ 244 | private static getAlchemyClient(chainId: number): Alchemy | null { 245 | if (this.alchemyClients.has(chainId)) { 246 | return this.alchemyClients.get(chainId)!; 247 | } 248 | 249 | const network = this.getAlchemyNetwork(chainId); 250 | if (!network) { 251 | console.error(`❌ No Alchemy network mapping found for chain ID ${chainId}`); 252 | return null; 253 | } 254 | 255 | if (!config.ALCHEMY_API_KEY) { 256 | console.error(`❌ ALCHEMY_API_KEY not configured`); 257 | return null; 258 | } 259 | 260 | const alchemy = new Alchemy({ 261 | apiKey: config.ALCHEMY_API_KEY, 262 | network: network, 263 | }); 264 | 265 | this.alchemyClients.set(chainId, alchemy); 266 | return alchemy; 267 | } 268 | 269 | /** 270 | * Check if currently monitoring 271 | */ 272 | static isMonitoring(): boolean { 273 | return this.currentSession !== null && this.monitoringInterval !== null; 274 | } 275 | 276 | /** 277 | * Get current session info 278 | */ 279 | static getCurrentSession(): PaymentSession | null { 280 | return this.currentSession; 281 | } 282 | } -------------------------------------------------------------------------------- /src/types/bridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic interface for cross-chain bridge providers 3 | */ 4 | export interface BridgeProvider { 5 | /** 6 | * Name of the bridge provider (e.g., "Layerswap", "Across", etc.) 7 | */ 8 | name: string; 9 | 10 | /** 11 | * Initialize the bridge provider 12 | */ 13 | initialize(): Promise; 14 | 15 | /** 16 | * Check if a chain is supported by the merchant 17 | */ 18 | isMerchantSupportedChain(chainId: number): boolean; 19 | 20 | /** 21 | * Check if there's a route from source chain to any merchant chain for a token 22 | */ 23 | checkRoute(sourceChainId: number, tokenSymbol: string): Promise; 24 | 25 | /** 26 | * Create a cross-chain swap 27 | */ 28 | createSwap(route: BridgeRoute, amount: number): Promise; 29 | } 30 | 31 | /** 32 | * Result of checking for a bridge route 33 | */ 34 | export interface BridgeRoute { 35 | hasRoute: boolean; 36 | bridgeName: string; 37 | sourceChainId: number; 38 | destinationChainId: number; 39 | destinationNetwork: string; 40 | tokenSymbol: string; 41 | } 42 | 43 | /** 44 | * Result of creating a bridge swap 45 | */ 46 | export interface BridgeSwapResult { 47 | swapId: string; 48 | depositAddress: string; 49 | depositAmount: number; 50 | callData?: string; 51 | tokenContract?: string; 52 | bridgeName: string; 53 | } 54 | 55 | /** 56 | * Payment routing result 57 | */ 58 | export interface PaymentRoute { 59 | type: 'direct' | 'bridge'; 60 | bridge?: { 61 | name: string; 62 | route: BridgeRoute; 63 | swapResult: BridgeSwapResult; 64 | }; 65 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Define a basic interface for the card object based on expected properties 2 | export interface CardData { 3 | type?: string; // e.g., 'TAG_ISO_14443_4' 4 | standard?: string; // e.g., 'TAG_ISO_14443_4' 5 | uid?: string; 6 | data?: Buffer; // Response from SELECT AID if autoProcessing is on 7 | atr?: Buffer; 8 | } 9 | 10 | // Interface for token data with price information 11 | export interface TokenWithPrice { 12 | address: string; 13 | symbol: string; 14 | name: string; 15 | balance: number; 16 | decimals: number; 17 | priceUSD: number; 18 | valueUSD: number; 19 | chainId: number; 20 | chainName: string; 21 | chainDisplayName: string; 22 | isNativeToken: boolean; 23 | } 24 | 25 | // Interface for Alchemy responses 26 | export interface AlchemyTokenBalance { 27 | contractAddress: string; 28 | tokenBalance: string; 29 | } 30 | 31 | export interface AlchemyTokenMetadata { 32 | decimals: number; 33 | symbol: string; 34 | name: string; 35 | } 36 | 37 | // Multi-chain balance aggregation 38 | export interface ChainBalances { 39 | chainId: number; 40 | chainName: string; 41 | chainDisplayName: string; 42 | tokens: TokenWithPrice[]; 43 | totalValueUSD: number; 44 | } 45 | 46 | export interface MultiChainPortfolio { 47 | address: string; 48 | chains: ChainBalances[]; 49 | totalValueUSD: number; 50 | allTokens: TokenWithPrice[]; 51 | } 52 | 53 | // Re-export bridge types 54 | export * from './bridge.js'; -------------------------------------------------------------------------------- /tests/basic.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic test to verify testing infrastructure is working 3 | */ 4 | 5 | import { jest } from '@jest/globals'; 6 | 7 | describe('Testing Infrastructure', () => { 8 | it('should have Jest working', () => { 9 | expect(true).toBe(true); 10 | }); 11 | 12 | it('should have test utilities available', () => { 13 | expect(global.testUtils).toBeDefined(); 14 | expect(typeof global.testUtils.createMockResponse).toBe('function'); 15 | expect(typeof global.testUtils.createMockError).toBe('function'); 16 | expect(typeof global.testUtils.wait).toBe('function'); 17 | }); 18 | 19 | it('should have environment variables set', () => { 20 | expect(process.env.NODE_ENV).toBe('test'); 21 | expect(process.env.ALCHEMY_API_KEY).toBe('test-alchemy-key'); 22 | expect(process.env.RECIPIENT_ADDRESS).toBe('0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6'); 23 | }); 24 | 25 | it('should be able to use test utilities', async () => { 26 | const mockResponse = global.testUtils.createMockResponse({ data: 'test' }); 27 | expect(mockResponse.data).toEqual({ data: 'test' }); 28 | expect(mockResponse.status).toBe(200); 29 | 30 | const mockError = global.testUtils.createMockError('test error'); 31 | expect(mockError.message).toBe('test error'); 32 | expect(mockError.status).toBe(500); 33 | 34 | const startTime = Date.now(); 35 | await global.testUtils.wait(10); 36 | const endTime = Date.now(); 37 | expect(endTime - startTime).toBeGreaterThanOrEqual(10); 38 | }); 39 | }); -------------------------------------------------------------------------------- /tests/mocks/nfc-pcsc.mock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock for nfc-pcsc library 3 | * Prevents real NFC hardware calls during testing 4 | */ 5 | 6 | export class Reader { 7 | public name: string; 8 | public connected: boolean = false; 9 | public card: any = null; 10 | 11 | constructor(name: string) { 12 | this.name = name; 13 | } 14 | 15 | connect(): Promise { 16 | this.connected = true; 17 | return Promise.resolve(); 18 | } 19 | 20 | disconnect(): Promise { 21 | this.connected = false; 22 | return Promise.resolve(); 23 | } 24 | 25 | transmit(_data: Buffer, _maxLength: number): Promise { 26 | // Mock NFC response - simulate wallet address 27 | const mockResponse = Buffer.from([ 28 | 0x90, 0x00, // Status OK 29 | // Mock wallet address: 0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6 30 | 0x37, 0x34, 0x32, 0x64, 0x33, 0x35, 0x43, 0x63, 0x36, 0x36, 0x33, 0x34, 0x43, 0x30, 0x35, 0x33, 0x32, 0x39, 0x32, 0x35, 0x61, 0x33, 0x62, 0x38, 0x44, 0x34, 0x43, 0x39, 0x64, 0x62, 0x39, 0x36, 0x43, 0x34, 0x62, 0x34, 0x64, 0x38, 0x62, 0x36 31 | ]); 32 | return Promise.resolve(mockResponse); 33 | } 34 | 35 | on(event: string, callback: (...args: any[]) => void): void { 36 | // Mock event handling 37 | if (event === 'card') { 38 | // Simulate card detection after a short delay 39 | setTimeout(() => { 40 | this.card = { uid: 'mock-card-uid' }; 41 | callback(this.card); 42 | }, 100); 43 | } 44 | } 45 | 46 | removeAllListeners(): void { 47 | // Mock cleanup 48 | } 49 | } 50 | 51 | export class Card { 52 | public uid: string; 53 | 54 | constructor(uid: string) { 55 | this.uid = uid; 56 | } 57 | 58 | getUID(): string { 59 | return this.uid; 60 | } 61 | } 62 | 63 | // Mock the library exports 64 | export default { 65 | Reader, 66 | Card, 67 | }; -------------------------------------------------------------------------------- /tests/mocks/ws.mock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock for WebSocket library 3 | * Prevents real WebSocket connections during testing 4 | */ 5 | 6 | export class WebSocket { 7 | public url: string; 8 | public readyState: number = 0; // CONNECTING 9 | public onopen: ((event: any) => void) | null = null; 10 | public onmessage: ((event: any) => void) | null = null; 11 | public onerror: ((event: any) => void) | null = null; 12 | public onclose: ((event: any) => void) | null = null; 13 | public send: (data: string | Buffer) => void; 14 | public close: (code?: number, reason?: string) => void; 15 | 16 | static readonly CONNECTING = 0; 17 | static readonly OPEN = 1; 18 | static readonly CLOSING = 2; 19 | static readonly CLOSED = 3; 20 | 21 | constructor(url: string) { 22 | this.url = url; 23 | 24 | // Mock send method 25 | this.send = jest.fn(); 26 | 27 | // Mock close method 28 | this.close = jest.fn((code = 1000, reason = '') => { 29 | this.readyState = WebSocket.CLOSED; 30 | if (this.onclose) { 31 | this.onclose({ code, reason }); 32 | } 33 | }); 34 | 35 | // Simulate connection after a short delay 36 | setTimeout(() => { 37 | this.readyState = WebSocket.OPEN; 38 | if (this.onopen) { 39 | this.onopen({}); 40 | } 41 | }, 10); 42 | } 43 | 44 | // Mock event listener methods 45 | addEventListener(event: string, listener: (...args: any[]) => void): void { 46 | switch (event) { 47 | case 'open': 48 | this.onopen = listener; 49 | break; 50 | case 'message': 51 | this.onmessage = listener; 52 | break; 53 | case 'error': 54 | this.onerror = listener; 55 | break; 56 | case 'close': 57 | this.onclose = listener; 58 | break; 59 | } 60 | } 61 | 62 | removeEventListener(_event: string, _listener: (...args: any[]) => void): void { 63 | // Mock cleanup 64 | } 65 | 66 | // Mock message sending 67 | mockMessage(data: any): void { 68 | if (this.onmessage) { 69 | this.onmessage({ data: JSON.stringify(data) }); 70 | } 71 | } 72 | 73 | // Mock error 74 | mockError(error: any): void { 75 | if (this.onerror) { 76 | this.onerror(error); 77 | } 78 | } 79 | } 80 | 81 | export default WebSocket; -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test setup file for Jest 3 | * Configures global mocks and test environment 4 | */ 5 | 6 | import { jest } from '@jest/globals'; 7 | 8 | // Global test timeout 9 | jest.setTimeout(10000); 10 | 11 | // Mock environment variables for testing 12 | process.env.NODE_ENV = 'test'; 13 | process.env.ALCHEMY_API_KEY = 'test-alchemy-key'; 14 | process.env.RECIPIENT_ADDRESS = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6'; 15 | 16 | // Suppress console output during tests unless explicitly needed 17 | const originalConsoleLog = console.log; 18 | const originalConsoleError = console.error; 19 | const originalConsoleWarn = console.warn; 20 | 21 | beforeAll(() => { 22 | // Only show console output for tests that explicitly need it 23 | if (process.env.DEBUG_TESTS !== 'true') { 24 | console.log = jest.fn(); 25 | console.error = jest.fn(); 26 | console.warn = jest.fn(); 27 | } 28 | }); 29 | 30 | afterAll(() => { 31 | // Restore console functions 32 | console.log = originalConsoleLog; 33 | console.error = originalConsoleError; 34 | console.warn = originalConsoleWarn; 35 | }); 36 | 37 | // Global test utilities 38 | global.testUtils = { 39 | // Mock response helpers 40 | createMockResponse: (data: any, status: number = 200) => ({ 41 | data, 42 | status, 43 | statusText: status === 200 ? 'OK' : 'Error', 44 | headers: {}, 45 | config: {}, 46 | }), 47 | 48 | // Mock error helpers 49 | createMockError: (message: string, status: number = 500) => ({ 50 | message, 51 | status, 52 | response: { 53 | data: { error: message }, 54 | status, 55 | statusText: 'Error', 56 | }, 57 | }), 58 | 59 | // Wait helper for async operations 60 | wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), 61 | }; 62 | 63 | // Type declarations for global test utilities 64 | declare global { 65 | // eslint-disable-next-line no-var 66 | var testUtils: { 67 | createMockResponse: (data: any, status?: number) => any; 68 | createMockError: (message: string, status?: number) => any; 69 | wait: (ms: number) => Promise; 70 | }; 71 | } -------------------------------------------------------------------------------- /tests/utils/testHelpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test utility functions for common testing scenarios 3 | */ 4 | 5 | import { jest } from '@jest/globals'; 6 | 7 | /** 8 | * Mock Alchemy SDK responses 9 | */ 10 | export const mockAlchemyResponses = { 11 | // Mock balance response 12 | balance: { 13 | result: '0x1000000000000000000', // 1 ETH in wei 14 | jsonrpc: '2.0', 15 | id: 1 16 | }, 17 | 18 | // Mock token balances response 19 | tokenBalances: { 20 | result: { 21 | address: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', 22 | tokenBalances: [ 23 | { 24 | contractAddress: '0xA0b86a33E6441b8c4C8C8C8C8C8C8C8C8C8C8C8C', 25 | tokenBalance: '0x100000000000000000', 26 | error: null 27 | } 28 | ] 29 | }, 30 | jsonrpc: '2.0', 31 | id: 1 32 | }, 33 | 34 | // Mock transaction monitoring response 35 | pendingTransaction: { 36 | result: { 37 | hash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 38 | to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', 39 | value: '0x100000000000000000', 40 | input: '0x', 41 | blockNumber: null 42 | }, 43 | jsonrpc: '2.0', 44 | id: 1 45 | } 46 | }; 47 | 48 | 49 | 50 | /** 51 | * Create a mock HTTP response 52 | */ 53 | export const createMockHttpResponse = (data: any, status: number = 200) => ({ 54 | data, 55 | status, 56 | statusText: status === 200 ? 'OK' : 'Error', 57 | headers: {}, 58 | config: {}, 59 | }); 60 | 61 | /** 62 | * Create a mock HTTP error 63 | */ 64 | export const createMockHttpError = (message: string, status: number = 500) => ({ 65 | message, 66 | status, 67 | response: { 68 | data: { error: message }, 69 | status, 70 | statusText: 'Error', 71 | }, 72 | }); 73 | 74 | /** 75 | * Wait for a specified number of milliseconds 76 | */ 77 | export const wait = (ms: number): Promise => 78 | new Promise(resolve => setTimeout(resolve, ms)); 79 | 80 | /** 81 | * Mock environment variables for testing 82 | */ 83 | export const mockEnvVars = { 84 | ALCHEMY_API_KEY: 'test-alchemy-key', 85 | RECIPIENT_ADDRESS: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', 86 | NODE_ENV: 'test', 87 | }; 88 | 89 | /** 90 | * Setup test environment 91 | */ 92 | export const setupTestEnv = () => { 93 | // Set environment variables 94 | Object.entries(mockEnvVars).forEach(([key, value]) => { 95 | process.env[key] = value; 96 | }); 97 | 98 | // Mock console methods to reduce noise 99 | jest.spyOn(console, 'log').mockImplementation(() => {}); 100 | jest.spyOn(console, 'error').mockImplementation(() => {}); 101 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 102 | }; 103 | 104 | /** 105 | * Cleanup test environment 106 | */ 107 | export const cleanupTestEnv = () => { 108 | // Restore console methods 109 | jest.restoreAllMocks(); 110 | 111 | // Clear environment variables 112 | Object.keys(mockEnvVars).forEach(key => { 113 | delete process.env[key]; 114 | }); 115 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["es2020", "dom"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "libReplacement": true, /* Enable lib replacement. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | 28 | /* Modules */ 29 | "module": "ESNext", /* Specify what module code is generated. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | "rootDir": "src", /* Specify the root folder within your source files. */ 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | "typeRoots": ["./types", "./node_modules/@types"], /* Specify multiple folders that act like './node_modules/@types'. */ 36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 38 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 39 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 40 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 44 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 45 | "resolveJsonModule": true, /* Enable importing .json files. */ 46 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 47 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 48 | 49 | /* JavaScript Support */ 50 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 51 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 52 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 53 | 54 | /* Emit */ 55 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 56 | "declarationMap": true, /* Create sourcemaps for d.ts files. */ 57 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 58 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 62 | "outDir": "dist", /* Specify an output folder for all emitted files. */ 63 | // "removeComments": true, /* Disable emitting comments. */ 64 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | 77 | /* Interop Constraints */ 78 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 79 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 80 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 81 | // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ 82 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 83 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 84 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 85 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 86 | 87 | /* Type Checking */ 88 | "strict": true, /* Enable all strict type-checking options. */ 89 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 90 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 91 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 92 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 93 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 94 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 95 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 96 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 97 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 98 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 99 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 100 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 101 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 102 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 103 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 104 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 105 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 106 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 107 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 108 | 109 | /* Completeness */ 110 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 111 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 112 | }, 113 | "include": ["src/**/*"], 114 | "exclude": ["node_modules", "dist"], 115 | "ts-node": { 116 | "esm": true, 117 | "experimentalSpecifierResolution": "node" 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /types/nfc-pcsc.d.ts: -------------------------------------------------------------------------------- 1 | /* minimal typings – just enough for our script */ 2 | declare module 'nfc-pcsc' { 3 | import { EventEmitter } from 'events'; 4 | 5 | export interface Reader extends EventEmitter { 6 | name: string; 7 | connect(): Promise; 8 | close(): void; 9 | aid: string; 10 | transmit(data: Buffer, resLen: number): Promise; 11 | on(event: 'card', listener: () => void): this; 12 | on(event: 'error', listener: (err: Error) => void): this; 13 | } 14 | 15 | export class NFC extends EventEmitter { 16 | on(event: 'reader', listener: (reader: Reader) => void): this; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /types/pcsclite.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pcsclite' { 2 | import { EventEmitter } from 'events'; 3 | interface CardConnectOpts { share_mode: number; protocol: number } 4 | interface Reader extends EventEmitter { 5 | name: string; 6 | state: number; 7 | SCARD_STATE_PRESENT: number; 8 | SCARD_SHARE_SHARED: number; 9 | SCARD_PROTOCOL_T0: number; 10 | SCARD_PROTOCOL_T1: number; 11 | SCARD_LEAVE_CARD: number; 12 | connect(opts: CardConnectOpts, cb: (err: any) => void): void; 13 | disconnect(disposition: number, cb: (err: any) => void): void; 14 | transmit(data: Buffer, recvLen: number, protocol: number, 15 | cb: (err: any, res: Buffer) => void): void; 16 | } 17 | export = function (): EventEmitter; // returns pcsc instance emitting 'reader' 18 | } 19 | --------------------------------------------------------------------------------