diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index eb855dec69..5c5ce1ca41 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -462,7 +462,7 @@ describe('BitcoinClient', () => { }); }); - // --- isTxComplete() Tests (uses getRawTx/getrawtransaction) --- // + // --- isTxComplete() Tests (uses getTx/gettransaction) --- // describe('isTxComplete()', () => { it('should return true when TX has blockhash and confirmations > minConfirmations', async () => { @@ -473,10 +473,11 @@ describe('BitcoinClient', () => { }); it('should return false when TX has no blockhash (unconfirmed)', async () => { - // getrawtransaction does not require wallet unlock + // gettransaction requires wallet unlock first + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase mockRpcPost.mockImplementationOnce(() => Promise.resolve({ - result: { txid: 'txid123', confirmations: 0, vin: [], vout: [] }, + result: { txid: 'txid123', confirmations: 0, time: 0, amount: 0 }, error: null, id: 'test', }), @@ -488,11 +489,11 @@ describe('BitcoinClient', () => { }); it('should return false when TX not found', async () => { - // getrawtransaction returns error -5 for non-existent TX + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, - error: { code: -5, message: 'No such mempool or blockchain transaction' }, + error: { code: -5, message: 'Invalid or non-wallet transaction id' }, id: 'test', }), ); @@ -503,23 +504,17 @@ describe('BitcoinClient', () => { }); it('should use 0 as default minConfirmations', async () => { - mockRpcPost.mockImplementationOnce(() => - Promise.resolve({ - result: { txid: 'txid123', blockhash: '000...', confirmations: 1, vin: [], vout: [] }, - error: null, - id: 'test', - }), - ); - + // Default mock returns confirmations: 6, so 6 > 0 should be true const result = await client.isTxComplete('txid123'); expect(result).toBe(true); }); it('should return false when confirmations equals minConfirmations (boundary)', async () => { + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase mockRpcPost.mockImplementationOnce(() => Promise.resolve({ - result: { txid: 'txid123', blockhash: '000...', confirmations: 5, vin: [], vout: [] }, + result: { txid: 'txid123', blockhash: '000...', confirmations: 5, time: 0, amount: 0 }, error: null, id: 'test', }), @@ -532,9 +527,10 @@ describe('BitcoinClient', () => { }); it('should handle undefined confirmations as 0', async () => { + mockRpcPost.mockImplementationOnce(() => Promise.resolve({ result: null, error: null, id: 'test' })); // walletpassphrase mockRpcPost.mockImplementationOnce(() => Promise.resolve({ - result: { txid: 'txid123', blockhash: '000...', vin: [], vout: [] }, + result: { txid: 'txid123', blockhash: '000...', time: 0, amount: 0 }, error: null, id: 'test', }), diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index 83644b00c7..73750ca0b7 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -109,7 +109,7 @@ export abstract class BitcoinBasedClient extends NodeClient { } async isTxComplete(txId: string, minConfirmations?: number): Promise { - const transaction = await this.getRawTx(txId); + const transaction = await this.getTx(txId); return ( transaction !== null && transaction.blockhash !== undefined && diff --git a/src/integration/blockchain/clementine/clementine-client.ts b/src/integration/blockchain/clementine/clementine-client.ts index 024cbd86bd..903a161269 100644 --- a/src/integration/blockchain/clementine/clementine-client.ts +++ b/src/integration/blockchain/clementine/clementine-client.ts @@ -143,14 +143,15 @@ export class ClementineClient { // Parse the deposit address from CLI output const addressMatch = - output.match(/(?:deposit\s+)?address[:\s]+([a-zA-Z0-9]+)/i) || - output.match(/bc1[a-zA-Z0-9]{59,}/i) || - output.match(/tb1[a-zA-Z0-9]{59,}/i); + output.match(/bc1p[a-zA-Z0-9]{58}/i) || + output.match(/tb1p[a-zA-Z0-9]{58}/i) || + output.match(/bc1q[a-zA-Z0-9]{38,}/i) || + output.match(/tb1q[a-zA-Z0-9]{38,}/i); if (!addressMatch) { throw new Error(`Failed to parse deposit address from CLI output: ${output}`); } - return { depositAddress: addressMatch[1] || addressMatch[0] }; + return { depositAddress: addressMatch[0] }; } /** diff --git a/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts index 48d7792ff3..bc2785bcf5 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/clementine-bridge.adapter.ts @@ -836,6 +836,6 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter { */ private isBtcTxRelayConfirmed(txId: string): Promise { const requiredConfirmations = BITCOIN_RELAY_CONFIRMATIONS[this.network]; - return this.bitcoinClient.isTxComplete(txId, requiredConfirmations); + return this.bitcoinClient.isTxComplete(txId, requiredConfirmations - 1); } } diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index d0a8e35881..aa35f4dfe9 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -55,9 +55,11 @@ import { import { RealUnitEmailRegistrationDto, RealUnitEmailRegistrationResponseDto, + RealUnitRegisterWalletDto, RealUnitRegistrationDto, RealUnitRegistrationResponseDto, RealUnitRegistrationStatus, + RealUnitWalletStatusDto, } from '../dto/realunit-registration.dto'; import { RealUnitSellConfirmDto, RealUnitSellDto, RealUnitSellPaymentInfoDto } from '../dto/realunit-sell.dto'; import { @@ -333,6 +335,22 @@ export class RealUnitController { return this.realunitService.confirmSell(jwt.user, +id, dto); } + // --- Wallet Status Endpoint --- + + @Get('wallet/status') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get wallet status and user data', + description: + 'Returns registration status for the connected wallet and user data if available. Can be used to check registration, get data for account merge, or display user profile.', + }) + @ApiOkResponse({ type: RealUnitWalletStatusDto }) + async getWalletStatus(@GetJwt() jwt: JwtPayload): Promise { + const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } }); + return this.realunitService.getAddressWalletStatus(user.userData, jwt.address); + } + // --- Registration Endpoints --- @Get('register/status') @@ -378,7 +396,7 @@ export class RealUnitController { @ApiOkResponse({ type: RealUnitRegistrationResponseDto }) @ApiAcceptedResponse({ type: RealUnitRegistrationResponseDto, - description: 'Registration accepted, manual review needed or forwarding to Aktionariat failed', + description: 'Registration accepted or forwarding to Aktionariat failed', }) @ApiBadRequestResponse({ description: 'Invalid signature, wallet mismatch, email registration not completed, or data mismatch', @@ -396,24 +414,28 @@ export class RealUnitController { res.status(statusCode).json(response); } - @Post('register') + @Post('register/wallet') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) - @ApiOperation({ summary: 'Register for RealUnit' }) - @ApiOkResponse({ type: RealUnitRegistrationResponseDto, description: 'Registration completed successfully' }) + @ApiOperation({ + summary: 'Complete RealUnit registration for given wallet address that is already owned by a user', + description: 'Completes a registration using existing data from the wallet status endpoint with a new signature.', + }) + @ApiOkResponse({ type: RealUnitRegistrationResponseDto }) @ApiAcceptedResponse({ type: RealUnitRegistrationResponseDto, - description: 'Registration accepted, pending manual review', + description: 'Registration accepted or forwarding to Aktionariat failed', }) - @ApiBadRequestResponse({ description: 'Invalid signature or wallet does not belong to user' }) - async register(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitRegistrationDto, @Res() res: Response): Promise { - const needsReview = await this.realunitService.register(jwt.account, dto); - - const response: RealUnitRegistrationResponseDto = { - status: needsReview ? RealUnitRegistrationStatus.PENDING_REVIEW : RealUnitRegistrationStatus.COMPLETED, - }; - - res.status(needsReview ? HttpStatus.ACCEPTED : HttpStatus.OK).json(response); + @ApiBadRequestResponse({ description: 'No pending registration, invalid signature, or wallet mismatch' }) + async completeRegistrationForWalletAddress( + @GetJwt() jwt: JwtPayload, + @Body() dto: RealUnitRegisterWalletDto, + @Res() res: Response, + ): Promise { + const status = await this.realunitService.completeRegistrationForWalletAddress(jwt.account, dto); + const response: RealUnitRegistrationResponseDto = { status }; + const statusCode = status === RealUnitRegistrationStatus.COMPLETED ? HttpStatus.CREATED : HttpStatus.ACCEPTED; + res.status(statusCode).json(response); } // --- Admin Endpoints --- diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 396cbe5a5b..08a19bd8c1 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -187,3 +187,74 @@ export class RealUnitRegistrationDto extends AktionariatRegistrationDto { @Type(() => KycPersonalData) kycData: KycPersonalData; } + +export class RealUnitUserDataDto { + @ApiProperty() + email: string; + + @ApiProperty({ description: 'Full name' }) + name: string; + + @ApiProperty({ enum: RealUnitUserType }) + type: RealUnitUserType; + + @ApiProperty({ description: 'Phone number in international format' }) + phoneNumber: string; + + @ApiProperty({ description: 'Birthday in yyyy-mm-dd format' }) + birthday: string; + + @ApiProperty({ description: '2-letter country code' }) + nationality: string; + + @ApiProperty({ description: 'Street address including house number' }) + addressStreet: string; + + @ApiProperty() + addressPostalCode: string; + + @ApiProperty() + addressCity: string; + + @ApiProperty({ description: '2-letter country code' }) + addressCountry: string; + + @ApiProperty({ description: 'Whether the user has Swiss tax residence' }) + swissTaxResidence: boolean; + + @ApiProperty({ enum: RealUnitLanguage }) + lang: RealUnitLanguage; + + @ApiPropertyOptional({ type: [CountryAndTin] }) + countryAndTINs?: CountryAndTin[]; + + @ApiProperty({ type: KycPersonalData }) + kycData: KycPersonalData; +} + +export class RealUnitWalletStatusDto { + @ApiProperty({ description: 'Whether the wallet is registered for RealUnit' }) + isRegistered: boolean; + + @ApiPropertyOptional({ type: RealUnitUserDataDto, description: 'User data if available' }) + userData?: RealUnitUserDataDto; +} + +export class RealUnitRegisterWalletDto { + @ApiProperty({ description: 'Ethereum wallet address (0x...)' }) + @IsNotEmpty() + @IsString() + @Matches(/^0x[a-fA-F0-9]{40}$/, { message: 'walletAddress must be a valid Ethereum address' }) + walletAddress: string; + + @ApiProperty({ description: 'EIP-712 signature of the registration data' }) + @IsNotEmpty() + @IsString() + signature: string; + + @ApiProperty({ description: 'Registration date in yyyy-mm-dd format (must be today)' }) + @IsNotEmpty() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'registrationDate must be in yyyy-mm-dd format' }) + registrationDate: string; +} diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 47fffcddfd..8620a4b94a 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -62,9 +62,12 @@ import { AktionariatRegistrationDto, RealUnitEmailRegistrationDto, RealUnitEmailRegistrationStatus, + RealUnitRegisterWalletDto, RealUnitRegistrationDto, RealUnitRegistrationStatus, + RealUnitUserDataDto, RealUnitUserType, + RealUnitWalletStatusDto, } from './dto/realunit-registration.dto'; import { RealUnitSellConfirmDto, RealUnitSellDto, RealUnitSellPaymentInfoDto } from './dto/realunit-sell.dto'; import { @@ -352,63 +355,8 @@ export class RealUnitService { // --- Registration Methods --- - // returns true if registration needs manual review, false if completed - async register(userDataId: number, dto: RealUnitRegistrationDto): Promise { - // validate DTO - await this.validateRegistrationDto(dto); - - // get and validate user - const userData = await this.userService - .getUserByAddress(dto.walletAddress, { - userData: { kycSteps: true, users: true, country: true, organizationCountry: true }, - }) - .then((u) => u?.userData); - - if (!userData) throw new NotFoundException('User not found'); - if (userData.id !== userDataId) throw new BadRequestException('Wallet address does not belong to user'); - - if (!userData.mail) { - // Email not set yet - try to set it (will fail if email already exists for another user) - await this.userDataService.trySetUserMail(userData, dto.email); - } else if (!Util.equalsIgnoreCase(dto.email, userData.mail)) { - throw new BadRequestException('Email does not match verified email'); - } - - // duplicate check - if (userData.getNonFailedStepWith(KycStepName.REALUNIT_REGISTRATION)) { - throw new BadRequestException('RealUnit registration already exists'); - } - - // store data with internal review - const kycStep = await this.kycService.createCustomKycStep( - userData, - KycStepName.REALUNIT_REGISTRATION, - ReviewStatus.INTERNAL_REVIEW, - dto, - ); - - const hasExistingData = userData.firstname != null; - if (hasExistingData) { - const dataMatches = this.isPersonalDataMatching(userData, dto); - if (!dataMatches) { - await this.kycService.saveKycStepUpdate(kycStep.manualReview('Existing KYC data does not match')); - return true; - } - } else { - await this.userDataService.updatePersonalData(userData, dto.kycData); - } - - // update always - await this.userDataService.updateUserDataInternal(userData, { - nationality: await this.countryService.getCountryWithSymbol(dto.nationality), - birthday: new Date(dto.birthday), - language: dto.lang && (await this.languageService.getLanguageBySymbol(dto.lang)), - tin: dto.countryAndTINs?.length ? JSON.stringify(dto.countryAndTINs) : undefined, - }); - - // forward to Aktionariat - const success = await this.forwardRegistration(kycStep, dto); - return !success; + hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { + return this.findRegistrationStep(userData, walletAddress).isForCurrentWallet; } async registerEmail(userDataId: number, dto: RealUnitEmailRegistrationDto): Promise { @@ -493,6 +441,70 @@ export class RealUnitService { return RealUnitRegistrationStatus.COMPLETED; } + // --- Wallet Methods --- + + getAddressWalletStatus(userData: UserData, walletAddress: string): RealUnitWalletStatusDto { + const { step, isForCurrentWallet } = this.findRegistrationStep(userData, walletAddress); + + return { + isRegistered: isForCurrentWallet, + userData: this.toUserDataDto(step), + }; + } + + async completeRegistrationForWalletAddress( + userDataId: number, + dto: RealUnitRegisterWalletDto, + ): Promise { + const userData = await this.userService + .getUserByAddress(dto.walletAddress, { + userData: { kycSteps: true, users: true, country: true }, + }) + .then((u) => u?.userData); + + if (!userData) throw new NotFoundException('User not found'); + if (userData.id !== userDataId) throw new BadRequestException('Wallet address does not belong to user'); + + const { step: registrationStep, isForCurrentWallet } = this.findRegistrationStep(userData, dto.walletAddress); + + if (isForCurrentWallet) { + throw new BadRequestException('RealUnit registration already exists for this wallet'); + } + + if (!registrationStep) { + throw new BadRequestException('No RealUnit registration found'); + } + + const registrationData = registrationStep.getResult(); + if (!registrationData) { + throw new BadRequestException('Invalid registration data'); + } + + // full registration DTO with new signature/wallet/date + const { signature: _sig, walletAddress: _wallet, registrationDate: _date, ...accountData } = registrationData; + const fullDto: RealUnitRegistrationDto = { + ...accountData, + walletAddress: dto.walletAddress, + signature: dto.signature, + registrationDate: dto.registrationDate, + }; + + if (!this.verifyRealUnitRegistrationSignature(fullDto)) { + throw new BadRequestException('Invalid signature'); + } + + const kycStep = await this.kycService.createCustomKycStep( + userData, + KycStepName.REALUNIT_REGISTRATION, + ReviewStatus.INTERNAL_REVIEW, + fullDto, + ); + + const success = await this.forwardRegistration(kycStep, fullDto); + + return success ? RealUnitRegistrationStatus.COMPLETED : RealUnitRegistrationStatus.FORWARDING_FAILED; + } + private async validateRegistrationDto(dto: RealUnitRegistrationDto): Promise { // signature validation if (!this.verifyRealUnitRegistrationSignature(dto)) { @@ -629,14 +641,49 @@ export class RealUnitService { if (!success) throw new BadRequestException('Failed to forward registration to Aktionariat'); } - hasRegistrationForWallet(userData: UserData, walletAddress: string): boolean { - return userData - .getStepsWith(KycStepName.REALUNIT_REGISTRATION) + /** + * Finds a registration step for the user. + * First tries to find a registration for the current wallet. + * If not found, falls back to finding a registration from another wallet (for account merge scenarios). + */ + private findRegistrationStep( + userData: UserData, + walletAddress: string, + ): { step: KycStep | undefined; isForCurrentWallet: boolean } { + const allSteps = userData.getStepsWith(KycStepName.REALUNIT_REGISTRATION); + + // First: look for registration for the current wallet (non-failed, non-canceled) + const currentWalletStep = allSteps .filter((s) => !(s.isFailed || s.isCanceled)) - .some((s) => { + .find((s) => { const result = s.getResult(); return result?.walletAddress && Util.equalsIgnoreCase(result.walletAddress, walletAddress); }); + + if (currentWalletStep) { + return { step: currentWalletStep, isForCurrentWallet: true }; + } + + // Second: look for registration from another wallet (for account merge) + const otherWalletStep = allSteps + .filter((s) => (s.isCompleted || s.isCanceled) && s.result) + .find((s) => { + const result = s.getResult(); + return result?.walletAddress && !Util.equalsIgnoreCase(result.walletAddress, walletAddress); + }); + + return { step: otherWalletStep, isForCurrentWallet: false }; + } + + private toUserDataDto(step: KycStep | undefined): RealUnitUserDataDto | undefined { + if (!step) return undefined; + + const registrationData = step.getResult(); + if (!registrationData) return undefined; + + const { signature: _sig, walletAddress: _wallet, registrationDate: _date, ...userDataDto } = registrationData; + + return userDataDto as RealUnitUserDataDto; } private isPersonalDataMatching(userData: UserData, dto: RealUnitRegistrationDto): boolean {