diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 998deb3..87d8812 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,49 +1,30 @@ -## Summary +## 🔗 Related Issue +Closes #issue-number -Closes #[issue number] +--- -Briefly describe what this PR does in 2-3 sentences. +## 🔖 Title + -## This repo is for the NestJS backend API only +--- -Before submitting, confirm your changes belong here: +## 📝 Description + -- [ ] My changes are inside src/ or test/ -- [ ] I have NOT added React, React Native, - or frontend component files -- [ ] I have NOT added Rust or Soroban contract code -- [ ] This is NestJS/TypeScript backend work +--- -## Type of change +## 🔄 Changes Made + +- [ ] +- [ ] +- [ ] -- [ ] Bug fix -- [ ] New endpoint -- [ ] New service or module -- [ ] Database migration -- [ ] Background job -- [ ] Test coverage +--- -## Testing +## 📸 Screenshots (if applicable) + -- [ ] npm run build passes with zero TypeScript errors -- [ ] npm test passes — all 184+ existing tests pass -- [ ] No new `any` types introduced anywhere -- [ ] Swagger decorators added to every new endpoint -- [ ] Migration file created for any schema changes -- [ ] New unit tests written for new service methods +--- -## Context files reviewed - -- [ ] context/architecture-context.md -- [ ] context/code-standards.md -- [ ] context/progress-tracker.md updated - -## Mandatory before requesting review - -Running these must all exit 0: -npm run build -npm test - -If either fails, fix it before opening this PR. -PRs with failing CI checks will be closed without review. -PRs that reduce the test count will be rejected. +## 🗒️ Additional Notes + \ No newline at end of file diff --git a/context/progress-tracker.md b/context/progress-tracker.md index b2f2103..a55ec14 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,16 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-19 + +- Wired `LiquidityContractClient` (restored under `src/blockchain/contracts/liquidity-contract.client.ts`) into `LiquidityService` constructor. +- Read contract ID from `ConfigService` under `LIQUIDITY_POOL_CONTRACT_ID`. +- Replaced placeholder deposit/withdraw XDR strings in `LiquidityService` with real transaction simulation and assembly (`buildUnsignedXdr`). +- Mapped smart contract simulation errors (e.g., custom error codes like 100-104) to HTTP 400 (`BadRequestException`) with typed error codes. +- Added E2E test `test/e2e/liquidity.e2e-spec.ts` asserting transaction XDR parsing and contract simulation error mapping. +- Updated existing `test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts` to mock the new `LiquidityContractClient` structure. +- Updated `test/unit/modules/liquidity/liquidity.service.spec.ts` unit tests. + ## 2026-07-16 - Added wallet-bound user roles (sponsor/vendor/mentor): `role` column diff --git a/src/blockchain/blockchain.module.ts b/src/blockchain/blockchain.module.ts new file mode 100644 index 0000000..dfcf4f4 --- /dev/null +++ b/src/blockchain/blockchain.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { SorobanService } from './soroban/soroban.service'; +import { LiquidityContractClient } from './contracts/liquidity-contract.client'; + +@Module({ + imports: [ConfigModule], + providers: [SorobanService, LiquidityContractClient], + exports: [SorobanService, LiquidityContractClient], +}) +export class BlockchainModule {} diff --git a/src/blockchain/contracts/liquidity-contract.client.ts b/src/blockchain/contracts/liquidity-contract.client.ts new file mode 100644 index 0000000..cee0e02 --- /dev/null +++ b/src/blockchain/contracts/liquidity-contract.client.ts @@ -0,0 +1,412 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { SorobanService } from '../soroban/soroban.service'; + +const STROOPS = 10_000_000n; +const SHARE_PRICE_BPS = 10_000n; + +export interface PoolStats { + totalLiquidity: bigint; + lockedLiquidity: bigint; + availableLiquidity: bigint; + totalShares: bigint; + sharePrice: bigint; + withdrawalFeeBps: bigint; +} + +@Injectable() +export class LiquidityContractClient { + private readonly logger = new Logger(LiquidityContractClient.name); + private readonly contractId: string; + + constructor( + private readonly sorobanService: SorobanService, + private readonly configService: ConfigService, + ) { + this.contractId = + this.configService.get('LIQUIDITY_POOL_CONTRACT_ID') || + this.configService.get('LIQUIDITY_CONTRACT_ID') || + ''; + + if (this.contractId) { + this.logger.log(`Liquidity contract loaded: ${this.contractId.slice(0, 8)}...`); + } else { + this.logger.warn('LIQUIDITY_POOL_CONTRACT_ID is not set - liquidity contract calls will fail'); + } + } + + async getLpShares(wallet: string): Promise { + this.ensureConfigured(); + + const addressArg = StellarSdk.nativeToScVal(StellarSdk.Address.fromString(wallet), { + type: 'address', + }); + + try { + return await this.readBigInt( + ['get_lp_shares', 'get_provider_shares', 'provider_shares', 'get_shares', 'shares_of'], + [addressArg], + 'provider shares', + true, + ); + } catch (error) { + if (this.isMissingProviderError(error)) { + this.logger.debug(`No LP shares for wallet ${wallet.slice(0, 8)}...`); + return 0n; + } + throw error; + } + } + + async getPoolStats(): Promise { + this.ensureConfigured(); + + try { + const result = await this.sorobanService.simulateContractCall( + this.contractId, + 'get_pool_stats', + [], + ); + const raw = StellarSdk.scValToNative(result) as Record; + const totalLiquidity = this.toBigInt(raw['total_liquidity']); + const availableLiquidity = this.toBigInt(raw['available_liquidity']); + const totalShares = this.toBigInt(raw['total_shares']); + const lockedLiquidity = + raw['locked_liquidity'] !== undefined + ? this.toBigInt(raw['locked_liquidity']) + : totalLiquidity - availableLiquidity; + const sharePrice = + raw['share_price'] !== undefined + ? this.toBigInt(raw['share_price']) + : totalShares > 0n + ? (totalLiquidity * SHARE_PRICE_BPS) / totalShares + : 0n; + + return { + totalLiquidity, + lockedLiquidity, + availableLiquidity, + totalShares, + sharePrice, + withdrawalFeeBps: await this.getWithdrawalFeeBps(), + }; + } catch (error) { + this.logger.warn(`get_pool_stats unavailable, falling back to granular reads: ${error.message}`); + + const [totalLiquidity, availableLiquidity, totalShares, withdrawalFeeBps] = + await Promise.all([ + this.readBigInt(['get_total_liquidity', 'total_liquidity'], [], 'total liquidity'), + this.readBigInt( + ['get_available_liquidity', 'available_liquidity', 'liquid_assets'], + [], + 'available liquidity', + ), + this.readBigInt(['get_total_shares', 'total_shares'], [], 'total shares'), + this.getWithdrawalFeeBps(), + ]); + + const lockedLiquidity = totalLiquidity > availableLiquidity ? totalLiquidity - availableLiquidity : 0n; + const sharePrice = totalShares > 0n ? (totalLiquidity * SHARE_PRICE_BPS) / totalShares : 0n; + + return { + totalLiquidity, + lockedLiquidity, + availableLiquidity, + totalShares, + sharePrice, + withdrawalFeeBps, + }; + } + } + + async calculateWithdrawal(sharesInStroops: bigint): Promise { + this.ensureConfigured(); + + const sharesArg = StellarSdk.nativeToScVal(sharesInStroops, { type: 'i128' }); + + try { + return await this.readBigInt( + ['calculate_withdrawal'], + [sharesArg], + 'withdrawal preview', + ); + } catch (error) { + this.logger.warn(`calculate_withdrawal unavailable, falling back to share-price math: ${error.message}`); + const stats = await this.getPoolStats(); + if (stats.totalShares <= 0n) { + return 0n; + } + return (sharesInStroops * stats.totalLiquidity) / stats.totalShares; + } + } + + async calculateDeposit(amountInStroops: bigint): Promise { + this.ensureConfigured(); + + const amountArg = StellarSdk.nativeToScVal(amountInStroops, { type: 'i128' }); + + try { + return await this.readBigInt( + ['calculate_deposit', 'calculate_deposit_shares', 'preview_deposit'], + [amountArg], + 'deposit shares preview', + ); + } catch (error) { + this.logger.warn(`calculate_deposit unavailable, falling back to share-price math: ${error.message}`); + const stats = await this.getPoolStats(); + if (stats.totalShares <= 0n || stats.totalLiquidity <= 0n) { + // First deposit: 1:1 ratio + return amountInStroops; + } + return (amountInStroops * stats.totalShares) / stats.totalLiquidity; + } + } + + async buildUnsignedXdr( + method: 'deposit' | 'withdraw', + args: [string, number], + ): Promise { + this.ensureConfigured(); + + const [userWallet, amount] = args; + const amountInStroops = BigInt(Math.round(amount * 10_000_000)); + + try { + const contract = new StellarSdk.Contract(this.contractId); + const server = this.sorobanService.getServer(); + const networkPassphrase = this.sorobanService.getNetworkPassphrase(); + + const userArg = StellarSdk.nativeToScVal(StellarSdk.Address.fromString(userWallet), { + type: 'address', + }); + const amountArg = StellarSdk.nativeToScVal(amountInStroops, { type: 'i128' }); + + const sourceKeypair = StellarSdk.Keypair.random(); + const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call(method, userArg, amountArg)) + .setTimeout(300) + .build(); + + const simulation = await server.simulateTransaction(tx); + + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + this.logger.error(`${method} simulation failed: ${errorMsg}`); + throw new Error(errorMsg); + } + + const assembledTx = StellarSdk.SorobanRpc.assembleTransaction( + tx, + simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse, + ).build(); + + return assembledTx.toXDR(); + } catch (error) { + if (!(error instanceof Error)) { + error = new Error(String(error)); + } + this.logger.error(`Failed to build ${method} transaction: ${(error as Error).message}`); + throw error; + } + } + + async buildDepositTx(userWallet: string, amountInStroops: bigint): Promise { + this.ensureConfigured(); + + try { + const contract = new StellarSdk.Contract(this.contractId); + const server = this.sorobanService.getServer(); + const networkPassphrase = this.sorobanService.getNetworkPassphrase(); + + const userArg = StellarSdk.nativeToScVal(StellarSdk.Address.fromString(userWallet), { + type: 'address', + }); + const amountArg = StellarSdk.nativeToScVal(amountInStroops, { type: 'i128' }); + + const sourceKeypair = StellarSdk.Keypair.random(); + const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call('deposit', userArg, amountArg)) + .setTimeout(300) + .build(); + + const simulation = await server.simulateTransaction(tx); + + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + this.logger.error(`deposit simulation failed: ${errorMsg}`); + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_SIMULATION_FAILED', + message: 'Failed to simulate liquidity deposit transaction. Please try again later.', + }); + } + + const assembledTx = StellarSdk.SorobanRpc.assembleTransaction( + tx, + simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse, + ).build(); + + return assembledTx.toXDR(); + } catch (error) { + if (error instanceof ServiceUnavailableException) { + throw error; + } + + this.logger.error(`Failed to build deposit transaction: ${error.message}`); + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_TX_BUILD_FAILED', + message: 'Failed to construct liquidity deposit transaction. Please try again later.', + }); + } + } + + async buildWithdrawTx(userWallet: string, sharesInStroops: bigint): Promise { + this.ensureConfigured(); + + try { + const contract = new StellarSdk.Contract(this.contractId); + const server = this.sorobanService.getServer(); + const networkPassphrase = this.sorobanService.getNetworkPassphrase(); + + const userArg = StellarSdk.nativeToScVal(StellarSdk.Address.fromString(userWallet), { + type: 'address', + }); + const sharesArg = StellarSdk.nativeToScVal(sharesInStroops, { type: 'i128' }); + + const sourceKeypair = StellarSdk.Keypair.random(); + const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call('withdraw', userArg, sharesArg)) + .setTimeout(300) + .build(); + + const simulation = await server.simulateTransaction(tx); + + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + this.logger.error(`withdraw simulation failed: ${errorMsg}`); + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_SIMULATION_FAILED', + message: 'Failed to simulate liquidity withdrawal transaction. Please try again later.', + }); + } + + const assembledTx = StellarSdk.SorobanRpc.assembleTransaction( + tx, + simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse, + ).build(); + + return assembledTx.toXDR(); + } catch (error) { + if (error instanceof ServiceUnavailableException) { + throw error; + } + + this.logger.error(`Failed to build withdraw transaction: ${error.message}`); + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_TX_BUILD_FAILED', + message: 'Failed to construct liquidity withdrawal transaction. Please try again later.', + }); + } + } + + private async getWithdrawalFeeBps(): Promise { + const methods = ['get_withdrawal_fee_bps', 'withdrawal_fee_bps', 'get_withdraw_fee_bps']; + + for (const method of methods) { + try { + return await this.readBigInt([method], [], 'withdrawal fee', true); + } catch { + // try next name + } + } + + this.logger.warn('Withdrawal fee method not available on contract; defaulting fee to 0 bps'); + return 0n; + } + + private async readBigInt( + methods: string[], + args: StellarSdk.xdr.ScVal[], + label: string, + allowContractError = false, + ): Promise { + let lastError: unknown; + + for (const method of methods) { + try { + const result = await this.sorobanService.simulateContractCall(this.contractId, method, args); + return this.toBigInt(StellarSdk.scValToNative(result)); + } catch (error) { + lastError = error; + if (allowContractError && this.isMissingProviderError(error)) { + throw error; + } + } + } + + this.logger.error(`Failed to read ${label} from liquidity contract`, lastError as Error); + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_CONTRACT_READ_FAILED', + message: `Failed to read ${label} from the liquidity pool contract. Please try again later.`, + }); + } + + private ensureConfigured(): void { + if (!this.contractId) { + throw new ServiceUnavailableException({ + code: 'BLOCKCHAIN_CONTRACT_NOT_CONFIGURED', + message: 'Liquidity pool contract is not configured. Please contact support.', + }); + } + } + + private isMissingProviderError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('HostError') || + message.includes('Status(ContractError') || + message.includes('Error(Contract') + ); + } + + private toBigInt(value: unknown): bigint { + if (typeof value === 'bigint') { + return value; + } + + if (typeof value === 'number') { + return BigInt(Math.round(value)); + } + + if (typeof value === 'string') { + return BigInt(value); + } + + if (value && typeof value === 'object' && 'toString' in (value as object)) { + return BigInt(String(value)); + } + + throw new Error(`Unsupported contract numeric value: ${String(value)}`); + } +} diff --git a/src/modules/liquidity/liquidity.module.ts b/src/modules/liquidity/liquidity.module.ts index 851c522..78d266a 100644 --- a/src/modules/liquidity/liquidity.module.ts +++ b/src/modules/liquidity/liquidity.module.ts @@ -6,13 +6,13 @@ import { LiquidityController } from './liquidity.controller'; import { LiquidityService } from './liquidity.service'; import { AuthModule } from '../auth/auth.module'; import { SupabaseService } from '../../database/supabase.client'; -import { StellarModule } from '../../stellar/stellar.module'; +import { BlockchainModule } from '../../blockchain/blockchain.module'; @Module({ imports: [ ConfigModule, AuthModule, - StellarModule, + BlockchainModule, CacheModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/src/modules/liquidity/liquidity.service.ts b/src/modules/liquidity/liquidity.service.ts index 338df2f..c17b8ce 100644 --- a/src/modules/liquidity/liquidity.service.ts +++ b/src/modules/liquidity/liquidity.service.ts @@ -9,7 +9,7 @@ import { import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { SupabaseService } from '../../database/supabase.client'; -import { LiquidityPoolContractClient } from '../../stellar/contracts/clients/liquidity-pool.client'; +import { LiquidityContractClient } from '../../blockchain/contracts/liquidity-contract.client'; import { InvestmentSummaryResponseDto } from './dto/investment-summary-response.dto'; import { LiquidityWithdrawRequestDto } from './dto/liquidity-withdraw-request.dto'; import { LiquidityWithdrawResponseDto } from './dto/liquidity-withdraw-response.dto'; @@ -23,6 +23,29 @@ const SHARE_PRICE_BPS = 10_000n; const LP_FEE_RATIO = 0.85; const MIN_DEPOSIT_AMOUNT = 10; +const SOROBAN_ERROR_MAP: Record = { + '100': { + code: 'LIQUIDITY_INSUFFICIENT_BALANCE', + message: 'Insufficient balance to complete this operation.', + }, + '101': { + code: 'LIQUIDITY_INVALID_AMOUNT', + message: 'The requested amount is invalid or must be greater than zero.', + }, + '102': { + code: 'LIQUIDITY_POOL_LOCKED', + message: 'The liquidity pool is currently locked.', + }, + '103': { + code: 'LIQUIDITY_INSUFFICIENT_SHARES', + message: 'You do not have enough pool shares to complete this withdrawal.', + }, + '104': { + code: 'LIQUIDITY_INSUFFICIENT_AVAILABLE_LIQUIDITY', + message: 'The pool does not currently have enough liquid funds to satisfy this withdrawal.', + }, +}; + @Injectable() export class LiquidityService { private readonly logger = new Logger(LiquidityService.name); @@ -30,7 +53,7 @@ export class LiquidityService { constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly supabaseService: SupabaseService, - private readonly liquidityClient: LiquidityPoolContractClient, + private readonly liquidityClient: LiquidityContractClient, ) {} async getInvestmentSummary(wallet: string): Promise { @@ -156,7 +179,7 @@ export class LiquidityService { this.liquidityClient.calculateDeposit(amountInStroops), ]); - const unsignedXdr = await this.liquidityClient.buildDepositTx(wallet, amountInStroops); + const unsignedXdr = await this.buildDepositXdr(wallet, dto.amount); const currentTotalLiquidity = this.fromStroops(poolStats.totalLiquidity); const currentSharePrice = @@ -218,7 +241,7 @@ export class LiquidityService { const fee = (expectedAmount * poolStats.withdrawalFeeBps) / SHARE_PRICE_BPS; const netAmount = expectedAmount - fee; const remainingShares = ownedShares - requestedShares; - const unsignedXdr = await this.liquidityClient.buildWithdrawTx(wallet, requestedShares); + const unsignedXdr = await this.buildWithdrawXdr(wallet, dto.shares); return { unsignedXdr, @@ -237,6 +260,43 @@ export class LiquidityService { }; } + async buildDepositXdr(walletAddress: string, amount: number): Promise { + try { + return await this.liquidityClient.buildUnsignedXdr('deposit', [walletAddress, amount]); + } catch (error) { + this.handleContractError(error, 'deposit'); + } + } + + async buildWithdrawXdr(walletAddress: string, shares: number): Promise { + try { + return await this.liquidityClient.buildUnsignedXdr('withdraw', [walletAddress, shares]); + } catch (error) { + this.handleContractError(error, 'withdraw'); + } + } + + private handleContractError(error: unknown, operation: string): never { + const errorMsg = error instanceof Error ? error.message : String(error); + const match = errorMsg.match(/ErrorCode\((\d+)\)|Error\(Contract,\s*#?(\d+)\)|ContractError\((\d+)\)|#(\d+)/); + + if (match) { + const errorCode = match[1] || match[2] || match[3] || match[4]; + const mapped = SOROBAN_ERROR_MAP[errorCode]; + if (mapped) { + throw new BadRequestException({ + code: mapped.code, + message: mapped.message, + }); + } + } + + throw new BadRequestException({ + code: 'BLOCKCHAIN_SIMULATION_FAILED', + message: `Failed to simulate ${operation}: ${errorMsg}`, + }); + } + private async getTotalInvested(wallet: string): Promise { const client = this.supabaseService.getServiceRoleClient(); diff --git a/test/e2e/helpers/test-setup.ts b/test/e2e/helpers/test-setup.ts index e916e3b..050b5d0 100644 --- a/test/e2e/helpers/test-setup.ts +++ b/test/e2e/helpers/test-setup.ts @@ -15,6 +15,7 @@ import { SorobanService } from '../../../src/blockchain/soroban/soroban.service' import { CreditLineContractClient } from '../../../src/stellar/contracts/clients/creditline.client'; import { ReputationContractClient } from '../../../src/stellar/contracts/clients/reputation.client'; import { LiquidityPoolContractClient } from '../../../src/stellar/contracts/clients/liquidity-pool.client'; +import { LiquidityContractClient } from '../../../src/blockchain/contracts/liquidity-contract.client'; import { VendorRegistryContractClient } from '../../../src/stellar/contracts/clients/vendor-registry.client'; import { ParametersContractClient } from '../../../src/stellar/contracts/clients/parameters.client'; import { randomUUID } from 'crypto'; @@ -327,6 +328,9 @@ export async function buildTestApp(): Promise<{ getPoolStats: jest.fn().mockResolvedValue(null), getVendor: jest.fn().mockResolvedValue(null), getParameters: jest.fn().mockResolvedValue(null), + buildUnsignedXdr: jest + .fn() + .mockResolvedValue('AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='), }; const mockCacheManager = { @@ -358,6 +362,8 @@ export async function buildTestApp(): Promise<{ .useValue(mockContractClient) .overrideProvider(LiquidityPoolContractClient) .useValue(mockContractClient) + .overrideProvider(LiquidityContractClient) + .useValue(mockContractClient) .overrideProvider(VendorRegistryContractClient) .useValue(mockContractClient) .overrideProvider(ParametersContractClient) diff --git a/test/e2e/liquidity.e2e-spec.ts b/test/e2e/liquidity.e2e-spec.ts new file mode 100644 index 0000000..63af2de --- /dev/null +++ b/test/e2e/liquidity.e2e-spec.ts @@ -0,0 +1,169 @@ +jest.unmock('stellar-sdk'); +import { Test, TestingModule } from '@nestjs/testing'; +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; +import { ValidationPipe } from '@nestjs/common'; +import { LiquidityModule } from '../../src/modules/liquidity/liquidity.module'; +import { SupabaseService } from '../../src/database/supabase.client'; +import { SorobanService } from '../../src/blockchain/soroban/soroban.service'; +import { JwtAuthGuard } from '../../src/common/guards/jwt-auth.guard'; +import { LiquidityContractClient } from '../../src/blockchain/contracts/liquidity-contract.client'; +import * as StellarSdk from 'stellar-sdk'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; + +describe('Liquidity E2E with Real RPC Client Integration', () => { + let app: NestFastifyApplication; + let sorobanService: SorobanService; + let liquidityClient: LiquidityContractClient; + + const validWallet = 'GC2US6RXEJJAHSZIWB2ZNEM7CHAHWHM2ACJB42W7TNBQPZC55F53SXSH'; + const STROOPS = 10_000_000n; + + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + + const mockJwtAuthGuard = { + canActivate: jest.fn((context) => { + const req = context.switchToHttp().getRequest(); + req.user = { wallet: validWallet, role: 'sponsor' }; + return true; + }), + }; + + const mockSupabaseFrom = { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { deposited_amount: 1000 }, + error: null, + }), + }; + + const mockSupabaseClient = { + from: jest.fn().mockReturnValue(mockSupabaseFrom), + }; + + const mockSupabaseService = { + getServiceRoleClient: jest.fn().mockReturnValue(mockSupabaseClient), + getClient: jest.fn().mockReturnValue(mockSupabaseClient), + }; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-jwt-secret-for-e2e-min32chars'; + process.env.LIQUIDITY_POOL_CONTRACT_ID = 'CCBK3YMI3RVGWFUREH5PZMG3HIU3L2XF6YXB2DPFQ4V42Q4JWXPGFSMB'; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [LiquidityModule], + }) + .overrideProvider(CACHE_MANAGER) + .useValue(mockCacheManager) + .overrideProvider(SupabaseService) + .useValue(mockSupabaseService) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtAuthGuard) + .compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + sorobanService = app.get(SorobanService); + liquidityClient = app.get(LiquidityContractClient); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + it('should build valid deposit XDR when simulation succeeds', async () => { + jest.spyOn(liquidityClient, 'getPoolStats').mockResolvedValue({ + totalLiquidity: 100000n * STROOPS, + lockedLiquidity: 90000n * STROOPS, + availableLiquidity: 10000n * STROOPS, + totalShares: 95000n * STROOPS, + sharePrice: 10500n, + withdrawalFeeBps: 50n, + }); + jest.spyOn(liquidityClient, 'calculateDeposit').mockResolvedValue(4761904761n); + + // Mock simulation response containing necessary auth/results elements + const mockSuccessResponse = { + result: { + retval: StellarSdk.nativeToScVal(100n, { type: 'i128' }), + events: [], + auth: [], + }, + results: [ + { + auth: [], + }, + ], + cost: { cpuInsns: '1000', memBytes: '1000' }, + latestLedger: 12345, + transactionData: 'AAAAAAAAAAAAAAAAAAAD6AAAA+gAAAPoAAAAAAAAAGQ=', + }; + + jest.spyOn(sorobanService.getServer(), 'simulateTransaction').mockResolvedValue(mockSuccessResponse as any); + + const res = await app.inject({ + method: 'POST', + url: '/liquidity/deposit', + headers: { authorization: 'Bearer test' }, + payload: { amount: 100 }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.data.unsignedXdr).toBeDefined(); + + const tx = StellarSdk.TransactionBuilder.fromXDR( + body.data.unsignedXdr, + StellarSdk.Networks.TESTNET, + ); + expect(tx).toBeDefined(); + expect(tx).toBeInstanceOf(StellarSdk.Transaction); + const normalTx = tx as StellarSdk.Transaction; + expect(normalTx.operations).toHaveLength(1); + expect(normalTx.operations[0].type).toBe('invokeHostFunction'); + }); + + it('should return 400 when contract simulation fails with mapped error code', async () => { + jest.spyOn(liquidityClient, 'getPoolStats').mockResolvedValue({ + totalLiquidity: 100000n * STROOPS, + lockedLiquidity: 90000n * STROOPS, + availableLiquidity: 10000n * STROOPS, + totalShares: 95000n * STROOPS, + sharePrice: 10500n, + withdrawalFeeBps: 50n, + }); + jest.spyOn(liquidityClient, 'calculateDeposit').mockResolvedValue(4761904761n); + + const mockErrorResponse = { + error: 'HostError: Error(Contract, #101)', + }; + + jest.spyOn(sorobanService.getServer(), 'simulateTransaction').mockResolvedValue(mockErrorResponse as any); + + const res = await app.inject({ + method: 'POST', + url: '/liquidity/deposit', + headers: { authorization: 'Bearer test' }, + payload: { amount: 100 }, + }); + + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.payload); + expect(body.code).toBe('LIQUIDITY_INVALID_AMOUNT'); + expect(body.message).toBe('The requested amount is invalid or must be greater than zero.'); + }); +}); diff --git a/test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts b/test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts index d4dc48f..b1e9c46 100644 --- a/test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts +++ b/test/e2e/modules/liquidity/liquidity-flow.e2e-spec.ts @@ -6,7 +6,7 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify import { LiquidityModule } from '../../../../src/modules/liquidity/liquidity.module'; import { TransactionsModule } from '../../../../src/modules/transactions/transactions.module'; import { SupabaseService } from '../../../../src/database/supabase.client'; -import { LiquidityPoolContractClient } from '../../../../src/stellar/contracts/clients/liquidity-pool.client'; +import { LiquidityContractClient } from '../../../../src/blockchain/contracts/liquidity-contract.client'; import { TransactionsService } from '../../../../src/modules/transactions/transactions.service'; import { JwtAuthGuard } from '../../../../src/common/guards/jwt-auth.guard'; import { TransactionType } from '../../../../src/modules/transactions/dto/submit-transaction-request.dto'; @@ -57,7 +57,7 @@ describe('Liquidity Operations Flow (e2e)', () => { throw new UnauthorizedException('No token provided'); } - req.user = { wallet: validWallet }; + req.user = { wallet: validWallet, role: 'sponsor' }; return true; }), }; @@ -69,6 +69,7 @@ describe('Liquidity Operations Flow (e2e)', () => { getLpShares: jest.fn(), calculateWithdrawal: jest.fn(), buildWithdrawTx: jest.fn(), + buildUnsignedXdr: jest.fn(), }; const mockTransactionsService = { @@ -143,7 +144,7 @@ describe('Liquidity Operations Flow (e2e)', () => { .useValue(mockCacheManager) .overrideProvider(SupabaseService) .useValue(mockSupabaseService) - .overrideProvider(LiquidityPoolContractClient) + .overrideProvider(LiquidityContractClient) .useValue(mockLiquidityPoolContractClient) .overrideProvider(TransactionsService) .useValue(mockTransactionsService) @@ -169,8 +170,8 @@ describe('Liquidity Operations Flow (e2e)', () => { state.totalLiquidity = 1000n * STROOPS; state.totalShares = 1000n * STROOPS; - state.availableLiquidity = 1000n * STROOPS; - state.lockedLiquidity = 0n; + state.availableLiquidity = 600n * STROOPS; + state.lockedLiquidity = 400n * STROOPS; state.withdrawalFeeBps = 50n; state.userShares = 0n; state.totalInvested = 0; @@ -221,6 +222,19 @@ describe('Liquidity Operations Flow (e2e)', () => { return 'AAAAAgWITHDRAW...'; }); + mockLiquidityPoolContractClient.buildUnsignedXdr.mockImplementation(async (method, [walletAddress, amount]) => { + if (method === 'deposit') { + const amountInStroops = BigInt(Math.round(amount * 10_000_000)); + state.pendingDepositAmount = amountInStroops; + state.pendingDepositShares = amountInStroops; + return 'AAAAAgDEPOSIT...'; + } else { + const sharesInStroops = BigInt(Math.round(amount * 10_000_000)); + state.pendingWithdrawShares = sharesInStroops; + return 'AAAAAgWITHDRAW...'; + } + }); + mockTransactionsService.submitTransaction.mockImplementation(async (_wallet, dto) => { state.submittedTxCount += 1; const hash = `${String(state.submittedTxCount).padStart(2, '0')}${'b'.repeat(62)}`; diff --git a/test/unit/modules/liquidity/liquidity.service.spec.ts b/test/unit/modules/liquidity/liquidity.service.spec.ts index 3dfe06c..26255e1 100644 --- a/test/unit/modules/liquidity/liquidity.service.spec.ts +++ b/test/unit/modules/liquidity/liquidity.service.spec.ts @@ -2,13 +2,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, HttpException } from '@nestjs/common'; import { LiquidityService } from '../../../../src/modules/liquidity/liquidity.service'; -import { LiquidityPoolContractClient } from '../../../../src/stellar/contracts/clients/liquidity-pool.client'; -import { MockLiquidityPoolContractClient } from '../../../../src/stellar/contracts/mocks/liquidity-pool.mock'; +import { LiquidityContractClient } from '../../../../src/blockchain/contracts/liquidity-contract.client'; import { SupabaseService } from '../../../../src/database/supabase.client'; +class MockLiquidityContractClient { + getLpShares = jest.fn(); + getPoolStats = jest.fn(); + calculateWithdrawal = jest.fn(); + calculateDeposit = jest.fn(); + buildUnsignedXdr = jest.fn(); +} + describe('LiquidityService', () => { let service: LiquidityService; - let mockLiquidityPoolContractClient: MockLiquidityPoolContractClient; + let mockLiquidityContractClient: MockLiquidityContractClient; const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; const STROOPS = 10_000_000n; @@ -38,12 +45,12 @@ describe('LiquidityService', () => { LiquidityService, { provide: CACHE_MANAGER, useValue: mockCacheManager }, { provide: SupabaseService, useValue: mockSupabaseService }, - { provide: LiquidityPoolContractClient, useClass: MockLiquidityPoolContractClient }, + { provide: LiquidityContractClient, useClass: MockLiquidityContractClient }, ], }).compile(); service = module.get(LiquidityService); - mockLiquidityPoolContractClient = module.get(LiquidityPoolContractClient); + mockLiquidityContractClient = module.get(LiquidityContractClient); jest.clearAllMocks(); mockSupabaseService.getServiceRoleClient.mockReturnValue(mockSupabaseClient); @@ -82,7 +89,7 @@ describe('LiquidityService', () => { describe('depositLiquidity', () => { it('should build a deposit transaction and preview', async () => { - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 100000n * STROOPS, lockedLiquidity: 90000n * STROOPS, availableLiquidity: 10000n * STROOPS, @@ -90,8 +97,8 @@ describe('LiquidityService', () => { sharePrice: 10500n, withdrawalFeeBps: 50n, }); - mockLiquidityPoolContractClient.calculateDeposit.mockResolvedValue(4761904761n); - mockLiquidityPoolContractClient.buildDepositTx.mockResolvedValue('AAAAAgDEPOSIT...'); + mockLiquidityContractClient.calculateDeposit.mockResolvedValue(4761904761n); + mockLiquidityContractClient.buildUnsignedXdr.mockResolvedValue('AAAAAgDEPOSIT...'); const result = await service.depositLiquidity(validWallet, { amount: 500 }); @@ -106,14 +113,14 @@ describe('LiquidityService', () => { currentTotalLiquidity: 100000, }, }); - expect(mockLiquidityPoolContractClient.buildDepositTx).toHaveBeenCalledWith( - validWallet, - 500n * STROOPS, + expect(mockLiquidityContractClient.buildUnsignedXdr).toHaveBeenCalledWith( + 'deposit', + [validWallet, 500], ); }); it('should default share price to 1 for the first deposit', async () => { - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 0n, lockedLiquidity: 0n, availableLiquidity: 0n, @@ -121,8 +128,8 @@ describe('LiquidityService', () => { sharePrice: 0n, withdrawalFeeBps: 0n, }); - mockLiquidityPoolContractClient.calculateDeposit.mockResolvedValue(100n * STROOPS); - mockLiquidityPoolContractClient.buildDepositTx.mockResolvedValue('AAAAAgDEPOSIT...'); + mockLiquidityContractClient.calculateDeposit.mockResolvedValue(100n * STROOPS); + mockLiquidityContractClient.buildUnsignedXdr.mockResolvedValue('AAAAAgDEPOSIT...'); const result = await service.depositLiquidity(validWallet, { amount: 100 }); @@ -142,22 +149,51 @@ describe('LiquidityService', () => { }, }); - expect(mockLiquidityPoolContractClient.getPoolStats).not.toHaveBeenCalled(); + expect(mockLiquidityContractClient.getPoolStats).not.toHaveBeenCalled(); }); it('should surface pool read errors from contract client', async () => { - mockLiquidityPoolContractClient.getPoolStats.mockRejectedValue(new Error('pool unavailable')); + mockLiquidityContractClient.getPoolStats.mockRejectedValue(new Error('pool unavailable')); await expect(service.depositLiquidity(validWallet, { amount: 200 })).rejects.toThrow( 'pool unavailable', ); }); + + it('should map contract simulation error to BadRequestException (400)', async () => { + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ + totalLiquidity: 100000n * STROOPS, + lockedLiquidity: 90000n * STROOPS, + availableLiquidity: 10000n * STROOPS, + totalShares: 95000n * STROOPS, + sharePrice: 10500n, + withdrawalFeeBps: 50n, + }); + mockLiquidityContractClient.calculateDeposit.mockResolvedValue(4761904761n); + mockLiquidityContractClient.buildUnsignedXdr.mockRejectedValue( + new Error('HostError: Error(Contract, #101)'), + ); + + await expect(service.depositLiquidity(validWallet, { amount: 500 })).rejects.toThrow( + BadRequestException, + ); + + try { + await service.depositLiquidity(validWallet, { amount: 500 }); + } catch (error) { + expect((error as BadRequestException).getResponse()).toEqual( + expect.objectContaining({ + code: 'LIQUIDITY_INVALID_AMOUNT', + }), + ); + } + }); }); describe('withdrawLiquidity', () => { it('should construct an unsigned XDR and preview for a valid partial withdrawal', async () => { - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(925n * STROOPS); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(925n * STROOPS); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 100000n * STROOPS, lockedLiquidity: 98500n * STROOPS, availableLiquidity: 1500n * STROOPS, @@ -165,8 +201,8 @@ describe('LiquidityService', () => { sharePrice: 10800n, withdrawalFeeBps: 50n, }); - mockLiquidityPoolContractClient.calculateWithdrawal.mockResolvedValue(540n * STROOPS); - mockLiquidityPoolContractClient.buildWithdrawTx.mockResolvedValue('AAAAAgAAAAA...'); + mockLiquidityContractClient.calculateWithdrawal.mockResolvedValue(540n * STROOPS); + mockLiquidityContractClient.buildUnsignedXdr.mockResolvedValue('AAAAAgAAAAA...'); const result = await service.withdrawLiquidity(validWallet, { shares: 500 }); @@ -185,9 +221,9 @@ describe('LiquidityService', () => { availableLiquidity: 1500, }, }); - expect(mockLiquidityPoolContractClient.buildWithdrawTx).toHaveBeenCalledWith( - validWallet, - 500n * STROOPS, + expect(mockLiquidityContractClient.buildUnsignedXdr).toHaveBeenCalledWith( + 'withdraw', + [validWallet, 500], ); }); @@ -196,13 +232,13 @@ describe('LiquidityService', () => { BadRequestException, ); - expect(mockLiquidityPoolContractClient.getLpShares).not.toHaveBeenCalled(); - expect(mockLiquidityPoolContractClient.getPoolStats).not.toHaveBeenCalled(); + expect(mockLiquidityContractClient.getLpShares).not.toHaveBeenCalled(); + expect(mockLiquidityContractClient.getPoolStats).not.toHaveBeenCalled(); }); it('should reject withdrawals above the user share balance', async () => { - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(4999999999n); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(4999999999n); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 100000n * STROOPS, lockedLiquidity: 90000n * STROOPS, availableLiquidity: 10000n * STROOPS, @@ -217,8 +253,8 @@ describe('LiquidityService', () => { }); it('should fail gracefully when pool available liquidity is insufficient', async () => { - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 100000n * STROOPS, lockedLiquidity: 99600n * STROOPS, availableLiquidity: 400n * STROOPS, @@ -226,7 +262,7 @@ describe('LiquidityService', () => { sharePrice: 10800n, withdrawalFeeBps: 0n, }); - mockLiquidityPoolContractClient.calculateWithdrawal.mockResolvedValue(540n * STROOPS); + mockLiquidityContractClient.calculateWithdrawal.mockResolvedValue(540n * STROOPS); await expect(service.withdrawLiquidity(validWallet, { shares: 500 })).rejects.toThrow( HttpException, @@ -246,8 +282,8 @@ describe('LiquidityService', () => { }); it('should support zero configured withdrawal fees', async () => { - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 100000n * STROOPS, lockedLiquidity: 90000n * STROOPS, availableLiquidity: 10000n * STROOPS, @@ -255,8 +291,8 @@ describe('LiquidityService', () => { sharePrice: 10000n, withdrawalFeeBps: 0n, }); - mockLiquidityPoolContractClient.calculateWithdrawal.mockResolvedValue(2505000000n); - mockLiquidityPoolContractClient.buildWithdrawTx.mockResolvedValue('AAAAAgAAAAA...'); + mockLiquidityContractClient.calculateWithdrawal.mockResolvedValue(2505000000n); + mockLiquidityContractClient.buildUnsignedXdr.mockResolvedValue('AAAAAgAAAAA...'); const result = await service.withdrawLiquidity(validWallet, { shares: 250.5 }); @@ -270,7 +306,7 @@ describe('LiquidityService', () => { describe('getPoolOverview', () => { it('should compute overview metrics from contract and database state', async () => { mockCacheManager.get.mockResolvedValue(undefined); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 2000n * STROOPS, lockedLiquidity: 500n * STROOPS, availableLiquidity: 1500n * STROOPS, @@ -321,7 +357,7 @@ describe('LiquidityService', () => { it('should use contract fallback and return zero utilization when pool stats fail', async () => { mockCacheManager.get.mockResolvedValue(undefined); - mockLiquidityPoolContractClient.getPoolStats.mockRejectedValue(new Error('rpc down')); + mockLiquidityContractClient.getPoolStats.mockRejectedValue(new Error('rpc down')); mockSupabaseClient.from.mockImplementation((table: string) => { if (table === 'loans') { @@ -374,15 +410,15 @@ describe('LiquidityService', () => { const result = await service.getInvestmentSummary(validWallet); expect(result).toEqual(cached); - expect(mockLiquidityPoolContractClient.getLpShares).not.toHaveBeenCalled(); + expect(mockLiquidityContractClient.getLpShares).not.toHaveBeenCalled(); expect(mockSupabaseService.getServiceRoleClient).not.toHaveBeenCalled(); }); it('should compute earnings and percentage from pool and deposit data', async () => { mockCacheManager.get.mockResolvedValue(undefined); - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); - mockLiquidityPoolContractClient.calculateWithdrawal.mockResolvedValue(1100n * STROOPS); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(1000n * STROOPS); + mockLiquidityContractClient.calculateWithdrawal.mockResolvedValue(1100n * STROOPS); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 2000n * STROOPS, lockedLiquidity: 700n * STROOPS, availableLiquidity: 1300n * STROOPS, @@ -440,8 +476,8 @@ describe('LiquidityService', () => { it('should return zeroed values when user has no deposits and no active loans', async () => { mockCacheManager.get.mockResolvedValue(undefined); - mockLiquidityPoolContractClient.getLpShares.mockResolvedValue(0n); - mockLiquidityPoolContractClient.getPoolStats.mockResolvedValue({ + mockLiquidityContractClient.getLpShares.mockResolvedValue(0n); + mockLiquidityContractClient.getPoolStats.mockResolvedValue({ totalLiquidity: 0n, lockedLiquidity: 0n, availableLiquidity: 0n,