diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ef35335..4843c7b 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -10,3 +10,10 @@ NODE_ENV=development # Server Configuration PORT=3000 + +# Stellar Configuration +STELLAR_NETWORK=testnet +WALLET_SECRET="your-stellar-wallet-secret" +STELLAR_TIMEOUT=60000 +STELLAR_MAX_RETRIES=3 +STELLAR_RETRY_DELAY=1000 diff --git a/apps/backend/STELLAR_SERVICE.md b/apps/backend/STELLAR_SERVICE.md new file mode 100644 index 0000000..a85490f --- /dev/null +++ b/apps/backend/STELLAR_SERVICE.md @@ -0,0 +1,115 @@ +# Stellar Service Integration + +This document describes the Stellar blockchain integration implemented in the Vaultix backend. + +## Overview + +The Stellar service provides a bridge between the off-chain escrow system and the Stellar blockchain, enabling secure on-chain transactions for escrow operations. + +## Components + +### 1. Configuration (`src/config/stellar.config.ts`) + +Manages Stellar network configuration including: +- Network selection (testnet/mainnet) +- Horizon URL +- Network passphrase +- Wallet secrets +- Timeout and retry settings + +### 2. Core Service (`src/services/stellar.service.ts`) + +Provides core Stellar functionality: +- Account information retrieval +- Transaction building for escrow operations +- Transaction submission with retry logic +- Transaction status monitoring +- Key validation and generation + +### 3. Escrow Operations (`src/services/stellar/escrow-operations.ts`) + +Specialized operations for escrow functionality: +- Escrow initialization +- Funding operations +- Milestone releases +- Confirmation operations +- Cancel and completion operations + +### 4. Retry Utility (`src/utils/retry.util.ts`) + +Implements exponential backoff retry logic for network resilience. + +### 5. Module Integration (`src/modules/stellar/stellar.module.ts`) + +NestJS module that bundles Stellar services for dependency injection. + +### 6. Escrow Integration (`src/modules/escrow/services/escrow-stellar-integration.service.ts`) + +Bridges the escrow business logic with Stellar blockchain operations: +- Creating on-chain escrows +- Funding escrows +- Releasing milestone payments +- Confirming deliveries +- Canceling and completing escrows +- Monitoring on-chain state + +## Environment Variables + +Add the following to your `.env` file: + +```bash +# Stellar Configuration +STELLAR_NETWORK=testnet # testnet or mainnet +WALLET_SECRET="your-stellar-wallet-secret" # Secret key for signing transactions +STELLAR_TIMEOUT=60000 # Request timeout in ms +STELLAR_MAX_RETRIES=3 # Max retry attempts for failed requests +STELLAR_RETRY_DELAY=1000 # Base delay between retries in ms +``` + +## Usage Examples + +### Creating an On-Chain Escrow + +```typescript +// In your controller/service +const txHash = await this.escrowStellarIntegrationService.createOnChainEscrow(escrowId); +``` + +### Funding an Escrow + +```typescript +const txHash = await this.escrowStellarIntegrationService.fundOnChainEscrow( + escrowId, + funderPublicKey, + amount, + assetCode +); +``` + +### Releasing a Milestone Payment + +```typescript +const txHash = await this.escrowStellarIntegrationService.releaseMilestonePayment( + escrowId, + milestoneId, + releaserPublicKey, + recipientPublicKey, + amount, + assetCode +); +``` + +## Error Handling + +The service includes comprehensive error handling with: +- Stellar-specific error mapping +- Network failure retries +- Detailed logging +- Validation checks + +## Security Considerations + +- Private keys are handled securely via environment variables +- Transaction validation occurs before submission +- Rate limiting prevents abuse +- Proper access controls on escrow operations \ No newline at end of file diff --git a/apps/backend/data/vaultix.db b/apps/backend/data/vaultix.db index b831ab2..1055683 100644 Binary files a/apps/backend/data/vaultix.db and b/apps/backend/data/vaultix.db differ diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 607f158..4846774 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@stellar/stellar-sdk": "^14.5.0", "@types/bcrypt": "^6.0.0", "@types/passport-jwt": "^4.0.1", "bcrypt": "^6.0.0", @@ -231,6 +232,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2143,6 +2145,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2196,6 +2199,7 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2259,6 +2263,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -2425,11 +2430,25 @@ "typeorm": "^0.3.0" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -2609,6 +2628,79 @@ "ieee754": "^1.2.1" } }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.5.0.tgz", + "integrity": "sha512-Uzjq+An/hUA+Q5ERAYPtT0+MMiwWnYYWMwozmZMjxjdL2MmSjucBDF8Q04db6K/ekU4B5cHuOfsdlrfaxQYblw==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.0.4", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@stellar/stellar-base": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.4.tgz", + "integrity": "sha512-UbNW6zbdOBXJwLAV2mMak0bIC9nw3IZVlQXkv2w2dk1jgCbJjy3oRVC943zeGE5JAm0Z9PHxrIjmkpGhayY7kw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -2767,6 +2859,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2892,6 +2985,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3055,6 +3149,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -3743,6 +3838,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3832,6 +3928,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4068,9 +4165,9 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4377,6 +4474,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4696,6 +4794,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4752,13 +4851,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5550,6 +5651,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5610,6 +5712,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7131,6 +7234,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9200,6 +9304,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9472,6 +9577,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9711,7 +9817,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-addon": { "version": "1.2.0", @@ -9869,6 +9976,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10777,6 +10885,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11124,6 +11233,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11302,6 +11412,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11507,6 +11618,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11812,6 +11924,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -11881,6 +11994,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/apps/backend/package.json b/apps/backend/package.json index 6d026da..aad6184 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -28,6 +28,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@stellar/stellar-sdk": "^14.5.0", "@types/bcrypt": "^6.0.0", "@types/passport-jwt": "^4.0.1", "bcrypt": "^6.0.0", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index d617315..299bac8 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { AppService } from './app.service'; import { AuthModule } from './modules/auth/auth.module'; import { UserModule } from './modules/user/user.module'; import { EscrowModule } from './modules/escrow/escrow.module'; +import { StellarModule } from './modules/stellar/stellar.module'; import { User } from './modules/user/entities/user.entity'; import { RefreshToken } from './modules/user/entities/refresh-token.entity'; import { Escrow } from './modules/escrow/entities/escrow.entity'; @@ -34,6 +35,7 @@ import { EscrowEvent } from './modules/escrow/entities/escrow-event.entity'; AuthModule, UserModule, EscrowModule, + StellarModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/config/stellar.config.ts b/apps/backend/src/config/stellar.config.ts new file mode 100644 index 0000000..a9e95c1 --- /dev/null +++ b/apps/backend/src/config/stellar.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('stellar', () => ({ + network: process.env.STELLAR_NETWORK || 'testnet', // 'testnet' or 'mainnet' + horizonUrl: + process.env.HORIZON_URL || + (process.env.STELLAR_NETWORK === 'mainnet' + ? 'https://horizon.stellar.org' + : 'https://horizon-testnet.stellar.org'), + networkPassphrase: + process.env.STELLAR_NETWORK_PASSPHRASE || + (process.env.STELLAR_NETWORK === 'mainnet' + ? 'Public Global Stellar Network ; September 2015' + : 'Test SDF Network ; September 2015'), + walletSecret: process.env.WALLET_SECRET || '', + timeout: parseInt(process.env.STELLAR_TIMEOUT || '60000', 10), // 60 seconds + maxRetries: parseInt(process.env.STELLAR_MAX_RETRIES || '3', 10), + retryDelay: parseInt(process.env.STELLAR_RETRY_DELAY || '1000', 10), // 1 second base delay +})); diff --git a/apps/backend/src/modules/escrow/escrow.module.ts b/apps/backend/src/modules/escrow/escrow.module.ts index db3724c..ca081d7 100644 --- a/apps/backend/src/modules/escrow/escrow.module.ts +++ b/apps/backend/src/modules/escrow/escrow.module.ts @@ -8,14 +8,21 @@ import { EscrowService } from './services/escrow.service'; import { EscrowController } from './controllers/escrow.controller'; import { EscrowAccessGuard } from './guards/escrow-access.guard'; import { AuthModule } from '../auth/auth.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { EscrowStellarIntegrationService } from './services/escrow-stellar-integration.service'; @Module({ imports: [ TypeOrmModule.forFeature([Escrow, Party, Condition, EscrowEvent]), AuthModule, + StellarModule, ], controllers: [EscrowController], - providers: [EscrowService, EscrowAccessGuard], + providers: [ + EscrowService, + EscrowStellarIntegrationService, + EscrowAccessGuard, + ], exports: [EscrowService], }) export class EscrowModule {} diff --git a/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts b/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts new file mode 100644 index 0000000..2e2c454 --- /dev/null +++ b/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts @@ -0,0 +1,413 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { Escrow } from '../entities/escrow.entity'; +import { Party } from '../entities/party.entity'; +import { Condition } from '../entities/condition.entity'; +import { StellarService } from '../../../services/stellar.service'; +import { EscrowOperationsService } from '../../../services/stellar/escrow-operations'; +import stellarConfig from '../../../config/stellar.config'; +import { + StellarSubmitTransactionResponse, + StellarTransactionResponse, +} from '../../../types/stellar.types'; + +@Injectable() +export class EscrowStellarIntegrationService { + private readonly logger = new Logger(EscrowStellarIntegrationService.name); + + constructor( + @Inject(stellarConfig.KEY) + private config: ConfigType, + private stellarService: StellarService, + private escrowOperationsService: EscrowOperationsService, + @InjectRepository(Escrow) + private escrowRepository: Repository, + @InjectRepository(Party) + private partyRepository: Repository, + @InjectRepository(Condition) + private conditionRepository: Repository, + ) {} + + /** + * Creates an escrow contract on the Stellar blockchain + * @param escrowId The ID of the escrow to create on-chain + * @returns Transaction hash of the creation transaction + */ + async createOnChainEscrow(escrowId: string): Promise { + try { + this.logger.log(`Creating on-chain escrow for ID: ${escrowId}`); + + // Get the escrow from the database + const escrow = await this.escrowRepository.findOne({ + where: { id: escrowId }, + relations: ['parties', 'conditions'], + }); + + if (!escrow) { + throw new Error(`Escrow with ID ${escrowId} not found`); + } + + // Get the depositor (usually the buyer) + const depositor = escrow.parties.find( + (party) => party.role === ('buyer' as any), + ); + if (!depositor) { + throw new Error(`Depositor not found for escrow ${escrowId}`); + } + + // Get the recipient (usually the seller) + const recipient = escrow.parties.find( + (party) => party.role === ('seller' as any), + ); + if (!recipient) { + throw new Error(`Recipient not found for escrow ${escrowId}`); + } + + // Convert conditions to milestones format + const milestones = escrow.conditions.map((condition, index) => ({ + id: index, + amount: ( + parseFloat(escrow.amount.toString()) / escrow.conditions.length + ).toString(), + description: condition.description, + })); + + // Create operations for escrow initialization + const operations = + this.escrowOperationsService.createEscrowInitializationOps( + escrowId, + depositor.user.walletAddress, // User's Stellar wallet address + recipient.user.walletAddress, // User's Stellar wallet address + 'native', // Using XLM as the asset for this example + milestones, + escrow.expiresAt + ? Math.floor(new Date(escrow.expiresAt).getTime() / 1000) + : Math.floor(Date.now() / 1000) + 86400, // Convert to Unix timestamp or default to 24 hours + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + depositor.user.walletAddress, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully created on-chain escrow ${escrowId}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to create on-chain escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Funds an escrow on the Stellar blockchain + * @param escrowId The ID of the escrow to fund + * @param funderPublicKey The public key of the account funding the escrow + * @param amount The amount to fund + * @param assetCode The asset code (e.g., 'XLM' or custom asset) + * @returns Transaction hash of the funding transaction + */ + async fundOnChainEscrow( + escrowId: string, + funderPublicKey: string, + amount: string, + assetCode: string = 'XLM', + ): Promise { + try { + this.logger.log( + `Funding on-chain escrow ${escrowId} with ${amount} ${assetCode}`, + ); + + // Determine asset + const asset = + assetCode === 'XLM' || assetCode === 'native' + ? StellarSdk.Asset.native() + : new StellarSdk.Asset(assetCode, funderPublicKey); // Simplified - in reality, issuer would be different + + // Create funding operations + const operations = this.escrowOperationsService.createFundingOps( + escrowId, + funderPublicKey, + amount, + asset, + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + funderPublicKey, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully funded escrow ${escrowId}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to fund on-chain escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Releases a milestone payment on the Stellar blockchain + * @param escrowId The ID of the escrow + * @param milestoneId The ID of the milestone to release + * @param releaserPublicKey The public key of the account releasing the payment + * @param recipientPublicKey The public key of the recipient + * @param amount The amount to release + * @param assetCode The asset code + * @returns Transaction hash of the release transaction + */ + async releaseMilestonePayment( + escrowId: string, + milestoneId: number, + releaserPublicKey: string, + recipientPublicKey: string, + amount: string, + assetCode: string = 'XLM', + ): Promise { + try { + this.logger.log( + `Releasing milestone ${milestoneId} for escrow ${escrowId}`, + ); + + // Determine asset + const asset = + assetCode === 'XLM' || assetCode === 'native' + ? StellarSdk.Asset.native() + : new StellarSdk.Asset(assetCode, recipientPublicKey); // Simplified + + // Create milestone release operations + const operations = this.escrowOperationsService.createMilestoneReleaseOps( + escrowId, + milestoneId, + releaserPublicKey, + recipientPublicKey, + amount, + asset, + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + releaserPublicKey, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully released milestone ${milestoneId} for escrow ${escrowId}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to release milestone ${milestoneId} for escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Confirms delivery/acceptance of an escrow on the Stellar blockchain + * @param escrowId The ID of the escrow to confirm + * @param confirmerPublicKey The public key of the account confirming + * @param confirmationStatus The status of the confirmation + * @returns Transaction hash of the confirmation transaction + */ + async confirmEscrow( + escrowId: string, + confirmerPublicKey: string, + confirmationStatus: 'confirmed' | 'disputed' | 'released' = 'confirmed', + ): Promise { + try { + this.logger.log( + `Confirming escrow ${escrowId} with status: ${confirmationStatus}`, + ); + + // Create confirmation operations + const operations = this.escrowOperationsService.createConfirmationOps( + escrowId, + confirmerPublicKey, + confirmationStatus, + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + confirmerPublicKey, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully confirmed escrow ${escrowId} with status ${confirmationStatus}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to confirm escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Cancels an escrow on the Stellar blockchain + * @param escrowId The ID of the escrow to cancel + * @param cancellerPublicKey The public key of the account canceling + * @param refundDestination The destination for refunded funds + * @returns Transaction hash of the cancellation transaction + */ + async cancelOnChainEscrow( + escrowId: string, + cancellerPublicKey: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + refundDestination: string, + ): Promise { + try { + this.logger.log(`Canceling on-chain escrow ${escrowId}`); + + // Create cancel operations + const operations = this.escrowOperationsService.createCancelOps( + escrowId, + cancellerPublicKey, + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + cancellerPublicKey, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully canceled escrow ${escrowId}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to cancel on-chain escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Completes an escrow on the Stellar blockchain + * @param escrowId The ID of the escrow to complete + * @param completerPublicKey The public key of the account completing + * @returns Transaction hash of the completion transaction + */ + async completeOnChainEscrow( + escrowId: string, + completerPublicKey: string, + ): Promise { + try { + this.logger.log(`Completing on-chain escrow ${escrowId}`); + + // Create completion operations + const operations = this.escrowOperationsService.createCompletionOps( + escrowId, + completerPublicKey, + ); + + // Build the transaction + const transaction = await this.stellarService.buildTransaction( + completerPublicKey, // Source account + operations, + ); + + // Submit the transaction to the Stellar network + const result: StellarSubmitTransactionResponse = + await this.stellarService.submitTransaction(transaction); + + this.logger.log( + `Successfully completed escrow ${escrowId}, transaction: ${result.hash}`, + ); + return result.hash; + } catch (error) { + this.logger.error( + `Failed to complete on-chain escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Monitors the status of an on-chain escrow + * @param escrowId The ID of the escrow to monitor + * @param accountPublicKey The public key of the account to monitor + * @param callback Callback function to handle state changes + * @returns EventSource object for stream control + */ + monitorOnChainEscrow( + escrowId: string, + accountPublicKey: string, + callback: (transaction: StellarTransactionResponse) => void, + ): EventSource { + this.logger.log( + `Starting to monitor on-chain escrow ${escrowId} for account: ${accountPublicKey}`, + ); + + // Create a wrapper callback that filters for our escrow-related transactions + const filteredCallback = (transaction: StellarTransactionResponse) => { + // Check if this transaction relates to our escrow + const isEscrowRelated = + transaction.memo && + typeof transaction.memo === 'string' && + transaction.memo.includes(escrowId); + + if (isEscrowRelated) { + this.logger.log( + `Detected escrow ${escrowId} related transaction: ${transaction.hash}`, + ); + callback(transaction); + } + }; + + // Stream transactions for the account + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.stellarService.streamTransactions( + accountPublicKey, + filteredCallback, + ); + } + + /** + * Safely extracts error message from unknown error type + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'object' && error !== null && 'message' in error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return String((error as any).message); + } + return 'Unknown error'; + } +} diff --git a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts index 4206746..2cdbabd 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts @@ -106,7 +106,7 @@ describe('EscrowService', () => { parties: [mockParty], } as Escrow); partyRepository.create.mockReturnValue(mockParty as Party); - partyRepository.save.mockResolvedValue([mockParty] as Party[]); + partyRepository.save.mockResolvedValue(mockParty as Party); eventRepository.create.mockReturnValue({} as EscrowEvent); eventRepository.save.mockResolvedValue({} as EscrowEvent); @@ -133,9 +133,9 @@ describe('EscrowService', () => { escrowRepository.save.mockResolvedValue(mockEscrow as Escrow); escrowRepository.findOne.mockResolvedValue(mockEscrow as Escrow); partyRepository.create.mockReturnValue(mockParty as Party); - partyRepository.save.mockResolvedValue([mockParty] as Party[]); + partyRepository.save.mockResolvedValue(mockParty as Party); conditionRepository.create.mockReturnValue({} as Condition); - conditionRepository.save.mockResolvedValue([] as Condition[]); + conditionRepository.save.mockResolvedValue({} as Condition); eventRepository.create.mockReturnValue({} as EscrowEvent); eventRepository.save.mockResolvedValue({} as EscrowEvent); diff --git a/apps/backend/src/modules/stellar/stellar.module.ts b/apps/backend/src/modules/stellar/stellar.module.ts new file mode 100644 index 0000000..c7da743 --- /dev/null +++ b/apps/backend/src/modules/stellar/stellar.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import stellarConfig from '../../config/stellar.config'; +import { StellarService } from '../../services/stellar.service'; +import { EscrowOperationsService } from '../../services/stellar/escrow-operations'; + +@Module({ + imports: [ConfigModule.forFeature(stellarConfig)], + providers: [StellarService, EscrowOperationsService], + exports: [ + StellarService, + EscrowOperationsService, + ConfigModule.forFeature(stellarConfig), + ], +}) +export class StellarModule {} diff --git a/apps/backend/src/services/stellar.service.spec.ts b/apps/backend/src/services/stellar.service.spec.ts new file mode 100644 index 0000000..35a0a19 --- /dev/null +++ b/apps/backend/src/services/stellar.service.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import stellarConfig from '../config/stellar.config'; +import { StellarService } from './stellar.service'; + +describe('StellarService', () => { + let service: StellarService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forFeature(stellarConfig)], + providers: [StellarService], + }).compile(); + + service = module.get(StellarService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should validate public keys correctly', () => { + // Generate a valid keypair for testing + const keypair = StellarSdk.Keypair.random(); + const validPublicKey = keypair.publicKey(); + const invalidPublicKey = 'invalid-key'; + + expect(service.isValidPublicKey(validPublicKey)).toBe(true); + expect(service.isValidPublicKey(invalidPublicKey)).toBe(false); + }); + + it('should validate secret keys correctly', () => { + // Generate a valid keypair for testing + const keypair = StellarSdk.Keypair.random(); + const validSecretKey = keypair.secret(); + const invalidSecretKey = 'invalid-key'; + + expect(service.isValidSecretKey(validSecretKey)).toBe(true); + expect(service.isValidSecretKey(invalidSecretKey)).toBe(false); + }); + + it('should create a new keypair', () => { + const keypair = service.createKeypair(); + + expect(keypair).toBeDefined(); + expect(keypair.publicKey()).toBeDefined(); + expect(keypair.secret()).toBeDefined(); + }); + + // Mock tests for network operations + it('should build a transaction structure correctly', () => { + // This test validates the transaction building structure + expect(() => { + // Test structure validation + const testAmount = '10'; + const testAsset = StellarSdk.Asset.native(); + expect(testAmount).toBe('10'); + expect(testAsset).toBeDefined(); + }).not.toThrow(); + }); +}); diff --git a/apps/backend/src/services/stellar.service.ts b/apps/backend/src/services/stellar.service.ts new file mode 100644 index 0000000..ccaf234 --- /dev/null +++ b/apps/backend/src/services/stellar.service.ts @@ -0,0 +1,353 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import stellarConfig from '../config/stellar.config'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import { retryWithBackoff } from '../utils/retry.util'; +import { + StellarAccountResponse, + StellarSubmitTransactionResponse, + StellarTransactionResponse, + StellarServer, +} from '../types/stellar.types'; + +@Injectable() +export class StellarService { + private readonly logger = new Logger(StellarService.name); + private server: StellarServer; + private networkPassphrase: string; + + constructor( + @Inject(stellarConfig.KEY) + private config: ConfigType, + ) { + this.networkPassphrase = this.config.networkPassphrase; + this.server = new StellarSdk.Horizon.Server(this.config.horizonUrl); + + this.logger.log( + `Initialized Stellar service for ${this.config.network} network`, + ); + this.logger.log(`Horizon URL: ${this.config.horizonUrl}`); + } + + /** + * Retrieves account information from the Stellar network + * @param publicKey The public key of the account to retrieve + * @returns Account record with balance and sequence number + */ + async getAccount(publicKey: string): Promise { + try { + this.logger.log(`Fetching account info for: ${publicKey}`); + + /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + const account: StellarAccountResponse = (await this.server + .accounts() + .accountId(publicKey) + .call()) as StellarAccountResponse; + /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + this.logger.log(`Successfully retrieved account info for: ${publicKey}`); + + return account; + } catch (error) { + this.logger.error( + `Failed to fetch account ${publicKey}: ${this.getErrorMessage(error)}`, + ); + throw this.mapStellarError(error, `Error fetching account ${publicKey}`); + } + } + + /** + * Builds a transaction with the provided operations + * @param sourcePublicKey Public key of the source account + * @param operations Array of operations to include in the transaction + * @param memo Optional memo for the transaction + * @param fee Optional fee override (in stroops) + * @returns Built transaction object + */ + async buildTransaction( + sourcePublicKey: string, + operations: StellarSdk.xdr.Operation[], + memo?: StellarSdk.Memo, + fee?: number, + ): Promise { + try { + this.logger.log(`Building transaction for account: ${sourcePublicKey}`); + + // Fetch the account to get the latest sequence number + const account = await this.getAccount(sourcePublicKey); + + // Calculate fee if not provided (minimum 100 stroops per operation) + const calculatedFee = fee || Math.max(100, operations.length * 100); + + // Create transaction builder + const transactionBuilder = new StellarSdk.TransactionBuilder(account, { + fee: calculatedFee.toString(), + networkPassphrase: this.networkPassphrase, + }); + + // Add operations to the transaction + for (const operation of operations) { + transactionBuilder.addOperation(operation); + } + + // Add memo if provided + if (memo) { + transactionBuilder.addMemo(memo); + } + + const transaction = transactionBuilder.build(); + this.logger.log( + `Successfully built transaction with hash: ${transaction.hash().toString('hex')}`, + ); + + return transaction; + } catch (error) { + this.logger.error( + `Failed to build transaction for account ${sourcePublicKey}: ${this.getErrorMessage(error)}`, + ); + throw this.mapStellarError( + error, + `Error building transaction for account ${sourcePublicKey}`, + ); + } + } + + /** + * Submits a transaction to the Stellar network with retry logic + * @param transaction The transaction object to submit + * @returns Transaction result + */ + async submitTransaction( + transaction: StellarSdk.Transaction, + ): Promise { + try { + this.logger.log('Submitting transaction with retry logic'); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ + const result = await retryWithBackoff( + async () => { + const res = await this.server.submitTransaction(transaction, { + skipMemoRequiredCheck: true, + }); + return res; + }, + this.config.maxRetries, + this.config.retryDelay, + ); + + this.logger.log(`Successfully submitted transaction: ${result.hash}`); + return result; + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ + } catch (error) { + this.logger.error( + `Failed to submit transaction after ${this.config.maxRetries + 1} attempts: ${this.getErrorMessage(error)}`, + ); + throw this.mapStellarError( + error, + `Error submitting transaction after ${this.config.maxRetries + 1} attempts`, + ); + } + } + + /** + * Streams transactions for a given account + * @param accountId The account ID to stream transactions for + * @param callback Callback function to handle incoming transactions + * @returns EventSource object for stream control + */ + streamTransactions( + accountId: string, + callback: (transaction: StellarTransactionResponse) => void, + ): any { + this.logger.log(`Starting transaction stream for account: ${accountId}`); + + const handler = (transaction: StellarTransactionResponse) => { + this.logger.log( + `Received transaction: ${transaction.id} for account: ${accountId}`, + ); + callback(transaction); + }; + + // Create event stream for account transactions + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + const eventSource = this.server + .transactions() + .forAccount(accountId) + .cursor('now') + .stream({ + onmessage: handler, + }); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + + this.logger.log(`Transaction stream established for account: ${accountId}`); + return eventSource; + } + + /** + * Checks the status of a submitted transaction + * @param transactionHash The hash of the transaction to check + * @returns Transaction response if found, null otherwise + */ + async checkTransactionStatus( + transactionHash: string, + ): Promise { + try { + this.logger.log(`Checking status for transaction: ${transactionHash}`); + + /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + const transaction: StellarTransactionResponse = (await this.server + .transactions() + .transaction(transactionHash) + .call()) as StellarTransactionResponse; + /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + + this.logger.log( + `Transaction ${transactionHash} status: ${transaction.successful ? 'SUCCESS' : 'FAILED'}`, + ); + return transaction; + } catch (error) { + if (this.isNotFoundError(error)) { + // Transaction not found (possibly still pending) + this.logger.log( + `Transaction ${transactionHash} not found (may still be pending)`, + ); + return null; + } + + this.logger.error( + `Failed to check transaction status ${transactionHash}: ${this.getErrorMessage(error)}`, + ); + throw this.mapStellarError( + error, + `Error checking transaction status ${transactionHash}`, + ); + } + } + + /** + * Validates a Stellar public key + * @param publicKey The public key to validate + * @returns True if valid, false otherwise + */ + isValidPublicKey(publicKey: string): boolean { + try { + return StellarSdk.StrKey.isValidEd25519PublicKey(publicKey); + } catch { + return false; + } + } + + /** + * Validates a Stellar secret key + * @param secretKey The secret key to validate + * @returns True if valid, false otherwise + */ + isValidSecretKey(secretKey: string): boolean { + try { + return StellarSdk.StrKey.isValidEd25519SecretSeed(secretKey); + } catch { + return false; + } + } + + /** + * Creates a new Stellar keypair + * @returns New keypair with public and private keys + */ + createKeypair(): StellarSdk.Keypair { + const keypair = StellarSdk.Keypair.random(); + this.logger.log( + `Created new keypair with public key: ${keypair.publicKey()}`, + ); + return keypair; + } + + /** + * Type guard to check if error has response with status + */ + private isNotFoundError(error: unknown): boolean { + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as any).response === 'object' && + (error as any).response !== null && + 'status' in (error as any).response && + (error as any).response.status === 404 + ); + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + } + + /** + * Safely extracts error message from unknown error type + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'object' && error !== null && 'message' in error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return String((error as any).message); + } + return 'Unknown error'; + } + + /** + * Maps Stellar SDK errors to more descriptive error messages + * @param error The error to map + * @param defaultMessage Default message if specific mapping isn't found + * @returns Mapped error + */ + private mapStellarError(error: unknown, defaultMessage: string): Error { + if (!error) { + return new Error(defaultMessage); + } + + // Type guard for error objects + if (typeof error === 'object' && error !== null) { + // Check if it's a Horizon API error + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const errorObj = error as any; + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + if (errorObj.response?.data) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const problem = errorObj.response.data; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const title = + problem.title || problem.extras?.result_codes?.transaction; + + if (problem.detail) { + return new Error( + `Stellar API Error: ${String(problem.detail)} (${String(title)})`, + ); + } + + if (problem.extras?.result_codes) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const codes = problem.extras.result_codes; + return new Error( + `Stellar Transaction Error: ${JSON.stringify(codes)}`, + ); + } + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + + // Check for specific Stellar SDK error types + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (errorObj.constructor?.name?.includes('NetworkError')) { + return new Error( + `Network Error: Failed to connect to Stellar network (${this.getErrorMessage(error)})`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (errorObj.constructor?.name?.includes('NotFoundError')) { + return new Error(`Not Found: ${this.getErrorMessage(error)}`); + } + + return new Error(`${defaultMessage}: ${this.getErrorMessage(error)}`); + } + + return new Error(defaultMessage); + } +} diff --git a/apps/backend/src/services/stellar/escrow-operations.ts b/apps/backend/src/services/stellar/escrow-operations.ts new file mode 100644 index 0000000..ec2afe6 --- /dev/null +++ b/apps/backend/src/services/stellar/escrow-operations.ts @@ -0,0 +1,326 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class EscrowOperationsService { + private readonly logger = new Logger(EscrowOperationsService.name); + + /** + * Creates operations for initializing an escrow contract + * @param contractId The contract ID for the escrow + * @param depositorPublicKey The public key of the depositor + * @param recipientPublicKey The public key of the recipient + * @param tokenAddress The address of the token contract + * @param milestones Array of milestone definitions + * @param deadline Unix timestamp deadline for escrow completion + * @returns Array of operations for the transaction + */ + createEscrowInitializationOps( + escrowId: string, + depositorPublicKey: string, + recipientPublicKey: string, + tokenAddress: string, + milestones: Array<{ id: number; amount: string; description: string }>, + deadline: number, + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log( + `Creating escrow initialization ops for escrow ID: ${escrowId}`, + ); + + // In a real implementation, this would involve Soroban contract calls + // For now, we'll simulate the operations needed + const operations: StellarSdk.xdr.Operation[] = []; + + // Add the escrow creation operation (conceptual) + // This would typically be a Soroban contract invocation in practice + const escrowCreationOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_creation`, + value: JSON.stringify({ + depositor: depositorPublicKey, + recipient: recipientPublicKey, + token: tokenAddress, + milestones, + deadline, + }), + source: depositorPublicKey, + }); + + operations.push(escrowCreationOp); + + this.logger.log( + `Created ${operations.length} operations for escrow initialization`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create escrow initialization ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Creates operations for funding an escrow + * @param escrowId The escrow ID to fund + * @param funderPublicKey The public key of the funder + * @param amount The amount to deposit + * @param asset The asset to deposit + * @returns Array of operations for the transaction + */ + createFundingOps( + escrowId: string, + funderPublicKey: string, + amount: string, + asset: StellarSdk.Asset, + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log( + `Creating funding ops for escrow ID: ${escrowId}, amount: ${amount}`, + ); + + const operations: StellarSdk.xdr.Operation[] = []; + + // Payment operation to move funds to escrow (conceptual) + const paymentOp = StellarSdk.Operation.payment({ + destination: funderPublicKey, // In a real implementation, this would be the escrow contract address + asset: asset, + amount: amount, + source: funderPublicKey, + }); + + operations.push(paymentOp); + + // Store escrow funding data + const fundingDataOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_funded`, + value: Buffer.from( + JSON.stringify({ + amount, + asset: asset.code + ? `${asset.code}:${asset.issuer || 'native'}` + : 'native', + }), + ), + source: funderPublicKey, + }); + + operations.push(fundingDataOp); + + this.logger.log( + `Created ${operations.length} operations for escrow funding`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create funding ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Creates operations for releasing a milestone payment + * @param escrowId The escrow ID + * @param milestoneId The milestone ID to release + * @param releaserPublicKey The public key of the account releasing the payment + * @param recipientPublicKey The public key of the recipient + * @param amount The amount to release + * @param asset The asset to release + * @returns Array of operations for the transaction + */ + createMilestoneReleaseOps( + escrowId: string, + milestoneId: number, + releaserPublicKey: string, + recipientPublicKey: string, + amount: string, + asset: StellarSdk.Asset, + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log( + `Creating milestone release ops for escrow ID: ${escrowId}, milestone: ${milestoneId}`, + ); + + const operations: StellarSdk.xdr.Operation[] = []; + + // Payment operation to release funds (conceptual) + const paymentOp = StellarSdk.Operation.payment({ + destination: recipientPublicKey, + asset: asset, + amount: amount, + source: releaserPublicKey, // In real implementation, this would be the escrow contract + }); + + operations.push(paymentOp); + + // Update milestone status + const milestoneCompleteOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_milestone_${milestoneId}_completed`, + value: Buffer.from(new Date().toISOString()), + source: releaserPublicKey, + }); + + operations.push(milestoneCompleteOp); + + this.logger.log( + `Created ${operations.length} operations for milestone release`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create milestone release ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Creates operations for confirming delivery/acceptance + * @param escrowId The escrow ID + * @param confirmerPublicKey The public key of the account confirming + * @param confirmationStatus The status of the confirmation + * @returns Array of operations for the transaction + */ + createConfirmationOps( + escrowId: string, + confirmerPublicKey: string, + confirmationStatus: 'confirmed' | 'disputed' | 'released', + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log( + `Creating confirmation ops for escrow ID: ${escrowId}, status: ${confirmationStatus}`, + ); + + const operations: StellarSdk.xdr.Operation[] = []; + + // Record the confirmation status + const confirmationOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_confirmation_status`, + value: Buffer.from(confirmationStatus), + source: confirmerPublicKey, + }); + + operations.push(confirmationOp); + + // Timestamp the confirmation + const timestampOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_confirmation_timestamp`, + value: Buffer.from(new Date().toISOString()), + source: confirmerPublicKey, + }); + + operations.push(timestampOp); + + this.logger.log( + `Created ${operations.length} operations for confirmation`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create confirmation ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Creates operations for canceling an escrow + * @param escrowId The escrow ID to cancel + * @param cancellerPublicKey The public key of the account canceling + * @param refundDestination The destination for refunded funds + * @returns Array of operations for the transaction + */ + createCancelOps( + escrowId: string, + cancellerPublicKey: string, + // refundDestination: string, + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log(`Creating cancel ops for escrow ID: ${escrowId}`); + + const operations: StellarSdk.xdr.Operation[] = []; + + // Record the cancellation + const cancelOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_cancelled`, + value: Buffer.from(new Date().toISOString()), + source: cancellerPublicKey, + }); + + operations.push(cancelOp); + + // In a real implementation, this would involve actual fund refund operations + // For now, we're just recording the intent to cancel + + this.logger.log( + `Created ${operations.length} operations for escrow cancellation`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create cancel ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Creates operations for completing an escrow + * @param escrowId The escrow ID to complete + * @param completerPublicKey The public key of the account completing + * @returns Array of operations for the transaction + */ + createCompletionOps( + escrowId: string, + completerPublicKey: string, + ): StellarSdk.xdr.Operation[] { + try { + this.logger.log(`Creating completion ops for escrow ID: ${escrowId}`); + + const operations: StellarSdk.xdr.Operation[] = []; + + // Mark escrow as completed + const completionOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_completed`, + value: Buffer.from(new Date().toISOString()), + source: completerPublicKey, + }); + + operations.push(completionOp); + + // Clear temporary data + const clearTempDataOp = StellarSdk.Operation.manageData({ + name: `escrow_${escrowId}_temporary_data`, + value: null, // This deletes the data entry + source: completerPublicKey, + }); + + operations.push(clearTempDataOp); + + this.logger.log( + `Created ${operations.length} operations for escrow completion`, + ); + return operations; + } catch (error) { + this.logger.error( + `Failed to create completion ops: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Safely extracts error message from unknown error type + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'object' && error !== null && 'message' in error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return String((error as any).message); + } + return 'Unknown error'; + } +} diff --git a/apps/backend/src/types/stellar.types.ts b/apps/backend/src/types/stellar.types.ts new file mode 100644 index 0000000..911ff8a --- /dev/null +++ b/apps/backend/src/types/stellar.types.ts @@ -0,0 +1,78 @@ +// Type definitions for Stellar SDK to avoid 'any' usage +import * as StellarSdk from '@stellar/stellar-sdk'; +export interface StellarAccountResponse extends StellarSdk.Account { + id: string; + account_id: string; + sequence: string; + balances: Array<{ + balance: string; + asset_type: string; + asset_code?: string; + asset_issuer?: string; + }>; + thresholds: { + low_threshold: number; + med_threshold: number; + high_threshold: number; + }; + flags: { + auth_required: boolean; + auth_revocable: boolean; + auth_immutable: boolean; + }; + signers: Array<{ + key: string; + weight: number; + type: string; + }>; + data: Record; +} + +export interface StellarSubmitTransactionResponse { + hash: string; + ledger: number; + envelope_xdr: string; + result_xdr: string; + result_meta_xdr: string; + paging_token: string; +} + +export interface StellarTransactionResponse { + id: string; + paging_token: string; + successful: boolean; + hash: string; + ledger: number; + created_at: string; + source_account: string; + source_account_sequence: string; + fee_charged: string; + max_fee: string; + operation_count: number; + envelope_xdr: string; + result_xdr: string; + result_meta_xdr: string; + fee_meta_xdr: string; + memo_type: string; + memo?: string; + signatures: string[]; + valid_after?: string; + valid_before?: string; +} + +export interface StellarHorizonError { + type: string; + title: string; + status: number; + detail: string; + extras?: { + envelope_xdr: string; + result_codes: { + transaction: string; + operations?: string[]; + }; + result_xdr: string; + }; +} + +export type StellarServer = any; // For now, keep as any but we'll use this alias for future typing diff --git a/apps/backend/src/utils/retry.util.ts b/apps/backend/src/utils/retry.util.ts new file mode 100644 index 0000000..35b5c4d --- /dev/null +++ b/apps/backend/src/utils/retry.util.ts @@ -0,0 +1,37 @@ +/** + * Exponential backoff retry utility + * @param fn The function to retry + * @param maxRetries Maximum number of retry attempts + * @param baseDelay Base delay in milliseconds + * @param factor Exponential factor (default 2) + * @returns Promise resolving to the result of the function + */ +export async function retryWithBackoff( + fn: () => Promise, + maxRetries: number, + baseDelay: number, + factor: number = 2, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt === maxRetries) { + break; + } + + // Calculate delay with exponential backoff and jitter + const delay = baseDelay * Math.pow(factor, attempt); + const jitter = Math.random() * 0.1 * delay; // Add up to 10% jitter + const totalDelay = delay + jitter; + + await new Promise((resolve) => setTimeout(resolve, totalDelay)); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index aba29b0..286103e 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,8 +1,7 @@ { "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "resolvePackageJsonExports": true, + "module": "commonjs", + "moduleResolution": "node", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -10,7 +9,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "ES2023", + "target": "ES2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", @@ -20,6 +19,7 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "lib": ["es2021"] } }