feat(shopinbit): add ShopInBit API client and service layer
What changed, and why it matters
This commit adds a new feature: a Dart client and service layer for integrating with the ShopInBit API inside the Stack Wallet app. It lets the wallet talk to ShopInBit to create support tickets, upload messages/attachments, manage vouchers, handle car-research invoices, register push notifications, and verify webhooks. The code also adds generic HTTP PATCH and DELETE helpers. From the diff alone there is no clear security bug, but several design choices are worth reviewing: the service defaults to sandbox mode, customer keys are stored in the app's general preferences box, and attachment URLs can optionally put authentication tokens in query parameters where they may leak in logs or referrers.
Treat this as a feature addition requiring a security design review rather than an incident. Verify that useQueryAuth is only used where headers are truly impossible and that resulting URLs are not logged or cached. Move the customer key from the general Hive prefs box to encrypted/secure storage if the platform supports it. Confirm sandbox: true default is intentional and cannot reach production endpoints accidentally. Add request/connection timeouts to the new HTTP methods. Ensure external_api_keys.dart is excluded from release builds and that partner secrets are not embedded in shipped binaries. Review how attachment paths are validated before being appended to /attachment-proxy URLs.
Security signals we found
New network client with bearer-token authentication and customer-key header
Optional query-parameter authentication for attachment URLs may expose secrets in logs, browser history, or Referer headers
Customer key stored in general preferences box (DB.boxNamePrefs) rather than a dedicated secure store
Service singleton defaults to sandbox: true and production baseUrl unless overridden
Webhook verifier uses HMAC-SHA256 with constant-time comparison and timestamp tolerance
HTTP PATCH/DELETE helpers added without explicit timeout handling visible in the diff
Partner access key and secret are compile-time constants in external_api_keys.dart
Evidence from the diff
The patch introduces lib/services/shopinbit/ containing a client, token manager, service singleton, webhook HMAC verifier, and barrel exports. HTTP gets patch() and delete() methods using dart:io HttpClient with optional SOCKS proxy via SocksTCPClient. ShopInBitClient authenticates with accessKey/partnerSecret to /token, caches the JWT, and exposes endpoints for tickets, messages, attachments, addresses, payments, vouchers, car-research invoices, push subscriptions, webhooks, and sandbox state manipulation. ShopInBitService is a singleton that lazily creates the client with sandbox: true, generates/loads/stores an externalCustomerKey in DB.boxNamePrefs, and exposes it to the UI. WebhookVerifier implements Stripe-style t,v1 HMAC-SHA256 with constant-time compare. Notable from diff: getAttachmentUrl can append token and customer_key as URL query params (useQueryAuth); the service starts in sandbox mode; prebuild.sh adds empty template constants for the partner credentials.
Changed components
lib/networking/http.dartlib/services/shopinbit/shopinbit_api.dartlib/services/shopinbit/shopinbit_service.dartlib/services/shopinbit/src/client.dartlib/services/shopinbit/src/token_manager.dartlib/services/shopinbit/src/webhook_verifier.dartscripts/prebuild.shInspect captured patch +930 / −1
diff --git a/lib/networking/http.dart b/lib/networking/http.dart
index 4771a10..efa997e 100644
--- a/lib/networking/http.dart
+++ b/lib/networking/http.dart
@@ -87,6 +87,65 @@ class HTTP {
}
}
+ Future<Response> patch({
+ required Uri url,
+ Map<String, String>? headers,
+ Object? body,
+ required ({InternetAddress host, int port})? proxyInfo,
+ }) async {
+ final httpClient = HttpClient();
+ try {
+ if (proxyInfo != null) {
+ SocksTCPClient.assignToHttpClient(httpClient, [
+ ProxySettings(proxyInfo.host, proxyInfo.port),
+ ]);
+ }
+ final HttpClientRequest request = await httpClient.patchUrl(url);
+
+ if (headers != null) {
+ headers.forEach((key, value) => request.headers.add(key, value));
+ }
+
+ request.write(body);
+
+ final response = await request.close();
+ return Response(await _bodyBytes(response), response.statusCode);
+ } catch (e, s) {
+ Logging.instance.w("HTTP.patch() rethrew: ", error: e, stackTrace: s);
+ rethrow;
+ } finally {
+ httpClient.close(force: true);
+ }
+ }
+
+ Future<Response> delete({
+ required Uri url,
+ Map<String, String>? headers,
+ required ({InternetAddress host, int port})? proxyInfo,
+ }) async {
+ final httpClient = HttpClient();
+ try {
+ if (proxyInfo != null) {
+ SocksTCPClient.assignToHttpClient(httpClient, [
+ ProxySettings(proxyInfo.host, proxyInfo.port),
+ ]);
+ }
+ final HttpClientRequest request = await httpClient.deleteUrl(url);
+
+ if (headers != null) {
+ headers.forEach((key, value) => request.headers.add(key, value));
+ }
+
+ final response = await request.close();
+ return Response(await _bodyBytes(response), response.statusCode);
+ } catch (e, s) {
+ Logging.instance.w("HTTP.delete() rethrew: ", error: e, stackTrace: s);
+ rethrow;
+ } finally {
+ httpClient.close(force: true);
+ }
+ }
+
Future<Uint8List> _bodyBytes(HttpClientResponse response) {
final completer = Completer<Uint8List>();
final List<int> bytes = [];
diff --git a/lib/services/shopinbit/shopinbit_api.dart b/lib/services/shopinbit/shopinbit_api.dart
new file mode 100644
index 0000000..fd1f12c
--- /dev/null
+++ b/lib/services/shopinbit/shopinbit_api.dart
@@ -0,0 +1,7 @@
+export 'src/client.dart';
+export 'src/token_manager.dart';
+export 'src/api_response.dart';
+export 'src/api_exception.dart';
+export 'src/webhook_verifier.dart';
+export 'src/endpoints.dart';
+export 'src/models/models.dart';
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
new file mode 100644
index 0000000..279f0b1
--- /dev/null
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -0,0 +1,70 @@
+import '../../db/hive/db.dart';
+import '../../external_api_keys.dart';
+import '../../utilities/logger.dart';
+import 'src/client.dart';
+
+class ShopInBitService {
+ static final instance = ShopInBitService._();
+ ShopInBitService._();
+
+ ShopInBitClient? _client;
+ String? _customerKey;
+
+ ShopInBitClient get client {
+ return _client ??= ShopInBitClient(
+ accessKey: kShopInBitAccessKey,
+ partnerSecret: kShopInBitPartnerSecret,
+ sandbox: true,
+ );
+ }
+
+ String? get customerKey => _customerKey;
+
+ Future<String> ensureCustomerKey() async {
+ if (_customerKey != null) return _customerKey!;
+ _customerKey =
+ DB.instance.get<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "shopInBitCustomerKey",
+ )
+ as String?;
+ if (_customerKey != null) {
+ Logging.instance.t("ShopInBitService: loaded customer key from DB");
+ client.externalCustomerKey = _customerKey;
+ return _customerKey!;
+ }
+ Logging.instance.i("ShopInBitService: generating new customer key");
+ final resp = await client.generateKey();
+ _customerKey = resp.valueOrThrow;
+ client.externalCustomerKey = _customerKey;
+ await DB.instance.put<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "shopInBitCustomerKey",
+ value: _customerKey,
+ );
+ Logging.instance.i("ShopInBitService: customer key stored");
+ return _customerKey!;
+ }
+
+ Future<void> setCustomerKey(String key) async {
+ _customerKey = key;
+ client.externalCustomerKey = key;
+ await DB.instance.put<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "shopInBitCustomerKey",
+ value: key,
+ );
+ Logging.instance.i("ShopInBitService: customer key manually set");
+ }
+
+ Future<void> clearCustomerKey() async {
+ _customerKey = null;
+ client.externalCustomerKey = null;
+ await DB.instance.put<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "shopInBitCustomerKey",
+ value: null,
+ );
+ Logging.instance.i("ShopInBitService: customer key cleared");
+ }
+}
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
new file mode 100644
index 0000000..16f7013
--- /dev/null
+++ b/lib/services/shopinbit/src/client.dart
@@ -0,0 +1,642 @@
+import 'dart:convert';
+import 'dart:io';
+
+import '../../../app_config.dart';
+import '../../../networking/http.dart';
+import '../../../utilities/logger.dart';
+import '../../../utilities/prefs.dart';
+import '../../tor_service.dart';
+import 'api_exception.dart';
+import 'api_response.dart';
+import 'endpoints.dart';
+import 'token_manager.dart';
+import 'models/address.dart';
+import 'models/car_research.dart';
+import 'models/message.dart';
+import 'models/payment.dart';
+import 'models/ticket.dart';
+import 'models/voucher.dart';
+
+const _kTag = "ShopInBitClient";
+
+class ShopInBitClient {
+ final String accessKey;
+ final String partnerSecret;
+ final String baseUrl;
+ final bool sandbox;
+ final HTTP _httpClient;
+ final TokenManager _tokenManager;
+
+ String? _externalCustomerKey;
+
+ String? get externalCustomerKey => _externalCustomerKey;
+ set externalCustomerKey(String? key) => _externalCustomerKey = key;
+
+ ShopInBitClient({
+ required this.accessKey,
+ required this.partnerSecret,
+ this.baseUrl = Endpoints.production,
+ this.sandbox = false,
+ String? externalCustomerKey,
+ HTTP? httpClient,
+ }) : _externalCustomerKey = externalCustomerKey,
+ _httpClient = httpClient ?? const HTTP(),
+ _tokenManager = TokenManager(
+ accessKey: accessKey,
+ partnerSecret: partnerSecret,
+ baseUrl: baseUrl,
+ httpClient: httpClient,
+ );
+
+ // -- Auth --
+
+ Future<ApiResponse<void>> authenticate() async {
+ try {
+ await _tokenManager.getValidToken();
+ return ApiResponse();
+ } on ApiException catch (e) {
+ return ApiResponse(exception: e);
+ } catch (e) {
+ return ApiResponse(exception: ApiException('Authentication failed: $e'));
+ }
+ }
+
+ // -- Utility --
+
+ Future<ApiResponse<String>> generateKey() async {
+ return _request(
+ 'GET',
+ '/generate-key',
+ needsCustomerKey: false,
+ parse: (json) {
+ return json['external_customer_key'] as String;
+ },
+ );
+ }
+
+ Future<ApiResponse<Map<String, dynamic>>> getHealth() async {
+ return _request(
+ 'GET',
+ '/health',
+ needsCustomerKey: false,
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<List<Map<String, dynamic>>>> getCountries() async {
+ return _requestRaw(
+ 'GET',
+ '/meta/countries',
+ needsCustomerKey: false,
+ parse: (body) {
+ final decoded = jsonDecode(body);
+ if (decoded is List) {
+ return decoded.cast<Map<String, dynamic>>();
+ }
+ return [decoded as Map<String, dynamic>];
+ },
+ );
+ }
+
+ // -- Tickets --
+
+ Future<ApiResponse<TicketRef>> createRequest({
+ required String customerPseudonym,
+ required String externalCustomerKey,
+ required String serviceType,
+ required String comment,
+ required String deliveryCountry,
+ String? voucherCode,
+ }) async {
+ return _request(
+ 'POST',
+ '/requests',
+ body: {
+ 'customer_pseudonym': customerPseudonym,
+ 'external_customer_key': externalCustomerKey,
+ 'service_type': serviceType,
+ 'comment': comment,
+ 'delivery_country': deliveryCountry,
+ if (voucherCode != null) 'voucher_code': voucherCode,
+ },
+ parse: (json) {
+ return TicketRef(
+ id: json['ticket_id'] is int
+ ? json['ticket_id'] as int
+ : int.parse(json['ticket_id'].toString()),
+ number: json['ticket_number'].toString(),
+ );
+ },
+ );
+ }
+
+ Future<ApiResponse<TicketStatus>> getTicketStatus(int ticketId) async {
+ return _request(
+ 'GET',
+ '/tickets/$ticketId/status',
+ parse: TicketStatus.fromJson,
+ );
+ }
+
+ Future<ApiResponse<TicketFull>> getTicketFull(int ticketId) async {
+ return _request(
+ 'GET',
+ '/tickets/$ticketId/full',
+ parse: TicketFull.fromJson,
+ );
+ }
+
+ Future<ApiResponse<List<TicketRef>>> getTicketsByCustomer(
+ String customerKey,
+ ) async {
+ return _request(
+ 'GET',
+ '/tickets/by-customer/$customerKey',
+ parse: (json) {
+ final list = json['tickets'] as List<dynamic>;
+ return list
+ .map((e) => TicketRef.fromJson(e as Map<String, dynamic>))
+ .toList();
+ },
+ );
+ }
+
+ // -- Messages --
+
+ Future<ApiResponse<Map<String, dynamic>>> sendMessage(
+ int ticketId,
+ String message,
+ ) async {
+ return _request(
+ 'POST',
+ '/tickets/$ticketId/messages',
+ body: {'message': message},
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<List<TicketMessage>>> getMessages(int ticketId) async {
+ return _request(
+ 'GET',
+ '/tickets/$ticketId/messages',
+ parse: (json) {
+ final list = json['messages'] as List<dynamic>;
+ return list
+ .map((e) => TicketMessage.fromJson(e as Map<String, dynamic>))
+ .toList();
+ },
+ );
+ }
+
+ // -- Attachments --
+
+ Future<ApiResponse<Map<String, dynamic>>> sendAttachments(
+ int ticketId, {
+ required String message,
+ required List<Map<String, String>> attachments,
+ }) async {
+ return _request(
+ 'POST',
+ '/tickets/$ticketId/attachments',
+ body: {'message': message, 'attachments': attachments},
+ parse: (json) => json,
+ );
+ }
+
+ /// Build a URL for fetching an attachment via `/attachment-proxy/<path>`.
+ ///
+ /// For use in HTTP clients that can set headers, use the returned URL with
+ /// the standard Authorization + External-Customer-Key headers.
+ /// For inline images (e.g. in HTML where headers can't be set), pass
+ /// [useQueryAuth] = true to append token and customer_key as query params.
+ Future<ApiResponse<Uri>> getAttachmentUrl(
+ String attachmentPath, {
+ bool useQueryAuth = false,
+ }) async {
+ try {
+ final token = await _tokenManager.getValidToken();
+ final resolved = _resolvePath('/attachment-proxy/$attachmentPath');
+ var uri = Uri.parse('$baseUrl$resolved');
+ if (useQueryAuth) {
+ uri = uri.replace(
+ queryParameters: {
+ 'token': token,
+ if (_externalCustomerKey != null)
+ 'customer_key': _externalCustomerKey!,
+ },
+ );
+ }
+ return ApiResponse(value: uri);
+ } on ApiException catch (e) {
+ return ApiResponse(exception: e);
+ } catch (e) {
+ return ApiResponse(exception: ApiException.network(e));
+ }
+ }
+
+ /// Download an attachment from `/attachment-proxy/<path>`.
+ Future<ApiResponse<Response>> getAttachment(String attachmentPath) async {
+ try {
+ final token = await _tokenManager.getValidToken();
+ final resolved = _resolvePath('/attachment-proxy/$attachmentPath');
+ final uri = Uri.parse('$baseUrl$resolved');
+ Logging.instance.t("$_kTag GET $uri");
+ final headers = _headers(token);
+ final response = await _httpClient.get(
+ url: uri,
+ headers: headers,
+ proxyInfo: _proxyInfo,
+ );
+ if (response.code >= 200 && response.code < 300) {
+ return ApiResponse(value: response);
+ } else {
+ Logging.instance.w(
+ "$_kTag GET $resolved HTTP:${response.code} "
+ "body: ${response.body}",
+ );
+ return ApiResponse(
+ exception: ApiException.fromResponse(response.code, response.body),
+ );
+ }
+ } on ApiException catch (e) {
+ Logging.instance.e(
+ "$_kTag getAttachment($attachmentPath) threw: ",
+ error: e,
+ );
+ return ApiResponse(exception: e);
+ } catch (e, s) {
+ Logging.instance.e(
+ "$_kTag getAttachment($attachmentPath) threw: ",
+ error: e,
+ stackTrace: s,
+ );
+ return ApiResponse(exception: ApiException.network(e));
+ }
+ }
+
+ // -- Address --
+
+ Future<ApiResponse<Map<String, dynamic>>> submitAddress(
+ int ticketId, {
+ required Address shipping,
+ Address? billing,
+ }) async {
+ return _request(
+ 'POST',
+ '/tickets/$ticketId/address',
+ body: {'shipping': shipping.toJson(), 'billing': billing?.toJson()},
+ parse: (json) => json,
+ );
+ }
+
+ // -- Payment --
+
+ Future<ApiResponse<PaymentInfo>> getPayment(
+ int ticketId, {
+ bool retry = false,
+ }) async {
+ final path = '/tickets/$ticketId/payment';
+ final query = retry ? {'retry': 'true'} : null;
+ return _request('GET', path, query: query, parse: PaymentInfo.fromJson);
+ }
+
+ // -- Vouchers --
+
+ /// Pre-check a voucher code (does not consume usage or create a ticket).
+ Future<ApiResponse<VoucherInfo>> checkVoucher(String code) async {
+ return _request(
+ 'GET',
+ '/vouchers/validate',
+ query: {'code': code},
+ parse: VoucherInfo.fromJson,
+ );
+ }
+
+ /// Redeem a VIP voucher (creates ticket in one call). VIP/VIP_PRIORITY only.
+ Future<ApiResponse<VipRedemptionResult>> redeemVipVoucher({
+ required String voucherCode,
+ required String customerPseudonym,
+ required String serviceType,
+ required String comment,
+ String? deliveryCountry,
+ }) async {
+ return _request(
+ 'POST',
+ '/vouchers/validate',
+ body: {
+ 'voucher_code': voucherCode,
+ 'customer_pseudonym': customerPseudonym,
+ 'service_type': serviceType,
+ 'comment': comment,
+ if (deliveryCountry != null) 'delivery_country': deliveryCountry,
+ },
+ parse: VipRedemptionResult.fromJson,
+ );
+ }
+
+ // -- Car Research Fee --
+
+ Future<ApiResponse<CarResearchInvoice>> createCarResearchInvoice({
+ required Address billing,
+ }) async {
+ return _request(
+ 'POST',
+ '/car-research/invoice',
+ body: {'billing': billing.toJson()},
+ parse: CarResearchInvoice.fromJson,
+ );
+ }
+
+ Future<ApiResponse<Map<String, dynamic>>> getCarResearchInvoiceStatus(
+ String invoiceId,
+ ) async {
+ return _request(
+ 'GET',
+ '/car-research/invoice/$invoiceId/status',
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<CarResearchPaymentResult>> logCarResearchPayment(
+ String invoiceId,
+ ) async {
+ return _request(
+ 'POST',
+ '/car-research/log-payment',
+ body: {'invoice_id': invoiceId},
+ parse: CarResearchPaymentResult.fromJson,
+ );
+ }
+
+ // -- Push Notifications --
+
+ Future<ApiResponse<Map<String, dynamic>>> registerPushSubscription({
+ String? deviceToken,
+ String? endpoint,
+ Map<String, String>? keys,
+ String? platform,
+ String? environment,
+ String? expirationTime,
+ int? ticketId,
+ }) async {
+ return _request(
+ 'POST',
+ '/notifications/push-subscriptions',
+ body: {
+ if (deviceToken != null) 'deviceToken': deviceToken,
+ if (endpoint != null) 'endpoint': endpoint,
+ if (keys != null) 'keys': keys,
+ if (platform != null) 'platform': platform,
+ if (environment != null) 'environment': environment,
+ if (expirationTime != null) 'expirationTime': expirationTime,
+ if (ticketId != null) 'ticketId': ticketId,
+ },
+ parse: (json) => json,
+ );
+ }
+
+ // -- Webhooks --
+
+ Future<ApiResponse<List<Map<String, dynamic>>>> listWebhooks() async {
+ return _request(
+ 'GET',
+ '/partners/webhooks',
+ needsCustomerKey: false,
+ parse: (json) {
+ if (json.containsKey('webhooks')) {
+ return (json['webhooks'] as List<dynamic>)
+ .cast<Map<String, dynamic>>();
+ }
+ return [json];
+ },
+ );
+ }
+
+ Future<ApiResponse<Map<String, dynamic>>> createWebhook({
+ required String webhookUrl,
+ required List<String> eventTypes,
+ }) async {
+ return _request(
+ 'POST',
+ '/partners/webhooks',
+ needsCustomerKey: false,
+ body: {'webhook_url': webhookUrl, 'event_types': eventTypes},
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<Map<String, dynamic>>> rotateWebhookSecret(
+ String webhookId,
+ ) async {
+ return _request(
+ 'POST',
+ '/partners/webhooks/$webhookId/rotate',
+ needsCustomerKey: false,
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<void>> deleteWebhook(String webhookId) async {
+ return _request(
+ 'DELETE',
+ '/partners/webhooks/$webhookId',
+ needsCustomerKey: false,
+ parse: (_) => null,
+ );
+ }
+
+ // -- Sandbox --
+
+ Future<ApiResponse<Map<String, dynamic>>> sandboxSetState(
+ int ticketId,
+ String state,
+ ) async {
+ return _request(
+ 'POST',
+ '/sandbox/state/$ticketId/$state',
+ parse: (json) => json,
+ );
+ }
+
+ Future<ApiResponse<Map<String, dynamic>>> sandboxSetPayment(
+ int ticketId,
+ String status,
+ ) async {
+ return _request(
+ 'POST',
+ '/sandbox/payment/$ticketId/$status',
+ parse: (json) => json,
+ );
+ }
+
+ // -- Internals --
+
+ ({InternetAddress host, int port})? get _proxyInfo =>
+ !AppConfig.hasFeature(AppFeature.tor)
+ ? null
+ : Prefs.instance.useTor
+ ? TorService.sharedInstance.getProxyInfo()
+ : null;
+
+ /// Prepend /sandbox to paths when in sandbox mode, except for paths that
+ /// already start with /sandbox, /meta, /health, or /token.
+ String _resolvePath(String path) {
+ if (!sandbox) return path;
+ if (path.startsWith('/sandbox') ||
+ path.startsWith('/meta') ||
+ path.startsWith('/health') ||
+ path.startsWith('/token') ||
+ path.startsWith('/partners')) {
+ return path;
+ }
+ return '/sandbox$path';
+ }
+
+ Map<String, String> _headers(String token, {bool needsCustomerKey = true}) {
+ final h = <String, String>{
+ 'Authorization': 'Bearer $token',
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ };
+ if (needsCustomerKey && _externalCustomerKey != null) {
+ h['External-Customer-Key'] = _externalCustomerKey!;
+ }
+ return h;
+ }
+
+ Future<Response> _send(
+ String method,
+ String path, {
+ Map<String, dynamic>? body,
+ Map<String, String>? query,
+ bool needsCustomerKey = true,
+ }) async {
+ final token = await _tokenManager.getValidToken();
+ final resolved = _resolvePath(path);
+ var uri = Uri.parse('$baseUrl$resolved');
+ if (query != null && query.isNotEmpty) {
+ uri = uri.replace(queryParameters: query);
+ }
+ final headers = _headers(token, needsCustomerKey: needsCustomerKey);
+ final proxy = _proxyInfo;
+
+ Logging.instance.t("$_kTag $method $uri");
+
+ switch (method) {
+ case 'GET':
+ return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy);
+ case 'POST':
+ return _httpClient.post(
+ url: uri,
+ headers: headers,
+ body: body != null ? jsonEncode(body) : null,
+ proxyInfo: proxy,
+ );
+ case 'PATCH':
+ return _httpClient.patch(
+ url: uri,
+ headers: headers,
+ body: body != null ? jsonEncode(body) : null,
+ proxyInfo: proxy,
+ );
+ case 'DELETE':
+ return _httpClient.delete(url: uri, headers: headers, proxyInfo: proxy);
+ default:
+ throw ApiException('Unsupported method: $method');
+ }
+ }
+
+ Future<ApiResponse<T>> _request<T>(
+ String method,
+ String path, {
+ Map<String, dynamic>? body,
+ Map<String, String>? query,
+ bool needsCustomerKey = true,
+ required T Function(Map<String, dynamic>) parse,
+ }) async {
+ try {
+ final response = await _send(
+ method,
+ path,
+ body: body,
+ query: query,
+ needsCustomerKey: needsCustomerKey,
+ );
+
+ final resolved = _resolvePath(path);
+
+ if (response.code >= 200 && response.code < 300) {
+ Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}");
+ if (response.body.isEmpty) {
+ return ApiResponse(value: parse({}));
+ }
+ final json = jsonDecode(response.body) as Map<String, dynamic>;
+ return ApiResponse(value: parse(json));
+ } else {
+ Logging.instance.w(
+ "$_kTag $method $resolved HTTP:${response.code} "
+ "body: ${response.body}",
+ );
+ return ApiResponse(
+ exception: ApiException.fromResponse(response.code, response.body),
+ );
+ }
+ } on ApiException catch (e) {
+ Logging.instance.e("$_kTag _request($method $path) threw: ", error: e);
+ return ApiResponse(exception: e);
+ } catch (e, s) {
+ Logging.instance.e(
+ "$_kTag _request($method $path) threw: ",
+ error: e,
+ stackTrace: s,
+ );
+ return ApiResponse(exception: ApiException.network(e));
+ }
+ }
+
+ /// Like [_request] but gives the parse function the raw response body
+ /// string, for endpoints that return non-object JSON (e.g. arrays).
+ Future<ApiResponse<T>> _requestRaw<T>(
+ String method,
+ String path, {
+ Map<String, dynamic>? body,
+ Map<String, String>? query,
+ bool needsCustomerKey = true,
+ required T Function(String) parse,
+ }) async {
+ try {
+ final response = await _send(
+ method,
+ path,
+ body: body,
+ query: query,
+ needsCustomerKey: needsCustomerKey,
+ );
+
+ final resolved = _resolvePath(path);
+
+ if (response.code >= 200 && response.code < 300) {
+ Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}");
+ return ApiResponse(value: parse(response.body));
+ } else {
+ Logging.instance.w(
+ "$_kTag $method $resolved HTTP:${response.code} "
+ "body: ${response.body}",
+ );
+ return ApiResponse(
+ exception: ApiException.fromResponse(response.code, response.body),
+ );
+ }
+ } on ApiException catch (e) {
+ Logging.instance.e("$_kTag _requestRaw($method $path) threw: ", error: e);
+ return ApiResponse(exception: e);
+ } catch (e, s) {
+ Logging.instance.e(
+ "$_kTag _requestRaw($method $path) threw: ",
+ error: e,
+ stackTrace: s,
+ );
+ return ApiResponse(exception: ApiException.network(e));
+ }
+ }
+}
diff --git a/lib/services/shopinbit/src/token_manager.dart b/lib/services/shopinbit/src/token_manager.dart
new file mode 100644
index 0000000..0f77a99
--- /dev/null
+++ b/lib/services/shopinbit/src/token_manager.dart
@@ -0,0 +1,98 @@
+import 'dart:async';
+import 'dart:convert';
+
+import '../../../app_config.dart';
+import '../../../networking/http.dart';
+import '../../../utilities/logger.dart';
+import '../../../utilities/prefs.dart';
+import '../../tor_service.dart';
+import 'api_exception.dart';
+import 'models/auth_token.dart';
+
+class TokenManager {
+ final String accessKey;
+ final String partnerSecret;
+ final String baseUrl;
+ final HTTP _httpClient;
+
+ AuthToken? _token;
+ Completer<String>? _refreshCompleter;
+
+ TokenManager({
+ required this.accessKey,
+ required this.partnerSecret,
+ required this.baseUrl,
+ HTTP? httpClient,
+ }) : _httpClient = httpClient ?? const HTTP();
+
+ Future<String> getValidToken() {
+ if (_token != null && !_token!.expiresSoon) {
+ return Future.value(_token!.accessToken);
+ }
+
+ if (_refreshCompleter != null) {
+ return _refreshCompleter!.future;
+ }
+
+ final completer = Completer<String>();
+ _refreshCompleter = completer;
+
+ _authenticate()
+ .then((token) {
+ _token = token;
+ completer.complete(token.accessToken);
+ })
+ .catchError((Object e) {
+ completer.completeError(e);
+ })
+ .whenComplete(() {
+ _refreshCompleter = null;
+ });
+
+ return completer.future;
+ }
+
+ Future<AuthToken> _authenticate() async {
+ final uri = Uri.parse('$baseUrl/token');
+ Logging.instance.t("ShopInBitClient POST $uri (authenticate)");
+
+ final Response response;
+ try {
+ response = await _httpClient.post(
+ url: uri,
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: Uri(
+ queryParameters: {'username': accessKey, 'password': partnerSecret},
+ ).query,
+ proxyInfo: !AppConfig.hasFeature(AppFeature.tor)
+ ? null
+ : Prefs.instance.useTor
+ ? TorService.sharedInstance.getProxyInfo()
+ : null,
+ );
+ } catch (e, s) {
+ Logging.instance.e(
+ "ShopInBitClient authenticate() network error: ",
+ error: e,
+ stackTrace: s,
+ );
+ throw ApiException.network(e);
+ }
+
+ if (response.code != 200) {
+ Logging.instance.w(
+ "ShopInBitClient authenticate() HTTP:${response.code} "
+ "body: ${response.body}",
+ );
+ throw ApiException.fromResponse(response.code, response.body);
+ }
+
+ Logging.instance.t("ShopInBitClient authenticate() success");
+ final json = jsonDecode(response.body) as Map<String, dynamic>;
+ return AuthToken.fromJson(json);
+ }
+
+ void invalidate() {
+ _token = null;
+ }
+}
diff --git a/lib/services/shopinbit/src/webhook_verifier.dart b/lib/services/shopinbit/src/webhook_verifier.dart
new file mode 100644
index 0000000..a596a3f
--- /dev/null
+++ b/lib/services/shopinbit/src/webhook_verifier.dart
@@ -0,0 +1,53 @@
+import 'dart:convert';
+
+import 'package:crypto/crypto.dart';
+
+class WebhookVerifier {
+ /// Verify a webhook delivery from ShopInBit.
+ ///
+ /// [body] is the raw request body.
+ /// [signatureHeader] is the `X-Concierge-Signature` header value,
+ /// formatted as `t=<unix_timestamp>,v1=<hex_hmac>`.
+ /// [secret] is the subscription secret.
+ /// [toleranceSeconds] is the max age of the timestamp (default 300 = 5 min).
+ static bool verify(
+ String body,
+ String signatureHeader,
+ String secret, {
+ int toleranceSeconds = 300,
+ }) {
+ final parts = <String, String>{};
+ for (final segment in signatureHeader.split(',')) {
+ final idx = segment.indexOf('=');
+ if (idx == -1) continue;
+ parts[segment.substring(0, idx)] = segment.substring(idx + 1);
+ }
+
+ final timestampStr = parts['t'];
+ final v1 = parts['v1'];
+ if (timestampStr == null || v1 == null) return false;
+
+ final timestamp = int.tryParse(timestampStr);
+ if (timestamp == null) return false;
+
+ // Check timestamp freshness.
+ final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
+ if ((now - timestamp).abs() > toleranceSeconds) return false;
+
+ // Compute HMAC-SHA256 of "<timestamp>.<body>".
+ final payload = '$timestampStr.$body';
+ final key = utf8.encode(secret);
+ final bytes = utf8.encode(payload);
+ final hmac = Hmac(sha256, key);
+ final digest = hmac.convert(bytes);
+ final expected = digest.toString();
+
+ // Constant-time comparison.
+ if (expected.length != v1.length) return false;
+ var result = 0;
+ for (var i = 0; i < expected.length; i++) {
+ result |= expected.codeUnitAt(i) ^ v1.codeUnitAt(i);
+ }
+ return result == 0;
+ }
+}
diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh
index 44d4e09..c1bb5bc 100755
--- a/scripts/prebuild.sh
+++ b/scripts/prebuild.sh
@@ -4,7 +4,7 @@
KEYS=../lib/external_api_keys.dart
if ! test -f "$KEYS"; then
echo 'prebuild.sh: creating template lib/external_api_keys.dart file'
- printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\n' > $KEYS
+ printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\n' > $KEYS
fi
# Create template wallet test parameter files if they don't already exist
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.