From c65ac5ba44a707fc3c69ed8aeae12f0d3223e05d Mon Sep 17 00:00:00 2001 From: EDOHWARES Date: Thu, 25 Jun 2026 14:26:30 +0100 Subject: [PATCH 1/2] Implement OAuth2 social login with Google and GitHub (#939) --- harvest-finance/backend/package-lock.json | 181 ++++++++++++++++++ harvest-finance/backend/package.json | 7 +- harvest-finance/backend/src/app.module.ts | 2 + .../backend/src/auth/auth.controller.ts | 56 +++++- .../backend/src/auth/auth.module.ts | 9 +- .../backend/src/auth/auth.oauth.spec.ts | 152 +++++++++++++++ .../backend/src/auth/auth.service.ts | 74 +++++++ .../src/auth/strategies/github.strategy.ts | 57 ++++++ .../src/auth/strategies/google.strategy.ts | 49 +++++ .../backend/src/config/config.module.ts | 2 +- .../backend/src/database/data-source.ts | 2 + .../backend/src/database/database.module.ts | 2 + .../backend/src/database/entities/index.ts | 1 + .../entities/user-oauth-link.entity.ts | 38 ++++ .../src/database/entities/user.entity.ts | 13 ++ .../stellar/services/stellar.service.spec.ts | 2 + .../src/stellar/services/stellar.service.ts | 3 + 17 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 harvest-finance/backend/src/auth/auth.oauth.spec.ts create mode 100644 harvest-finance/backend/src/auth/strategies/github.strategy.ts create mode 100644 harvest-finance/backend/src/auth/strategies/google.strategy.ts create mode 100644 harvest-finance/backend/src/database/entities/user-oauth-link.entity.ts diff --git a/harvest-finance/backend/package-lock.json b/harvest-finance/backend/package-lock.json index 2cfdbebe7..595bfd776 100644 --- a/harvest-finance/backend/package-lock.json +++ b/harvest-finance/backend/package-lock.json @@ -50,7 +50,10 @@ "exceljs": "^4.4.0", "fast-csv": "^5.0.5", "graphql": "^16.14.0", + "joi": "^18.2.3", "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.18.0", "pg": "^8.18.0", @@ -80,6 +83,8 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/pino": "^7.0.4", "@types/pino-http": "^5.8.4", @@ -2104,6 +2109,54 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -6281,6 +6334,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -6291,6 +6354,30 @@ "@types/express": "*" } }, + "node_modules/@types/passport-github2": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.2.9.tgz", + "integrity": "sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -6302,6 +6389,18 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -7897,6 +7996,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -12460,6 +12568,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -13480,6 +13606,12 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13710,6 +13842,29 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -13720,6 +13875,26 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -16895,6 +17070,12 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", diff --git a/harvest-finance/backend/package.json b/harvest-finance/backend/package.json index 58a156e85..dcf3e65ee 100644 --- a/harvest-finance/backend/package.json +++ b/harvest-finance/backend/package.json @@ -70,7 +70,10 @@ "exceljs": "^4.4.0", "fast-csv": "^5.0.5", "graphql": "^16.14.0", + "joi": "^18.2.3", "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.18.0", "pg": "^8.18.0", @@ -100,6 +103,8 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/pino": "^7.0.4", "@types/pino-http": "^5.8.4", @@ -137,4 +142,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/harvest-finance/backend/src/app.module.ts b/harvest-finance/backend/src/app.module.ts index 3880f72a3..fca9fcf6a 100644 --- a/harvest-finance/backend/src/app.module.ts +++ b/harvest-finance/backend/src/app.module.ts @@ -54,6 +54,7 @@ import { SorobanEvent, Transaction, User, + UserOAuthLink, Vault, VaultDeposit, Verification, @@ -109,6 +110,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; database: configService.get('DB_NAME'), entities: [ User, + UserOAuthLink, Order, Transaction, Verification, diff --git a/harvest-finance/backend/src/auth/auth.controller.ts b/harvest-finance/backend/src/auth/auth.controller.ts index 42f1f7129..6260320c5 100644 --- a/harvest-finance/backend/src/auth/auth.controller.ts +++ b/harvest-finance/backend/src/auth/auth.controller.ts @@ -6,7 +6,9 @@ import { Req, HttpCode, HttpStatus, + Get, } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; import type { Request } from 'express'; import { ApiTags, @@ -173,7 +175,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @ApiBearerAuth() - @ApiOperation({ summary: 'Logout user', description: 'Invalidates the user's refresh token, effectively logging them out. Requires a valid JWT access token in the Authorization header.' }) + @ApiOperation({ summary: 'Logout user', description: 'Invalidates the user\'s refresh token, effectively logging them out. Requires a valid JWT access token in the Authorization header.' }) @ApiResponse({ status: 200, description: 'Logged out successfully', @@ -349,4 +351,56 @@ export class AuthController { }, }; } + + /** + * Redirect to Google OAuth provider + */ + @Get('google') + @UseGuards(AuthGuard('google')) + @ApiOperation({ summary: 'Login via Google', description: 'Redirects to Google login consent screen.' }) + async googleAuth(@Req() req) { + // Handled by passport guard + } + + /** + * Google OAuth callback handler + */ + @Get('google/callback') + @UseGuards(AuthGuard('google')) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Google Auth callback', description: 'Handles redirect callback from Google, registers/links account, and issues JWT tokens.' }) + @ApiResponse({ + status: 200, + description: 'Successfully authenticated', + type: AuthResponseDto, + }) + async googleAuthRedirect(@Req() req): Promise { + return this.authService.loginWithOAuth(req.user); + } + + /** + * Redirect to GitHub OAuth provider + */ + @Get('github') + @UseGuards(AuthGuard('github')) + @ApiOperation({ summary: 'Login via GitHub', description: 'Redirects to GitHub login screen.' }) + async githubAuth(@Req() req) { + // Handled by passport guard + } + + /** + * GitHub OAuth callback handler + */ + @Get('github/callback') + @UseGuards(AuthGuard('github')) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'GitHub Auth callback', description: 'Handles redirect callback from GitHub, registers/links account, and issues JWT tokens.' }) + @ApiResponse({ + status: 200, + description: 'Successfully authenticated', + type: AuthResponseDto, + }) + async githubAuthRedirect(@Req() req): Promise { + return this.authService.loginWithOAuth(req.user); + } } diff --git a/harvest-finance/backend/src/auth/auth.module.ts b/harvest-finance/backend/src/auth/auth.module.ts index a01f5137b..ae0335010 100644 --- a/harvest-finance/backend/src/auth/auth.module.ts +++ b/harvest-finance/backend/src/auth/auth.module.ts @@ -6,12 +6,15 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { StellarStrategy } from './strategies/stellar.strategy'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { GithubStrategy } from './strategies/github.strategy'; import { User } from '../database/entities/user.entity'; +import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; import { CommonModule } from '../common/common.module'; @Module({ imports: [ - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, UserOAuthLink]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: 'super_secret_jwt_key', @@ -22,7 +25,7 @@ import { CommonModule } from '../common/common.module'; CommonModule, ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, StellarStrategy], - exports: [AuthService, JwtStrategy, StellarStrategy, PassportModule], + providers: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy], + exports: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, PassportModule], }) export class AuthModule {} diff --git a/harvest-finance/backend/src/auth/auth.oauth.spec.ts b/harvest-finance/backend/src/auth/auth.oauth.spec.ts new file mode 100644 index 000000000..c32f01e29 --- /dev/null +++ b/harvest-finance/backend/src/auth/auth.oauth.spec.ts @@ -0,0 +1,152 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { CustomLoggerService } from '../logger/custom-logger.service'; +import { User, UserRole } from '../database/entities/user.entity'; +import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; + +describe('AuthService OAuth', () => { + let service: AuthService; + let mockUserRepository: any; + let mockOAuthLinkRepository: any; + let mockJwtService: any; + let mockConfigService: any; + let mockCacheManager: any; + let mockLogger: any; + + beforeEach(async () => { + mockUserRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + mockOAuthLinkRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + mockJwtService = { + signAsync: jest.fn(), + }; + + mockConfigService = { + get: jest.fn(), + }; + + mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + + mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(UserOAuthLink), + useValue: mockOAuthLinkRepository, + }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: 'CACHE_MANAGER', + useValue: mockCacheManager, + }, + { + provide: CustomLoggerService, + useValue: mockLogger, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + describe('validateOrCreateOAuthUser', () => { + it('should return user if OAuth link already exists', async () => { + const mockUser = { id: 'user-id', email: 'test@example.com' }; + const mockLink = { id: 'link-id', user: mockUser }; + mockOAuthLinkRepository.findOne.mockResolvedValue(mockLink); + + const result = await service.validateOrCreateOAuthUser('google', 'google-id', 'test@example.com'); + expect(result).toBe(mockUser); + expect(mockUserRepository.update).toHaveBeenCalledWith('user-id', expect.any(Object)); + }); + + it('should link to existing user if email matches but link does not exist', async () => { + const mockUser = { id: 'user-id', email: 'test@example.com' }; + mockOAuthLinkRepository.findOne.mockResolvedValue(null); + mockUserRepository.findOne.mockResolvedValue(mockUser); + mockOAuthLinkRepository.create.mockReturnValue({ userId: 'user-id', oauthProvider: 'google', oauthId: 'google-id' }); + mockOAuthLinkRepository.save.mockResolvedValue({}); + + const result = await service.validateOrCreateOAuthUser('google', 'google-id', 'test@example.com'); + expect(result).toBe(mockUser); + expect(mockOAuthLinkRepository.create).toHaveBeenCalledWith({ + userId: 'user-id', + oauthProvider: 'google', + oauthId: 'google-id', + }); + expect(mockOAuthLinkRepository.save).toHaveBeenCalled(); + }); + + it('should create a new user and link if email and link do not exist', async () => { + mockOAuthLinkRepository.findOne.mockResolvedValue(null); + mockUserRepository.findOne.mockResolvedValue(null); + + const newMockUser = { id: 'new-user-id', email: 'new@example.com' }; + mockUserRepository.create.mockReturnValue(newMockUser); + mockUserRepository.save.mockResolvedValue(newMockUser); + + mockOAuthLinkRepository.create.mockReturnValue({ userId: 'new-user-id', oauthProvider: 'google', oauthId: 'google-id' }); + mockOAuthLinkRepository.save.mockResolvedValue({}); + + const result = await service.validateOrCreateOAuthUser('google', 'google-id', 'new@example.com', 'Alice', 'Smith'); + expect(result).toBe(newMockUser); + expect(mockUserRepository.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + firstName: 'Alice', + lastName: 'Smith', + })); + expect(mockUserRepository.save).toHaveBeenCalled(); + expect(mockOAuthLinkRepository.create).toHaveBeenCalledWith({ + userId: 'new-user-id', + oauthProvider: 'google', + oauthId: 'google-id', + }); + }); + }); + + describe('loginWithOAuth', () => { + it('should return token payload', async () => { + const mockUser = { id: 'user-id', email: 'test@example.com', role: UserRole.BUYER, firstName: 'Alice', lastName: 'Smith' } as any; + mockJwtService.signAsync.mockResolvedValueOnce('access_token').mockResolvedValueOnce('refresh_token'); + mockUserRepository.update.mockResolvedValue({}); + + const result = await service.loginWithOAuth(mockUser); + expect(result).toHaveProperty('access_token', 'access_token'); + expect(result).toHaveProperty('refresh_token', 'refresh_token'); + expect(result.user).toHaveProperty('email', 'test@example.com'); + }); + }); +}); diff --git a/harvest-finance/backend/src/auth/auth.service.ts b/harvest-finance/backend/src/auth/auth.service.ts index 43911b05a..5d313fd54 100644 --- a/harvest-finance/backend/src/auth/auth.service.ts +++ b/harvest-finance/backend/src/auth/auth.service.ts @@ -15,6 +15,7 @@ import type { Cache } from 'cache-manager'; import { randomBytes } from 'crypto'; import { CustomLoggerService } from '../logger/custom-logger.service'; import { User, UserRole } from '../database/entities/user.entity'; +import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -42,6 +43,8 @@ export class AuthService { constructor( @InjectRepository(User) private userRepository: Repository, + @InjectRepository(UserOAuthLink) + private oauthLinkRepository: Repository, private jwtService: JwtService, private configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, @@ -394,4 +397,75 @@ export class AuthService { stellar_address: user.stellarAddress, }; } + + /** + * Validate or create an OAuth user on callback, linking accounts if email matches. + */ + async validateOrCreateOAuthUser( + oauthProvider: string, + oauthId: string, + email: string, + firstName?: string, + lastName?: string, + ): Promise { + // 1. Check if OAuth link already exists + let existingLink = await this.oauthLinkRepository.findOne({ + where: { oauthProvider, oauthId }, + relations: ['user'], + }); + + if (existingLink) { + // Update last login + await this.userRepository.update(existingLink.user.id, { lastLogin: new Date() }); + return existingLink.user; + } + + // 2. Check if a user with the same email exists + let user = await this.userRepository.findOne({ where: { email } }); + + if (!user) { + // Create a new user on first login + const randomPassword = randomBytes(16).toString('hex'); + const hashedPassword = await bcrypt.hash(randomPassword, this.saltRounds); + + user = this.userRepository.create({ + email, + password: hashedPassword, + role: UserRole.BUYER, // default role + firstName: firstName || null, + lastName: lastName || null, + isActive: true, + }); + + user = await this.userRepository.save(user); + this.logger.log(`Created new OAuth user: ${email} via ${oauthProvider}`, 'AuthService'); + } else { + this.logger.log(`Linking existing user: ${email} to OAuth provider ${oauthProvider}`, 'AuthService'); + } + + // 3. Link OAuth provider to the user + const link = this.oauthLinkRepository.create({ + userId: user.id, + oauthProvider, + oauthId, + }); + await this.oauthLinkRepository.save(link); + + // Update last login + await this.userRepository.update(user.id, { lastLogin: new Date() }); + + return user; + } + + /** + * Log in user via OAuth and generate access/refresh tokens. + */ + async loginWithOAuth(user: User): Promise { + const tokens = await this.generateTokens(user); + return { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + user: this.mapUserToResponse(user), + }; + } } diff --git a/harvest-finance/backend/src/auth/strategies/github.strategy.ts b/harvest-finance/backend/src/auth/strategies/github.strategy.ts new file mode 100644 index 000000000..32cb0d199 --- /dev/null +++ b/harvest-finance/backend/src/auth/strategies/github.strategy.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-github2'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github') { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + super({ + clientID: configService.get('GITHUB_CLIENT_ID') || 'github-client-id-placeholder', + clientSecret: configService.get('GITHUB_CLIENT_SECRET') || 'github-client-secret-placeholder', + callbackURL: configService.get('GITHUB_CALLBACK_URL') || 'http://localhost:3000/auth/github/callback', + scope: ['user:email'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: (err: any, user: any, info?: any) => void, + ): Promise { + const { id, emails, displayName, username } = profile; + const email = emails && emails[0] ? emails[0].value : null; + + if (!email) { + return done(new Error('No email found from Github profile. Ensure your email is public on Github.'), false); + } + + let firstName: string | undefined = undefined; + let lastName: string | undefined = undefined; + if (displayName) { + const nameParts = displayName.trim().split(' '); + firstName = nameParts[0]; + lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined; + } else if (username) { + firstName = username; + } + + try { + const user = await this.authService.validateOrCreateOAuthUser( + 'github', + id, + email, + firstName, + lastName, + ); + done(null, user); + } catch (err) { + done(err, false); + } + } +} diff --git a/harvest-finance/backend/src/auth/strategies/google.strategy.ts b/harvest-finance/backend/src/auth/strategies/google.strategy.ts new file mode 100644 index 000000000..405b54114 --- /dev/null +++ b/harvest-finance/backend/src/auth/strategies/google.strategy.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + super({ + clientID: configService.get('GOOGLE_CLIENT_ID') || 'google-client-id-placeholder', + clientSecret: configService.get('GOOGLE_CLIENT_SECRET') || 'google-client-secret-placeholder', + callbackURL: configService.get('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/auth/google/callback', + scope: ['email', 'profile'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { id, name, emails } = profile; + const email = emails && emails[0] ? emails[0].value : null; + const firstName = name && name.givenName ? name.givenName : undefined; + const lastName = name && name.familyName ? name.familyName : undefined; + + if (!email) { + return done(new Error('No email found from Google profile'), false); + } + + try { + const user = await this.authService.validateOrCreateOAuthUser( + 'google', + id, + email, + firstName, + lastName, + ); + done(null, user); + } catch (err) { + done(err, false); + } + } +} diff --git a/harvest-finance/backend/src/config/config.module.ts b/harvest-finance/backend/src/config/config.module.ts index 083f3cb13..f327cbc99 100644 --- a/harvest-finance/backend/src/config/config.module.ts +++ b/harvest-finance/backend/src/config/config.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/config'; +import { Module } from '@nestjs/common'; import { ConfigModule as NestConfigModule } from '@nestjs/config'; import { envValidationSchema } from './env.validation'; import databaseConfig from './database.config'; diff --git a/harvest-finance/backend/src/database/data-source.ts b/harvest-finance/backend/src/database/data-source.ts index febfb1be4..05d12cc1b 100644 --- a/harvest-finance/backend/src/database/data-source.ts +++ b/harvest-finance/backend/src/database/data-source.ts @@ -2,6 +2,7 @@ import { DataSource, DataSourceOptions } from 'typeorm'; import { config } from 'dotenv'; import * as path from 'path'; import { User } from './entities/user.entity'; +import { UserOAuthLink } from './entities/user-oauth-link.entity'; import { Order } from './entities/order.entity'; import { Transaction } from './entities/transaction.entity'; import { Verification } from './entities/verification.entity'; @@ -37,6 +38,7 @@ const options: DataSourceOptions = { database: process.env.DB_NAME || 'harvest_finance', entities: [ User, + UserOAuthLink, Order, Transaction, Verification, diff --git a/harvest-finance/backend/src/database/database.module.ts b/harvest-finance/backend/src/database/database.module.ts index 6831e9243..75f64e916 100644 --- a/harvest-finance/backend/src/database/database.module.ts +++ b/harvest-finance/backend/src/database/database.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User, + UserOAuthLink, Order, Transaction, Verification, @@ -20,6 +21,7 @@ import { imports: [ TypeOrmModule.forFeature([ User, + UserOAuthLink, Order, Transaction, Verification, diff --git a/harvest-finance/backend/src/database/entities/index.ts b/harvest-finance/backend/src/database/entities/index.ts index d2cb60daf..386cb4246 100644 --- a/harvest-finance/backend/src/database/entities/index.ts +++ b/harvest-finance/backend/src/database/entities/index.ts @@ -24,6 +24,7 @@ export { TransactionType, } from './transaction.entity'; export { User, UserRole } from './user.entity'; +export { UserOAuthLink } from './user-oauth-link.entity'; export { Vault, VaultStatus, VaultType } from './vault.entity'; export { VaultDeposit } from './vault-deposit.entity'; export { Verification, VerificationStatus } from './verification.entity'; diff --git a/harvest-finance/backend/src/database/entities/user-oauth-link.entity.ts b/harvest-finance/backend/src/database/entities/user-oauth-link.entity.ts new file mode 100644 index 000000000..49a44dca4 --- /dev/null +++ b/harvest-finance/backend/src/database/entities/user-oauth-link.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('user_oauth_links') +@Index('idx_user_oauth_links_provider_id', ['oauthProvider', 'oauthId'], { unique: true }) +@Index('idx_user_oauth_links_user_id', ['userId']) +export class UserOAuthLink { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ name: 'oauth_provider' }) + oauthProvider: string; // 'google', 'github', etc. + + @Column({ name: 'oauth_id' }) + oauthId: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => User, (user) => user.oauthLinks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/harvest-finance/backend/src/database/entities/user.entity.ts b/harvest-finance/backend/src/database/entities/user.entity.ts index 70611715c..a33aa08f4 100644 --- a/harvest-finance/backend/src/database/entities/user.entity.ts +++ b/harvest-finance/backend/src/database/entities/user.entity.ts @@ -11,6 +11,7 @@ import { import { Order } from './order.entity'; import { Verification } from './verification.entity'; import { CreditScore } from './credit-score.entity'; +import { UserOAuthLink } from './user-oauth-link.entity'; /** * User roles in the agricultural marketplace @@ -36,6 +37,8 @@ export enum UserRole { @Index('idx_users_role', ['role']) @Index('idx_users_stellar_address', ['stellarAddress']) @Index('idx_users_solana_address', ['solanaAddress']) +@Index('idx_users_ethereum_address', ['ethereumAddress']) +@Index('idx_users_polygon_address', ['polygonAddress']) export class User { @PrimaryGeneratedColumn('uuid') id: string; @@ -59,6 +62,12 @@ export class User { @Column({ name: 'solana_address', nullable: true }) solanaAddress: string | null; + @Column({ name: 'ethereum_address', nullable: true }) + ethereumAddress: string | null; + + @Column({ name: 'polygon_address', nullable: true }) + polygonAddress: string | null; + @Column({ name: 'is_active', default: true }) isActive: boolean; @@ -112,4 +121,8 @@ export class User { /** Credit score for farmers */ @OneToOne(() => CreditScore, (creditScore) => creditScore.farmer) creditScore: CreditScore; + + /** OAuth provider links */ + @OneToMany(() => UserOAuthLink, (link) => link.user) + oauthLinks: UserOAuthLink[]; } diff --git a/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts b/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts index bdb68f2b2..87cc2f587 100644 --- a/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts +++ b/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts @@ -10,6 +10,8 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CircuitBreaker } from '../utils/circuit-breaker'; +import { isRetryableStellarError } from '../utils/stellar-retry'; describe('StellarService - Escrow Creation', () => { let service: StellarService; diff --git a/harvest-finance/backend/src/stellar/services/stellar.service.ts b/harvest-finance/backend/src/stellar/services/stellar.service.ts index 0b08ec504..8820a74b0 100644 --- a/harvest-finance/backend/src/stellar/services/stellar.service.ts +++ b/harvest-finance/backend/src/stellar/services/stellar.service.ts @@ -30,6 +30,9 @@ import { PriorityFeeInfo, StellarBalance, } from '../interfaces/stellar.interfaces'; +import { CircuitBreaker, CircuitBreakerOpenError, CircuitBreakerStateChange } from '../utils/circuit-breaker'; +import { isRetryableStellarError } from '../utils/stellar-retry'; +import { retry } from '../../common/utils/retry'; @Injectable() export class StellarService implements OnModuleInit { From 00d49c3d930098b736c4b137726f1609ff0ded78 Mon Sep 17 00:00:00 2001 From: EDOHWARES Date: Thu, 25 Jun 2026 14:49:02 +0100 Subject: [PATCH 2/2] feat(backend): implement Stellar payment channel monitoring and stream-driven deposit confirmation --- .../src/domain-events/domain-event-names.ts | 1 + .../events/payment-received.event.ts | 11 ++ .../backend/src/domain-events/index.ts | 1 + .../backend/src/health/health.controller.ts | 15 ++ .../backend/src/health/health.module.ts | 3 +- .../services/stellar-client.service.ts | 171 ++++++++++++++++++ .../backend/src/stellar/stellar.module.ts | 5 +- .../src/vaults/vaults.integration.spec.ts | 125 +++++++------ .../backend/src/vaults/vaults.service.spec.ts | 2 +- .../backend/src/vaults/vaults.service.ts | 132 +++++++++++--- 10 files changed, 374 insertions(+), 92 deletions(-) create mode 100644 harvest-finance/backend/src/domain-events/events/payment-received.event.ts create mode 100644 harvest-finance/backend/src/stellar/services/stellar-client.service.ts diff --git a/harvest-finance/backend/src/domain-events/domain-event-names.ts b/harvest-finance/backend/src/domain-events/domain-event-names.ts index 382b303c7..19e5dedab 100644 --- a/harvest-finance/backend/src/domain-events/domain-event-names.ts +++ b/harvest-finance/backend/src/domain-events/domain-event-names.ts @@ -7,6 +7,7 @@ export const DomainEventNames = { DEPOSIT_CONFIRMED: 'vault.deposit.confirmed', WITHDRAWAL_INITIATED: 'vault.withdrawal.initiated', VAULT_PAUSED: 'vault.paused', + PAYMENT_RECEIVED: 'stellar.payment.received', } as const; export type DomainEventName = diff --git a/harvest-finance/backend/src/domain-events/events/payment-received.event.ts b/harvest-finance/backend/src/domain-events/events/payment-received.event.ts new file mode 100644 index 000000000..5857d98c8 --- /dev/null +++ b/harvest-finance/backend/src/domain-events/events/payment-received.event.ts @@ -0,0 +1,11 @@ +export class PaymentReceivedEvent { + constructor( + public readonly transactionHash: string, + public readonly from: string, + public readonly to: string, + public readonly amount: number, + public readonly assetCode: string, + public readonly memo?: string, + public readonly occurredAt: Date = new Date(), + ) {} +} diff --git a/harvest-finance/backend/src/domain-events/index.ts b/harvest-finance/backend/src/domain-events/index.ts index a84915d06..b3b472a74 100644 --- a/harvest-finance/backend/src/domain-events/index.ts +++ b/harvest-finance/backend/src/domain-events/index.ts @@ -8,4 +8,5 @@ export { VaultCreatedEvent } from './events/vault-created.event'; export { DepositConfirmedEvent } from './events/deposit-confirmed.event'; export { WithdrawalInitiatedEvent } from './events/withdrawal-initiated.event'; export { VaultPausedEvent } from './events/vault-paused.event'; +export { PaymentReceivedEvent } from './events/payment-received.event'; export { DomainEventsModule } from './domain-events.module'; diff --git a/harvest-finance/backend/src/health/health.controller.ts b/harvest-finance/backend/src/health/health.controller.ts index 7cb740ef2..309745dc0 100644 --- a/harvest-finance/backend/src/health/health.controller.ts +++ b/harvest-finance/backend/src/health/health.controller.ts @@ -3,8 +3,11 @@ import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, + HealthCheckError, + HealthIndicatorResult, } from '@nestjs/terminus'; import { SkipThrottle } from '@nestjs/throttler'; +import { StellarClientService } from '../stellar/services/stellar-client.service'; @SkipThrottle() @Controller('health') @@ -12,6 +15,7 @@ export class HealthController { constructor( private health: HealthCheckService, private db: TypeOrmHealthIndicator, + private stellarClient: StellarClientService, ) {} @Get() @@ -19,6 +23,17 @@ export class HealthController { check() { return this.health.check([ () => this.db.pingCheck('database', { timeout: 1500 }), + async (): Promise => { + const streamHealth = this.stellarClient.getStreamHealth(); + if (!streamHealth.isConnected) { + throw new HealthCheckError('Stellar stream is disconnected', { + 'stellar-payment-stream': streamHealth, + }); + } + return { + 'stellar-payment-stream': streamHealth, + }; + }, ]); } } diff --git a/harvest-finance/backend/src/health/health.module.ts b/harvest-finance/backend/src/health/health.module.ts index deedde586..48c48a792 100644 --- a/harvest-finance/backend/src/health/health.module.ts +++ b/harvest-finance/backend/src/health/health.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; import { TerminusModule } from '@nestjs/terminus'; +import { StellarModule } from '../stellar/stellar.module'; @Module({ - imports: [TerminusModule], + imports: [TerminusModule, StellarModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/harvest-finance/backend/src/stellar/services/stellar-client.service.ts b/harvest-finance/backend/src/stellar/services/stellar-client.service.ts new file mode 100644 index 000000000..c5e65e15e --- /dev/null +++ b/harvest-finance/backend/src/stellar/services/stellar-client.service.ts @@ -0,0 +1,171 @@ +import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as StellarSdk from 'stellar-sdk'; +import { DomainEventNames } from '../../domain-events/domain-event-names'; +import { PaymentReceivedEvent } from '../../domain-events/events/payment-received.event'; + +@Injectable() +export class StellarClientService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(StellarClientService.name); + private server: StellarSdk.Horizon.Server; + private accountId: string; + private closeStreamFn: (() => void) | null = null; + private isConnected = false; + private reconnectTimeout: NodeJS.Timeout | null = null; + private backoffDelay = 1000; + private readonly maxBackoffDelay = 30000; + private lastLedgerCloseTime = new Date(); + + constructor( + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + ) { + const network = this.configService.get('STELLAR_NETWORK', 'testnet'); + if (network === 'mainnet') { + this.server = new StellarSdk.Horizon.Server('https://horizon.stellar.org'); + } else { + this.server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org'); + } + + this.accountId = this.configService.get('STELLAR_PLATFORM_PUBLIC_KEY') || 'GDQP2CHOUZQCIBXIHLFS4D5R7U6WCCSPHQFUG7HOUCQ2YVS6A5W5Y5YG'; + } + + onModuleInit() { + const isTest = this.configService.get('NODE_ENV') === 'test'; + if (!isTest) { + this.startStreaming(); + } else { + this.logger.log('Skipping Stellar streaming connection in test environment'); + } + } + + onModuleDestroy() { + this.stopStreaming(); + } + + public getStreamHealth(): { status: 'up' | 'down'; isConnected: boolean; lastEventTime: Date } { + return { + status: this.isConnected ? 'up' : 'down', + isConnected: this.isConnected, + lastEventTime: this.lastLedgerCloseTime, + }; + } + + // Exposed for testing/triggering manually + public startStreaming() { + if (this.closeStreamFn) { + this.closeStreamFn(); + this.closeStreamFn = null; + } + + this.logger.log(`Starting Stellar payment channel stream for account ${this.accountId}`); + + try { + this.closeStreamFn = this.server + .payments() + .forAccount(this.accountId) + .stream({ + onmessage: (payment) => { + this.handlePayment(payment); + }, + onerror: (error) => { + this.handleStreamError(error); + }, + }); + + this.isConnected = true; + this.backoffDelay = 1000; + } catch (err) { + this.logger.error('Failed to establish Stellar payment stream', err); + this.handleStreamError(err); + } + } + + public stopStreaming() { + if (this.closeStreamFn) { + try { + this.closeStreamFn(); + } catch (e) { + // ignore + } + this.closeStreamFn = null; + } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.isConnected = false; + } + + private handlePayment(payment: any) { + this.lastLedgerCloseTime = new Date(); + this.isConnected = true; + this.backoffDelay = 1000; + + if (payment.to !== this.accountId) { + return; + } + + if (payment.type !== 'payment' && payment.type !== 'path_payment_strict_receive' && payment.type !== 'path_payment_strict_send') { + return; + } + + this.logger.log(`Incoming Stellar payment detected: ${payment.amount} ${payment.asset_code || 'XLM'} from ${payment.from}`); + + this.fetchTransactionMemo(payment.transaction_hash) + .then((memo) => { + const amount = parseFloat(payment.amount); + const assetCode = payment.asset_code || 'XLM'; + + const event = new PaymentReceivedEvent( + payment.transaction_hash, + payment.from, + payment.to, + amount, + assetCode, + memo, + new Date(payment.created_at || Date.now()), + ); + + this.eventEmitter.emit(DomainEventNames.PAYMENT_RECEIVED, event); + }) + .catch((err) => { + this.logger.error(`Failed to fetch transaction details for hash ${payment.transaction_hash}`, err); + }); + } + + private async fetchTransactionMemo(txHash: string): Promise { + try { + const tx = await this.server.transactions().transaction(txHash).call(); + return tx.memo_type !== 'none' ? tx.memo : undefined; + } catch (err) { + this.logger.error(`Error fetching transaction ${txHash}`, err); + return undefined; + } + } + + private handleStreamError(error: any) { + this.logger.warn(`Stellar payment stream disconnected or encountered an error: ${error?.message || error}`); + this.isConnected = false; + + if (this.closeStreamFn) { + try { + this.closeStreamFn(); + } catch (e) { + // ignore + } + this.closeStreamFn = null; + } + + if (!this.reconnectTimeout) { + this.logger.log(`Scheduling reconnect in ${this.backoffDelay}ms`); + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.startStreaming(); + }, this.backoffDelay); + + this.backoffDelay = Math.min(this.backoffDelay * 2, this.maxBackoffDelay); + } + } +} diff --git a/harvest-finance/backend/src/stellar/stellar.module.ts b/harvest-finance/backend/src/stellar/stellar.module.ts index 5090deb9f..0a4b8799d 100644 --- a/harvest-finance/backend/src/stellar/stellar.module.ts +++ b/harvest-finance/backend/src/stellar/stellar.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { CommonModule } from '../common/common.module'; import { StellarService } from './services/stellar.service'; +import { StellarClientService } from './services/stellar-client.service'; import { StellarController } from './stellar.controller'; @Module({ imports: [ConfigModule, CommonModule], - providers: [StellarService], + providers: [StellarService, StellarClientService], controllers: [StellarController], - exports: [StellarService], + exports: [StellarService, StellarClientService], }) export class StellarModule {} diff --git a/harvest-finance/backend/src/vaults/vaults.integration.spec.ts b/harvest-finance/backend/src/vaults/vaults.integration.spec.ts index 01bc5e36d..cd0083920 100644 --- a/harvest-finance/backend/src/vaults/vaults.integration.spec.ts +++ b/harvest-finance/backend/src/vaults/vaults.integration.spec.ts @@ -8,6 +8,10 @@ import { } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { VaultsService } from './vaults.service'; +import { ExternalPaymentEventType } from './dto/external-payment-notification.dto'; +import { PaymentReceivedEvent, DepositCompletedEvent, WithdrawalConfirmedEvent, DomainEventNames } from '../domain-events'; +import { User } from '../database/entities/user.entity'; +import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; import { Vault, VaultStatus, @@ -76,6 +80,7 @@ describe('VaultsService — Yield Strategy Integration', () => { transaction: jest.fn((cb: (em: typeof mockManager) => unknown) => cb(mockManager), ), + getRepository: jest.fn(), }; const mockVaultRepository = { @@ -187,7 +192,7 @@ describe('VaultsService — Yield Strategy Integration', () => { getRawOne: jest.fn().mockResolvedValue({ total }), }); - it('should route funds from user into vault and confirm deposit atomically', async () => { + it('should route funds from user into vault and return pending deposit', async () => { const vault = buildVault(); const updatedVault = buildVault({ totalDeposits: 1000, @@ -200,10 +205,6 @@ describe('VaultsService — Yield Strategy Integration', () => { mockManager.save.mockResolvedValue(pendingDeposit); mockManager.increment.mockResolvedValue(undefined); mockManager.findOne.mockResolvedValue(updatedVault); - mockDepositRepository.findOne - .mockResolvedValueOnce(pendingDeposit) - .mockResolvedValueOnce(confirmedDeposit); - mockDepositRepository.update.mockResolvedValue(undefined); mockDepositRepository.createQueryBuilder.mockReturnValue(buildQB('1000')); const result = await service.depositToVault(VAULT_ID, { @@ -211,11 +212,11 @@ describe('VaultsService — Yield Strategy Integration', () => { amount: 1000, }); - expect(result.deposit.status).toBe(DepositStatus.CONFIRMED); + expect(result.deposit.status).toBe(DepositStatus.PENDING); expect(result.deposit.amount).toBe(1000); expect(result.userTotalDeposits).toBe(1000); expect(mockDataSource.transaction).toHaveBeenCalledTimes(1); - expect(mockDepositEventService.appendEvent).toHaveBeenCalledTimes(2); + expect(mockDepositEventService.appendEvent).toHaveBeenCalledTimes(1); expect(mockManager.increment).toHaveBeenCalledWith( Vault, { id: VAULT_ID }, @@ -224,25 +225,33 @@ describe('VaultsService — Yield Strategy Integration', () => { ); }); - it('should emit real-time deposit event to strategy subscribers', async () => { - const vault = buildVault(); - const updatedVault = buildVault({ totalDeposits: 500 }); - + it('should emit real-time deposit event on PaymentReceivedEvent', async () => { + const vault = buildVault({ totalDeposits: 500 }); mockVaultRepository.findOne.mockResolvedValue(vault); - mockDepositRepository.create.mockReturnValue({ - ...pendingDeposit, - amount: 500, + + const mockUser = { id: USER_ID, stellarAddress: 'GUSER' }; + mockDataSource.getRepository.mockReturnValue({ + findOne: jest.fn().mockResolvedValue(mockUser), }); - mockManager.save.mockResolvedValue({ ...pendingDeposit, amount: 500 }); - mockManager.increment.mockResolvedValue(undefined); - mockManager.findOne.mockResolvedValue(updatedVault); + mockDepositRepository.findOne + .mockResolvedValueOnce({ ...pendingDeposit, amount: 500 }) .mockResolvedValueOnce({ ...pendingDeposit, amount: 500 }) .mockResolvedValueOnce({ ...confirmedDeposit, amount: 500 }); + mockDepositRepository.update.mockResolvedValue(undefined); mockDepositRepository.createQueryBuilder.mockReturnValue(buildQB('500')); - await service.depositToVault(VAULT_ID, { userId: USER_ID, amount: 500 }); + const event = new PaymentReceivedEvent( + 'mock_tx_123', + 'GUSER', + 'GPLATFORM', + 500, + 'XLM', + undefined, + ); + + await service.handlePaymentReceived(event); expect(mockVaultGateway.emitDeposit).toHaveBeenCalledWith( expect.objectContaining({ @@ -323,20 +332,20 @@ describe('VaultsService — Yield Strategy Integration', () => { it('should notify user after deposit confirmed', async () => { const vault = buildVault(); - const updatedVault = buildVault({ totalDeposits: 1000 }); - mockVaultRepository.findOne.mockResolvedValue(vault); - mockDepositRepository.create.mockReturnValue(pendingDeposit); - mockManager.save.mockResolvedValue(pendingDeposit); - mockManager.increment.mockResolvedValue(undefined); - mockManager.findOne.mockResolvedValue(updatedVault); mockDepositRepository.findOne .mockResolvedValueOnce(pendingDeposit) .mockResolvedValueOnce(confirmedDeposit); mockDepositRepository.update.mockResolvedValue(undefined); mockDepositRepository.createQueryBuilder.mockReturnValue(buildQB('1000')); - await service.depositToVault(VAULT_ID, { userId: USER_ID, amount: 1000 }); + await service.applyExternalPaymentNotification({ + depositId: DEPOSIT_ID, + eventType: ExternalPaymentEventType.PAYMENT_CONFIRMED, + transactionHash: 'mock_tx_123', + stellarTransactionId: 'stellar_tx_123', + externalEventId: 'ext_event_123', + }); expect(mockNotificationsService.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -416,6 +425,7 @@ describe('VaultsService — Yield Strategy Integration', () => { vaultId: VAULT_ID, amount: 500, status: WithdrawalStatus.PENDING, + vault: buildVault(), }; const confirmedWithdrawal = { @@ -432,7 +442,7 @@ describe('VaultsService — Yield Strategy Integration', () => { getRawOne: jest.fn().mockResolvedValue({ total }), }); - it('should route funds out of vault and confirm withdrawal atomically', async () => { + it('should route funds out of vault and return pending withdrawal', async () => { const vault = buildVault({ totalDeposits: 5000 }); const updatedVault = buildVault({ totalDeposits: 4500 }); @@ -442,12 +452,10 @@ describe('VaultsService — Yield Strategy Integration', () => { mockManager.save.mockResolvedValue(pendingWithdrawal); mockManager.decrement.mockResolvedValue(undefined); mockManager.findOne.mockResolvedValue(updatedVault); - mockWithdrawalRepository.update.mockResolvedValue(undefined); - mockWithdrawalRepository.findOne.mockResolvedValue(confirmedWithdrawal); const result = await service.withdrawFromVault(VAULT_ID, USER_ID, 500); - expect(result.withdrawal.status).toBe(WithdrawalStatus.CONFIRMED); + expect(result.withdrawal.status).toBe(WithdrawalStatus.PENDING); expect(result.withdrawal.amount).toBe(500); expect(mockDataSource.transaction).toHaveBeenCalledTimes(1); expect(mockManager.decrement).toHaveBeenCalledWith( @@ -458,27 +466,22 @@ describe('VaultsService — Yield Strategy Integration', () => { ); }); - it('should emit real-time withdrawal event to strategy subscribers', async () => { - const vault = buildVault({ totalDeposits: 5000 }); - const updatedVault = buildVault({ totalDeposits: 4500 }); - - mockVaultRepository.findOne.mockResolvedValue(vault); - mockDepositRepository.createQueryBuilder.mockReturnValue(buildQB('5000')); - mockWithdrawalRepository.create.mockReturnValue(pendingWithdrawal); - mockManager.save.mockResolvedValue(pendingWithdrawal); - mockManager.decrement.mockResolvedValue(undefined); - mockManager.findOne.mockResolvedValue(updatedVault); + it('should emit withdrawal confirmed event on successful external confirmation', async () => { + mockWithdrawalRepository.findOne + .mockResolvedValueOnce(pendingWithdrawal) + .mockResolvedValueOnce(confirmedWithdrawal); mockWithdrawalRepository.update.mockResolvedValue(undefined); - mockWithdrawalRepository.findOne.mockResolvedValue(confirmedWithdrawal); - await service.withdrawFromVault(VAULT_ID, USER_ID, 500); + await service.applyExternalWithdrawalNotification({ + withdrawalId: WITHDRAWAL_ID, + eventType: ExternalPaymentEventType.PAYMENT_CONFIRMED, + transactionHash: 'mock_withdraw_tx_123', + externalEventId: 'ext_event_123', + }); - expect(mockVaultGateway.emitWithdrawal).toHaveBeenCalledWith( - expect.objectContaining({ - vaultId: VAULT_ID, - amount: 500, - userId: USER_ID, - }), + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + DomainEventNames.WITHDRAWAL_CONFIRMED, + expect.any(WithdrawalConfirmedEvent), ); }); @@ -514,19 +517,25 @@ describe('VaultsService — Yield Strategy Integration', () => { }); it('should notify user after withdrawal is confirmed', async () => { - const vault = buildVault({ totalDeposits: 5000 }); - const updatedVault = buildVault({ totalDeposits: 4500 }); + const handler = new WithdrawalConfirmedHandler( + mockNotificationsService as any, + mockLogger as any, + mockVaultGateway as any, + mockEventEmitter as any, + ); - mockVaultRepository.findOne.mockResolvedValue(vault); - mockDepositRepository.createQueryBuilder.mockReturnValue(buildQB('5000')); - mockWithdrawalRepository.create.mockReturnValue(pendingWithdrawal); - mockManager.save.mockResolvedValue(pendingWithdrawal); - mockManager.decrement.mockResolvedValue(undefined); - mockManager.findOne.mockResolvedValue(updatedVault); - mockWithdrawalRepository.update.mockResolvedValue(undefined); - mockWithdrawalRepository.findOne.mockResolvedValue(confirmedWithdrawal); + const event = new WithdrawalConfirmedEvent( + WITHDRAWAL_ID, + USER_ID, + VAULT_ID, + 500, + 'Harvest Yield Vault', + 4500, + 'mock_withdraw_tx_123', + new Date(), + ); - await service.withdrawFromVault(VAULT_ID, USER_ID, 500); + await handler.handle(event); expect(mockNotificationsService.create).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/harvest-finance/backend/src/vaults/vaults.service.spec.ts b/harvest-finance/backend/src/vaults/vaults.service.spec.ts index 97e55e9e6..a5c5f015f 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.spec.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.spec.ts @@ -182,7 +182,7 @@ describe('VaultsService', () => { it('should sanitize the vault ID before lookup', async () => { mockVaultRepository.findOne.mockResolvedValue(mockVault); - mockSanitizer.validateUUID.mockReturnValue('vault-1'); + mockSanitizer.validateUUID.mockReturnValueOnce('vault-1'); await service.getVaultById('vault-1'); diff --git a/harvest-finance/backend/src/vaults/vaults.service.ts b/harvest-finance/backend/src/vaults/vaults.service.ts index 070362d74..dc932fe63 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.ts @@ -34,12 +34,13 @@ import { VaultApproval } from '../database/entities/vault-approval.entity'; import { User } from '../database/entities/user.entity'; import { DepositEventService } from './deposit-event.service'; import { DepositEventResponseDto } from './dto/deposit-event-response.dto'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { DepositCompletedEvent, DomainEventNames, WithdrawalConfirmedEvent, WithdrawalCompletedEvent, + PaymentReceivedEvent, } from '../domain-events'; const MAX_SAFE_DEPOSIT = 1e30; @@ -191,39 +192,16 @@ export class VaultsService { ); } - const confirmedDeposit = await this.confirmDeposit(result.deposit.id); - const userTotalDeposits = await this.getUserTotalDeposits(userId); this.logger.log( - `Deposit of ${amount} confirmed into vault ${vaultId} by user ${userId}`, + `Deposit of ${amount} initiated for vault ${vaultId} by user ${userId}`, 'VaultsService', ); - this.vaultGateway.emitDeposit({ - vaultId, - vaultName: vault.vaultName, - asset: vault.type, - amount, - userId, - newBalance: result.vault ? Number(result.vault.totalDeposits) : 0, - }); - - this.eventEmitter.emit( - DomainEventNames.DEPOSIT_COMPLETED, - new DepositCompletedEvent( - confirmedDeposit.id, - userId, - vaultId, - amount, - vault.vaultName, - result.vault ? Number(result.vault.totalDeposits) : 0, - ), - ); - return { vault: result.vault ? this.mapVaultToResponse(result.vault) : null, - deposit: this.mapDepositToResponse(confirmedDeposit), + deposit: this.mapDepositToResponse(result.deposit), userTotalDeposits, }; } @@ -1003,7 +981,7 @@ export class VaultsService { const vault = await this.getVaultById(vaultId); // Only vault owner or admin can update multi-signature config - if (vault.ownerId !== userId && !this.isCurrentUserAdmin(userId)) { + if (vault.ownerId !== userId && !(await this.isCurrentUserAdmin(userId))) { throw new UnauthorizedException('Only vault owner or admin can update multi-signature configuration'); } @@ -1030,7 +1008,7 @@ export class VaultsService { const vault = await this.getVaultById(vaultId); // Only vault owner or admin can request approvals - if (vault.ownerId !== userId && !this.isCurrentUserAdmin(userId)) { + if (vault.ownerId !== userId && !(await this.isCurrentUserAdmin(userId))) { throw new UnauthorizedException('Only vault owner or admin can request approvals'); } @@ -1127,7 +1105,7 @@ export class VaultsService { const vault = await this.getVaultById(vaultId); // Only vault owner or admin can pause vault - if (vault.ownerId !== userId && !this.isCurrentUserAdmin(userId)) { + if (vault.ownerId !== userId && !(await this.isCurrentUserAdmin(userId))) { throw new UnauthorizedException('Only vault owner or admin can pause vault'); } @@ -1149,7 +1127,7 @@ export class VaultsService { const vault = await this.getVaultById(vaultId); // Only vault owner or admin can resume vault - if (vault.ownerId !== userId && !this.isCurrentUserAdmin(userId)) { + if (vault.ownerId !== userId && !(await this.isCurrentUserAdmin(userId))) { throw new UnauthorizedException('Only vault owner or admin can resume vault'); } @@ -1176,4 +1154,98 @@ export class VaultsService { }); return user?.role === 'ADMIN'; } + + @OnEvent(DomainEventNames.PAYMENT_RECEIVED, { async: true }) + async handlePaymentReceived(event: PaymentReceivedEvent): Promise { + this.logger.log( + `Received payment event: tx=${event.transactionHash} from=${event.from} amount=${event.amount} memo=${event.memo}`, + 'VaultsService', + ); + + // Try to match the payment to a pending deposit + let deposit: Deposit | null = null; + + // 1. Try matching by memo as deposit ID if it's a valid UUID + if (event.memo && this.isValidUuid(event.memo)) { + deposit = await this.depositRepository.findOne({ + where: { id: event.memo, status: DepositStatus.PENDING }, + relations: ['vault'], + }); + } + + // 2. Try matching by user's stellar address and amount + if (!deposit) { + const user = await this.dataSource.getRepository(User).findOne({ + where: { stellarAddress: event.from }, + }); + + if (user) { + deposit = await this.depositRepository.findOne({ + where: { + userId: user.id, + amount: event.amount, + status: DepositStatus.PENDING, + }, + relations: ['vault'], + order: { createdAt: 'ASC' }, + }); + } + } + + if (!deposit) { + this.logger.warn( + `Could not match incoming payment to any pending deposit: tx=${event.transactionHash}`, + 'VaultsService', + ); + return; + } + + this.logger.log( + `Matching payment found for deposit ${deposit.id}. Confirming...`, + 'VaultsService', + ); + + // Confirm the deposit using applyExternalPaymentNotification + const { deposit: confirmedDeposit } = await this.applyExternalPaymentNotification({ + depositId: deposit.id, + eventType: ExternalPaymentEventType.PAYMENT_CONFIRMED, + transactionHash: event.transactionHash, + stellarTransactionId: event.transactionHash, + externalEventId: `stellar_stream_${event.transactionHash}`, + occurredAt: event.occurredAt, + }); + + // Retrieve updated vault state + const vault = await this.vaultRepository.findOne({ + where: { id: deposit.vaultId }, + }); + + // Notify client/realtime Gateway + this.vaultGateway.emitDeposit({ + vaultId: deposit.vaultId, + vaultName: vault ? vault.vaultName : 'Vault', + asset: vault ? vault.type : 'Asset', + amount: Number(deposit.amount), + userId: deposit.userId, + newBalance: vault ? Number(vault.totalDeposits) : 0, + }); + + // Emit DepositCompletedEvent + this.eventEmitter.emit( + DomainEventNames.DEPOSIT_COMPLETED, + new DepositCompletedEvent( + confirmedDeposit.id, + deposit.userId, + deposit.vaultId, + Number(deposit.amount), + vault ? vault.vaultName : 'Vault', + vault ? Number(vault.totalDeposits) : 0, + ), + ); + } + + private isValidUuid(val: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(val); + } }