Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
233 changes: 233 additions & 0 deletions lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'dart:convert' show base64Encode;
import 'dart:math' show pow;
import 'dart:typed_data' show Uint8List;

import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:bb_mobile/core/exchange/data/models/dca_model.dart';
Expand All @@ -24,6 +26,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
final _ordersPath = '/ak/api-orders';
final _orderTriggerPath = '/ak/api-ordertrigger';
final _recipientsPath = '/ak/api-recipients';
final _kycPath = '/ak/api-kyc';
final _messagesPath = '/ak/api-commcenter';

BullbitcoinApiDatasource({required Dio bullbitcoinApiHttpClient})
Expand Down Expand Up @@ -585,6 +588,236 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
return resp.data['result'] as Map<String, dynamic>;
}

// ==================== Recipients API ====================

/// List recipients with optional filters for default wallets
Future<List<Map<String, dynamic>>> listMyRecipients({
Comment thread
mocodesmo marked this conversation as resolved.
required String apiKey,
List<String>? recipientTypes,
bool? isDefault,
}) async {
final filters = <String, dynamic>{};
if (recipientTypes != null) {
filters['recipientTypes'] = recipientTypes;
}
if (isDefault != null) {
filters['isDefault'] = isDefault;
}

final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'listMyRecipients',
'params': {'filters': filters},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to list recipients');
}

final error = resp.data['error'];
if (error != null) {
throw Exception('Failed to list recipients: $error');
}

final elements = resp.data['result']['elements'] as List<dynamic>?;
if (elements == null) return [];
return elements.cast<Map<String, dynamic>>();
}

/// Create a new recipient (default wallet)
Future<Map<String, dynamic>> createMyRecipient({
required String apiKey,
required String recipientType,
required String address,
required bool isOwner,
required bool isDefault,
}) async {
final recipientDetails = _buildRecipientDetails(recipientType, address);

final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'createMyRecipient',
'params': {
'element': {
'recipientType': recipientType,
'isOwner': isOwner,
'isDefault': isDefault,
...recipientDetails,
},
},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to create recipient');
}

final error = resp.data['error'];
if (error != null) {
final message = error['message'] ?? 'Unknown error';
throw Exception('Failed to create recipient: $message');
}

return resp.data['result']['element'] as Map<String, dynamic>;
}

/// Update an existing recipient
Future<Map<String, dynamic>> updateMyRecipient({
required String apiKey,
required String recipientId,
required String recipientType,
String? address,
bool? isDefault,
bool isOwner = true,
}) async {
final element = <String, dynamic>{
'recipientId': recipientId,
'recipientType': recipientType,
'isOwner': isOwner,
};

if (address != null) {
// For updates, use the generic 'address' field (not type-specific fields)
element['address'] = address;
}
if (isDefault != null) {
element['isDefault'] = isDefault;
}

final resp = await _http.post(
_recipientsPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'updateMyRecipient',
'params': {'element': element},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to update recipient');
}

final error = resp.data['error'];
if (error != null) {
final message = error['message'] ?? 'Unknown error';
throw Exception('Failed to update recipient: $message');
}

return resp.data['result']['element'] as Map<String, dynamic>;
}

Map<String, dynamic> _buildRecipientDetails(
String recipientType,
String address,
) {
// For OUT_BITCOIN_ADDRESS, OUT_LIGHTNING_ADDRESS, OUT_LIQUID_ADDRESS,
// the API expects the generic 'address' field, not type-specific fields
switch (recipientType) {
case 'OUT_BITCOIN_ADDRESS':
case 'OUT_LIGHTNING_ADDRESS':
case 'OUT_LIQUID_ADDRESS':
return {'address': address};
default:
return {'address': address};
}
}

// ==================== Order Stats API ====================

/// Get order statistics for the user
Future<Map<String, dynamic>> getOrderStats({required String apiKey}) async {
final resp = await _http.post(
_ordersPath,
data: {
'jsonrpc': '2.0',
'id': '0',
'method': 'getOrderStats',
'params': {},
},
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('Failed to get order stats');
}

final error = resp.data['error'];
if (error != null) {
throw Exception('Failed to get order stats: $error');
}

return resp.data['result']['element'] as Map<String, dynamic>;
}

// ==================== KYC Upload API ====================

/// Upload a KYC document file using base64 encoding (same approach as chat)
Future<void> uploadKycDocument({
required String apiKey,
required List<int> fileBytes,
required String fileName,
required String docType,
required String sourceDetail,
}) async {
// Determine file type from extension
final extension = fileName.split('.').last.toLowerCase();
final fileType = switch (extension) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'pdf' => 'application/pdf',
'gif' => 'image/gif',
'webp' => 'image/webp',
_ => 'application/octet-stream',
};

final requestData = {
'jsonrpc': '2.0',
'id': '1',
'method': 'createMyKYCIDDocument',
'params': {
'element': {
'idTypeCode': docType,
'sourceDetail': sourceDetail,
'fileName': fileName,
'fileType': fileType,
'fileSize': fileBytes.length,
'fileData': base64Encode(Uint8List.fromList(fileBytes)),
},
},
};

final resp = await _http.post(
_kycPath,
data: requestData,
options: Options(headers: {'X-API-Key': apiKey}),
);

if (resp.statusCode != 200) {
throw Exception('File upload failed with status: ${resp.statusCode}');
}

final responseData = resp.data as Map<String, dynamic>?;
if (responseData != null) {
final error = responseData['error'];
if (error != null) {
throw Exception('File upload failed: $error');
}
}
}

// ==================== Announcements API ====================

Future<List<Map<String, dynamic>>> listAnnouncements({
required String apiKey,
}) async {
Expand Down
1 change: 1 addition & 0 deletions lib/core/exchange/data/mappers/user_summary_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class UserSummaryMapper {
currency: model.currency,
dca: model.dca.toEntity(),
autoBuy: _mapUserAutoBuy(model.autoBuy),
emailNotificationsEnabled: model.emailNotificationsEnabled,
);
}

Expand Down
75 changes: 75 additions & 0 deletions lib/core/exchange/data/models/kyc_upload_model.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:bb_mobile/core/exchange/domain/entity/file_upload.dart';

/// Model for KYC document upload request
class KycUploadRequestModel {
final String fileName;
final List<int> fileBytes;
final KycDocType docType;
final KycSourceDetail sourceDetail;

const KycUploadRequestModel({
required this.fileName,
required this.fileBytes,
required this.docType,
required this.sourceDetail,
});

String get docTypeValue {
switch (docType) {
case KycDocType.id:
return 'ID';
case KycDocType.proofOfAddress:
return 'PROOF_OF_ADDRESS';
case KycDocType.other:
return 'OTHER';
}
}

String get sourceDetailValue {
switch (sourceDetail) {
case KycSourceDetail.secureUpload:
return 'SECURE_UPLOAD';
case KycSourceDetail.kyc:
return 'KYC';
}
}
}

/// Enum for KYC document types
enum KycDocType {
id,
proofOfAddress,
other,
}

/// Enum for KYC source details
enum KycSourceDetail {
secureUpload,
kyc,
}

/// Model for KYC upload response
class KycUploadResponseModel {
final String? documentId;
final String? status;

const KycUploadResponseModel({
this.documentId,
this.status,
});

factory KycUploadResponseModel.fromJson(Map<String, dynamic> json) {
return KycUploadResponseModel(
documentId: json['documentId'] as String?,
status: json['status'] as String?,
);
}

FileUploadResult toEntity() {
return FileUploadResult(
documentId: documentId,
isSuccess: status == 'SUCCESS' || documentId != null,
);
}
}

Loading