import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/chain_models.dart'; import 'cert_pinning.dart'; /// Read/relay client for the MoonBite explorer JSON API. /// /// This service NEVER sees private keys. The wallet derives keys and signs /// transactions on-device (see `wallet/`); this class only fetches chain state /// and relays already-signed raw transactions. class ChainService { /// Live explorer (demo data until a real moonbited backend is wired up). static const String defaultBaseUrl = 'https://moonbite.org'; String baseUrl; final http.Client _http; final Duration timeout; ChainService({ String? baseUrl, http.Client? httpClient, this.timeout = const Duration(seconds: 15), }) : baseUrl = (baseUrl ?? defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''), // Default to the certificate-pinned client so production traffic can't // be MITM'd via a rogue CA. Tests inject their own mock client. _http = httpClient ?? createPinnedHttpClient() { _requireHttps(this.baseUrl); } static void _requireHttps(String url) { if (Uri.parse(url).scheme != 'https') { throw ArgumentError( 'ChainService requires an https base URL (no cleartext / downgrade); ' 'refusing "$url".', ); } } void setBaseUrl(String url) { final cleaned = url.replaceAll(RegExp(r'/+$'), ''); _requireHttps(cleaned); baseUrl = cleaned; } Uri _u(String path) => Uri.parse('$baseUrl$path'); Map _decode(http.Response r) { final body = r.body.isEmpty ? '{}' : r.body; final Map json = jsonDecode(body) as Map; if (r.statusCode >= 400) { throw ChainApiException( (json['error'] ?? 'HTTP ${r.statusCode}').toString(), r.statusCode, ); } return json; } /// Chain tip + node summary. Future getStatus() async { final r = await _http.get(_u('/api/status')).timeout(timeout); return ChainStatus.fromJson(_decode(r)); } /// Spendable outputs for [address] (via server-side scantxoutset). Future> getUtxos(String address) async { final r = await _http.get(_u('/api/address/$address/utxos')).timeout(timeout); final json = _decode(r); final list = (json['utxos'] as List?) ?? const []; return list .map((e) => Utxo.fromJson(e as Map)) .toList(growable: false); } /// Confirmed / unconfirmed / total balance for [address]. Future getBalance(String address) async { final r = await _http.get(_u('/api/address/$address/balance')).timeout(timeout); return WalletBalance.fromJson(_decode(r)); } /// Suggested fee rate in BIG per kB. Future getFeeRate() async { final r = await _http.get(_u('/api/fee')).timeout(timeout); final json = _decode(r); return (json['feerate'] ?? 0.00001).toDouble(); } /// Relays a fully-signed raw transaction; returns the accepted txid. Future broadcast(String rawTxHex) async { final r = await _http .post( _u('/api/tx/broadcast'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'rawtx': rawTxHex}), ) .timeout(timeout); final json = _decode(r); return (json['txid'] ?? '').toString(); } /// Decoded transaction JSON as returned by the node. Future> getTransaction(String txid) async { final r = await _http.get(_u('/api/tx/$txid')).timeout(timeout); return _decode(r); } } class ChainApiException implements Exception { final String message; final int statusCode; ChainApiException(this.message, this.statusCode); @override String toString() => 'ChainApiException($statusCode): $message'; }