Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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',
}),
Expand All @@ -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',
}),
);
Expand All @@ -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',
}),
Expand All @@ -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',
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export abstract class BitcoinBasedClient extends NodeClient {
}

async isTxComplete(txId: string, minConfirmations?: number): Promise<boolean> {
const transaction = await this.getRawTx(txId);
const transaction = await this.getTx(txId);
return (
transaction !== null &&
transaction.blockhash !== undefined &&
Expand Down
9 changes: 5 additions & 4 deletions src/integration/blockchain/clementine/clementine-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,6 @@ export class ClementineBridgeAdapter extends LiquidityActionAdapter {
*/
private isBtcTxRelayConfirmed(txId: string): Promise<boolean> {
const requiredConfirmations = BITCOIN_RELAY_CONFIRMATIONS[this.network];
return this.bitcoinClient.isTxComplete(txId, requiredConfirmations);
return this.bitcoinClient.isTxComplete(txId, requiredConfirmations - 1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<RealUnitWalletStatusDto> {
const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true } });
return this.realunitService.getAddressWalletStatus(user.userData, jwt.address);
}

// --- Registration Endpoints ---

@Get('register/status')
Expand Down Expand Up @@ -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',
Expand All @@ -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<void> {
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<void> {
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 ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading