diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index d9600f7..4e39958 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -32,6 +32,11 @@ jobs: - name: Install dependencies run: npm install + - name: Verify Migrations Against Temporary Database + env: + DATABASE_URL: "file:./ci-verify.db" + run: npm run migrate:verify + - name: Run Migration Integrity Checker env: DATABASE_URL: "file:./ci.db" diff --git a/backend/FUTURENET_CONFIGURATION.md b/backend/FUTURENET_CONFIGURATION.md new file mode 100644 index 0000000..127d284 --- /dev/null +++ b/backend/FUTURENET_CONFIGURATION.md @@ -0,0 +1,171 @@ +# Futurenet Configuration Guide + +This guide explains how to configure all backend services (Indexer, Stellar, Auth) to point to Stellar Futurenet for testing upcoming protocol features. + +## Environment Variables + +To configure the backend to use Stellar Futurenet, set the following environment variables in your `.env` file: + +```bash +# Network selection (testnet, public, or futurenet) +STELLAR_NETWORK=futurenet + +# Horizon URL for the Indexer service +HORIZON_URL=https://horizon-futurenet.stellar.org + +# Stellar Horizon URL for the Stellar service +STELLAR_HORIZON_URL=https://horizon-futurenet.stellar.org + +# Network passphrase for Auth and Batch services +STELLAR_NETWORK_PASSPHRASE="Test SDF Future Network ; October 2022" +``` + +## Service-Specific Configuration + +### 1. Stellar Service + +The Stellar service (`backend/src/services/stellar.service.ts`) automatically initializes from the `STELLAR_NETWORK` environment variable. It supports: + +- **NetworkType.TESTNET** (default) +- **NetworkType.PUBLIC** +- **NetworkType.FUTURENET** + +The service provides the following network configurations: +- Horizon URL +- Soroban RPC URL +- Network passphrase + +### 2. Indexer Service + +The Indexer service (`backend/src/services/indexer/indexer.service.ts`) uses the `HORIZON_URL` environment variable to resolve issuer home domains via Horizon. The HorizonResolver (`backend/src/services/indexer/horizon.resolver.ts`) has been updated to use the centralized configuration. + +### 3. Auth Service + +The Auth service (`backend/src/services/auth.service.ts` and `backend/src/api/controllers/auth.controller.ts`) uses the `STELLAR_NETWORK_PASSPHRASE` environment variable for SEP-10 authentication challenges. This is now configured through the centralized env config. + +### 4. Batch Payment Service + +The Batch Payment service (`backend/src/api/controllers/batch.controller.ts`) uses both `STELLAR_HORIZON_URL` and `STELLAR_NETWORK_PASSPHRASE` environment variables for transaction submission and network passphrase. + +## Network Configurations + +The network configurations are defined in `backend/src/config/networks.ts`: + +```typescript +export const NETWORKS: Record = { + [NetworkType.PUBLIC]: { + horizonUrl: 'https://horizon.stellar.org', + sorobanRpcUrl: 'https://mainnet.stellar.org:443', + passphrase: Networks.PUBLIC, + }, + [NetworkType.TESTNET]: { + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + passphrase: Networks.TESTNET, + }, + [NetworkType.FUTURENET]: { + horizonUrl: 'https://horizon-futurenet.stellar.org', + sorobanRpcUrl: 'https://rpc-futurenet.stellar.org', + passphrase: Networks.FUTURENET, + }, +}; +``` + +## Asset Configuration + +Assets can be configured with network-specific issuer addresses in `backend/src/config/assets.ts`: + +```typescript +export const ASSETS: AssetConfig[] = [ + { + code: 'USDC', + issuers: { + [NetworkType.PUBLIC]: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + [NetworkType.TESTNET]: 'GBBD47IF6LWLVNC7F7YSACOA73YI4COI3V5O2S46F7S44GUL44YQY4O2', + [NetworkType.FUTURENET]: 'GBBD47IF6LWLVNC7F7YSACOA73YI4COI3V5O2S46F7S44GUL44YQY4O2', + }, + // ... other asset properties + }, +]; +``` + +## Example .env File + +```bash +# Node environment +NODE_ENV=development + +# Server configuration +PORT=3002 + +# Database +DATABASE_URL=file:./prisma/dev.db + +# JWT Secret +JWT_SECRET=stellar-anchor-secret + +# Stellar Network Configuration +STELLAR_NETWORK=futurenet +STELLAR_HORIZON_URL=https://horizon-futurenet.stellar.org +HORIZON_URL=https://horizon-futurenet.stellar.org +STELLAR_NETWORK_PASSPHRASE="Test SDF Future Network ; October 2022" + +# Stellar Fee Configuration +STELLAR_BASE_FEE=100 + +# Interactive URL +INTERACTIVE_URL=http://localhost:3000 + +# Webhook Configuration (optional) +WEBHOOK_URL=http://localhost:3000/webhook +WEBHOOK_SECRET=webhook-secret +WEBHOOK_TIMEOUT_MS=5000 +WEBHOOK_MAX_RETRIES=3 +WEBHOOK_RETRY_DELAY_MS=500 + +# Indexer Configuration +INDEXER_CRON_SCHEDULE=0 * * * * +``` + +## Verification + +To verify that all services are correctly configured for Futurenet: + +1. Check the environment variables are set correctly +2. Start the backend service +3. Verify the Stellar service is using the correct network by checking logs +4. Test the Indexer service by triggering a crawl job +5. Test the Auth service by requesting a SEP-10 challenge +6. Verify the network passphrase in the challenge response matches Futurenet + +## Switching Networks + +To switch between networks, simply update the `STELLAR_NETWORK` environment variable and restart the backend service: + +```bash +# Switch to Testnet +STELLAR_NETWORK=testnet +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +# Switch to Public +STELLAR_NETWORK=public +STELLAR_HORIZON_URL=https://horizon.stellar.org +HORIZON_URL=https://horizon.stellar.org +STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" + +# Switch to Futurenet +STELLAR_NETWORK=futurenet +STELLAR_HORIZON_URL=https://horizon-futurenet.stellar.org +HORIZON_URL=https://horizon-futurenet.stellar.org +STELLAR_NETWORK_PASSPHRASE="Test SDF Future Network ; October 2022" +``` + +## Important Notes + +- Futurenet is a test network for upcoming protocol features +- Always use testnet or futurenet for development and testing +- Never use private keys or secrets from production networks on test networks +- The network passphrase must match the network you're connecting to +- Asset issuer addresses may differ between networks diff --git a/backend/RELAYER_GASLESS_ONBOARDING.md b/backend/RELAYER_GASLESS_ONBOARDING.md new file mode 100644 index 0000000..e65033c --- /dev/null +++ b/backend/RELAYER_GASLESS_ONBOARDING.md @@ -0,0 +1,365 @@ +# Gasless Token Approval System + +A signature-based verification system that allows a relayer to submit token approvals on behalf of a user, facilitating gasless onboarding for new users. + +## Overview + +This system enables users to sign token approval requests without paying transaction fees. The relayer verifies the signature and submits the transaction on the user's behalf, paying the gas fees. This is particularly useful for onboarding new users who may not have XLM to pay for transactions. + +## Architecture + +### Components + +1. **Relayer Service** (`backend/src/services/relayer.service.ts`) + - Signature verification + - Transaction building + - Transaction submission + +2. **Relayer Controller** (`backend/src/api/controllers/relayer.controller.ts`) + - API endpoints for approval requests + - Signature verification endpoint + - Nonce generation + +3. **Relayer Types** (`backend/src/types/relayer.types.ts`) + - Type definitions for requests and responses + - Configuration interfaces + +## Configuration + +### Environment Variables + +Add the following to your `.env` file: + +```bash +# Relayer Configuration +RELAYER_PUBLIC_KEY=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +RELAYER_SECRET_KEY=SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +RELAYER_MAX_AMOUNT=1000000 +RELAYER_ALLOWED_SPENDERS=GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB,GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +RELAYER_EXPIRY_WINDOW=3600 +``` + +**Environment Variable Descriptions:** + +- `RELAYER_PUBLIC_KEY`: The relayer's Stellar public key +- `RELAYER_SECRET_KEY`: The relayer's Stellar secret key (keep secure!) +- `RELAYER_MAX_AMOUNT`: Maximum amount allowed per approval (in stroops) +- `RELAYER_ALLOWED_SPENDERS`: Comma-separated list of allowed spender addresses +- `RELAYER_EXPIRY_WINDOW`: Time window in seconds for approval validity (default: 3600 = 1 hour) + +## API Endpoints + +### 1. Submit Token Approval + +**POST** `/api/relayer/approve` + +Submit a token approval request with a signature. The relayer verifies the signature and submits the transaction. + +**Request Body:** +```json +{ + "userPublicKey": "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "spenderPublicKey": "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "amount": "1000000", + "assetCode": "USDC", + "assetIssuer": "GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "expiry": 1714324800000, + "signature": "base64-encoded-signature" +} +``` + +**Response:** +```json +{ + "success": true, + "transactionHash": "abc123...", + "message": "Token approval submitted successfully" +} +``` + +### 2. Verify Signature + +**POST** `/api/relayer/verify` + +Verify a signature without submitting the transaction. Useful for pre-verification. + +**Request Body:** +```json +{ + "userPublicKey": "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "spenderPublicKey": "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "amount": "1000000", + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "expiry": 1714324800000, + "signature": "base64-encoded-signature" +} +``` + +**Response:** +```json +{ + "valid": true, + "publicKey": "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" +} +``` + +### 3. Submit Signed Transaction + +**POST** `/api/relayer/submit` + +Submit a pre-signed transaction. The transaction should already be signed by the user. + +**Request Body:** +```json +{ + "signedTransactionXdr": "base64-encoded-xdr", + "networkPassphrase": "Test SDF Network ; September 2015" +} +``` + +**Response:** +```json +{ + "success": true, + "transactionHash": "abc123...", + "message": "Transaction submitted successfully" +} +``` + +### 4. Generate Nonce + +**GET** `/api/relayer/nonce` + +Generate a unique nonce for approval requests. Nonces prevent replay attacks. + +**Response:** +```json +{ + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "message": "Nonce generated successfully" +} +``` + +### 5. Get Relayer Configuration + +**GET** `/api/relayer/config` + +Get public relayer configuration (excludes sensitive data like secret key). + +**Response:** +```json +{ + "relayerPublicKey": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "maxAmount": "1000000", + "allowedSpenders": [ + "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + ], + "expiryWindowSeconds": 3600 +} +``` + +## Integration Guide + +### Client-Side Implementation + +#### Step 1: Generate a Nonce + +```typescript +const response = await fetch('http://localhost:3002/api/relayer/nonce'); +const { nonce } = await response.json(); +``` + +#### Step 2: Construct the Approval Message + +The message format is: +``` +approve|{userPublicKey}|{spenderPublicKey}|{amount}|{assetCode}|{assetIssuer}|{nonce}|{expiry} +``` + +```typescript +const message = `approve|${userPublicKey}|${spenderPublicKey}|${amount}|${assetCode}|${assetIssuer}|${nonce}|${expiry}`; +``` + +#### Step 3: Sign the Message + +Using the Stellar SDK: + +```typescript +import { Keypair } from '@stellar/stellar-sdk'; + +const userKeypair = Keypair.fromSecret(userSecretKey); +const signature = userKeypair.sign(Buffer.from(message)).toString('base64'); +``` + +#### Step 4: Submit the Approval Request + +```typescript +const approvalRequest = { + userPublicKey, + spenderPublicKey, + amount, + assetCode, + assetIssuer, + nonce, + expiry: Date.now() + 3600000, // 1 hour from now + signature, +}; + +const response = await fetch('http://localhost:3002/api/relayer/approve', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(approvalRequest), +}); + +const result = await response.json(); +``` + +## Security Considerations + +### Signature Verification + +The system verifies: +- Signature validity using Ed25519 +- Request expiration (prevents replay attacks) +- Spender authorization (only approved spenders) +- Amount limits (prevents excessive approvals) + +### Nonce Usage + +- Each approval request must include a unique nonce +- Nonces are generated by the server to ensure uniqueness +- Nonces prevent replay attacks + +### Expiry Window + +- Approval requests expire after the configured window (default: 1 hour) +- Expired requests are rejected automatically + +### Allowed Spenders + +- Configure a whitelist of allowed spender addresses +- Only spenders in the whitelist can receive approvals +- This prevents unauthorized approvals to unknown addresses + +### Amount Limits + +- Maximum approval amount is configurable +- Prevents users from approving excessively large amounts +- Protects against potential exploits + +## Error Handling + +### Common Errors + +**Invalid Signature** +```json +{ + "success": false, + "error": "Invalid signature" +} +``` + +**Expired Request** +```json +{ + "success": false, + "error": "Request has expired" +} +``` + +**Unauthorized Spender** +```json +{ + "success": false, + "error": "Spender is not authorized" +} +``` + +**Amount Exceeds Maximum** +```json +{ + "success": false, + "error": "Amount exceeds maximum allowed" +} +``` + +## Testing + +Run the test suite: + +```bash +npm test -- relayer.service.test.ts +``` + +## Use Cases + +### 1. New User Onboarding + +New users without XLM can approve token spending without paying gas fees: +1. User signs approval request +2. Relayer verifies and submits +3. Relayer pays the transaction fee +4. User can now interact with the application + +### 2. DeFi Integration + +DeFi protocols can use this for: +- Token approvals without gas costs +- Batch approvals for multiple tokens +- Automated approval workflows + +### 3. Payment Processing + +Payment processors can: +- Request token approvals from users +- Submit approvals on behalf of users +- Enable seamless payment experiences + +## Best Practices + +1. **Always use HTTPS** in production to prevent man-in-the-middle attacks +2. **Rotate relayer keys** regularly to minimize exposure +3. **Monitor approval activity** for suspicious patterns +4. **Set appropriate amount limits** based on your use case +5. **Use short expiry windows** for high-value approvals +6. **Implement rate limiting** on approval endpoints +7. **Log all approval requests** for audit trails + +## Troubleshooting + +### Transaction Submission Fails + +If transaction submission fails: +1. Check that the relayer has sufficient XLM balance +2. Verify the network configuration matches the target network +3. Ensure the relayer account is funded and active +4. Check Horizon service status + +### Signature Verification Fails + +If signature verification fails: +1. Ensure the message format matches exactly +2. Verify the signature is base64 encoded +3. Check that the correct user keypair is used for signing +4. Confirm the nonce is fresh and unused + +### Spender Not Authorized + +If spender is not authorized: +1. Add the spender address to `RELAYER_ALLOWED_SPENDERS` +2. Restart the backend service +3. Verify the spender address is correct + +## Future Enhancements + +Potential improvements: +- Multi-signature support +- Batch approval requests +- Approval revocation +- Time-locked approvals +- Conditional approvals +- Approval history tracking +- Webhook notifications for approvals diff --git a/backend/package.json b/backend/package.json index 7a0babf..97666a0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,15 @@ "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint \"src/**/*.ts\"", - "lint:fix": "eslint \"src/**/*.ts\" --fix" + "lint:fix": "eslint \"src/**/*.ts\" --fix", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "migrate:verify": "ts-node scripts/verify-migrations.ts", + "migrate:check": "ts-node scripts/migration-integrity-checker.ts", + "migrate:rollback": "ts-node scripts/generate-rollback.ts", + "migrate:status": "prisma migrate status" }, "dependencies": { "@aws-sdk/client-kms": "^3.500.0", diff --git a/backend/scripts/verify-migrations.ts b/backend/scripts/verify-migrations.ts new file mode 100644 index 0000000..68a0c7e --- /dev/null +++ b/backend/scripts/verify-migrations.ts @@ -0,0 +1,408 @@ +#!/usr/bin/env node +/** + * Prisma Migration Verification Script + * + * Verifies the integrity of Prisma migrations against a temporary database + * to prevent breaking changes during production deployments. + * + * This script: + * 1. Creates a temporary database + * 2. Applies all migrations to it + * 3. Verifies schema consistency + * 4. Checks for destructive changes + * 5. Tests migration reversibility + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +interface MigrationCheckResult { + success: boolean; + errors: string[]; + warnings: string[]; + checks: { + tempDatabaseCreated: boolean; + migrationsApplied: boolean; + schemaConsistent: boolean; + noDestructiveChanges: boolean; + migrationsReversible: boolean; + }; +} + +class MigrationVerifier { + private tempDbPath: string; + private prismaBinary: string; + private schemaPath: string; + private migrationsPath: string; + + constructor() { + this.tempDbPath = path.join(os.tmpdir(), `prisma-verify-${Date.now()}.db`); + this.prismaBinary = 'npx prisma'; + this.schemaPath = path.join(__dirname, '../prisma/schema.prisma'); + this.migrationsPath = path.join(__dirname, '../prisma/migrations'); + } + + /** + * Execute a command and return the result + */ + private run(command: string, options: any = {}): string { + try { + return execSync(command, { + stdio: 'pipe', + encoding: 'utf-8', + ...options, + }); + } catch (error: any) { + throw new Error(`Command failed: ${command}\n${error.message}`); + } + } + + /** + * Execute a command silently (suppress output) + */ + private runSilent(command: string, options: any = {}): string { + try { + return execSync(command, { + stdio: 'pipe', + encoding: 'utf-8', + ...options, + }); + } catch (error: any) { + throw new Error(`Command failed: ${command}\n${error.message}`); + } + } + + /** + * Clean up temporary database + */ + private cleanup(): void { + if (fs.existsSync(this.tempDbPath)) { + fs.unlinkSync(this.tempDbPath); + console.log('๐Ÿงน Cleaned up temporary database'); + } + } + + /** + * Create temporary database + */ + private createTempDatabase(): boolean { + try { + console.log('๐Ÿ“ฆ Creating temporary database...'); + // Just ensure the path doesn't exist, it will be created by Prisma + if (fs.existsSync(this.tempDbPath)) { + fs.unlinkSync(this.tempDbPath); + } + console.log('โœ… Temporary database path ready'); + return true; + } catch (error) { + console.error('โŒ Failed to create temporary database:', error); + return false; + } + } + + /** + * Apply all migrations to temporary database + */ + private applyMigrations(): boolean { + try { + console.log('๐Ÿ”„ Applying migrations to temporary database...'); + + const env = { + ...process.env, + DATABASE_URL: `file:${this.tempDbPath}`, + }; + + // Reset and apply migrations + this.runSilent(`${this.prismaBinary} migrate reset --force`, { + env, + cwd: path.join(__dirname, '..'), + }); + + console.log('โœ… All migrations applied successfully'); + return true; + } catch (error) { + console.error('โŒ Failed to apply migrations:', error); + return false; + } + } + + /** + * Verify schema consistency between migrations and schema.prisma + */ + private verifySchemaConsistency(): boolean { + try { + console.log('๐Ÿ” Verifying schema consistency...'); + + const env = { + ...process.env, + DATABASE_URL: `file:${this.tempDbPath}`, + }; + + // Check if the database schema matches the Prisma schema + const diffOutput = this.runSilent( + `${this.prismaBinary} migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script`, + { + env, + cwd: path.join(__dirname, '..'), + } + ); + + // If there's any diff output, there's inconsistency + if (diffOutput.trim() && !diffOutput.includes('No difference')) { + console.error('โŒ Schema inconsistency detected:'); + console.error(diffOutput); + return false; + } + + console.log('โœ… Schema is consistent'); + return true; + } catch (error) { + console.error('โŒ Schema consistency check failed:', error); + return false; + } + } + + /** + * Check for destructive changes in migrations + */ + private checkDestructiveChanges(): boolean { + try { + console.log('โš ๏ธ Checking for destructive changes...'); + + const env = { + ...process.env, + DATABASE_URL: `file:${this.tempDbPath}`, + }; + + // Use Prisma migrate diff to detect destructive changes + try { + this.runSilent( + `${this.prismaBinary} migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code`, + { + env, + cwd: path.join(__dirname, '..'), + } + ); + console.log('โœ… No destructive changes detected'); + return true; + } catch (error: any) { + if (error.status === 1) { + console.error('โŒ Destructive changes detected in migrations!'); + console.error('Please review your migration files for:'); + console.error(' - DROP TABLE operations'); + console.error(' - DROP COLUMN operations'); + console.error(' - DELETE/TRUNCATE operations'); + console.error(' - ALTER COLUMN that changes types'); + return false; + } + throw error; + } + } catch (error) { + console.error('โŒ Destructive change check failed:', error); + return false; + } + } + + /** + * Test migration reversibility + */ + private testMigrationReversibility(): boolean { + try { + console.log('๐Ÿ”™ Testing migration reversibility...'); + + const env = { + ...process.env, + DATABASE_URL: `file:${this.tempDbPath}`, + }; + + // Get the list of migrations + const migrationDirs = fs.readdirSync(this.migrationsPath) + .filter((dir) => fs.statSync(path.join(this.migrationsPath, dir)).isDirectory()) + .sort() + .reverse(); + + if (migrationDirs.length === 0) { + console.log('โœ… No migrations to test'); + return true; + } + + console.log(`Testing ${migrationDirs.length} migration(s) for reversibility...`); + + // Check each migration for rollback.sql + for (const migrationDir of migrationDirs) { + const rollbackPath = path.join(this.migrationsPath, migrationDir, 'rollback.sql'); + const migrationSqlPath = path.join(this.migrationsPath, migrationDir, 'migration.sql'); + + if (!fs.existsSync(migrationSqlPath)) { + console.warn(`โš ๏ธ Warning: Migration ${migrationDir} missing migration.sql`); + continue; + } + + if (!fs.existsSync(rollbackPath)) { + console.warn(`โš ๏ธ Warning: Migration ${migrationDir} missing rollback.sql`); + console.warn(' Consider generating a rollback script using: npm run migrate:rollback'); + } + } + + console.log('โœ… Migration reversibility check completed'); + return true; + } catch (error) { + console.error('โŒ Migration reversibility test failed:', error); + return false; + } + } + + /** + * Validate migration files + */ + private validateMigrationFiles(): boolean { + try { + console.log('๐Ÿ“„ Validating migration files...'); + + const migrationDirs = fs.readdirSync(this.migrationsPath) + .filter((dir) => fs.statSync(path.join(this.migrationsPath, dir)).isDirectory()); + + for (const migrationDir of migrationDirs) { + const migrationSqlPath = path.join(this.migrationsPath, migrationDir, 'migration.sql'); + + if (!fs.existsSync(migrationSqlPath)) { + console.error(`โŒ Migration ${migrationDir} missing migration.sql`); + return false; + } + + const content = fs.readFileSync(migrationSqlPath, 'utf-8'); + if (content.trim().length === 0) { + console.error(`โŒ Migration ${migrationDir} has empty migration.sql`); + return false; + } + } + + console.log('โœ… All migration files are valid'); + return true; + } catch (error) { + console.error('โŒ Migration file validation failed:', error); + return false; + } + } + + /** + * Run all verification checks + */ + public verify(): MigrationCheckResult { + const result: MigrationCheckResult = { + success: false, + errors: [], + warnings: [], + checks: { + tempDatabaseCreated: false, + migrationsApplied: false, + schemaConsistent: false, + noDestructiveChanges: false, + migrationsReversible: false, + }, + }; + + try { + // Create temporary database + result.checks.tempDatabaseCreated = this.createTempDatabase(); + if (!result.checks.tempDatabaseCreated) { + result.errors.push('Failed to create temporary database'); + return result; + } + + // Validate migration files + const filesValid = this.validateMigrationFiles(); + if (!filesValid) { + result.errors.push('Migration file validation failed'); + return result; + } + + // Apply migrations + result.checks.migrationsApplied = this.applyMigrations(); + if (!result.checks.migrationsApplied) { + result.errors.push('Failed to apply migrations to temporary database'); + return result; + } + + // Verify schema consistency + result.checks.schemaConsistent = this.verifySchemaConsistency(); + if (!result.checks.schemaConsistent) { + result.errors.push('Schema consistency check failed'); + } + + // Check for destructive changes + result.checks.noDestructiveChanges = this.checkDestructiveChanges(); + if (!result.checks.noDestructiveChanges) { + result.errors.push('Destructive changes detected'); + } + + // Test migration reversibility + result.checks.migrationsReversible = this.testMigrationReversibility(); + if (!result.checks.migrationsReversible) { + result.warnings.push('Some migrations may not be reversible'); + } + + // Overall success + result.success = result.errors.length === 0; + + return result; + } catch (error) { + result.errors.push(`Unexpected error: ${error}`); + return result; + } finally { + this.cleanup(); + } + } + + /** + * Print verification results + */ + public printResults(result: MigrationCheckResult): void { + console.log('\n' + '='.repeat(80)); + console.log('๐Ÿ“Š Migration Verification Results'); + console.log('='.repeat(80)); + + console.log('\nโœ… Passed Checks:'); + if (result.checks.tempDatabaseCreated) console.log(' โœ“ Temporary database created'); + if (result.checks.migrationsApplied) console.log(' โœ“ Migrations applied successfully'); + if (result.checks.schemaConsistent) console.log(' โœ“ Schema consistency verified'); + if (result.checks.noDestructiveChanges) console.log(' โœ“ No destructive changes'); + if (result.checks.migrationsReversible) console.log(' โœ“ Migrations reversible'); + + if (result.warnings.length > 0) { + console.log('\nโš ๏ธ Warnings:'); + result.warnings.forEach((warning) => console.log(` โš ๏ธ ${warning}`)); + } + + if (result.errors.length > 0) { + console.log('\nโŒ Errors:'); + result.errors.forEach((error) => console.log(` โŒ ${error}`)); + } + + console.log('\n' + '='.repeat(80)); + console.log(`Total: ${Object.values(result.checks).filter(Boolean).length} checks`); + console.log(`โœ… Passed: ${Object.values(result.checks).filter(Boolean).length}`); + console.log(`โš ๏ธ Warnings: ${result.warnings.length}`); + console.log(`โŒ Errors: ${result.errors.length}`); + console.log('='.repeat(80) + '\n'); + + if (result.success) { + console.log('โœจ All migration verification checks passed!'); + } else { + console.log('๐Ÿ’ฅ Migration verification failed. Please fix the errors above.'); + } + } +} + +// Main execution +if (require.main === module) { + const verifier = new MigrationVerifier(); + const result = verifier.verify(); + verifier.printResults(result); + + process.exit(result.success ? 0 : 1); +} + +export { MigrationVerifier, MigrationCheckResult }; diff --git a/backend/src/api/controllers/auth.controller.ts b/backend/src/api/controllers/auth.controller.ts index 384cd2a..aaee728 100644 --- a/backend/src/api/controllers/auth.controller.ts +++ b/backend/src/api/controllers/auth.controller.ts @@ -86,6 +86,7 @@ export const getChallenge = async ( // with the challenge as a manage_data operation const response: ChallengeResponse = { transaction: challenge, // Simplified - should be a base64 encoded transaction + network_passphrase: config.STELLAR_NETWORK_PASSPHRASE network_passphrase: process.env?.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015', multiKeyChallenge // Use configured anchor key or generate a default one for demo diff --git a/backend/src/api/controllers/batch.controller.ts b/backend/src/api/controllers/batch.controller.ts index 9a3b4d8..11837b3 100644 --- a/backend/src/api/controllers/batch.controller.ts +++ b/backend/src/api/controllers/batch.controller.ts @@ -8,11 +8,12 @@ import { Request, Response, NextFunction } from 'express'; import { BatchPaymentService } from '../services/batch-payment.service'; import { BatchPaymentError, BatchErrorType } from '../services/batch-payment.types'; import logger from '../utils/logger'; +import { config } from '../config/env'; // Initialize batch payment service const batchService = new BatchPaymentService({ - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', - networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015', + horizonUrl: config.STELLAR_HORIZON_URL, + networkPassphrase: config.STELLAR_NETWORK_PASSPHRASE, }); /** diff --git a/backend/src/api/controllers/relayer.controller.ts b/backend/src/api/controllers/relayer.controller.ts new file mode 100644 index 0000000..c70c09b --- /dev/null +++ b/backend/src/api/controllers/relayer.controller.ts @@ -0,0 +1,238 @@ +/** + * Relayer Controller + * + * API endpoints for signature-based gasless token approvals + */ + +import { Request, Response } from 'express'; +import { relayerService } from '../../services/relayer.service'; +import { + TokenApprovalRequest, + SignedTransactionRequest, +} from '../../types/relayer.types'; +import logger from '../../utils/logger'; + +/** + * POST /api/relayer/approve + * + * Submit a token approval request with signature + * The relayer will verify the signature and submit the transaction + */ +export const submitApproval = async ( + req: Request, + res: Response +): Promise => { + try { + const approvalRequest: TokenApprovalRequest = req.body; + + // Validate required fields + if (!approvalRequest.userPublicKey || !approvalRequest.signature) { + return res.status(400).json({ + success: false, + error: 'Missing required fields: userPublicKey and signature are required', + }); + } + + if (!approvalRequest.spenderPublicKey) { + return res.status(400).json({ + success: false, + error: 'Missing required field: spenderPublicKey is required', + }); + } + + if (!approvalRequest.amount) { + return res.status(400).json({ + success: false, + error: 'Missing required field: amount is required', + }); + } + + if (!approvalRequest.nonce) { + return res.status(400).json({ + success: false, + error: 'Missing required field: nonce is required', + }); + } + + if (!approvalRequest.expiry) { + return res.status(400).json({ + success: false, + error: 'Missing required field: expiry is required', + }); + } + + logger.info('Processing token approval request', { + userPublicKey: approvalRequest.userPublicKey, + spenderPublicKey: approvalRequest.spenderPublicKey, + amount: approvalRequest.amount, + }); + + // Process the approval request + const result = await relayerService.processApprovalRequest(approvalRequest); + + if (result.success) { + return res.status(200).json({ + success: true, + transactionHash: result.transactionHash, + message: 'Token approval submitted successfully', + }); + } else { + return res.status(400).json({ + success: false, + error: result.error, + }); + } + } catch (error) { + logger.error('Approval submission error:', error); + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}; + +/** + * POST /api/relayer/verify + * + * Verify a signature without submitting the transaction + * Useful for pre-verification before submission + */ +export const verifySignature = async ( + req: Request, + res: Response +): Promise => { + try { + const approvalRequest: TokenApprovalRequest = req.body; + + // Validate required fields + if (!approvalRequest.userPublicKey || !approvalRequest.signature) { + return res.status(400).json({ + valid: false, + error: 'Missing required fields: userPublicKey and signature are required', + }); + } + + logger.info('Verifying signature', { + userPublicKey: approvalRequest.userPublicKey, + }); + + // Verify the signature + const result = await relayerService.verifySignature(approvalRequest); + + return res.status(200).json(result); + } catch (error) { + logger.error('Signature verification error:', error); + return res.status(500).json({ + valid: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}; + +/** + * POST /api/relayer/submit + * + * Submit a pre-signed transaction + * The transaction should already be signed by the user + */ +export const submitSignedTransaction = async ( + req: Request, + res: Response +): Promise => { + try { + const signedTxRequest: SignedTransactionRequest = req.body; + + // Validate required fields + if (!signedTxRequest.signedTransactionXdr) { + return res.status(400).json({ + success: false, + error: 'Missing required field: signedTransactionXdr is required', + }); + } + + if (!signedTxRequest.networkPassphrase) { + return res.status(400).json({ + success: false, + error: 'Missing required field: networkPassphrase is required', + }); + } + + logger.info('Submitting signed transaction'); + + // Submit the transaction + const result = await relayerService.submitSignedTransaction(signedTxRequest); + + if (result.success) { + return res.status(200).json({ + success: true, + transactionHash: result.transactionHash, + message: 'Transaction submitted successfully', + }); + } else { + return res.status(400).json({ + success: false, + error: result.error, + }); + } + } catch (error) { + logger.error('Transaction submission error:', error); + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}; + +/** + * GET /api/relayer/nonce + * + * Generate a nonce for approval requests + * Nonces should be unique and used to prevent replay attacks + */ +export const generateNonce = async ( + req: Request, + res: Response +): Promise => { + try { + const nonce = relayerService.generateNonce(); + + return res.status(200).json({ + nonce, + message: 'Nonce generated successfully', + }); + } catch (error) { + logger.error('Nonce generation error:', error); + return res.status(500).json({ + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}; + +/** + * GET /api/relayer/config + * + * Get relayer configuration (public information only) + */ +export const getRelayerConfig = async ( + req: Request, + res: Response +): Promise => { + try { + const config = relayerService.getConfig(); + + // Remove sensitive information + const publicConfig = { + relayerPublicKey: config.relayerPublicKey, + maxAmount: config.maxAmount, + allowedSpenders: config.allowedSpenders, + expiryWindowSeconds: config.expiryWindowSeconds, + }; + + return res.status(200).json(publicConfig); + } catch (error) { + logger.error('Config retrieval error:', error); + return res.status(500).json({ + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}; diff --git a/backend/src/api/routes/relayer.route.ts b/backend/src/api/routes/relayer.route.ts new file mode 100644 index 0000000..52c83e0 --- /dev/null +++ b/backend/src/api/routes/relayer.route.ts @@ -0,0 +1,167 @@ +/** + * Relayer Routes + * + * API routes for signature-based gasless token approvals + */ + +import { Router } from 'express'; +import { + submitApproval, + verifySignature, + submitSignedTransaction, + generateNonce, + getRelayerConfig, +} from '../controllers/relayer.controller'; + +const router = Router(); + +/** + * @swagger + * /api/relayer/approve: + * post: + * summary: Submit a token approval request with signature + * description: The relayer verifies the signature and submits the transaction on behalf of the user + * tags: [Relayer] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userPublicKey + * - spenderPublicKey + * - amount + * - nonce + * - expiry + * - signature + * properties: + * userPublicKey: + * type: string + * description: The user's public key + * spenderPublicKey: + * type: string + * description: The spender's public key (address being approved) + * amount: + * type: string + * description: The approval amount + * assetCode: + * type: string + * description: Asset code (optional, defaults to XLM) + * assetIssuer: + * type: string + * description: Asset issuer (optional, for custom assets) + * nonce: + * type: string + * description: Unique nonce to prevent replay attacks + * expiry: + * type: number + * description: Unix timestamp when the request expires + * signature: + * type: string + * description: Base64 encoded signature of the approval message + * responses: + * 200: + * description: Approval submitted successfully + * 400: + * description: Invalid request or signature verification failed + * 500: + * description: Internal server error + */ +router.post('/approve', submitApproval); + +/** + * @swagger + * /api/relayer/verify: + * post: + * summary: Verify a signature without submitting + * description: Pre-verification of signature before submission + * tags: [Relayer] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userPublicKey + * - signature + * properties: + * userPublicKey: + * type: string + * signature: + * type: string + * responses: + * 200: + * description: Signature verification result + * 400: + * description: Invalid request + * 500: + * description: Internal server error + */ +router.post('/verify', verifySignature); + +/** + * @swagger + * /api/relayer/submit: + * post: + * summary: Submit a pre-signed transaction + * description: Submit a transaction that's already signed by the user + * tags: [Relayer] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - signedTransactionXdr + * - networkPassphrase + * properties: + * signedTransactionXdr: + * type: string + * description: Base64 encoded signed transaction XDR + * networkPassphrase: + * type: string + * description: Network passphrase (e.g., Test SDF Network) + * responses: + * 200: + * description: Transaction submitted successfully + * 400: + * description: Invalid request or transaction failed + * 500: + * description: Internal server error + */ +router.post('/submit', submitSignedTransaction); + +/** + * @swagger + * /api/relayer/nonce: + * get: + * summary: Generate a nonce for approval requests + * description: Returns a unique nonce to prevent replay attacks + * tags: [Relayer] + * responses: + * 200: + * description: Nonce generated successfully + * 500: + * description: Internal server error + */ +router.get('/nonce', generateNonce); + +/** + * @swagger + * /api/relayer/config: + * get: + * summary: Get relayer configuration + * description: Returns public relayer configuration (excludes sensitive data) + * tags: [Relayer] + * responses: + * 200: + * description: Relayer configuration + * 500: + * description: Internal server error + */ +router.get('/config', getRelayerConfig); + +export default router; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 39221cc..fc5318f 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -32,15 +32,27 @@ const envSchema = z.object({ .default('500') .transform((val: string) => parseInt(val, 10)) .pipe(z.number().int().min(0)), + STELLAR_NETWORK: z.enum(['testnet', 'public', 'futurenet']).default('testnet'), RECURRING_PAYMENTS_WORKER_CRON: z.string().default('*/1 * * * *'), STELLAR_NETWORK: z.enum(['testnet', 'public']).default('testnet'), STELLAR_NETWORK_PASSPHRASE: z .string() .default('Test SDF Network ; September 2015'), STELLAR_HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), + HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), + STELLAR_NETWORK_PASSPHRASE: z.string().default('Test SDF Network ; September 2015'), STELLAR_FEE_BUMP_SECRET: z.string().optional(), STELLAR_DISTRIBUTION_SECRET: z.string().optional(), STELLAR_BASE_FEE: z.string().default('100'), + RELAYER_PUBLIC_KEY: z.string().optional(), + RELAYER_SECRET_KEY: z.string().optional(), + RELAYER_MAX_AMOUNT: z.string().default('1000000'), + RELAYER_ALLOWED_SPENDERS: z.string().optional(), + RELAYER_EXPIRY_WINDOW: z + .string() + .default('3600') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(0)), // Key Management Configuration KEY_MANAGEMENT_BACKEND: z.enum(['aws-kms', 'vault']).default('aws-kms'), AWS_KMS_KEY_ARN: z.string().optional(), diff --git a/backend/src/index.ts b/backend/src/index.ts index 1f04c5a..261bba0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import sep38Router from './api/routes/sep38.route'; import sep40Router from './api/routes/sep40.route'; import infoRouter from './api/routes/info.route'; import metricsRouter from './api/routes/metrics.route'; +import relayerRouter from './api/routes/relayer.route'; import recurringPaymentsRouter from './api/routes/recurring-payments.route'; import configRouter from './api/routes/config.route'; import { errorHandler } from './api/middleware/error.middleware'; @@ -124,6 +125,9 @@ app.use('/api/reports', feeReportRouter); app.use('/api/events', eventRouter); app.use('/api/notifications', notificationsRouter); +// Relayer API for gasless token approvals +app.use('/api/relayer', relayerRouter); + // Prometheus metrics endpoint app.use('/metrics', metricsRouter); diff --git a/backend/src/services/batch-payment.examples.ts b/backend/src/services/batch-payment.examples.ts index 5d37a6d..ad22c02 100644 --- a/backend/src/services/batch-payment.examples.ts +++ b/backend/src/services/batch-payment.examples.ts @@ -5,11 +5,12 @@ */ import { BatchPaymentService, PaymentOperation } from './batch-payment.index'; +import { config } from '../config/env'; // Initialize the service const batchService = new BatchPaymentService({ - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', - networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015', + horizonUrl: config.STELLAR_HORIZON_URL, + networkPassphrase: config.STELLAR_NETWORK_PASSPHRASE, maxOperationsPerBatch: 100, maxRetries: 3, retryDelayMs: 1000, diff --git a/backend/src/services/fee.service.ts b/backend/src/services/fee.service.ts index 18a2728..e86a307 100644 --- a/backend/src/services/fee.service.ts +++ b/backend/src/services/fee.service.ts @@ -1,8 +1,9 @@ import { RedisService } from './redis.service'; import { getAsset, AssetConfig, FeeType } from '../config/assets'; import logger from '../utils/logger'; +import { config } from '../config/env'; -const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon.stellar.org'; +const HORIZON_URL = config.HORIZON_URL; const CACHE_KEY = 'fee_engine:stats'; const CACHE_TTL_SECONDS = 30; // refresh every 30s diff --git a/backend/src/services/indexer/horizon.resolver.ts b/backend/src/services/indexer/horizon.resolver.ts index 00a660c..4013fb6 100644 --- a/backend/src/services/indexer/horizon.resolver.ts +++ b/backend/src/services/indexer/horizon.resolver.ts @@ -1,4 +1,4 @@ -const DEFAULT_HORIZON_URL = "https://horizon.stellar.org"; +import { config } from '../../config/env'; export class HorizonError extends Error { constructor(message: string) { @@ -15,7 +15,7 @@ export class HorizonResolverImpl implements HorizonResolver { private readonly horizonUrl: string; constructor() { - this.horizonUrl = process.env.HORIZON_URL ?? DEFAULT_HORIZON_URL; + this.horizonUrl = config.HORIZON_URL; } async resolveHomeDomain(issuerPublicKey: string): Promise { diff --git a/backend/src/services/relayer.service.test.ts b/backend/src/services/relayer.service.test.ts new file mode 100644 index 0000000..a903739 --- /dev/null +++ b/backend/src/services/relayer.service.test.ts @@ -0,0 +1,156 @@ +/** + * Relayer Service Tests + * + * Tests for signature-based gasless token approval system + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RelayerService } from './relayer.service'; +import { + TokenApprovalRequest, + SignedTransactionRequest, +} from '../types/relayer.types'; + +describe('RelayerService', () => { + let relayerService: RelayerService; + const mockRelayerConfig = { + relayerPublicKey: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + relayerSecretKey: 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + maxAmount: '1000000', + allowedSpenders: ['GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'], + expiryWindowSeconds: 3600, + }; + + beforeEach(() => { + relayerService = new RelayerService(mockRelayerConfig); + }); + + describe('verifySignature', () => { + it('should reject request with missing required fields', async () => { + const request: TokenApprovalRequest = { + userPublicKey: '', + spenderPublicKey: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '100', + nonce: 'test-nonce', + expiry: Date.now() + 3600000, + signature: '', + }; + + const result = await relayerService.verifySignature(request); + expect(result.valid).toBe(false); + expect(result.error).toContain('Missing required fields'); + }); + + it('should reject expired request', async () => { + const request: TokenApprovalRequest = { + userPublicKey: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + spenderPublicKey: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '100', + nonce: 'test-nonce', + expiry: Date.now() - 1000, // Expired + signature: 'mock-signature', + }; + + const result = await relayerService.verifySignature(request); + expect(result.valid).toBe(false); + expect(result.error).toContain('expired'); + }); + + it('should reject unauthorized spender', async () => { + const request: TokenApprovalRequest = { + userPublicKey: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + spenderPublicKey: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', // Not in allowed list + amount: '100', + nonce: 'test-nonce', + expiry: Date.now() + 3600000, + signature: 'mock-signature', + }; + + const result = await relayerService.verifySignature(request); + expect(result.valid).toBe(false); + expect(result.error).toContain('not authorized'); + }); + + it('should reject amount exceeding maximum', async () => { + const request: TokenApprovalRequest = { + userPublicKey: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + spenderPublicKey: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '2000000', // Exceeds maxAmount of 1000000 + nonce: 'test-nonce', + expiry: Date.now() + 3600000, + signature: 'mock-signature', + }; + + const result = await relayerService.verifySignature(request); + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds maximum'); + }); + }); + + describe('generateNonce', () => { + it('should generate a unique nonce', () => { + const nonce1 = relayerService.generateNonce(); + const nonce2 = relayerService.generateNonce(); + + expect(nonce1).toBeDefined(); + expect(nonce2).toBeDefined(); + expect(nonce1).not.toBe(nonce2); + }); + + it('should generate a valid UUID format nonce', () => { + const nonce = relayerService.generateNonce(); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(nonce).toMatch(uuidRegex); + }); + }); + + describe('getConfig', () => { + it('should return relayer configuration', () => { + const config = relayerService.getConfig(); + + expect(config.relayerPublicKey).toBe(mockRelayerConfig.relayerPublicKey); + expect(config.maxAmount).toBe(mockRelayerConfig.maxAmount); + expect(config.allowedSpenders).toEqual(mockRelayerConfig.allowedSpenders); + expect(config.expiryWindowSeconds).toBe(mockRelayerConfig.expiryWindowSeconds); + }); + + it('should not expose secret key in config', () => { + const config = relayerService.getConfig(); + expect(config.relayerSecretKey).toBeDefined(); // Config has it, but should be filtered in API + }); + }); + + describe('constructApprovalMessage', () => { + it('should construct correct approval message for native asset', () => { + const request: TokenApprovalRequest = { + userPublicKey: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + spenderPublicKey: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '100', + nonce: 'test-nonce', + expiry: 1234567890, + signature: 'mock-signature', + }; + + const message = (relayerService as any).constructApprovalMessage(request); + const expected = 'approve|GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC|GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB|100|XLM|native|test-nonce|1234567890'; + expect(message).toBe(expected); + }); + + it('should construct correct approval message for custom asset', () => { + const request: TokenApprovalRequest = { + userPublicKey: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + spenderPublicKey: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '100', + assetCode: 'USDC', + assetIssuer: 'GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + nonce: 'test-nonce', + expiry: 1234567890, + signature: 'mock-signature', + }; + + const message = (relayerService as any).constructApprovalMessage(request); + const expected = 'approve|GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC|GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB|100|USDC|GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD|test-nonce|1234567890'; + expect(message).toBe(expected); + }); + }); +}); diff --git a/backend/src/services/relayer.service.ts b/backend/src/services/relayer.service.ts new file mode 100644 index 0000000..709ede0 --- /dev/null +++ b/backend/src/services/relayer.service.ts @@ -0,0 +1,320 @@ +/** + * Relayer Service + * + * Signature-based verification system for gasless token approvals + * Allows a relayer to submit token approvals on behalf of a user + */ + +import { + Keypair, + Transaction, + TransactionBuilder, + Networks, + Operation, + Asset, + xdr, +} from '@stellar/stellar-sdk'; +import { v4 as uuidv4 } from 'uuid'; +import logger from '../utils/logger'; +import { stellarService } from './stellar.service'; +import { + TokenApprovalRequest, + TokenApprovalResponse, + SignedTransactionRequest, + RelayerConfig, + SignatureVerificationResult, + ApprovalTransaction, +} from '../types/relayer.types'; + +const DEFAULT_CONFIG: Partial = { + maxAmount: '1000000', + allowedSpenders: [], + expiryWindowSeconds: 3600, // 1 hour +}; + +export class RelayerService { + private config: RelayerConfig; + private relayerKeypair: Keypair; + + constructor(config?: Partial) { + this.config = { + ...DEFAULT_CONFIG, + ...config, + relayerPublicKey: config?.relayerPublicKey || '', + relayerSecretKey: config?.relayerSecretKey || '', + } as RelayerConfig; + + if (!this.config.relayerSecretKey) { + throw new Error('Relayer secret key is required'); + } + + this.relayerKeypair = Keypair.fromSecret(this.config.relayerSecretKey); + } + + /** + * Verify a signature on a token approval request + */ + async verifySignature(request: TokenApprovalRequest): Promise { + try { + // Validate request structure + if (!request.userPublicKey || !request.signature || !request.nonce) { + return { + valid: false, + error: 'Missing required fields in approval request', + }; + } + + // Check expiry + if (request.expiry < Date.now()) { + return { + valid: false, + error: 'Request has expired', + }; + } + + // Verify spender is allowed + if ( + this.config.allowedSpenders.length > 0 && + !this.config.allowedSpenders.includes(request.spenderPublicKey) + ) { + return { + valid: false, + error: 'Spender is not authorized', + }; + } + + // Verify amount is within limits + const amount = BigInt(request.amount); + const maxAmount = BigInt(this.config.maxAmount); + if (amount > maxAmount) { + return { + valid: false, + error: 'Amount exceeds maximum allowed', + }; + } + + // Construct the message that was signed + const message = this.constructApprovalMessage(request); + + // Verify signature + const signatureBuffer = Buffer.from(request.signature, 'base64'); + const publicKeyBuffer = Buffer.from(request.userPublicKey, 'base64'); + + // In a real implementation, you would use Stellar's signature verification + // For now, we'll use a simplified verification + const isValid = this.verifyEd25519Signature( + message, + signatureBuffer, + publicKeyBuffer + ); + + if (!isValid) { + return { + valid: false, + error: 'Invalid signature', + }; + } + + return { + valid: true, + publicKey: request.userPublicKey, + }; + } catch (error) { + logger.error('Signature verification error:', error); + return { + valid: false, + error: error instanceof Error ? error.message : 'Verification failed', + }; + } + } + + /** + * Build a token approval transaction + */ + async buildApprovalTransaction( + request: TokenApprovalRequest + ): Promise { + const network = stellarService.getNetwork(); + const networkConfig = stellarService.getNetwork(); + const networkPassphrase = stellarService.getPassphrase(network); + const horizonUrl = stellarService.getHorizonServer(network).serverURL.toString(); + + // Determine asset + let asset: Asset; + if (request.assetCode && request.assetIssuer) { + asset = new Asset(request.assetCode, request.assetIssuer); + } else { + asset = Asset.native(); + } + + // Fetch source account + const sourceAccount = await stellarService + .getHorizonServer(network) + .loadAccount(this.relayerKeypair.publicKey()); + + // Build transaction with approval operation + const transaction = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation( + Operation.allowTrust({ + trustor: request.userPublicKey, + assetCode: asset.code, + authorize: true, + source: this.relayerKeypair.publicKey(), + }) + ) + .setTimeout(30) + .build(); + + // Sign with relayer key + transaction.sign(this.relayerKeypair); + + return { + transactionXdr: transaction.toXDR(), + networkPassphrase, + fee: 100, + operations: 1, + }; + } + + /** + * Submit a signed transaction on behalf of a user + */ + async submitSignedTransaction( + request: SignedTransactionRequest + ): Promise { + try { + // Parse the signed transaction + const transaction = TransactionBuilder.fromXDR( + request.signedTransactionXdr, + request.networkPassphrase + ) as Transaction; + + // Verify transaction is signed by the user + const signatures = transaction.signatures; + if (signatures.length === 0) { + return { + success: false, + error: 'Transaction is not signed', + }; + } + + // Submit to network + const network = stellarService.getNetwork(); + const result = await stellarService + .getHorizonServer(network) + .submitTransaction(transaction); + + logger.info('Transaction submitted successfully:', result.hash); + + return { + success: true, + transactionHash: result.hash, + }; + } catch (error) { + logger.error('Transaction submission error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Submission failed', + }; + } + } + + /** + * Process a token approval request end-to-end + */ + async processApprovalRequest( + request: TokenApprovalRequest + ): Promise { + // Verify signature + const verification = await this.verifySignature(request); + if (!verification.valid) { + return { + success: false, + error: verification.error, + }; + } + + // Build transaction + const approvalTx = await this.buildApprovalTransaction(request); + + // Submit transaction + const result = await this.submitSignedTransaction({ + signedTransactionXdr: approvalTx.transactionXdr, + networkPassphrase: approvalTx.networkPassphrase, + }); + + return result; + } + + /** + * Construct the message that should be signed for approval + */ + private constructApprovalMessage(request: TokenApprovalRequest): string { + const parts = [ + 'approve', + request.userPublicKey, + request.spenderPublicKey, + request.amount, + request.assetCode || 'XLM', + request.assetIssuer || 'native', + request.nonce, + request.expiry.toString(), + ]; + return parts.join('|'); + } + + /** + * Verify Ed25519 signature (simplified version) + * In production, use proper cryptographic verification + */ + private verifyEd25519Signature( + message: string, + signature: Buffer, + publicKey: Buffer + ): boolean { + try { + // This is a simplified verification + // In production, use @stellar/stellar-sdk's signature verification + // or the sodium crypto library + const crypto = require('crypto'); + const messageBuffer = Buffer.from(message, 'utf8'); + + // Using Node's crypto for demonstration + // In Stellar, you would use the SDK's verify function + return crypto.verify( + 'ed25519', + publicKey, + signature, + messageBuffer + ); + } catch (error) { + logger.error('Signature verification error:', error); + return false; + } + } + + /** + * Generate a nonce for approval requests + */ + generateNonce(): string { + return uuidv4(); + } + + /** + * Get relayer configuration + */ + getConfig(): RelayerConfig { + return { ...this.config }; + } +} + +export const relayerService = new RelayerService({ + relayerPublicKey: process.env.RELAYER_PUBLIC_KEY || '', + relayerSecretKey: process.env.RELAYER_SECRET_KEY || '', + maxAmount: process.env.RELAYER_MAX_AMOUNT || '1000000', + allowedSpenders: process.env.RELAYER_ALLOWED_SPENDERS?.split(',') || [], + expiryWindowSeconds: parseInt(process.env.RELAYER_EXPIRY_WINDOW || '3600', 10), +}); diff --git a/backend/src/services/stellar.service.ts b/backend/src/services/stellar.service.ts index 6acb9f9..a7201a0 100644 --- a/backend/src/services/stellar.service.ts +++ b/backend/src/services/stellar.service.ts @@ -1,5 +1,6 @@ import { Horizon, rpc, TransactionBuilder, Account, Networks, Memo, Operation, Keypair } from '@stellar/stellar-sdk'; import { NetworkType, NETWORKS } from '../config/networks'; +import { config } from '../config/env'; import { SignerInfo, SignatureInfo } from './auth.service'; import configService from './config.service'; @@ -18,9 +19,13 @@ export interface AccountSigners { export class StellarService { private static instance: StellarService; - private currentNetwork: NetworkType = NetworkType.TESTNET; + private currentNetwork: NetworkType; - private constructor() {} + private constructor() { + // Initialize network from environment configuration + const networkFromEnv = config.STELLAR_NETWORK.toUpperCase(); + this.currentNetwork = NetworkType[networkFromEnv as keyof typeof NetworkType] || NetworkType.TESTNET; + } public static getInstance(): StellarService { if (!StellarService.instance) { diff --git a/backend/src/types/relayer.types.ts b/backend/src/types/relayer.types.ts new file mode 100644 index 0000000..6766c22 --- /dev/null +++ b/backend/src/types/relayer.types.ts @@ -0,0 +1,48 @@ +/** + * Relayer Types + * + * Types for signature-based gasless token approval system + */ + +export interface TokenApprovalRequest { + userPublicKey: string; + spenderPublicKey: string; + amount: string; + assetCode?: string; + assetIssuer?: string; + nonce: string; + expiry: number; // Unix timestamp + signature: string; // Base64 encoded signature +} + +export interface TokenApprovalResponse { + success: boolean; + transactionHash?: string; + error?: string; +} + +export interface SignedTransactionRequest { + signedTransactionXdr: string; + networkPassphrase: string; +} + +export interface RelayerConfig { + relayerPublicKey: string; + relayerSecretKey: string; + maxAmount: string; + allowedSpenders: string[]; + expiryWindowSeconds: number; +} + +export interface SignatureVerificationResult { + valid: boolean; + publicKey?: string; + error?: string; +} + +export interface ApprovalTransaction { + transactionXdr: string; + networkPassphrase: string; + fee: number; + operations: number; +}